@trim21/personal-pi-extensions 0.1.573 → 0.1.575

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,8 @@
11
11
  | [bwrap](#bwrap) | 基于 bubblewrap 的 OS 级沙箱,提供文件系统和网络隔离 |
12
12
  | [写保护(内置)](#写保护内置) | 写工具内置:限制文件写入在 workspace 内,外部写入需审批 |
13
13
  | [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
14
+ | [opencode-grep](#opencode-grep) | opencode 风格 grep,基于 ripgrep 的内容搜索 |
15
+ | [opencode-glob](#opencode-glob) | opencode 风格 glob,基于 ripgrep 的文件名匹配 |
14
16
  | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时,spawn 子 agent 识别图片 |
15
17
  | [session-name](#session-name) | 首个 user prompt 自动生成会话名,失败仅告警不命名 |
16
18
  | [todowrite](#todowrite) | opencode 风格的任务列表工具,完整列表替换语义 |
@@ -19,7 +21,7 @@
19
21
  | [openai-cost](#openai-cost) | OpenAI Chat Completions,费用取自响应 `usage.cost` |
20
22
 
21
23
  > **两套工具风格,按预期只启用其中一套**:本包同时提供 opencode 风格
22
- > (小写 `read`/`edit`/`write`/`bash`/`todowrite`/`question`)与 Claude Code
24
+ > (小写 `read`/`edit`/`write`/`grep`/`glob`/`bash`/`todowrite`/`question`)与 Claude Code
23
25
  > 风格(大写 `Read`/`Edit`/`Write`/`Bash`/`Grep`/`Glob`/`TodoWrite`/
24
26
  > `AskUserQuestion`)两套工具集,二者共享 bwrap 沙箱与写保护实现。两套同时
25
27
  > 启用会带来预期外的冗余:同名命令重复注册(如 `/bwrap` 出现 `/bwrap:1`
@@ -146,6 +148,32 @@ pi -e ./src/opencode-edit.ts
146
148
 
147
149
  ---
148
150
 
151
+ ## opencode-grep
152
+
153
+ opencode 风格的 `grep` 工具,替换 pi 内置 `grep`。参数与输出格式对齐 opencode 的 [`grep`](https://github.com/anomalyco/opencode) 工具:`pattern` / `path` / `include`,输出以 `Found N matches` 开头,按文件分组(`<绝对路径>:` + ` Line N: <文本>`)。隐藏文件参与搜索、`.git` 排除、结果上限 100 条(触顶时提示 `(more matches available)` 并附截断说明)。
154
+
155
+ 执行层在 `src/opencode/ripgrep.ts`:`rg` 子进程流式读取 stdout,读满 100 条即终止进程,宽泛 pattern 不会把整个结果集读进内存;退出码语义与上游一致(1 = 无匹配,2 = 部分文件读失败仍返回已有结果,正则语法错误单独报错)。
156
+
157
+ 与上游的三处有意差异:行文本去掉 rg JSON 带出的行尾换行(否则每条匹配后面会多一个空行);`path` 指向文件时只搜该文件(上游按目录搜索);`path` 不存在时报错(含同目录相近名字提示),而不是静默返回 `No files found`。
158
+
159
+ ### 使用
160
+
161
+ 随 `src/opencode/index.ts` 一起加载。
162
+
163
+ ---
164
+
165
+ ## opencode-glob
166
+
167
+ opencode 风格的 `glob` 工具(与 pi 内置 `find` 并存)。参数 `pattern` / `path`,内部是 `rg --files` 语义:尊重 `.gitignore`、不列隐藏文件、不按修改时间排序、排除 `.git`,输出绝对路径,上限 100 条(截断时附 `(Results are truncated: ...)`)。与 Claude Code 风格 `Glob` 的差异(`--no-ignore` / `--hidden` / `--sort=modified`)是各自跟随上游的结果。
168
+
169
+ `path` 不存在或指向文件时报错(含同目录相近名字提示)。
170
+
171
+ ### 使用
172
+
173
+ 随 `src/opencode/index.ts` 一起加载。
174
+
175
+ ---
176
+
149
177
  ## vision-agent
150
178
 
151
179
  视觉代理扩展。主模型不支持视觉(如 DeepSeek)时自动启用 `describe_image` 工具;主模型支持视觉时自动隐藏,图片由 pi 原生透传。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.573",
3
+ "version": "0.1.575",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -6,8 +6,10 @@ Commands run in a sandbox: no write access outside the workspace and no network
6
6
 
7
7
  Long command output is automatically truncated and the full output is saved to a file. Avoid piping commands through `tail` or `2>&1 | tee test.log` to limit output unless you have a specific reason.
8
8
 
9
- IMPORTANT: Avoid using this tool to run `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:
9
+ IMPORTANT: Avoid using this tool to run `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:
10
10
 
11
+ - File search: Use `glob` (NOT find or ls)
12
+ - Content search: Use `grep` (NOT grep or rg)
11
13
  - Read files: Use `read` (NOT cat/head/tail)
12
14
  - Edit files: Use `edit` (NOT sed/awk)
13
15
  - Write files: Use `write` (NOT echo >/cat <<EOF)
@@ -329,7 +329,8 @@ function truncationFromPage(page: LinePage): TruncationResult {
329
329
  };
330
330
  }
331
331
 
332
- async function didYouMean(filePath: string): Promise<string> {
332
+ /** 同目录下名字相近的候选(供 read / edit / grep 的路径错误提示复用)。 */
333
+ export async function didYouMean(filePath: string): Promise<string> {
333
334
  const dir = dirname(filePath);
334
335
  const base = basename(filePath);
335
336
 
@@ -0,0 +1,6 @@
1
+ ### glob tool
2
+
3
+ - Fast file pattern matching tool that works with any codebase size
4
+ - Supports glob patterns like "**/\*.js" or "src/**/*.ts"
5
+ - Returns matching file paths
6
+ - Use this tool when you need to find files by name patterns
@@ -0,0 +1,120 @@
1
+ /**
2
+ * opencode 风格 glob 工具。
3
+ *
4
+ * 对齐上游 packages/opencode/src/tool/glob.ts:参数只有 pattern / path,内部执行
5
+ * rg --files(附带 --no-config、pattern 的 --glob,并排除 .git 目录),上限 100 条,
6
+ * 输出绝对路径,空结果 "No files found"。与 claude-code 风格 Glob 的差异是刻意
7
+ * 跟随上游的:尊重 .gitignore、不列隐藏文件、不按修改时间排序。
8
+ *
9
+ * 一处有意差异:path 不存在或不是目录时报错(上游直接让 rg 失败),并给出同目录
10
+ * 相近名字的提示。
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { stat } from "node:fs/promises";
15
+ import { resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import { Type } from "typebox";
20
+
21
+ import { didYouMean } from "./files.js";
22
+ import { RIPGREP_RESULT_LIMIT, runRipgrep } from "./ripgrep.js";
23
+
24
+ /** Tool guidance, kept in markdown so it reads like documentation. */
25
+ const GLOB_PROMPT = readFileSync(fileURLToPath(new URL("glob.md", import.meta.url)), "utf8").trim();
26
+
27
+ export function buildGlobArgs(pattern: string): string[] {
28
+ return ["--no-config", "--files", `--glob=${pattern}`, "--glob=!**/.git/**", "."];
29
+ }
30
+
31
+ /** rg --files 输出的路径:去掉前导 `./`,分隔符统一成 `/`。 */
32
+ function normalizeRipgrepPath(text: string): string {
33
+ return text.replace(/^(?:\.[\\/])+/u, "").replaceAll("\\", "/");
34
+ }
35
+
36
+ export function renderGlobOutput(files: readonly string[], truncated: boolean): string {
37
+ if (files.length === 0) return "No files found";
38
+ const output = [...files];
39
+ if (truncated) {
40
+ output.push(
41
+ "",
42
+ `(Results are truncated: showing first ${RIPGREP_RESULT_LIMIT} results. Consider using a more specific path or pattern.)`,
43
+ );
44
+ }
45
+ return output.join("\n");
46
+ }
47
+
48
+ async function glob(
49
+ params: { pattern: string; path?: string },
50
+ ctx: ExtensionContext,
51
+ signal: AbortSignal | undefined,
52
+ ): Promise<{ text: string; search: string; count: number; truncated: boolean }> {
53
+ // resolve 对绝对路径原样返回,相对路径按调用 cwd 解析
54
+ const search = resolve(ctx.cwd, params.path ?? ".");
55
+ let info;
56
+ try {
57
+ info = await stat(search);
58
+ } catch (error) {
59
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
60
+ const suggestion = await didYouMean(search);
61
+ throw new Error(
62
+ `Directory does not exist: ${params.path ?? search}. Note: your current working directory is ${ctx.cwd}.${suggestion}`,
63
+ { cause: error },
64
+ );
65
+ }
66
+ throw error;
67
+ }
68
+ if (!info.isDirectory()) {
69
+ throw new Error(`glob path must be a directory: ${search}`);
70
+ }
71
+
72
+ const { items, truncated } = await runRipgrep(buildGlobArgs(params.pattern), {
73
+ cwd: search,
74
+ signal,
75
+ limit: RIPGREP_RESULT_LIMIT,
76
+ parse: (line) => (line.length === 0 ? undefined : normalizeRipgrepPath(line)),
77
+ });
78
+ const files = items.map((file) => resolve(search, file));
79
+ return { text: renderGlobOutput(files, truncated), search, count: files.length, truncated };
80
+ }
81
+
82
+ export default function opencodeGlob(pi: ExtensionAPI): void {
83
+ pi.registerTool({
84
+ name: "glob",
85
+ label: "glob",
86
+ description: [
87
+ "Fast file pattern matching that works with any codebase size.",
88
+ 'Supports glob patterns such as "**/*.js" and "src/**/*.ts"; results are capped at 100 files.',
89
+ ].join("\n"),
90
+ promptSnippet: "Find files by name patterns",
91
+ promptGuidelines: [GLOB_PROMPT],
92
+ parameters: Type.Object(
93
+ {
94
+ pattern: Type.String({ description: "The glob pattern to match files against" }),
95
+ path: Type.Optional(
96
+ Type.String({
97
+ description:
98
+ 'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
99
+ }),
100
+ ),
101
+ },
102
+ { additionalProperties: false },
103
+ ),
104
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
105
+ signal?.throwIfAborted();
106
+ const { text, search, count, truncated } = await glob(params, ctx, signal);
107
+ return {
108
+ content: [{ type: "text" as const, text }],
109
+ details: {
110
+ pendant: {
111
+ subtitle:
112
+ count === 0
113
+ ? `no files in ${search}`
114
+ : `${count} file${count === 1 ? "" : "s"}${truncated ? " (truncated)" : ""}`,
115
+ },
116
+ },
117
+ };
118
+ },
119
+ });
120
+ }
@@ -0,0 +1,9 @@
1
+ ### grep tool
2
+
3
+ - Fast content search tool that works with any codebase size
4
+ - Searches file contents using regular expressions
5
+ - Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
6
+ - Filter files by pattern with the include parameter (eg. "_.js", "_.{ts,tsx}")
7
+ - Returns file paths and line numbers with matching lines
8
+ - Use this tool when you need to find files containing specific patterns
9
+ - If you need to count matches per file or use flags this tool does not expose, use the Bash tool with `rg` (ripgrep) directly instead of shelling out to `grep`.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * opencode 风格 grep 工具。
3
+ *
4
+ * 对齐上游 packages/opencode/src/tool/grep.ts:参数只有 pattern / path / include,
5
+ * 输出以 `Found N matches` 开头、按文件分组(`<path>:` + ` Line N: <text>`),
6
+ * 结果上限 100 条,隐藏文件参与搜索、.git 排除。
7
+ *
8
+ * 三处有意差异(都是上游实现的毛病):
9
+ * - 行文本去掉 rg JSON 带出的行尾换行,否则每条匹配后面多一个空行;
10
+ * - `path` 指向文件时只搜该文件(上游仍按目录搜索,仅把结果路径按目录解析);
11
+ * - `path` 不存在时报错(对齐 claude-code 的 Grep),而不是静默返回 No files found。
12
+ */
13
+
14
+ import { readFileSync } from "node:fs";
15
+ import { stat } from "node:fs/promises";
16
+ import { dirname, isAbsolute, join, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { Type } from "typebox";
21
+
22
+ import { parseWithSchema } from "../lib/parse-with-schema.js";
23
+ import { didYouMean } from "./files.js";
24
+ import { RIPGREP_RESULT_LIMIT, runRipgrep } from "./ripgrep.js";
25
+
26
+ /** Tool guidance, kept in markdown so it reads like documentation. */
27
+ const GREP_PROMPT = readFileSync(fileURLToPath(new URL("grep.md", import.meta.url)), "utf8").trim();
28
+
29
+ /** 单行文本上限(上游 2000 字符,超出截断加省略号)。 */
30
+ const MAX_LINE_LENGTH = 2000;
31
+
32
+ /** 单条 rg JSON 记录上限(上游 64 KiB)。 */
33
+ const MAX_RECORD_BYTES = 64 * 1024;
34
+
35
+ /** rg --json 的 match 记录(只取工具用到的字段)。 */
36
+ const rawMatchSchema = Type.Object({
37
+ data: Type.Object({
38
+ path: Type.Object({ text: Type.String() }),
39
+ lines: Type.Object({ text: Type.String() }),
40
+ line_number: Type.Integer(),
41
+ }),
42
+ });
43
+
44
+ export interface GrepMatch {
45
+ /** 相对 rg cwd 的路径,调用方负责解析成绝对路径。 */
46
+ path: string;
47
+ line: number;
48
+ text: string;
49
+ }
50
+
51
+ /** rg 输出的路径:去掉前导 `./`,分隔符统一成 `/`。 */
52
+ function normalizeRipgrepPath(text: string): string {
53
+ return text.replace(/^(?:\.[\\/])+/u, "").replaceAll("\\", "/");
54
+ }
55
+
56
+ /** 行文本:去掉行尾换行,超长截断(不留半个 surrogate pair)。 */
57
+ function normalizeLineText(text: string): string {
58
+ const line = text.replace(/[\r\n]+$/u, "");
59
+ if (line.length <= MAX_LINE_LENGTH) return line;
60
+ return line.slice(0, MAX_LINE_LENGTH).replace(/[\uD800-\uDBFF]$/u, "") + "...";
61
+ }
62
+
63
+ /**
64
+ * 解析一行 rg --json 输出。非 match 记录(begin/end/summary)返回 undefined;
65
+ * match 记录形状不符或超过大小上限则抛错(损坏的输出不该被当成空结果)。
66
+ */
67
+ export function parseGrepRecord(line: string): GrepMatch | undefined {
68
+ if (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES) {
69
+ throw new Error(`ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`);
70
+ }
71
+ let json: unknown;
72
+ try {
73
+ json = JSON.parse(line);
74
+ } catch (error) {
75
+ throw new Error("Invalid ripgrep JSON output", { cause: error });
76
+ }
77
+ if (typeof json !== "object" || json === null || (json as { type?: unknown }).type !== "match") {
78
+ return undefined;
79
+ }
80
+ const record = parseWithSchema(rawMatchSchema, json);
81
+ return {
82
+ path: normalizeRipgrepPath(record.data.path.text),
83
+ line: record.data.line_number,
84
+ text: normalizeLineText(record.data.lines.text),
85
+ };
86
+ }
87
+
88
+ export function buildGrepArgs(
89
+ pattern: string,
90
+ include: string | undefined,
91
+ target: string,
92
+ ): string[] {
93
+ return [
94
+ "--no-config",
95
+ "--json",
96
+ "--hidden",
97
+ "--no-messages",
98
+ ...(include ? [`--glob=${include}`] : []),
99
+ "--glob=!**/.git/**",
100
+ "--",
101
+ pattern,
102
+ target,
103
+ ];
104
+ }
105
+
106
+ /** 渲染搜索结果:上游格式,按文件分组。 */
107
+ export function renderGrepOutput(matches: readonly GrepMatch[], truncated: boolean): string {
108
+ if (matches.length === 0) return "No files found";
109
+ const output = [`Found ${matches.length} matches${truncated ? " (more matches available)" : ""}`];
110
+ let current = "";
111
+ for (const match of matches) {
112
+ if (current !== match.path) {
113
+ if (current !== "") output.push("");
114
+ current = match.path;
115
+ output.push(`${match.path}:`);
116
+ }
117
+ output.push(` Line ${match.line}: ${match.text}`);
118
+ }
119
+ if (truncated) {
120
+ output.push("", "(Results truncated. Consider using a more specific path or pattern.)");
121
+ }
122
+ return output.join("\n");
123
+ }
124
+
125
+ /** 搜索根:绝对路径原样,相对路径按调用 cwd 解析。 */
126
+ export function resolveSearchRoot(path: string | undefined, cwd: string): string {
127
+ if (path === undefined || path === "") return cwd;
128
+ return isAbsolute(path) ? path : join(cwd, path);
129
+ }
130
+
131
+ async function grep(
132
+ params: { pattern: string; path?: string; include?: string },
133
+ ctx: ExtensionContext,
134
+ signal: AbortSignal | undefined,
135
+ ): Promise<{ text: string; subtitle: string }> {
136
+ if (params.pattern === "") {
137
+ throw new Error("pattern is required");
138
+ }
139
+ const requested = resolveSearchRoot(params.path, ctx.cwd);
140
+ let info;
141
+ try {
142
+ info = await stat(requested);
143
+ } catch (error) {
144
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
145
+ const suggestion = await didYouMean(requested);
146
+ throw new Error(
147
+ `Path does not exist: ${params.path ?? requested}. Note: your current working directory is ${ctx.cwd}.${suggestion}`,
148
+ { cause: error },
149
+ );
150
+ }
151
+ throw error;
152
+ }
153
+ // 目录:rg 的 cwd 即该目录、目标为 ".";文件:cwd 取其父目录、目标为该文件
154
+ const searchDir = info.isDirectory() ? requested : dirname(requested);
155
+ const target = info.isDirectory() ? "." : requested;
156
+ const { items, truncated } = await runRipgrep(
157
+ buildGrepArgs(params.pattern, params.include, target),
158
+ { cwd: searchDir, signal, limit: RIPGREP_RESULT_LIMIT, parse: parseGrepRecord },
159
+ );
160
+ const matches = items.map((match) => ({ ...match, path: resolve(searchDir, match.path) }));
161
+ const fileCount = new Set(matches.map((match) => match.path)).size;
162
+ return {
163
+ text: renderGrepOutput(matches, truncated),
164
+ subtitle:
165
+ matches.length === 0
166
+ ? "no matches"
167
+ : `${matches.length} match${matches.length === 1 ? "" : "es"} in ${fileCount} file${fileCount === 1 ? "" : "s"}${truncated ? " (truncated)" : ""}`,
168
+ };
169
+ }
170
+
171
+ export default function opencodeGrep(pi: ExtensionAPI): void {
172
+ pi.registerTool({
173
+ name: "grep",
174
+ label: "grep",
175
+ description: [
176
+ "Fast content search with ripgrep: returns matching file paths with line numbers.",
177
+ "Hidden files are searched and .git is excluded; results are capped at 100 matches.",
178
+ ].join("\n"),
179
+ promptSnippet: "Search file contents with ripgrep",
180
+ promptGuidelines: [GREP_PROMPT],
181
+ parameters: Type.Object(
182
+ {
183
+ pattern: Type.String({ description: "The regex pattern to search for in file contents" }),
184
+ path: Type.Optional(
185
+ Type.String({
186
+ description: "The file or directory to search in. Defaults to the working directory.",
187
+ }),
188
+ ),
189
+ include: Type.Optional(
190
+ Type.String({
191
+ description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
192
+ }),
193
+ ),
194
+ },
195
+ { additionalProperties: false },
196
+ ),
197
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
198
+ signal?.throwIfAborted();
199
+ const { text, subtitle } = await grep(params, ctx, signal);
200
+ return {
201
+ content: [{ type: "text" as const, text }],
202
+ details: { pendant: { subtitle } },
203
+ };
204
+ },
205
+ });
206
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * opencode —— 统一注册 opencode 风格工具扩展。
3
3
  *
4
- * 聚合 files(read / edit / write 统一构建,共享 LSP service)、todo /
5
- * question / bash,一次加载全部注册;各工具的公开 API(匹配引擎、纯函数等)
6
- * 也从这里重新导出,方便测试与其他模块(如 lib/write-guard)引用。
4
+ * 聚合 files(read / edit / write 统一构建,共享 LSP service)、grep /
5
+ * glob(ripgrep 搜索)、todo / question / bash,一次加载全部注册;各工具的
6
+ * 公开 API(匹配引擎、纯函数等)也从这里重新导出,方便测试与其他模块
7
+ * (如 lib/write-guard)引用。
7
8
  *
8
9
  * Usage:
9
10
  * pi -e ./opencode/index.ts
@@ -18,6 +19,8 @@ import { createBwrapRuntime } from "../bwrap/runtime.js";
18
19
  import { createRequestPolicy } from "../lib/request-policy.js";
19
20
  import opencodeBash from "./bash.js";
20
21
  import opencodeFileTools from "./files.js";
22
+ import opencodeGlob from "./glob.js";
23
+ import opencodeGrep from "./grep.js";
21
24
  import opencodeQuestion from "./question.js";
22
25
  import opencodeTodo from "./todo.js";
23
26
 
@@ -38,6 +41,8 @@ export {
38
41
  resolveBom,
39
42
  type TruncationResult,
40
43
  } from "./files.js";
44
+ export { default as opencodeGlob } from "./glob.js";
45
+ export { default as opencodeGrep } from "./grep.js";
41
46
  export { default as opencodeQuestion } from "./question.js";
42
47
  export { default as opencodeTodo } from "./todo.js";
43
48
 
@@ -46,6 +51,8 @@ export default function opencode(pi: ExtensionAPI) {
46
51
  // 独立入口(web/fetch.ts 等)各自创建一份并经 pi.events 保持同步。
47
52
  const policy = createRequestPolicy(pi.events);
48
53
  opencodeFileTools(pi, { policy });
54
+ opencodeGrep(pi);
55
+ opencodeGlob(pi);
49
56
  opencodeTodo(pi);
50
57
  opencodeQuestion(pi);
51
58
  opencodeBash(pi, createBwrapRuntime(policy));
@@ -0,0 +1,159 @@
1
+ /**
2
+ * ripgrep 运行器,供 opencode 风格 grep / glob 复用。
3
+ *
4
+ * 对齐上游 packages/core/src/ripgrep.ts 的 run():以 `rg` 子进程流式读取
5
+ * stdout,按解析结果计数,读满 limit + 1 条即杀掉进程提前结束(宽泛 pattern
6
+ * 不会把整个结果集读进内存)。退出码语义与上游一致:0 成功、1 无匹配、2 部分
7
+ * 文件读失败(仍返回已收集的结果),正则语法错误单独报错。
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+
12
+ /** 单次搜索最多返回的结果数(上游 limit = 100)。 */
13
+ export const RIPGREP_RESULT_LIMIT = 100;
14
+
15
+ /** stderr 最多保留的字节数(上游 ERROR_BYTES)。 */
16
+ const ERROR_BYTES = 8 * 1024;
17
+
18
+ export interface RipgrepRunOptions<R> {
19
+ cwd: string;
20
+ signal?: AbortSignal;
21
+ /** 最多返回的解析结果数;读满 limit + 1 条即终止 rg。 */
22
+ limit: number;
23
+ /** 解析一行 stdout;返回 undefined 表示该行不是结果(如 rg 的 begin/end/summary 记录)。 */
24
+ parse: (line: string) => R | undefined;
25
+ }
26
+
27
+ export interface RipgrepRunResult<R> {
28
+ items: R[];
29
+ /** 结果数超过 limit,已提前终止 rg(可能还有更多结果)。 */
30
+ truncated: boolean;
31
+ }
32
+
33
+ const isInvalidPattern = (stderr: string) =>
34
+ stderr.includes("regex parse error") || stderr.includes("error parsing regex");
35
+
36
+ /**
37
+ * 跑一次 rg 并返回解析后的结果。
38
+ *
39
+ * 路径类参数用 `--` 与 pattern 分隔,pattern 不会被当成选项;调用方负责给出
40
+ * `--no-config`、`--glob` 等选项。
41
+ */
42
+ export function runRipgrep<R>(
43
+ args: string[],
44
+ options: RipgrepRunOptions<R>,
45
+ ): Promise<RipgrepRunResult<R>> {
46
+ return new Promise((resolve, reject) => {
47
+ let child;
48
+ try {
49
+ child = spawn("rg", args, { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"] });
50
+ } catch (error) {
51
+ reject(error instanceof Error ? error : new Error(String(error)));
52
+ return;
53
+ }
54
+
55
+ const { signal } = options;
56
+ // 事件回调之间共享的状态放进对象:闭包里的 let 会被 TS 控制流判定为「恒假」,
57
+ // 触发 eslint 的 no-unnecessary-condition 误报
58
+ const state = { done: false, settled: false, truncated: false };
59
+ let stderr = "";
60
+ let parseError: Error | undefined;
61
+ let pending = "";
62
+ const items: R[] = [];
63
+
64
+ const stop = () => {
65
+ if (state.done) return;
66
+ state.done = true;
67
+ child.kill("SIGTERM");
68
+ };
69
+
70
+ /** 收尾(resolve/reject 只会发生一次,并摘掉 abort 监听)。 */
71
+ const settle = (finish: () => void) => {
72
+ if (state.settled) return;
73
+ state.settled = true;
74
+ signal?.removeEventListener("abort", onAbort);
75
+ finish();
76
+ };
77
+
78
+ const parseLine = (line: string) => {
79
+ if (state.done || line.length === 0) return;
80
+ let item: R | undefined;
81
+ try {
82
+ item = options.parse(line);
83
+ } catch (error) {
84
+ // 解析失败来自事件回调,必须转成 reject,否则会变成未捕获异常
85
+ parseError = error instanceof Error ? error : new Error(String(error));
86
+ stop();
87
+ return;
88
+ }
89
+ if (item === undefined) return;
90
+ items.push(item);
91
+ if (items.length > options.limit) {
92
+ state.truncated = true;
93
+ stop();
94
+ }
95
+ };
96
+
97
+ child.stderr.setEncoding("utf8");
98
+ child.stderr.on("data", (chunk: string) => {
99
+ if (stderr.length < ERROR_BYTES) stderr += chunk;
100
+ });
101
+
102
+ child.stdout.setEncoding("utf8");
103
+ child.stdout.on("data", (chunk: string) => {
104
+ pending += chunk;
105
+ const lines = pending.split("\n");
106
+ // 末尾没有换行的部分是不完整记录,留到下一块
107
+ pending = lines.pop() ?? "";
108
+ for (const line of lines) parseLine(line);
109
+ });
110
+
111
+ child.on("error", (error) => {
112
+ // spawn 失败(rg 不在 PATH 等)时 close 不保证触发,必须在这里收尾,
113
+ // 否则 promise 永远不落定,工具调用会挂住
114
+ settle(() => {
115
+ reject(
116
+ (error as NodeJS.ErrnoException).code === "ENOENT"
117
+ ? new Error("ripgrep (rg) was not found on PATH; install ripgrep to use this tool.", {
118
+ cause: error,
119
+ })
120
+ : error,
121
+ );
122
+ });
123
+ });
124
+
125
+ const onAbort = () => stop();
126
+ signal?.addEventListener("abort", onAbort, { once: true });
127
+
128
+ // pending 里剩下的内容按定义是不完整记录(rg 的记录一律以 \n 收尾,
129
+ // 被 kill 时可能停在半条),直接丢掉
130
+ child.on("close", (code) => {
131
+ settle(() => {
132
+ // 取消优先于结果与退出码:调用方靠 throwIfAborted 统一处理取消
133
+ if (signal?.aborted) {
134
+ reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted"));
135
+ return;
136
+ }
137
+ if (parseError) {
138
+ reject(parseError);
139
+ return;
140
+ }
141
+ if (state.truncated) {
142
+ resolve({ items: items.slice(0, options.limit), truncated: true });
143
+ return;
144
+ }
145
+ const message = stderr.trim();
146
+ if (code === 2 && isInvalidPattern(message)) {
147
+ reject(new Error(`Invalid pattern: ${message}`));
148
+ return;
149
+ }
150
+ if (code !== 0 && code !== 1 && code !== 2) {
151
+ reject(new Error(message || `ripgrep exited with code ${code}`));
152
+ return;
153
+ }
154
+ // 退出码 1 = 无匹配;退出码 2 = 部分文件读失败,已有结果照常返回
155
+ resolve({ items: code === 1 ? [] : items, truncated: false });
156
+ });
157
+ });
158
+ });
159
+ }
@@ -101,12 +101,19 @@ const SETTINGS_PATH = join(getAgentDir(), "settings.json");
101
101
  * (read/edit/write) likewise share opencode/files.ts (they share the LSP
102
102
  * service instance); that file also registers the shared `lsp-rename` and
103
103
  * LSP inspect tools, which stay hidden unless a subagent declares them.
104
+ *
105
+ * The lowercase search tools map to the opencode implementations: `grep`
106
+ * overrides pi's built-in grep, and `glob` adds a tool pi has no built-in for
107
+ * (its `find` stays available). Each is a self-contained file registering one
108
+ * tool, so they load independently — `grep` without `glob`.
104
109
  */
105
110
  const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
106
111
  read: "opencode/files.ts",
107
112
  edit: "opencode/files.ts",
108
113
  write: "opencode/files.ts",
109
114
  bash: "opencode/bash.ts",
115
+ grep: "opencode/grep.ts",
116
+ glob: "opencode/glob.ts",
110
117
  Grep: "claude-code/grep.ts",
111
118
  Glob: "claude-code/glob.ts",
112
119
  Read: "claude-code/files.ts",