@trim21/personal-pi-extensions 0.0.225 → 0.0.230

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.
@@ -1,218 +1,22 @@
1
- import { glob as fsGlob, stat } from "node:fs/promises";
2
- import { isAbsolute, resolve } from "node:path";
1
+ /**
2
+ * Claude Code style search tools — `Glob` and `Grep`.
3
+ *
4
+ * Aggregation entry that registers both tools; each tool also lives in its own
5
+ * file (glob.ts / grep.ts) so spawn-agent subagents can load them
6
+ * independently via the `Glob` / `Grep` frontmatter tool names.
7
+ */
3
8
 
4
- import { StringEnum } from "@earendil-works/pi-ai";
5
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import { Type } from "typebox";
7
10
 
8
- import { throwIfAborted } from "./common.js";
9
-
10
- const GLOB_RESULT_LIMIT = 100;
11
- const GREP_OUTPUT_MODES = ["content", "files_with_matches", "count"] as const;
12
-
13
- type GrepOutputMode = (typeof GREP_OUTPUT_MODES)[number];
14
-
15
- function searchRoot(path: string | undefined, cwd: string): string {
16
- if (!path) return cwd;
17
- return isAbsolute(path) ? path : resolve(cwd, path);
18
- }
19
-
20
- function truncateOutput(output: string, maxCharacters = 30_000): string {
21
- if (output.length <= maxCharacters) return output;
22
- return `${output.slice(0, maxCharacters)}\n\n[Output truncated at ${maxCharacters} characters]`;
23
- }
24
-
25
- export async function globFiles(
26
- pattern: string,
27
- cwd: string,
28
- signal?: AbortSignal,
29
- ): Promise<string[]> {
30
- throwIfAborted(signal);
31
- const matches: { path: string; mtimeMs: number }[] = [];
32
- for await (const match of fsGlob(pattern, { cwd, exclude: [".git/**"], withFileTypes: false })) {
33
- throwIfAborted(signal);
34
- const absolutePath = resolve(cwd, match);
35
- try {
36
- const value = await stat(absolutePath);
37
- if (value.isFile()) matches.push({ path: absolutePath, mtimeMs: value.mtimeMs });
38
- } catch {
39
- // A concurrent filesystem change can remove a match before stat.
40
- }
41
- }
42
- return matches
43
- .toSorted((left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path))
44
- .slice(0, GLOB_RESULT_LIMIT)
45
- .map((match) => match.path);
46
- }
47
-
48
- interface GrepParameters {
49
- pattern: string;
50
- path?: string;
51
- glob?: string;
52
- output_mode?: GrepOutputMode;
53
- "-B"?: number;
54
- "-A"?: number;
55
- "-C"?: number;
56
- context?: number;
57
- "-n"?: boolean;
58
- "-i"?: boolean;
59
- type?: string;
60
- head_limit?: number;
61
- offset?: number;
62
- multiline?: boolean;
63
- }
64
-
65
- export function buildGrepArguments(params: GrepParameters, cwd: string): string[] {
66
- const mode = params.output_mode ?? "files_with_matches";
67
- const args = ["--color=never"];
68
- switch (mode) {
69
- case "files_with_matches": {
70
- args.push("--files-with-matches");
71
- break;
72
- }
73
- case "count": {
74
- args.push("--count-matches");
75
- break;
76
- }
77
- case "content": {
78
- args.push("--no-heading", "--with-filename");
79
- if (params["-n"] !== false) args.push("--line-number");
80
- const before = params["-B"];
81
- const after = params["-A"];
82
- const around = params.context ?? params["-C"];
83
- if (around === undefined) {
84
- if (before !== undefined) args.push("--before-context", String(before));
85
- if (after !== undefined) args.push("--after-context", String(after));
86
- } else args.push("--context", String(around));
87
-
88
- break;
89
- }
90
- // No default
91
- }
92
- if (params["-i"] === true) args.push("--ignore-case");
93
- if (params.glob) args.push("--glob", params.glob);
94
- if (params.type) args.push("--type", params.type);
95
- if (params.multiline === true) args.push("--multiline", "--multiline-dotall");
96
- args.push("--", params.pattern, searchRoot(params.path, cwd));
97
- return args;
98
- }
99
-
100
- export function pageGrepOutput(output: string, offset = 0, headLimit = 0): string {
101
- const lines = output ? output.replace(/\n$/, "").split("\n") : [];
102
- if (offset >= lines.length && lines.length > 0) return "No entries at this offset";
103
- const selected = headLimit > 0 ? lines.slice(offset, offset + headLimit) : lines.slice(offset);
104
- return truncateOutput(selected.join("\n"));
105
- }
11
+ import { registerGlobTool } from "./glob.js";
12
+ import { registerGrepTool } from "./grep.js";
106
13
 
107
14
  export function registerSearchTools(pi: ExtensionAPI): void {
108
- pi.registerTool({
109
- name: "Glob",
110
- label: "Glob",
111
- description: [
112
- "Fast file pattern matching tool that works with any codebase size.",
113
- 'Supports glob patterns such as "**/*.js" and "src/**/*.ts".',
114
- "Returns matching file paths sorted by modification time.",
115
- ].join("\n"),
116
- parameters: Type.Object(
117
- {
118
- pattern: Type.String({ description: "The glob pattern to match files against" }),
119
- path: Type.Optional(
120
- Type.String({
121
- description:
122
- "The directory to search in. If omitted, the current working directory is used.",
123
- }),
124
- ),
125
- },
126
- { additionalProperties: false },
127
- ),
128
- async execute(_id, params, signal, _onUpdate, ctx) {
129
- const root = searchRoot(params.path, ctx.cwd);
130
- const matches = await globFiles(params.pattern, root, signal);
131
- return {
132
- content: [
133
- { type: "text", text: matches.length > 0 ? matches.join("\n") : "No files found" },
134
- ],
135
- details: { count: matches.length },
136
- };
137
- },
138
- });
139
-
140
- pi.registerTool({
141
- name: "Grep",
142
- label: "Grep",
143
- description: [
144
- "A powerful search tool built on ripgrep.",
145
- "Supports regular expressions, file globs, file types, multiline matching, context lines, and paginated output.",
146
- 'output_mode defaults to "files_with_matches"; use "content" for matching lines or "count" for match counts.',
147
- ].join("\n"),
148
- parameters: Type.Object(
149
- {
150
- pattern: Type.String({ description: "The regular expression pattern to search for" }),
151
- path: Type.Optional(
152
- Type.String({
153
- description: "File or directory to search. Defaults to the current directory.",
154
- }),
155
- ),
156
- glob: Type.Optional(
157
- Type.String({ description: 'Glob filter such as "*.js" or "*.{ts,tsx}"' }),
158
- ),
159
- output_mode: Type.Optional(
160
- StringEnum(GREP_OUTPUT_MODES, {
161
- description: "Output mode. Defaults to files_with_matches.",
162
- }),
163
- ),
164
- "-B": Type.Optional(
165
- Type.Number({ description: "Lines to show before each match in content mode" }),
166
- ),
167
- "-A": Type.Optional(
168
- Type.Number({ description: "Lines to show after each match in content mode" }),
169
- ),
170
- "-C": Type.Optional(
171
- Type.Number({ description: "Lines to show before and after each match" }),
172
- ),
173
- context: Type.Optional(
174
- Type.Number({ description: "Lines to show before and after each match" }),
175
- ),
176
- "-n": Type.Optional(
177
- Type.Boolean({ description: "Show line numbers in content mode; defaults true" }),
178
- ),
179
- "-i": Type.Optional(Type.Boolean({ description: "Case-insensitive search" })),
180
- type: Type.Optional(
181
- Type.String({ description: "ripgrep file type such as js, py, rust, or go" }),
182
- ),
183
- head_limit: Type.Optional(
184
- Type.Number({ description: "Limit output to the first N entries after offset" }),
185
- ),
186
- offset: Type.Optional(Type.Number({ description: "Skip the first N output entries" })),
187
- multiline: Type.Optional(
188
- Type.Boolean({ description: "Allow patterns to span multiple lines" }),
189
- ),
190
- },
191
- { additionalProperties: false },
192
- ),
193
- async execute(_id, params, signal, _onUpdate, ctx) {
194
- if (
195
- params.offset !== undefined &&
196
- (!Number.isSafeInteger(params.offset) || params.offset < 0)
197
- ) {
198
- throw new Error("offset must be a non-negative integer");
199
- }
200
- if (
201
- params.head_limit !== undefined &&
202
- (!Number.isSafeInteger(params.head_limit) || params.head_limit < 0)
203
- ) {
204
- throw new Error("head_limit must be a non-negative integer");
205
- }
206
- const result = await pi.exec("rg", buildGrepArguments(params, ctx.cwd), { signal });
207
- throwIfAborted(signal);
208
- if (result.code !== 0 && result.code !== 1) {
209
- throw new Error(result.stderr.trim() || `ripgrep exited with code ${result.code}`);
210
- }
211
- if (result.code === 1 || result.stdout === "") {
212
- return { content: [{ type: "text", text: "No files found" }], details: { matches: 0 } };
213
- }
214
- const text = pageGrepOutput(result.stdout, params.offset ?? 0, params.head_limit ?? 0);
215
- return { content: [{ type: "text", text }], details: undefined };
216
- },
217
- });
15
+ registerGlobTool(pi);
16
+ registerGrepTool(pi);
218
17
  }
18
+
19
+ // Re-exported for tests and other modules that import pure functions from
20
+ // "./search.js"; the implementations live in the split files above.
21
+ export { globFiles } from "./glob.js";
22
+ export { buildGrepArguments, pageGrepOutput } from "./grep.js";
@@ -2,6 +2,8 @@ import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
 
5
+ import { selectWithOptionalInput } from "../lib/ui.js";
6
+
5
7
  const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
6
8
  const OTHER_OPTION = "Other";
7
9
  const DONE_OPTION = "Done";
@@ -45,15 +47,17 @@ async function askSingle(
45
47
  signal: AbortSignal | undefined,
46
48
  ): Promise<string> {
47
49
  const title = `${question.header}: ${question.question}`;
48
- const selected = await ctx.ui.select(
50
+ const result = await selectWithOptionalInput(
49
51
  title,
50
- [...question.options.map((option) => option.label), OTHER_OPTION],
52
+ [
53
+ ...question.options.map((option) => ({ label: option.label })),
54
+ { label: OTHER_OPTION, inputPrompt: "Type your answer" },
55
+ ],
56
+ ctx.ui,
51
57
  { signal },
52
58
  );
53
- if (selected === undefined) return "Unanswered";
54
- if (selected !== OTHER_OPTION) return selected;
55
- const answer = await ctx.ui.input(title, "Type your answer", { signal });
56
- return answer?.trim() || "Unanswered";
59
+ if (result === undefined) return "Unanswered";
60
+ return result.prompted ? result.input || "Unanswered" : result.label;
57
61
  }
58
62
 
59
63
  async function askMultiple(
@@ -65,16 +69,22 @@ async function askMultiple(
65
69
  const remaining = new Set(question.options.map((option) => option.label));
66
70
  const selected: string[] = [];
67
71
  while (remaining.size > 0) {
68
- const choice = await ctx.ui.select(title, [...remaining, OTHER_OPTION, DONE_OPTION], {
69
- signal,
70
- });
71
- if (choice === undefined || choice === DONE_OPTION) break;
72
- if (choice === OTHER_OPTION) {
73
- const answer = await ctx.ui.input(title, "Type your answer", { signal });
74
- if (answer?.trim()) selected.push(answer.trim());
72
+ const result = await selectWithOptionalInput(
73
+ title,
74
+ [
75
+ ...[...remaining].map((label) => ({ label })),
76
+ { label: OTHER_OPTION, inputPrompt: "Type your answer" },
77
+ { label: DONE_OPTION },
78
+ ],
79
+ ctx.ui,
80
+ { signal },
81
+ );
82
+ if (result === undefined || result.label === DONE_OPTION) break;
83
+ if (result.prompted) {
84
+ if (result.input) selected.push(result.input);
75
85
  break;
76
86
  }
77
- if (remaining.delete(choice)) selected.push(choice);
87
+ if (remaining.delete(result.label)) selected.push(result.label);
78
88
  }
79
89
  return selected.length > 0 ? selected.join(", ") : "Unanswered";
80
90
  }
@@ -8,6 +8,7 @@ const DEFAULT_TIMEOUT_MS = 120_000;
8
8
  const MAX_TIMEOUT_MS = 600_000;
9
9
 
10
10
  export function registerShellTools(pi: ExtensionAPI): void {
11
+ bwrapRuntime.setup(pi);
11
12
  pi.registerTool({
12
13
  name: "Bash",
13
14
  label: "Bash",
package/src/lib/ui.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shared interactive UI helpers used by multiple extensions.
3
+ */
4
+
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+
7
+ export interface SelectAction {
8
+ label: string;
9
+ /**
10
+ * Set to turn this entry into a free-text input: picking it opens an input
11
+ * dialog instead of returning immediately. The typed text (trimmed) is
12
+ * reported via `SelectActionResult.input`; `undefined` means the input
13
+ * dialog was cancelled, `""` that an empty value was submitted.
14
+ */
15
+ inputPrompt?: string;
16
+ }
17
+
18
+ export interface SelectActionResult {
19
+ label: string;
20
+ /** Whether the picked action carries an inputPrompt (false for plain actions) */
21
+ prompted: boolean;
22
+ /** Meaningful when prompted: undefined=cancelled input, ""=blank submission, otherwise the trimmed text */
23
+ input?: string;
24
+ }
25
+
26
+ /**
27
+ * Combined select + optional input dialog.
28
+ *
29
+ * Shows a selection list built from `actions`. If the user picks an action
30
+ * with `inputPrompt`, an input dialog opens for free-text entry. Returns
31
+ * `undefined` when the selection list is dismissed.
32
+ */
33
+ export async function selectWithOptionalInput(
34
+ title: string,
35
+ actions: readonly SelectAction[],
36
+ ui: ExtensionContext["ui"],
37
+ opts: { signal?: AbortSignal } = {},
38
+ ): Promise<SelectActionResult | undefined> {
39
+ const { signal } = opts;
40
+ const choice = await ui.select(
41
+ title,
42
+ actions.map((action) => action.label),
43
+ { signal },
44
+ );
45
+ if (choice === undefined) return undefined;
46
+ const action = actions.find((candidate) => candidate.label === choice);
47
+ if (action?.inputPrompt === undefined) return { label: choice, prompted: false };
48
+ const answer = await ui.input(title, action.inputPrompt, { signal });
49
+ return {
50
+ label: choice,
51
+ prompted: true,
52
+ input: answer === undefined ? undefined : answer.trim(),
53
+ };
54
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Workspace write guard, embedded directly in each write/edit tool.
3
+ *
4
+ * File-modifying tools gate themselves:
5
+ * - Paths inside the workspace or /tmp are auto-allowed.
6
+ * - Paths outside require user approval via a confirmation dialog showing a
7
+ * `diff` code block preview of the pending change.
8
+ * - Headless sessions (no UI) reject outside writes outright.
9
+ *
10
+ * Callers have already parsed their tool arguments, so the guard only takes the
11
+ * resolved pieces: the raw target path and the pending change (oldText/newText).
12
+ */
13
+
14
+ import { readFile } from "node:fs/promises";
15
+ import { basename, isAbsolute, relative, sep } from "node:path";
16
+
17
+ import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
18
+
19
+ import { normalizeForEdit, replace } from "../opencode/edit-engine.js";
20
+
21
+ const ALWAYS_ALLOW = ["/tmp"];
22
+ const MAX_PREVIEW_LINES = 100;
23
+
24
+ function isInside(dir: string, filePath: string): boolean {
25
+ const rel = relative(dir, filePath);
26
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
27
+ }
28
+
29
+ function isPathAllowed(absolutePath: string, cwd: string): boolean {
30
+ if (isInside(cwd, absolutePath)) return true;
31
+
32
+ for (const allowed of ALWAYS_ALLOW) {
33
+ if (isInside(allowed, absolutePath)) return true;
34
+ }
35
+
36
+ return false;
37
+ }
38
+
39
+ /** Wrap patch text in a `diff` code block, truncating very large diffs. */
40
+ function wrapDiff(patch: string): string {
41
+ const lines = patch.split("\n");
42
+ if (lines.length > MAX_PREVIEW_LINES) {
43
+ const truncated = lines.slice(0, MAX_PREVIEW_LINES).join("\n");
44
+ return `\`\`\`diff\n${truncated}\n… (preview truncated to ${MAX_PREVIEW_LINES} lines)\n\`\`\``;
45
+ }
46
+ return `\`\`\`diff\n${patch}\n\`\`\``;
47
+ }
48
+
49
+ /** The pending file change, described by the caller from already-parsed args. */
50
+ export interface PendingChange {
51
+ /** Text to replace; empty for whole-file writes. */
52
+ oldText: string;
53
+ /** Replacement text. */
54
+ newText: string;
55
+ /** Replace all occurrences of oldText (edits only). */
56
+ replaceAll?: boolean;
57
+ }
58
+
59
+ /**
60
+ * Build a `diff` code block preview of the pending change.
61
+ * Returns undefined when the diff cannot be computed.
62
+ */
63
+ export async function buildDiffPreview(
64
+ resolvedPath: string,
65
+ change: PendingChange,
66
+ ): Promise<string | undefined> {
67
+ let oldContent = "";
68
+ try {
69
+ oldContent = await readFile(resolvedPath, "utf8");
70
+ } catch {
71
+ // Unreadable or missing file: treat as empty so writes show as full additions.
72
+ }
73
+
74
+ if (change.oldText === "") {
75
+ // Whole-file write: show the full addition/replacement patch.
76
+ return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, change.newText, 2));
77
+ }
78
+
79
+ // Edit: reuse the real matching engine to locate oldText, giving a
80
+ // line-numbered patch when it matches. Fall back to a parameter diff when the
81
+ // edit cannot be applied (oldText not found, ambiguous, or no file).
82
+ try {
83
+ const normalized = normalizeForEdit(oldContent);
84
+ const newContent = replace(normalized, change.oldText, change.newText, change.replaceAll);
85
+ // The full path is shown in the dialog title, so the patch header only
86
+ // carries the file name.
87
+ return wrapDiff(generateUnifiedPatch(basename(resolvedPath), normalized, newContent, 2));
88
+ } catch {
89
+ const removed = change.oldText.split("\n").map((line) => `-${line}`);
90
+ const added = change.newText.split("\n").map((line) => `+${line}`);
91
+ return wrapDiff([...removed, ...added].join("\n"));
92
+ }
93
+ }
94
+
95
+ export interface WriteGuardContext {
96
+ cwd: string;
97
+ hasUI: boolean;
98
+ abort?: () => void;
99
+ ui?: {
100
+ select: (title: string, options: string[]) => Promise<string | undefined>;
101
+ input: (title: string, placeholder?: string) => Promise<string | undefined>;
102
+ };
103
+ }
104
+
105
+ export interface WriteGuardOptions {
106
+ toolName: string;
107
+ /** The resolved absolute target path (caller has already parsed its args). */
108
+ absolutePath: string;
109
+ change: PendingChange;
110
+ }
111
+
112
+ /**
113
+ * Gate a write/edit call by its target path: auto-allows workspace and /tmp
114
+ * writes, otherwise asks for user approval (or rejects in headless sessions).
115
+ * Throws when the write is denied.
116
+ */
117
+ export async function guardWriteAccess(
118
+ ctx: WriteGuardContext | undefined,
119
+ opts: WriteGuardOptions,
120
+ ): Promise<void> {
121
+ if (!ctx) return;
122
+ const { absolutePath } = opts;
123
+ if (isPathAllowed(absolutePath, ctx.cwd)) return;
124
+
125
+ if (!ctx.hasUI || !ctx.ui) {
126
+ throw new Error(`Path "${absolutePath}" is outside workspace. No UI available for approval.`);
127
+ }
128
+
129
+ while (true) {
130
+ const diffPreview = await buildDiffPreview(absolutePath, opts.change);
131
+ const title =
132
+ `Model requests write access outside workspace:\n\n` +
133
+ ` Tool: ${opts.toolName}\n` +
134
+ ` Path: ${absolutePath}\n` +
135
+ (diffPreview ? `\n${diffPreview}\n` : "") +
136
+ `\nAllow?`;
137
+
138
+ const choice = await ctx.ui.select(title, ["Approve once", "Block", "Block with reason"]);
139
+ if (choice === undefined) {
140
+ ctx.abort?.();
141
+ throw new Error("Write outside workspace cancelled by user.");
142
+ }
143
+ if (choice === "Approve once") return;
144
+ if (choice === "Block") throw new Error("Write outside workspace denied by user.");
145
+ const feedback = await ctx.ui.input("Why was this write denied?");
146
+ if (feedback === undefined) continue;
147
+ throw new Error(
148
+ feedback
149
+ ? `Write outside workspace denied: ${feedback}`
150
+ : "Write outside workspace denied by user.",
151
+ );
152
+ }
153
+ }
@@ -8,6 +8,7 @@ const DEFAULT_TIMEOUT_MS = 120_000;
8
8
  const MAX_TIMEOUT_MS = 600_000;
9
9
 
10
10
  export default function opencodeBash(pi: ExtensionAPI): void {
11
+ bwrapRuntime.setup(pi);
11
12
  pi.registerTool({
12
13
  name: "bash",
13
14
  label: "bash",
@@ -27,7 +28,10 @@ export default function opencodeBash(pi: ExtensionAPI): void {
27
28
  }),
28
29
  ),
29
30
  timeout: Type.Optional(
30
- Type.Number({ description: "Optional timeout in milliseconds (max 600000)" }),
31
+ Type.Number({
32
+ description: "Optional timeout in milliseconds (max 600000)",
33
+ default: 600,
34
+ }),
31
35
  ),
32
36
  dangerouslyDisableSandbox: Type.Optional(
33
37
  Type.Boolean({
@@ -7,8 +7,8 @@
7
7
  * and wrapped in a pi extension so the behaviour is identical to opencode.
8
8
  *
9
9
  * Shared by:
10
- * - opencode-edit.ts — the edit tool implementation
11
- * - workspace-guard.ts — the diff preview shown in the approval dialog
10
+ * - opencode-edit.ts — the edit tool implementation
11
+ * - lib/write-guard.ts — the diff preview shown in the approval dialog
12
12
  */
13
13
 
14
14
  // ── BOM & line ending helpers ─────────────────────────────────────────────────
@@ -11,7 +11,7 @@
11
11
  * formatter run.
12
12
  *
13
13
  * The matching engine (replacers + replace()) lives in opencode/edit-engine.ts
14
- * and is also used by workspace-guard for the diff preview.
14
+ * and is also used by lib/write-guard for the diff preview.
15
15
  *
16
16
  * Usage:
17
17
  * pi -e ./opencode-edit.ts
@@ -29,6 +29,7 @@ import {
29
29
  } from "@earendil-works/pi-coding-agent";
30
30
  import { Type } from "typebox";
31
31
 
32
+ import { guardWriteAccess } from "../lib/write-guard.js";
32
33
  import {
33
34
  detectLineEnding,
34
35
  normalizeToLF,
@@ -79,6 +80,12 @@ export default function opencodeEdit(pi: ExtensionAPI) {
79
80
 
80
81
  const absolutePath = isAbsolute(filePath) ? filePath : resolve(ctx.cwd, filePath);
81
82
 
83
+ await guardWriteAccess(ctx, {
84
+ toolName: "edit",
85
+ absolutePath,
86
+ change: { oldText: oldString, newText: newString, replaceAll },
87
+ });
88
+
82
89
  const throwIfAborted = (): void => {
83
90
  if (signal?.aborted) throw new Error("Operation aborted");
84
91
  };
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 聚合 read / edit / write / todo / question / bash 六个工具,一次加载全部注册;
5
5
  * 各工具的公开 API(匹配引擎、纯函数等)也从这里重新导出,方便
6
- * 测试与其他模块(如 workspace-guard)引用。
6
+ * 测试与其他模块(如 lib/write-guard)引用。
7
7
  *
8
8
  * Usage:
9
9
  * pi -e ./opencode/index.ts
@@ -23,6 +23,8 @@
23
23
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
25
 
26
+ import { selectWithOptionalInput } from "../lib/ui.js";
27
+
26
28
  // ── constants ────────────────────────────────────────────────────────────────
27
29
 
28
30
  export const TOOL_NAME = "question";
@@ -117,14 +119,16 @@ function dialogTitle(q: Question): string {
117
119
 
118
120
  async function askSingle(q: Question, ctx: ExtensionContext): Promise<Answer> {
119
121
  const title = dialogTitle(q);
120
- const options = [...q.options.map((o) => o.label), CUSTOM_LABEL];
121
- const choice = await ctx.ui.select(title, options);
122
- if (choice === undefined) return [];
123
- if (choice === CUSTOM_LABEL) {
124
- const typed = await ctx.ui.input(title, "Type your answer…");
125
- return typed?.trim() ? [typed.trim()] : [];
126
- }
127
- return [choice];
122
+ const result = await selectWithOptionalInput(
123
+ title,
124
+ [
125
+ ...q.options.map((o) => ({ label: o.label })),
126
+ { label: CUSTOM_LABEL, inputPrompt: "Type your answer…" },
127
+ ],
128
+ ctx.ui,
129
+ );
130
+ if (result === undefined) return [];
131
+ return result.prompted ? (result.input ? [result.input] : []) : [result.label];
128
132
  }
129
133
 
130
134
  async function askMultiple(q: Question, ctx: ExtensionContext): Promise<Answer> {
@@ -28,6 +28,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
28
28
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
29
29
  import { Type } from "typebox";
30
30
 
31
+ import { guardWriteAccess } from "../lib/write-guard.js";
31
32
  import { stripBom } from "./edit-engine.js";
32
33
 
33
34
  /**
@@ -69,6 +70,11 @@ export default function opencodeWrite(pi: ExtensionAPI) {
69
70
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
70
71
  const { filePath: rawPath, content } = params;
71
72
  const absolutePath = resolvePath(ctx.cwd, rawPath);
73
+ await guardWriteAccess(ctx, {
74
+ toolName: "write",
75
+ absolutePath,
76
+ change: { oldText: "", newText: content },
77
+ });
72
78
  const dir = dirname(absolutePath);
73
79
 
74
80
  const throwIfAborted = () => {