@trim21/personal-pi-extensions 0.0.216 → 0.0.222

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.
@@ -0,0 +1,296 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { createBashTool } from "@earendil-works/pi-coding-agent";
7
+ import { type TObject, Type } from "typebox";
8
+
9
+ import { type CommandSpec, parseCommand } from "../lib/cli.js";
10
+ import {
11
+ type BwrapMode,
12
+ createBwrapBashOperations,
13
+ findBwrap,
14
+ loadBwrapConfig,
15
+ resolveBwrap,
16
+ resolveBwrapPath,
17
+ type ResolvedBwrap,
18
+ resolveHeadlessBwrap,
19
+ } from "./core.js";
20
+
21
+ export type EscalationDecision = { kind: "dialog" } | { kind: "deny"; reason: string };
22
+
23
+ export function resolveEscalation(opts: { hasUI: boolean }): EscalationDecision {
24
+ if (!opts.hasUI) {
25
+ return {
26
+ kind: "deny",
27
+ reason:
28
+ "request_full_access requires an interactive session with user approval; no UI is available in this session.",
29
+ };
30
+ }
31
+ return { kind: "dialog" };
32
+ }
33
+
34
+ export interface BwrapExecutionRequest {
35
+ toolCallId: string;
36
+ command: string;
37
+ timeout?: number;
38
+ requestFullAccess?: boolean;
39
+ requestFullAccessReason?: string;
40
+ signal?: AbortSignal;
41
+ onUpdate?: Parameters<ReturnType<typeof createBashTool>["execute"]>[3];
42
+ ctx: ExtensionContext;
43
+ }
44
+
45
+ function escapeHtml(text: string): string {
46
+ return text
47
+ .replaceAll("&", "&amp;")
48
+ .replaceAll("<", "&lt;")
49
+ .replaceAll(">", "&gt;")
50
+ .replaceAll('"', "&quot;");
51
+ }
52
+
53
+ function fenceCodeBlock(code: string): string {
54
+ const longestRun = Math.max(...(code.match(/`+/g)?.map((match) => match.length) ?? [0]));
55
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
56
+ return `${fence}\n${code}\n${fence}`;
57
+ }
58
+
59
+ function notifyMode(
60
+ ctx: { ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } },
61
+ mode: BwrapMode,
62
+ ): void {
63
+ const labels: Record<BwrapMode, string> = {
64
+ "allow-all": "allow-all: sandbox off, network on",
65
+ "workspace-write": "workspace-write: sandbox on, network off",
66
+ "allow-net": "allow-net: sandbox on, network on, workspace writable",
67
+ readonly: "readonly: sandbox on, network off, read-only fs",
68
+ };
69
+ ctx.ui.notify(labels[mode], "info");
70
+ }
71
+
72
+ export class BwrapRuntime {
73
+ private resolved: ResolvedBwrap | undefined;
74
+ private sandboxDisabled = false;
75
+
76
+ setup(pi: ExtensionAPI): void {
77
+ pi.registerFlag("no-bwrap", {
78
+ description: "Disable bwrap sandboxing for bash commands",
79
+ type: "boolean",
80
+ default: false,
81
+ });
82
+
83
+ pi.on("session_start", (_event, ctx) => {
84
+ this.sandboxDisabled = pi.getFlag("no-bwrap") === true && ctx.hasUI;
85
+ this.resolved = undefined;
86
+ const runtime = this.resolve(ctx);
87
+ if (runtime.bwrapEnabled) {
88
+ try {
89
+ findBwrap(runtime.bwrapPath);
90
+ } catch (error) {
91
+ this.sandboxDisabled = true;
92
+ this.resolved = undefined;
93
+ ctx.ui.notify(error instanceof Error ? error.message : "bwrap not found", "error");
94
+ return;
95
+ }
96
+ }
97
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${runtime.mode}`));
98
+ ctx.ui.notify(
99
+ runtime.bwrapEnabled
100
+ ? `bwrap initialized (${runtime.mode})`
101
+ : `bwrap mode: ${runtime.mode}`,
102
+ "info",
103
+ );
104
+ });
105
+
106
+ pi.on("session_shutdown", () => {
107
+ this.reset();
108
+ });
109
+
110
+ pi.on("before_agent_start", (event, ctx) => {
111
+ const runtime = this.resolve(ctx);
112
+ const prompt = ctx.hasUI
113
+ ? `\n\n## Command Execution\nCurrent bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.\n`
114
+ : "\n\n## Command Execution\nThis headless session is forced into bwrap readonly mode. Unsandboxed execution cannot be approved.\n";
115
+ return { systemPrompt: event.systemPrompt + prompt };
116
+ });
117
+
118
+ this.registerCommands(pi);
119
+ }
120
+
121
+ setMode(cwd: string, mode: BwrapMode): ResolvedBwrap {
122
+ this.resolved = resolveBwrap({ ...loadBwrapConfig(cwd), mode });
123
+ this.sandboxDisabled = false;
124
+ return this.resolved;
125
+ }
126
+
127
+ reset(): void {
128
+ this.resolved = undefined;
129
+ this.sandboxDisabled = false;
130
+ }
131
+
132
+ async execute(request: BwrapExecutionRequest) {
133
+ const runtime = this.resolve(request.ctx);
134
+ if (request.requestFullAccess === true && runtime.bwrapEnabled) {
135
+ await this.approveFullAccess(request.ctx, request.command, request.requestFullAccessReason);
136
+ }
137
+ const bash =
138
+ runtime.bwrapEnabled && request.requestFullAccess !== true
139
+ ? createBashTool(request.ctx.cwd, { operations: createBwrapBashOperations(runtime) })
140
+ : createBashTool(request.ctx.cwd);
141
+ return bash.execute(
142
+ request.toolCallId,
143
+ { command: request.command, timeout: request.timeout },
144
+ request.signal,
145
+ request.onUpdate,
146
+ );
147
+ }
148
+
149
+ private resolve(ctx: Pick<ExtensionContext, "cwd" | "hasUI">): ResolvedBwrap {
150
+ const config = loadBwrapConfig(ctx.cwd);
151
+ if (!ctx.hasUI) return resolveHeadlessBwrap(config);
152
+ if (this.sandboxDisabled) return resolveBwrap({ ...config, mode: "allow-all" });
153
+ if (!this.resolved) this.resolved = resolveBwrap(config);
154
+ return this.resolved;
155
+ }
156
+
157
+ private async approveFullAccess(
158
+ ctx: ExtensionContext,
159
+ command: string,
160
+ reason: string | undefined,
161
+ ): Promise<void> {
162
+ const policy = resolveEscalation({ hasUI: ctx.hasUI });
163
+ if (policy.kind === "deny") throw new Error(policy.reason);
164
+ const description = `Allow this command to run without sandbox?\n---\n\nReason: ${escapeHtml(reason ?? "(No reason provided by model)")}\n---\n${fenceCodeBlock(command)}`;
165
+ while (true) {
166
+ const choice = await ctx.ui.select(
167
+ description,
168
+ ["Approve once", "Block", "Block with reason"],
169
+ {
170
+ signal: ctx.signal,
171
+ },
172
+ );
173
+ if (choice === undefined) {
174
+ ctx.abort();
175
+ throw new Error("User denied the command execution.");
176
+ }
177
+ if (choice === "Approve once") return;
178
+ if (choice === "Block") throw new Error("User denied unsandboxed execution.");
179
+ const feedback = await ctx.ui.input("Why was this denied?", undefined, {
180
+ signal: ctx.signal,
181
+ });
182
+ if (feedback === undefined) continue;
183
+ throw new Error(
184
+ feedback
185
+ ? `User denied unsandboxed execution: ${feedback}`
186
+ : "User denied unsandboxed execution.",
187
+ );
188
+ }
189
+ }
190
+
191
+ private registerCommands(pi: ExtensionAPI): void {
192
+ const specs = {
193
+ bwrap: {
194
+ name: "bwrap",
195
+ usage: "",
196
+ description: "Show bwrap sandbox configuration",
197
+ flags: Type.Object({}),
198
+ },
199
+ "bwrap-allow-all": {
200
+ name: "bwrap-allow-all",
201
+ usage: "",
202
+ description: "Disable bwrap sandbox, full access",
203
+ flags: Type.Object({}),
204
+ },
205
+ "bwrap-workspace-write": {
206
+ name: "bwrap-workspace-write",
207
+ usage: "",
208
+ description: "Sandbox on, network off, workspace writable",
209
+ flags: Type.Object({}),
210
+ },
211
+ "bwrap-allow-net": {
212
+ name: "bwrap-allow-net",
213
+ usage: "",
214
+ description: "Sandbox on, network on, workspace writable",
215
+ flags: Type.Object({}),
216
+ },
217
+ "bwrap-readonly": {
218
+ name: "bwrap-readonly",
219
+ usage: "",
220
+ description: "Sandbox on, network off, no writes",
221
+ flags: Type.Object({}),
222
+ },
223
+ } as const satisfies Record<string, CommandSpec<TObject>>;
224
+
225
+ pi.registerCommand("bwrap", {
226
+ description: specs.bwrap.description,
227
+ handler: (args, ctx) =>
228
+ this.runCommand(pi, specs.bwrap, args, ctx, (commandCtx) => {
229
+ const runtime = this.resolve(commandCtx);
230
+ if (!runtime.bwrapEnabled) {
231
+ commandCtx.ui.notify(`bwrap disabled (mode: ${runtime.mode})`, "info");
232
+ return;
233
+ }
234
+ const writable = runtime.writablePaths.map((path) =>
235
+ resolveBwrapPath(path, commandCtx.cwd),
236
+ );
237
+ const tmpfs = runtime.tmpfsPaths.map((path) => resolveBwrapPath(path, commandCtx.cwd));
238
+ commandCtx.ui.notify(
239
+ `bwrap ${runtime.mode} ${runtime.network ? "net" : "no-net"} write:[${writable.join(", ")}] tmpfs:[${tmpfs.join(", ") || "-"}]`,
240
+ "info",
241
+ );
242
+ }),
243
+ });
244
+
245
+ for (const [name, mode] of [
246
+ ["bwrap-allow-all", "allow-all"],
247
+ ["bwrap-workspace-write", "workspace-write"],
248
+ ["bwrap-allow-net", "allow-net"],
249
+ ["bwrap-readonly", "readonly"],
250
+ ] as const) {
251
+ pi.registerCommand(name, {
252
+ description: specs[name].description,
253
+ handler: (args, ctx) =>
254
+ this.runCommand(pi, specs[name], args, ctx, (commandCtx) =>
255
+ this.switchMode(pi, mode, commandCtx),
256
+ ),
257
+ });
258
+ }
259
+ }
260
+
261
+ private switchMode(pi: ExtensionAPI, mode: BwrapMode, ctx: ExtensionCommandContext): void {
262
+ if (!ctx.hasUI) {
263
+ ctx.ui.notify("bwrap mode cannot be changed without an interactive UI", "warning");
264
+ return;
265
+ }
266
+ this.setMode(ctx.cwd, mode);
267
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${mode}`));
268
+ notifyMode(ctx, mode);
269
+ pi.sendMessage({
270
+ customType: "info",
271
+ content: `Bwrap sandbox mode changed to "${mode}".`,
272
+ display: true,
273
+ });
274
+ }
275
+
276
+ private runCommand(
277
+ pi: ExtensionAPI,
278
+ spec: CommandSpec<TObject>,
279
+ args: string,
280
+ ctx: ExtensionCommandContext,
281
+ run: (ctx: ExtensionCommandContext) => void | Promise<void>,
282
+ ): Promise<void> {
283
+ const parsed = parseCommand(spec, args);
284
+ if (parsed.kind !== "ok") {
285
+ pi.sendMessage({ customType: "info", content: parsed.text, display: true });
286
+ return Promise.resolve();
287
+ }
288
+ return Promise.resolve(run(ctx));
289
+ }
290
+ }
291
+
292
+ export function createBwrapRuntime(): BwrapRuntime {
293
+ return new BwrapRuntime();
294
+ }
295
+
296
+ export const bwrapRuntime = createBwrapRuntime();
@@ -0,0 +1,29 @@
1
+ import { isAbsolute, normalize } from "node:path";
2
+
3
+ export interface FileSnapshot {
4
+ digest: string;
5
+ textEditable: boolean;
6
+ }
7
+
8
+ export interface ClaudeCodeState {
9
+ readonly reads: Map<string, FileSnapshot>;
10
+ }
11
+
12
+ export function createClaudeCodeState(): ClaudeCodeState {
13
+ return { reads: new Map() };
14
+ }
15
+
16
+ export function requireAbsolutePath(filePath: string, parameter = "file_path"): string {
17
+ if (!isAbsolute(filePath)) {
18
+ throw new Error(`The ${parameter} parameter must be an absolute path, not a relative path.`);
19
+ }
20
+ return normalize(filePath);
21
+ }
22
+
23
+ export function throwIfAborted(signal: AbortSignal | undefined): void {
24
+ if (signal?.aborted) throw new Error("Operation aborted");
25
+ }
26
+
27
+ export function snapshotsEqual(left: FileSnapshot, right: FileSnapshot): boolean {
28
+ return left.digest === right.digest;
29
+ }
@@ -0,0 +1,320 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
+ import { dirname, extname } from "node:path";
5
+
6
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ generateDiffString,
10
+ generateUnifiedPatch,
11
+ withFileMutationQueue,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import { Type } from "typebox";
14
+
15
+ import {
16
+ type ClaudeCodeState,
17
+ type FileSnapshot,
18
+ requireAbsolutePath,
19
+ snapshotsEqual,
20
+ throwIfAborted,
21
+ } from "./common.js";
22
+
23
+ const DEFAULT_READ_LINES = 2000;
24
+ const SAMPLE_BYTES = 4096;
25
+ const IMAGE_MIMES = new Map<string, string>([
26
+ [".gif", "image/gif"],
27
+ [".jpeg", "image/jpeg"],
28
+ [".jpg", "image/jpeg"],
29
+ [".png", "image/png"],
30
+ [".webp", "image/webp"],
31
+ ]);
32
+
33
+ export interface FileToolDetails {
34
+ diff?: string;
35
+ patch?: string;
36
+ firstChangedLine?: number;
37
+ }
38
+
39
+ function snapshotOf(content: Uint8Array | string, textEditable = true): FileSnapshot {
40
+ return { digest: createHash("sha256").update(content).digest("hex"), textEditable };
41
+ }
42
+
43
+ async function assertReadableFile(filePath: string): Promise<Awaited<ReturnType<typeof stat>>> {
44
+ const value = await stat(filePath);
45
+ if (!value.isFile()) throw new Error(`File not found: ${filePath}`);
46
+ await access(filePath, constants.R_OK);
47
+ return value;
48
+ }
49
+
50
+ function isBinary(sample: Uint8Array): boolean {
51
+ let suspicious = 0;
52
+ for (const byte of sample) {
53
+ if (byte === 0) return true;
54
+ if (byte < 9 || (byte > 13 && byte < 32)) suspicious++;
55
+ }
56
+ return sample.length > 0 && suspicious / sample.length > 0.3;
57
+ }
58
+
59
+ function splitFileLines(content: string): string[] {
60
+ if (content === "") return [];
61
+ const lines = content.split("\n");
62
+ if (content.endsWith("\n")) lines.pop();
63
+ return lines;
64
+ }
65
+
66
+ export function formatReadOutput(
67
+ content: string,
68
+ offset = 1,
69
+ limit = DEFAULT_READ_LINES,
70
+ ): { text: string; complete: boolean; totalLines: number } {
71
+ const lines = splitFileLines(content);
72
+ const totalLines = lines.length;
73
+ if (totalLines === 0) {
74
+ if (offset > 1)
75
+ return {
76
+ text: `<system-reminder>Warning: the file exists but has fewer lines than the provided offset (${offset}). The file has 0 lines.</system-reminder>`,
77
+ complete: false,
78
+ totalLines,
79
+ };
80
+ return {
81
+ text: "<system-reminder>Warning: the file exists but has empty contents.</system-reminder>",
82
+ complete: true,
83
+ totalLines,
84
+ };
85
+ }
86
+ if (offset > totalLines) {
87
+ return {
88
+ text: `<system-reminder>Warning: the file exists but has fewer lines than the provided offset (${offset}). The file has ${totalLines} lines.</system-reminder>`,
89
+ complete: false,
90
+ totalLines,
91
+ };
92
+ }
93
+
94
+ const selected = lines.slice(offset - 1, offset - 1 + limit);
95
+ const numbered = selected
96
+ .map(
97
+ (line, index) =>
98
+ `${String(offset + index).padStart(6)}\t${line.length > 2000 ? line.slice(0, 2000) : line}`,
99
+ )
100
+ .join("\n");
101
+ const complete = offset === 1 && selected.length === totalLines;
102
+ const hasMore = offset - 1 + selected.length < totalLines;
103
+ const notice = hasMore
104
+ ? `\n\n<system-reminder>PARTIAL view: showing lines ${offset}-${offset + selected.length - 1} of ${totalLines}. Use offset and limit to read more.</system-reminder>`
105
+ : "";
106
+ return { text: numbered + notice, complete, totalLines };
107
+ }
108
+
109
+ function countMatches(content: string, needle: string): number {
110
+ if (needle === "") return 0;
111
+ let count = 0;
112
+ let index = 0;
113
+ while ((index = content.indexOf(needle, index)) !== -1) {
114
+ count++;
115
+ index += needle.length;
116
+ }
117
+ return count;
118
+ }
119
+
120
+ export function exactReplace(
121
+ content: string,
122
+ oldString: string,
123
+ newString: string,
124
+ replaceAll = false,
125
+ ): string {
126
+ if (oldString === newString)
127
+ throw new Error("No changes to apply: old_string and new_string are identical.");
128
+ if (oldString === "") throw new Error("old_string must not be empty.");
129
+ const matches = countMatches(content, oldString);
130
+ if (matches === 0) throw new Error("String to replace not found in file.");
131
+ if (!replaceAll && matches > 1) {
132
+ throw new Error(
133
+ `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, provide more context to make old_string unique.`,
134
+ );
135
+ }
136
+ if (replaceAll) return content.split(oldString).join(newString);
137
+ const index = content.indexOf(oldString);
138
+ return content.slice(0, index) + newString + content.slice(index + oldString.length);
139
+ }
140
+
141
+ async function requireCurrentRead(state: ClaudeCodeState, filePath: string): Promise<void> {
142
+ const readSnapshot = state.reads.get(filePath);
143
+ if (!readSnapshot)
144
+ throw new Error(`File has not been read yet. Read it first before writing to it: ${filePath}`);
145
+ if (!readSnapshot.textEditable) {
146
+ throw new Error(`Cannot edit or overwrite a binary file with a text tool: ${filePath}`);
147
+ }
148
+ const currentContent = await readFile(filePath);
149
+ const current = snapshotOf(currentContent);
150
+ if (!snapshotsEqual(readSnapshot, current)) {
151
+ throw new Error(`File has been modified since read. Read it again before writing: ${filePath}`);
152
+ }
153
+ }
154
+
155
+ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): void {
156
+ pi.registerTool({
157
+ name: "Read",
158
+ label: "Read",
159
+ description: [
160
+ "Reads a file from the local filesystem. You can access any file directly using this tool.",
161
+ "The file_path parameter must be an absolute path. By default, it reads up to 2000 lines from the beginning.",
162
+ "Results use cat -n style line numbers starting at 1. Images are returned visually.",
163
+ "This tool reads files, not directories.",
164
+ ].join("\n"),
165
+ parameters: Type.Object(
166
+ {
167
+ file_path: Type.String({ description: "The absolute path to the file to read" }),
168
+ offset: Type.Optional(
169
+ Type.Number({ description: "The line number to start reading from" }),
170
+ ),
171
+ limit: Type.Optional(Type.Number({ description: "The number of lines to read" })),
172
+ pages: Type.Optional(
173
+ Type.String({ description: 'Page range for PDF files (for example "1-5" or "3")' }),
174
+ ),
175
+ },
176
+ { additionalProperties: false },
177
+ ),
178
+ async execute(_id, params, signal) {
179
+ throwIfAborted(signal);
180
+ const filePath = requireAbsolutePath(params.file_path);
181
+ if (
182
+ params.offset !== undefined &&
183
+ (!Number.isSafeInteger(params.offset) || params.offset < 1)
184
+ ) {
185
+ throw new Error("offset must be a positive integer");
186
+ }
187
+ if (params.limit !== undefined && (!Number.isSafeInteger(params.limit) || params.limit < 1)) {
188
+ throw new Error("limit must be a positive integer");
189
+ }
190
+ await assertReadableFile(filePath);
191
+ throwIfAborted(signal);
192
+
193
+ const extension = extname(filePath).toLowerCase();
194
+ if (extension === ".pdf") {
195
+ throw new Error(
196
+ "PDF page rendering is not supported by this pi tool host. Use an external PDF extraction tool before calling Read.",
197
+ );
198
+ }
199
+ const imageMime = IMAGE_MIMES.get(extension);
200
+ if (imageMime) {
201
+ const image = await readFile(filePath);
202
+ const data = image.toString("base64");
203
+ const content: (TextContent | ImageContent)[] = [
204
+ { type: "text", text: `Read image file [${imageMime}]` },
205
+ { type: "image", data, mimeType: imageMime },
206
+ ];
207
+ state.reads.set(filePath, snapshotOf(image, false));
208
+ return { content, details: undefined };
209
+ }
210
+
211
+ const buffer = await readFile(filePath);
212
+ if (isBinary(buffer.subarray(0, SAMPLE_BYTES)))
213
+ throw new Error(`Cannot read binary file: ${filePath}`);
214
+ const formatted = formatReadOutput(
215
+ buffer.toString("utf8"),
216
+ params.offset ?? 1,
217
+ params.limit ?? DEFAULT_READ_LINES,
218
+ );
219
+ state.reads.set(filePath, snapshotOf(buffer));
220
+ return { content: [{ type: "text", text: formatted.text }], details: undefined };
221
+ },
222
+ });
223
+
224
+ pi.registerTool({
225
+ name: "Edit",
226
+ label: "Edit",
227
+ description: [
228
+ "Performs exact string replacements in files.",
229
+ "You must use Read on the file before editing it.",
230
+ "old_string must match exactly and must be unique unless replace_all is true.",
231
+ "This tool does not use regular expressions or fuzzy matching.",
232
+ ].join("\n"),
233
+ parameters: Type.Object(
234
+ {
235
+ file_path: Type.String({ description: "The absolute path to the file to modify" }),
236
+ old_string: Type.String({ description: "The text to replace" }),
237
+ new_string: Type.String({ description: "The text to replace it with" }),
238
+ replace_all: Type.Optional(
239
+ Type.Boolean({ description: "Replace all occurrences of old_string", default: false }),
240
+ ),
241
+ },
242
+ { additionalProperties: false },
243
+ ),
244
+ async execute(_id, params, signal) {
245
+ throwIfAborted(signal);
246
+ const filePath = requireAbsolutePath(params.file_path);
247
+ return withFileMutationQueue(filePath, async () => {
248
+ await requireCurrentRead(state, filePath);
249
+ await access(filePath, constants.R_OK | constants.W_OK);
250
+ const original = await readFile(filePath, "utf8");
251
+ throwIfAborted(signal);
252
+ const updated = exactReplace(
253
+ original,
254
+ params.old_string,
255
+ params.new_string,
256
+ params.replace_all ?? false,
257
+ );
258
+ await writeFile(filePath, updated, "utf8");
259
+ state.reads.set(filePath, snapshotOf(updated));
260
+ const diff = generateDiffString(original, updated);
261
+ return {
262
+ content: [{ type: "text", text: `The file ${filePath} has been updated successfully.` }],
263
+ details: {
264
+ diff: diff.diff,
265
+ patch: generateUnifiedPatch(filePath, original, updated),
266
+ firstChangedLine: diff.firstChangedLine,
267
+ } satisfies FileToolDetails,
268
+ };
269
+ });
270
+ },
271
+ });
272
+
273
+ pi.registerTool({
274
+ name: "Write",
275
+ label: "Write",
276
+ description: [
277
+ "Writes a file to the local filesystem.",
278
+ "This tool overwrites an existing file with the full content provided.",
279
+ "If the file exists, you must use Read first. Prefer Edit for partial changes.",
280
+ ].join("\n"),
281
+ parameters: Type.Object(
282
+ {
283
+ file_path: Type.String({
284
+ description: "The absolute path to the file to write (must be absolute, not relative)",
285
+ }),
286
+ content: Type.String({ description: "The content to write to the file" }),
287
+ },
288
+ { additionalProperties: false },
289
+ ),
290
+ async execute(_id, params, signal) {
291
+ throwIfAborted(signal);
292
+ const filePath = requireAbsolutePath(params.file_path);
293
+ return withFileMutationQueue(filePath, async () => {
294
+ let original: string | undefined;
295
+ try {
296
+ const value = await stat(filePath);
297
+ if (value.isFile()) {
298
+ await requireCurrentRead(state, filePath);
299
+ original = await readFile(filePath, "utf8");
300
+ }
301
+ } catch (error) {
302
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
303
+ }
304
+ throwIfAborted(signal);
305
+ await mkdir(dirname(filePath), { recursive: true });
306
+ await writeFile(filePath, params.content, "utf8");
307
+ state.reads.set(filePath, snapshotOf(params.content));
308
+ const diff = generateDiffString(original ?? "", params.content);
309
+ return {
310
+ content: [{ type: "text", text: `File created successfully at: ${filePath}` }],
311
+ details: {
312
+ diff: diff.diff,
313
+ patch: generateUnifiedPatch(filePath, original ?? "", params.content),
314
+ firstChangedLine: diff.firstChangedLine,
315
+ } satisfies FileToolDetails,
316
+ };
317
+ });
318
+ },
319
+ });
320
+ }
@@ -0,0 +1,15 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { createClaudeCodeState } from "./common.js";
4
+ import { registerFileTools } from "./files.js";
5
+ import { registerSearchTools } from "./search.js";
6
+ import { registerSessionTools } from "./session-tools.js";
7
+ import { registerShellTools } from "./shell.js";
8
+
9
+ export default function claudeCodeTools(pi: ExtensionAPI): void {
10
+ const state = createClaudeCodeState();
11
+ registerFileTools(pi, state);
12
+ registerSearchTools(pi);
13
+ registerShellTools(pi);
14
+ registerSessionTools(pi);
15
+ }