@trim21/personal-pi-extensions 0.1.572 → 0.1.574
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 +32 -2
- package/package.json +1 -1
- package/src/claude-code/common.ts +8 -51
- package/src/claude-code/edit-utils.ts +5 -113
- package/src/claude-code/files.ts +38 -94
- package/src/claude-code/read.md +1 -1
- package/src/lib/file-reads.ts +125 -0
- package/src/opencode/bash.md +26 -1
- package/src/opencode/files.ts +102 -14
- package/src/opencode/glob.md +6 -0
- package/src/opencode/glob.ts +120 -0
- package/src/opencode/grep.md +9 -0
- package/src/opencode/grep.ts +206 -0
- package/src/opencode/index.ts +10 -3
- package/src/opencode/ripgrep.ts +159 -0
package/src/opencode/bash.md
CHANGED
|
@@ -4,4 +4,29 @@ Executes a given bash command synchronously and returns its output.
|
|
|
4
4
|
|
|
5
5
|
Commands run in a sandbox: no write access outside the workspace and no network access. Use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.
|
|
6
6
|
|
|
7
|
-
Long command output is automatically truncated and the full output is saved to a file. Avoid piping commands through `tail` to limit output unless you have a specific reason.
|
|
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
|
+
|
|
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
|
+
|
|
11
|
+
- File search: Use `glob` (NOT find or ls)
|
|
12
|
+
- Content search: Use `grep` (NOT grep or rg)
|
|
13
|
+
- Read files: Use `read` (NOT cat/head/tail)
|
|
14
|
+
- Edit files: Use `edit` (NOT sed/awk)
|
|
15
|
+
- Write files: Use `write` (NOT echo >/cat <<EOF)
|
|
16
|
+
- Communication: Output text directly (NOT echo/printf)
|
|
17
|
+
While the Bash tool can do similar things, it's better to use the built-in tools as they provide a better user experience and make it easier to review tool calls and give permission.
|
|
18
|
+
|
|
19
|
+
- You may specify an optional timeout in milliseconds (up to 7200000ms / 2 hours). By default, your command will timeout after 120000ms (2 minutes).
|
|
20
|
+
- Prefer to use the workdir argument over `cd ...`
|
|
21
|
+
- When issuing multiple commands:
|
|
22
|
+
- If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. Example: if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
|
|
23
|
+
- If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together.
|
|
24
|
+
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail.
|
|
25
|
+
- DO NOT use newlines to separate commands (newlines are ok in quoted strings).
|
|
26
|
+
- For git commands:
|
|
27
|
+
- Prefer to create a new commit rather than amending an existing commit.
|
|
28
|
+
- Before running destructive operations (e.g., git reset --hard, git push --force, git checkout --), consider whether there is a safer alternative that achieves the same goal. Only use destructive operations when they are truly the best approach.
|
|
29
|
+
- Never skip hooks (--no-verify) or bypass signing (--no-gpg-sign, -c commit.gpgsign=false) unless the user has explicitly asked for it. If a hook fails, investigate and fix the underlying issue.
|
|
30
|
+
- Avoid unnecessary `sleep` commands:
|
|
31
|
+
- Do not sleep between commands that can run immediately — just run them.
|
|
32
|
+
- Do not retry failing commands in a sleep loop — diagnose the root cause.
|
package/src/opencode/files.ts
CHANGED
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
* 字符截断、1 起始 offset、目录排序;读取后等待并报告该文档的诊断。
|
|
11
11
|
* 不接 PDF、不接 <system-reminder>;图片 magic 检测保留。
|
|
12
12
|
* - edit:匹配引擎 + 把 old/new 转到文件换行后再替换;写后等待文档诊断。
|
|
13
|
-
*
|
|
13
|
+
* edit 要求文件已被 read 读过且内容未变(read 记账见下)。
|
|
14
|
+
* - write:BOM 保留(source.bom || next.bom);写后同 edit 的诊断输出。write 本身
|
|
15
|
+
* 不要求先 read,但会刷新记账,保证紧接着的 edit 不必重新 read。
|
|
16
|
+
*
|
|
17
|
+
* read 记账:read / edit / write / lsp-rename 各自把受影响文件的内容指纹写进
|
|
18
|
+
* details.reads(随 session 持久化),session_start / session_tree 时从当前分支
|
|
19
|
+
* 重放。这是 edit 的 read-before-edit 守卫的基础。
|
|
14
20
|
*
|
|
15
21
|
* 匹配引擎在 edit-engine.ts,也被 lib/write-guard 复用。
|
|
16
22
|
*/
|
|
@@ -29,6 +35,16 @@ import {
|
|
|
29
35
|
} from "@earendil-works/pi-coding-agent";
|
|
30
36
|
import { Type } from "typebox";
|
|
31
37
|
|
|
38
|
+
import {
|
|
39
|
+
createReadsState,
|
|
40
|
+
fileDigest,
|
|
41
|
+
type FileSnapshot,
|
|
42
|
+
type ReadsState,
|
|
43
|
+
readStateKey,
|
|
44
|
+
requireCurrentRead,
|
|
45
|
+
restoreReads,
|
|
46
|
+
snapshotOf,
|
|
47
|
+
} from "../lib/file-reads.js";
|
|
32
48
|
import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
|
|
33
49
|
import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
|
|
34
50
|
import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
|
|
@@ -313,7 +329,8 @@ function truncationFromPage(page: LinePage): TruncationResult {
|
|
|
313
329
|
};
|
|
314
330
|
}
|
|
315
331
|
|
|
316
|
-
|
|
332
|
+
/** 同目录下名字相近的候选(供 read / edit / grep 的路径错误提示复用)。 */
|
|
333
|
+
export async function didYouMean(filePath: string): Promise<string> {
|
|
317
334
|
const dir = dirname(filePath);
|
|
318
335
|
const base = basename(filePath);
|
|
319
336
|
|
|
@@ -359,7 +376,12 @@ async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
|
|
|
359
376
|
return results;
|
|
360
377
|
}
|
|
361
378
|
|
|
362
|
-
|
|
379
|
+
// ── read-before-edit 记账 ────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
/** 会更新 reads 记账(src/lib/file-reads.ts)并随 details 持久化快照的工具名。 */
|
|
382
|
+
const READS_TOOL_NAMES = new Set(["read", "edit", "write", "lsp-rename"]);
|
|
383
|
+
|
|
384
|
+
function registerReadTool(pi: ExtensionAPI, getService: () => LspService, state: ReadsState): void {
|
|
363
385
|
pi.registerTool({
|
|
364
386
|
name: "read",
|
|
365
387
|
label: "read",
|
|
@@ -369,7 +391,10 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
369
391
|
"Use read to examine files instead of cat, sed, or tail. A negative offset reads from the end of the file (offset=-2 reads the last 2 lines).",
|
|
370
392
|
],
|
|
371
393
|
parameters: Type.Object({
|
|
372
|
-
filePath: Type.String({
|
|
394
|
+
filePath: Type.String({
|
|
395
|
+
description:
|
|
396
|
+
"The path to the file or directory to read (absolute or relative to the working directory)",
|
|
397
|
+
}),
|
|
373
398
|
offset: Type.Optional(
|
|
374
399
|
Type.Integer({
|
|
375
400
|
description:
|
|
@@ -454,9 +479,15 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
454
479
|
{ type: "text", text: "Image read successfully" },
|
|
455
480
|
{ type: "image", data: base64, mimeType },
|
|
456
481
|
];
|
|
482
|
+
const key = await readStateKey(absolutePath);
|
|
483
|
+
const snapshot = snapshotOf(buffer, false);
|
|
484
|
+
state.reads.set(key, snapshot);
|
|
457
485
|
return {
|
|
458
486
|
content,
|
|
459
|
-
details: {
|
|
487
|
+
details: {
|
|
488
|
+
reads: { [key]: snapshot },
|
|
489
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath) },
|
|
490
|
+
},
|
|
460
491
|
};
|
|
461
492
|
}
|
|
462
493
|
|
|
@@ -467,6 +498,11 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
467
498
|
};
|
|
468
499
|
}
|
|
469
500
|
|
|
501
|
+
// 记账指纹先于分页读取:文件若在读取期间被改动,指纹对应的是改动前的
|
|
502
|
+
// 内容,后续 edit 会要求重新 read(失败方向安全)。
|
|
503
|
+
const key = await readStateKey(absolutePath);
|
|
504
|
+
const snapshot: FileSnapshot = { digest: await fileDigest(absolutePath), textEditable: true };
|
|
505
|
+
|
|
470
506
|
const effectiveOffset = offset || 1;
|
|
471
507
|
const page = await readLines(absolutePath, {
|
|
472
508
|
offset: effectiveOffset,
|
|
@@ -498,6 +534,8 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
498
534
|
outputText = `${header}${numbered}\n\n(End of file - total ${page.count} lines)${footer}`;
|
|
499
535
|
}
|
|
500
536
|
|
|
537
|
+
state.reads.set(key, snapshot);
|
|
538
|
+
|
|
501
539
|
// opencode: 与 edit / write 同一条驻留路径:didOpen 后等待该文件的诊断并报告
|
|
502
540
|
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
503
541
|
signal,
|
|
@@ -513,6 +551,7 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
513
551
|
content,
|
|
514
552
|
details: {
|
|
515
553
|
...details,
|
|
554
|
+
reads: { [key]: snapshot },
|
|
516
555
|
pendant: {
|
|
517
556
|
subtitle: formatSubtitlePath(
|
|
518
557
|
ctx.cwd,
|
|
@@ -544,6 +583,7 @@ function registerEditTool(
|
|
|
544
583
|
pi: ExtensionAPI,
|
|
545
584
|
getService: () => LspService,
|
|
546
585
|
policy: RequestPolicy,
|
|
586
|
+
state: ReadsState,
|
|
547
587
|
): void {
|
|
548
588
|
pi.registerTool({
|
|
549
589
|
name: "edit",
|
|
@@ -606,12 +646,16 @@ function registerEditTool(
|
|
|
606
646
|
await mkdir(dirname(absolutePath), { recursive: true });
|
|
607
647
|
signal?.throwIfAborted();
|
|
608
648
|
await writeFile(absolutePath, newString, "utf8");
|
|
649
|
+
// 新建文件无需先 read(无处可读),写后记账;realpath 要等文件落盘
|
|
650
|
+
const key = await readStateKey(absolutePath);
|
|
651
|
+
const snapshot = snapshotOf(newString);
|
|
652
|
+
state.reads.set(key, snapshot);
|
|
609
653
|
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
610
654
|
signal,
|
|
611
655
|
});
|
|
612
656
|
return [
|
|
613
657
|
"Edit applied successfully.",
|
|
614
|
-
{ diff: "", patch: "", firstChangedLine: 0 },
|
|
658
|
+
{ diff: "", patch: "", firstChangedLine: 0, reads: { [key]: snapshot } },
|
|
615
659
|
diagnostics,
|
|
616
660
|
] as const;
|
|
617
661
|
}
|
|
@@ -635,10 +679,16 @@ function registerEditTool(
|
|
|
635
679
|
throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
|
|
636
680
|
}
|
|
637
681
|
|
|
682
|
+
// 指纹校验必须发生在写入之前:未 read 或外部已改动都拒绝编辑
|
|
638
683
|
const rawContent = await readFile(absolutePath, "utf8");
|
|
684
|
+
const key = await readStateKey(absolutePath);
|
|
685
|
+
requireCurrentRead(state, key, absolutePath, rawContent);
|
|
686
|
+
|
|
639
687
|
const applied = applyEdit(rawContent, oldString, newString, replaceAll);
|
|
640
688
|
signal?.throwIfAborted();
|
|
641
689
|
await writeFile(absolutePath, applied.finalContent, "utf8");
|
|
690
|
+
const snapshot = snapshotOf(applied.finalContent);
|
|
691
|
+
state.reads.set(key, snapshot);
|
|
642
692
|
|
|
643
693
|
const diffOld = normalizeToLF(applied.contentOld);
|
|
644
694
|
const diffNew = normalizeToLF(applied.contentNew);
|
|
@@ -649,7 +699,12 @@ function registerEditTool(
|
|
|
649
699
|
});
|
|
650
700
|
return [
|
|
651
701
|
"Edit applied successfully.",
|
|
652
|
-
{
|
|
702
|
+
{
|
|
703
|
+
diff: diffResult.diff,
|
|
704
|
+
patch,
|
|
705
|
+
firstChangedLine: diffResult.firstChangedLine,
|
|
706
|
+
reads: { [key]: snapshot },
|
|
707
|
+
},
|
|
653
708
|
diagnostics,
|
|
654
709
|
] as const;
|
|
655
710
|
},
|
|
@@ -702,6 +757,7 @@ function registerWriteTool(
|
|
|
702
757
|
pi: ExtensionAPI,
|
|
703
758
|
getService: () => LspService,
|
|
704
759
|
policy: RequestPolicy,
|
|
760
|
+
state: ReadsState,
|
|
705
761
|
): void {
|
|
706
762
|
pi.registerTool({
|
|
707
763
|
name: "write",
|
|
@@ -712,7 +768,8 @@ function registerWriteTool(
|
|
|
712
768
|
promptGuidelines: ["Use write only for new files or complete rewrites."],
|
|
713
769
|
parameters: Type.Object({
|
|
714
770
|
filePath: Type.String({
|
|
715
|
-
description:
|
|
771
|
+
description:
|
|
772
|
+
"The path to the file to write (absolute or relative to the working directory)",
|
|
716
773
|
}),
|
|
717
774
|
content: Type.String({ description: "The content to write to the file" }),
|
|
718
775
|
}),
|
|
@@ -753,11 +810,15 @@ function registerWriteTool(
|
|
|
753
810
|
await mkdir(dir, { recursive: true });
|
|
754
811
|
signal?.throwIfAborted();
|
|
755
812
|
await writeFile(absolutePath, desiredBom + nextText, "utf8");
|
|
813
|
+
// write 不要求先 read,但写后记账:紧接着的 edit 不该再要求重新 read
|
|
814
|
+
const key = await readStateKey(absolutePath);
|
|
815
|
+
const snapshot = snapshotOf(desiredBom + nextText);
|
|
816
|
+
state.reads.set(key, snapshot);
|
|
756
817
|
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
757
818
|
signal,
|
|
758
819
|
});
|
|
759
820
|
|
|
760
|
-
return ["Wrote file successfully.", {}, diagnostics] as const;
|
|
821
|
+
return ["Wrote file successfully.", { reads: { [key]: snapshot } }, diagnostics] as const;
|
|
761
822
|
},
|
|
762
823
|
);
|
|
763
824
|
|
|
@@ -786,10 +847,11 @@ export function registerFileTools(
|
|
|
786
847
|
pi: ExtensionAPI,
|
|
787
848
|
getService: () => LspService,
|
|
788
849
|
policy: RequestPolicy,
|
|
850
|
+
state: ReadsState,
|
|
789
851
|
): void {
|
|
790
|
-
registerReadTool(pi, getService);
|
|
791
|
-
registerEditTool(pi, getService, policy);
|
|
792
|
-
registerWriteTool(pi, getService, policy);
|
|
852
|
+
registerReadTool(pi, getService, state);
|
|
853
|
+
registerEditTool(pi, getService, policy, state);
|
|
854
|
+
registerWriteTool(pi, getService, policy, state);
|
|
793
855
|
// lsp-rename / inspect 工具由 manager 的 onEnabled 回调注册,不在这里注册。
|
|
794
856
|
}
|
|
795
857
|
|
|
@@ -806,16 +868,42 @@ export default function opencodeFileTools(
|
|
|
806
868
|
// 聚合入口(index.ts)注入与 bash runtime 共享的那一份;独立入口自建并靠
|
|
807
869
|
// pi.events 跟随同一开关。
|
|
808
870
|
const policy = options?.policy ?? createRequestPolicy(pi.events);
|
|
871
|
+
const state = createReadsState();
|
|
809
872
|
const manager = createLspManager(
|
|
810
873
|
pi,
|
|
811
874
|
{
|
|
812
875
|
onEnabled: (pi, service) => {
|
|
813
|
-
registerLspRenameTool(pi, service, {
|
|
876
|
+
registerLspRenameTool(pi, service, {
|
|
877
|
+
policy,
|
|
878
|
+
recordReads: async (applied) => {
|
|
879
|
+
const reads: Record<string, FileSnapshot> = {};
|
|
880
|
+
for (const fileEdit of applied) {
|
|
881
|
+
const key = await readStateKey(fileEdit.path);
|
|
882
|
+
const snapshot = snapshotOf(fileEdit.newText);
|
|
883
|
+
state.reads.set(key, snapshot);
|
|
884
|
+
reads[key] = snapshot;
|
|
885
|
+
}
|
|
886
|
+
return reads;
|
|
887
|
+
},
|
|
888
|
+
});
|
|
814
889
|
registerLspInspectTools(pi, service);
|
|
815
890
|
},
|
|
816
891
|
},
|
|
817
892
|
options,
|
|
818
893
|
);
|
|
894
|
+
|
|
895
|
+
// 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的已读
|
|
896
|
+
// 记账随之丢失,这里从当前分支的历史工具结果恢复;digest 仍是当时的值,文件
|
|
897
|
+
// 若在此期间被外部修改,edit 时的指纹对比照样要求重新 read。
|
|
898
|
+
pi.on("session_start", (_event, ctx) => {
|
|
899
|
+
restoreReads(state, ctx.sessionManager, READS_TOOL_NAMES);
|
|
900
|
+
});
|
|
901
|
+
// rewind / 树内跳转走 branch(),只发 session_tree:重放当前分支,丢弃被抛弃
|
|
902
|
+
// 分支的记账,避免 state 与当前分支脱节。
|
|
903
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
904
|
+
restoreReads(state, ctx.sessionManager, READS_TOOL_NAMES);
|
|
905
|
+
});
|
|
906
|
+
|
|
819
907
|
// 文件工具无条件注册;service 惰性获取,disabled 时为 no-op。
|
|
820
|
-
registerFileTools(pi, () => manager.mustLazyGetService(), policy);
|
|
908
|
+
registerFileTools(pi, () => manager.mustLazyGetService(), policy, state);
|
|
821
909
|
}
|
|
@@ -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
|
+
}
|
package/src/opencode/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* opencode —— 统一注册 opencode 风格工具扩展。
|
|
3
3
|
*
|
|
4
|
-
* 聚合 files(read / edit / write 统一构建,共享 LSP service)、
|
|
5
|
-
* question / bash
|
|
6
|
-
*
|
|
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));
|