@cortexkit/aft-opencode 0.2.0 → 0.3.0

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.
Files changed (50) hide show
  1. package/dist/config.d.ts +1 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +6 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +4 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +21 -3
  8. package/dist/index.js.map +1 -1
  9. package/dist/metadata-store.d.ts +29 -0
  10. package/dist/metadata-store.d.ts.map +1 -0
  11. package/dist/metadata-store.js +53 -0
  12. package/dist/metadata-store.js.map +1 -0
  13. package/dist/patch-parser.d.ts +33 -0
  14. package/dist/patch-parser.d.ts.map +1 -0
  15. package/dist/patch-parser.js +237 -0
  16. package/dist/patch-parser.js.map +1 -0
  17. package/dist/tools/ast.d.ts.map +1 -1
  18. package/dist/tools/ast.js +159 -62
  19. package/dist/tools/ast.js.map +1 -1
  20. package/dist/tools/editing.d.ts +3 -2
  21. package/dist/tools/editing.d.ts.map +1 -1
  22. package/dist/tools/editing.js +4 -146
  23. package/dist/tools/editing.js.map +1 -1
  24. package/dist/tools/hoisted.d.ts +26 -0
  25. package/dist/tools/hoisted.d.ts.map +1 -0
  26. package/dist/tools/hoisted.js +749 -0
  27. package/dist/tools/hoisted.js.map +1 -0
  28. package/dist/tools/imports.d.ts.map +1 -1
  29. package/dist/tools/imports.js +15 -5
  30. package/dist/tools/imports.js.map +1 -1
  31. package/dist/tools/lsp.d.ts.map +1 -1
  32. package/dist/tools/lsp.js +25 -108
  33. package/dist/tools/lsp.js.map +1 -1
  34. package/dist/tools/navigation.d.ts.map +1 -1
  35. package/dist/tools/navigation.js +9 -3
  36. package/dist/tools/navigation.js.map +1 -1
  37. package/dist/tools/reading.d.ts +2 -1
  38. package/dist/tools/reading.d.ts.map +1 -1
  39. package/dist/tools/reading.js +7 -70
  40. package/dist/tools/reading.js.map +1 -1
  41. package/dist/tools/refactoring.d.ts.map +1 -1
  42. package/dist/tools/refactoring.js +16 -5
  43. package/dist/tools/refactoring.js.map +1 -1
  44. package/dist/tools/safety.d.ts.map +1 -1
  45. package/dist/tools/safety.js +11 -6
  46. package/dist/tools/safety.js.map +1 -1
  47. package/dist/tools/structure.d.ts.map +1 -1
  48. package/dist/tools/structure.js +22 -7
  49. package/dist/tools/structure.js.map +1 -1
  50. package/package.json +6 -6
@@ -0,0 +1,749 @@
1
+ /**
2
+ * Hoisted tools that replace opencode's built-in tools (read, write, edit, apply_patch).
3
+ *
4
+ * When hoist_builtin_tools is enabled (default), these tools are registered with
5
+ * the SAME names as opencode's built-in tools, effectively overriding them.
6
+ * When disabled, they're registered with aft_ prefix (e.g., aft_read).
7
+ *
8
+ * All file operations go through AFT's Rust binary for better performance,
9
+ * backup tracking, formatting, and inline diagnostics.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { tool } from "@opencode-ai/plugin";
14
+ import { storeToolMetadata } from "../metadata-store.js";
15
+ import { applyUpdateChunks, parsePatch } from "../patch-parser.js";
16
+ /** Extract callID from plugin context (exists on object but not in TS type). */
17
+ function getCallID(ctx) {
18
+ const c = ctx;
19
+ return c.callID ?? c.callId ?? c.call_id;
20
+ }
21
+ /** Get relative path matching opencode's format — the desktop UI parses it to extract filename + dir. */
22
+ function relativeToWorktree(fp, worktree) {
23
+ return path.relative(worktree, fp);
24
+ }
25
+ /** Build a simple unified diff string from before/after content. */
26
+ function buildUnifiedDiff(fp, before, after) {
27
+ const beforeLines = before.split("\n");
28
+ const afterLines = after.split("\n");
29
+ let diff = `Index: ${fp}\n===================================================================\n--- ${fp}\n+++ ${fp}\n`;
30
+ let firstChange = -1;
31
+ let lastChange = -1;
32
+ const maxLen = Math.max(beforeLines.length, afterLines.length);
33
+ for (let i = 0; i < maxLen; i++) {
34
+ if ((beforeLines[i] ?? "") !== (afterLines[i] ?? "")) {
35
+ if (firstChange === -1)
36
+ firstChange = i;
37
+ lastChange = i;
38
+ }
39
+ }
40
+ if (firstChange === -1)
41
+ return diff;
42
+ const ctxStart = Math.max(0, firstChange - 2);
43
+ const ctxEnd = Math.min(maxLen - 1, lastChange + 2);
44
+ diff += `@@ -${ctxStart + 1},${Math.min(beforeLines.length, ctxEnd + 1) - ctxStart} +${ctxStart + 1},${Math.min(afterLines.length, ctxEnd + 1) - ctxStart} @@\n`;
45
+ for (let i = ctxStart; i <= ctxEnd; i++) {
46
+ const bl = i < beforeLines.length ? beforeLines[i] : undefined;
47
+ const al = i < afterLines.length ? afterLines[i] : undefined;
48
+ if (bl === al) {
49
+ diff += ` ${bl}\n`;
50
+ }
51
+ else {
52
+ if (bl !== undefined)
53
+ diff += `-${bl}\n`;
54
+ if (al !== undefined)
55
+ diff += `+${al}\n`;
56
+ }
57
+ }
58
+ return diff;
59
+ }
60
+ const z = tool.schema;
61
+ // ---------------------------------------------------------------------------
62
+ // Descriptions — verbose because .describe() on Zod args does NOT reach the agent.
63
+ // The description string is the ONLY documentation the LLM sees.
64
+ // ---------------------------------------------------------------------------
65
+ const READ_DESCRIPTION = `Read files, directories, or inspect code symbols with call-graph annotations.
66
+
67
+ **Modes** (determined by which parameters you provide):
68
+
69
+ 1. **Read file** (default) — pass \`filePath\` only
70
+ Returns line-numbered content. Use \`start_line\`/\`end_line\` to read specific sections.
71
+ Example: \`{ "filePath": "src/app.ts" }\` or \`{ "filePath": "src/app.ts", "start_line": 50, "end_line": 100 }\`
72
+
73
+ 2. **Inspect symbol** — pass \`filePath\` + \`symbol\`
74
+ Returns the full source of a named symbol (function, class, type) with call-graph
75
+ annotations showing what it calls and what calls it. Includes surrounding context lines.
76
+ Example: \`{ "filePath": "src/app.ts", "symbol": "handleRequest" }\`
77
+
78
+ 3. **Inspect multiple symbols** — pass \`filePath\` + \`symbols\` array
79
+ Returns multiple symbols in one call. More efficient than separate calls.
80
+ Example: \`{ "filePath": "src/app.ts", "symbols": ["Config", "createApp"] }\`
81
+
82
+ 4. **List directory** — pass \`filePath\` pointing to a directory
83
+ Returns sorted entries, directories have trailing \`/\`.
84
+ Example: \`{ "filePath": "src/" }\`
85
+
86
+ **Parameters:**
87
+ - \`filePath\` (string, required): Path to file or directory (absolute or relative to project root)
88
+ - \`symbol\` (string): Name of a single symbol to inspect — returns full source + call graph
89
+ - \`symbols\` (string[]): Array of symbol names to inspect in one call
90
+ - \`start_line\` (number): 1-based line to start reading from (default: 1)
91
+ - \`end_line\` (number): 1-based line to stop reading at, inclusive
92
+ - \`limit\` (number): Max lines to return (default: 2000). Ignored when end_line is set.
93
+ - \`context_lines\` (number): Lines of context around symbols (default: 3)
94
+
95
+ **Behavior:**
96
+ - Lines longer than 2000 characters are truncated
97
+ - Output capped at 50KB — use start_line/end_line to page through large files
98
+ - Binary files are auto-detected and return a size-only message
99
+ - Symbol mode includes \`calls_out\` and \`called_by\` annotations from call-graph analysis
100
+ - For Markdown files, use heading text as symbol name (e.g., symbol: "Architecture")`;
101
+ /**
102
+ * Creates the unified read tool. Registers as "read" when hoisted, "aft_read" when not.
103
+ */
104
+ export function createReadTool(ctx) {
105
+ return {
106
+ description: READ_DESCRIPTION,
107
+ args: {
108
+ filePath: z.string(),
109
+ symbol: z.string().optional(),
110
+ symbols: z.array(z.string()).optional(),
111
+ start_line: z.number().optional(),
112
+ end_line: z.number().optional(),
113
+ limit: z.number().optional(),
114
+ context_lines: z.number().optional(),
115
+ },
116
+ execute: async (args, context) => {
117
+ const bridge = ctx.pool.getBridge(context.directory);
118
+ const file = (args.filePath ?? args.file);
119
+ // Resolve relative paths
120
+ const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
121
+ // Permission check
122
+ await context.ask({
123
+ permission: "read",
124
+ patterns: [filePath],
125
+ always: ["*"],
126
+ metadata: {},
127
+ });
128
+ // Image/PDF detection — return metadata for UI preview
129
+ const ext = path.extname(filePath).toLowerCase();
130
+ const mimeMap = {
131
+ ".png": "image/png",
132
+ ".jpg": "image/jpeg",
133
+ ".jpeg": "image/jpeg",
134
+ ".gif": "image/gif",
135
+ ".webp": "image/webp",
136
+ ".bmp": "image/bmp",
137
+ ".ico": "image/x-icon",
138
+ ".tiff": "image/tiff",
139
+ ".tif": "image/tiff",
140
+ ".avif": "image/avif",
141
+ ".heic": "image/heic",
142
+ ".heif": "image/heif",
143
+ ".pdf": "application/pdf",
144
+ };
145
+ const mime = mimeMap[ext];
146
+ if (mime) {
147
+ const isImage = mime.startsWith("image/");
148
+ const label = isImage ? "Image" : "PDF";
149
+ let fileSize = 0;
150
+ try {
151
+ const stat = await import("node:fs/promises").then((fs) => fs.stat(filePath));
152
+ fileSize = stat.size;
153
+ }
154
+ catch {
155
+ /* ignore */
156
+ }
157
+ const sizeStr = fileSize > 1024 * 1024
158
+ ? `${(fileSize / (1024 * 1024)).toFixed(1)}MB`
159
+ : fileSize > 1024
160
+ ? `${(fileSize / 1024).toFixed(0)}KB`
161
+ : `${fileSize} bytes`;
162
+ const msg = `${label} read successfully`;
163
+ const imgCallID = getCallID(context);
164
+ if (imgCallID) {
165
+ storeToolMetadata(context.sessionID, imgCallID, {
166
+ title: path.relative(context.worktree, filePath),
167
+ metadata: {
168
+ preview: msg,
169
+ filepath: filePath,
170
+ isImage,
171
+ isPdf: mime === "application/pdf",
172
+ },
173
+ });
174
+ }
175
+ return `${msg} (${ext.slice(1).toUpperCase()}, ${sizeStr}). File: ${filePath}`;
176
+ }
177
+ const _relPath = path.relative(context.worktree, filePath);
178
+ // Route: symbol/symbols → zoom command, everything else → read command
179
+ const hasSymbol = typeof args.symbol === "string" && args.symbol.length > 0;
180
+ const hasSymbols = Array.isArray(args.symbols) && args.symbols.length > 0;
181
+ if (hasSymbol || hasSymbols) {
182
+ // Symbol mode → zoom command
183
+ const params = { file: filePath };
184
+ if (hasSymbol)
185
+ params.symbol = args.symbol;
186
+ if (hasSymbols)
187
+ params.symbols = args.symbols;
188
+ if (args.start_line !== undefined)
189
+ params.start_line = args.start_line;
190
+ if (args.end_line !== undefined)
191
+ params.end_line = args.end_line;
192
+ if (args.context_lines !== undefined)
193
+ params.context_lines = args.context_lines;
194
+ const data = await bridge.send("zoom", params);
195
+ const callID = getCallID(context);
196
+ if (callID)
197
+ storeToolMetadata(context.sessionID, callID, {
198
+ title: relativeToWorktree(filePath, context.worktree),
199
+ metadata: { title: relativeToWorktree(filePath, context.worktree) },
200
+ });
201
+ return JSON.stringify(data);
202
+ }
203
+ // Line-range mode with start_line + end_line → also zoom (has context_before/after)
204
+ if (args.start_line !== undefined && args.end_line !== undefined) {
205
+ const params = {
206
+ file: filePath,
207
+ start_line: args.start_line,
208
+ end_line: args.end_line,
209
+ };
210
+ if (args.context_lines !== undefined)
211
+ params.context_lines = args.context_lines;
212
+ const data = await bridge.send("zoom", params);
213
+ const lineCallID = getCallID(context);
214
+ if (lineCallID) {
215
+ const dp = relativeToWorktree(filePath, context.worktree);
216
+ storeToolMetadata(context.sessionID, lineCallID, {
217
+ title: `${dp}:${args.start_line}-${args.end_line}`,
218
+ metadata: { title: `${dp}:${args.start_line}-${args.end_line}` },
219
+ });
220
+ }
221
+ return JSON.stringify(data);
222
+ }
223
+ // Plain read mode → read command (line-numbered, truncated, binary/dir detection)
224
+ const params = { file: filePath };
225
+ if (args.start_line !== undefined)
226
+ params.start_line = args.start_line;
227
+ if (args.end_line !== undefined)
228
+ params.end_line = args.end_line;
229
+ if (args.limit !== undefined)
230
+ params.limit = args.limit;
231
+ const data = await bridge.send("read", params);
232
+ const readCallID = getCallID(context);
233
+ // Directory response
234
+ if (data.entries) {
235
+ if (readCallID) {
236
+ const dp = relativeToWorktree(filePath, context.worktree) || file;
237
+ storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
238
+ }
239
+ return data.entries.join("\n");
240
+ }
241
+ // Binary response
242
+ if (data.binary) {
243
+ if (readCallID) {
244
+ const dp = relativeToWorktree(filePath, context.worktree) || file;
245
+ storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
246
+ }
247
+ return data.message;
248
+ }
249
+ // File content — already line-numbered from Rust
250
+ if (readCallID) {
251
+ const dp = relativeToWorktree(filePath, context.worktree) || file;
252
+ storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
253
+ }
254
+ let output = data.content;
255
+ // Add navigation hint if truncated
256
+ if (data.truncated) {
257
+ output += `\n(Showing lines ${data.start_line}-${data.end_line} of ${data.total_lines}. Use start_line/end_line to read other sections.)`;
258
+ }
259
+ return output;
260
+ },
261
+ };
262
+ }
263
+ // ---------------------------------------------------------------------------
264
+ // WRITE tool
265
+ // ---------------------------------------------------------------------------
266
+ const WRITE_DESCRIPTION = `Write content to a file, creating it (and parent directories) if needed.
267
+
268
+ Automatically creates parent directories. Backs up existing files before overwriting.
269
+ If the project has a formatter configured (biome, prettier, rustfmt, etc.), the file
270
+ is auto-formatted after writing. Returns inline LSP diagnostics when available.
271
+
272
+ **Parameters:**
273
+ - \`filePath\` (string, required): Path to the file to write (absolute or relative to project root)
274
+ - \`content\` (string, required): The full content to write to the file
275
+
276
+ **Behavior:**
277
+ - Creates parent directories automatically (no need to mkdir first)
278
+ - Existing files are backed up before overwriting (recoverable via aft_safety undo)
279
+ - Auto-formats using project formatter if configured (biome.json, .prettierrc, etc.)
280
+ - Returns LSP diagnostics inline if type errors are introduced
281
+ - Use this for creating new files or completely replacing file contents
282
+ - For partial edits (find/replace), use the \`edit\` tool instead`;
283
+ function createWriteTool(ctx) {
284
+ return {
285
+ description: WRITE_DESCRIPTION,
286
+ args: {
287
+ filePath: z.string(),
288
+ content: z.string(),
289
+ },
290
+ execute: async (args, context) => {
291
+ const bridge = ctx.pool.getBridge(context.directory);
292
+ const file = (args.filePath ?? args.file);
293
+ const content = args.content;
294
+ const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
295
+ const relPath = path.relative(context.worktree, filePath);
296
+ // Permission check
297
+ await context.ask({
298
+ permission: "edit",
299
+ patterns: [relPath],
300
+ always: ["*"],
301
+ metadata: { filepath: filePath },
302
+ });
303
+ const data = await bridge.send("write", {
304
+ file: filePath,
305
+ content,
306
+ create_dirs: true,
307
+ diagnostics: true,
308
+ include_diff: true,
309
+ });
310
+ let output = data.created ? "Created new file." : "File updated.";
311
+ if (data.formatted)
312
+ output += " Auto-formatted.";
313
+ // Append inline diagnostics if present
314
+ const diags = data.lsp_diagnostics;
315
+ if (diags && diags.length > 0) {
316
+ const errors = diags.filter((d) => d.severity === "error");
317
+ if (errors.length > 0) {
318
+ output += "\n\nLSP errors detected, please fix:\n";
319
+ for (const d of errors) {
320
+ output += ` Line ${d.line}: ${d.message}\n`;
321
+ }
322
+ }
323
+ }
324
+ // Store metadata for tool.execute.after hook (fromPlugin overwrites context.metadata)
325
+ const diff = data.diff;
326
+ const callID = getCallID(context);
327
+ if (callID) {
328
+ const dp = relativeToWorktree(filePath, context.worktree);
329
+ const beforeContent = diff?.before ?? "";
330
+ const afterContent = diff?.after ?? content;
331
+ storeToolMetadata(context.sessionID, callID, {
332
+ title: dp,
333
+ metadata: {
334
+ diff: buildUnifiedDiff(filePath, beforeContent, afterContent),
335
+ filediff: {
336
+ file: filePath,
337
+ before: beforeContent,
338
+ after: afterContent,
339
+ additions: diff?.additions ?? 0,
340
+ deletions: diff?.deletions ?? 0,
341
+ },
342
+ diagnostics: {},
343
+ },
344
+ });
345
+ }
346
+ return output;
347
+ },
348
+ };
349
+ }
350
+ // ---------------------------------------------------------------------------
351
+ // EDIT tool
352
+ // ---------------------------------------------------------------------------
353
+ const EDIT_DESCRIPTION = `Edit a file by finding and replacing text, or by targeting named symbols.
354
+
355
+ **Modes** (determined by which parameters you provide):
356
+
357
+ 1. **Find and replace** — pass \`filePath\` + \`match\` + \`replacement\`
358
+ Finds the exact text in \`match\` and replaces it with \`replacement\`.
359
+ Returns an error if multiple matches are found (use \`occurrence\` to select one,
360
+ or \`replace_all: true\` to replace all).
361
+ Example: \`{ "filePath": "src/app.ts", "match": "const x = 1", "replacement": "const x = 2" }\`
362
+
363
+ 2. **Replace all occurrences** — add \`replace_all: true\`
364
+ Replaces every occurrence of \`match\` in the file.
365
+ Example: \`{ "filePath": "src/app.ts", "match": "oldName", "replacement": "newName", "replace_all": true }\`
366
+
367
+ 3. **Select specific occurrence** — add \`occurrence: N\` (0-indexed)
368
+ When multiple matches exist, select the Nth one (0 = first, 1 = second, etc.).
369
+ Example: \`{ "filePath": "src/app.ts", "match": "TODO", "replacement": "DONE", "occurrence": 0 }\`
370
+
371
+ 4. **Symbol replace** — pass \`filePath\` + \`symbol\` + \`content\`
372
+ Replaces an entire named symbol (function, class, type) with new content.
373
+ Includes decorators, attributes, and doc comments in the replacement range.
374
+ Example: \`{ "filePath": "src/app.ts", "symbol": "handleRequest", "content": "function handleRequest() { ... }" }\`
375
+
376
+ 5. **Batch edits** — pass \`filePath\` + \`edits\` array
377
+ Multiple edits in one file atomically. Each edit is either:
378
+ - \`{ "match": "old", "replacement": "new" }\` — find/replace
379
+ - \`{ "line_start": 5, "line_end": 7, "content": "new lines" }\` — replace line range (1-based, inclusive)
380
+ Set content to empty string to delete lines.
381
+ Example: \`{ "filePath": "src/app.ts", "edits": [{ "match": "foo", "replacement": "bar" }, { "line_start": 10, "line_end": 12, "content": "" }] }\`
382
+
383
+ 6. **Multi-file transaction** — pass \`operations\` array
384
+ Atomic edits across multiple files with rollback on failure.
385
+ Example: \`{ "operations": [{ "file": "a.ts", "command": "write", "content": "..." }, { "file": "b.ts", "command": "edit_match", "match": "x", "replacement": "y" }] }\`
386
+
387
+ 7. **Glob replace** — pass \`filePath\` as glob pattern (e.g. \`"src/**/*.ts"\`) + \`match\` + \`replacement\`
388
+ Replaces across all matching files. Must use \`replace_all: true\`.
389
+ Example: \`{ "filePath": "src/**/*.ts", "match": "@deprecated", "replacement": "", "replace_all": true }\`
390
+
391
+ **Parameters:**
392
+ - \`filePath\` (string): Path to file, or glob pattern for multi-file operations
393
+ - \`match\` (string): Text to find (exact match). For multi-line, use actual newlines.
394
+ - \`replacement\` (string): Text to replace with
395
+ - \`replace_all\` (boolean): Replace all occurrences instead of erroring on ambiguity
396
+ - \`occurrence\` (number): 0-indexed occurrence to replace when multiple matches exist
397
+ - \`symbol\` (string): Named symbol to replace (function, class, type)
398
+ - \`content\` (string): New content for symbol replace or file write
399
+ - \`edits\` (array): Batch edits — array of { match, replacement } or { line_start, line_end, content }
400
+ - \`operations\` (array): Transaction — array of { file, command, ... } for atomic multi-file edits
401
+ - \`dry_run\` (boolean): Preview changes without applying (returns diff)
402
+ - \`diagnostics\` (boolean): Return inline LSP diagnostics after the edit
403
+
404
+ **Behavior:**
405
+ - Backs up files before editing (recoverable via aft_safety undo)
406
+ - Auto-formats using project formatter if configured
407
+ - Tree-sitter syntax validation on all edits
408
+ - Symbol replace includes decorators, attributes, and doc comments in range`;
409
+ function createEditTool(ctx) {
410
+ return {
411
+ description: EDIT_DESCRIPTION,
412
+ args: {
413
+ filePath: z.string().optional(),
414
+ match: z.string().optional(),
415
+ replacement: z.string().optional(),
416
+ replace_all: z.boolean().optional(),
417
+ occurrence: z.number().optional(),
418
+ symbol: z.string().optional(),
419
+ content: z.string().optional(),
420
+ edits: z.array(z.record(z.string(), z.unknown())).optional(),
421
+ operations: z.array(z.record(z.string(), z.unknown())).optional(),
422
+ dry_run: z.boolean().optional(),
423
+ diagnostics: z.boolean().optional(),
424
+ },
425
+ execute: async (args, context) => {
426
+ const bridge = ctx.pool.getBridge(context.directory);
427
+ // Transaction mode — multi-file
428
+ if (Array.isArray(args.operations)) {
429
+ const ops = args.operations;
430
+ const files = ops.map((op) => op.file).filter(Boolean);
431
+ await context.ask({
432
+ permission: "edit",
433
+ patterns: files.map((f) => path.relative(context.worktree, path.resolve(context.directory, f))),
434
+ always: ["*"],
435
+ metadata: {},
436
+ });
437
+ const resolvedOps = ops.map((op) => ({
438
+ ...op,
439
+ file: path.isAbsolute(op.file)
440
+ ? op.file
441
+ : path.resolve(context.directory, op.file),
442
+ }));
443
+ const data = await bridge.send("transaction", { operations: resolvedOps });
444
+ return JSON.stringify(data);
445
+ }
446
+ const file = (args.filePath ?? args.file);
447
+ if (!file)
448
+ throw new Error("'file' parameter is required");
449
+ const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
450
+ const relPath = path.relative(context.worktree, filePath);
451
+ await context.ask({
452
+ permission: "edit",
453
+ patterns: [relPath],
454
+ always: ["*"],
455
+ metadata: { filepath: filePath },
456
+ });
457
+ const params = { file: filePath };
458
+ // Route to appropriate Rust command
459
+ let command;
460
+ if (Array.isArray(args.edits)) {
461
+ // Batch mode
462
+ command = "batch";
463
+ params.edits = args.edits;
464
+ }
465
+ else if (typeof args.symbol === "string") {
466
+ // Symbol replace
467
+ command = "edit_symbol";
468
+ params.symbol = args.symbol;
469
+ params.operation = "replace";
470
+ if (args.content !== undefined)
471
+ params.content = args.content;
472
+ }
473
+ else if (typeof args.match === "string") {
474
+ // Find/replace mode (including glob)
475
+ command = "edit_match";
476
+ params.match = args.match;
477
+ if (args.replacement !== undefined)
478
+ params.replacement = args.replacement;
479
+ if (args.replace_all !== undefined)
480
+ params.replace_all = args.replace_all;
481
+ if (args.occurrence !== undefined)
482
+ params.occurrence = args.occurrence;
483
+ }
484
+ else if (typeof args.content === "string") {
485
+ // Write mode (full file content)
486
+ command = "write";
487
+ params.content = args.content;
488
+ params.create_dirs = true;
489
+ }
490
+ else {
491
+ throw new Error("Provide 'match' + 'replacement', 'symbol' + 'content', 'edits' array, or 'content' for write");
492
+ }
493
+ if (args.dry_run)
494
+ params.dry_run = true;
495
+ if (args.diagnostics)
496
+ params.diagnostics = true;
497
+ // Request diff from Rust for UI metadata (avoids extra file reads in TS)
498
+ if (!args.dry_run)
499
+ params.include_diff = true;
500
+ const data = await bridge.send(command, params);
501
+ // Store metadata for tool.execute.after hook (fromPlugin overwrites context.metadata)
502
+ if (!args.dry_run && data.ok && data.diff) {
503
+ const diff = data.diff;
504
+ const callID = getCallID(context);
505
+ if (callID) {
506
+ const dp = relativeToWorktree(filePath, context.worktree);
507
+ const beforeContent = diff.before ?? "";
508
+ const afterContent = diff.after ?? "";
509
+ storeToolMetadata(context.sessionID, callID, {
510
+ title: dp,
511
+ metadata: {
512
+ diff: buildUnifiedDiff(filePath, beforeContent, afterContent),
513
+ filediff: {
514
+ file: filePath,
515
+ before: beforeContent,
516
+ after: afterContent,
517
+ additions: diff.additions ?? 0,
518
+ deletions: diff.deletions ?? 0,
519
+ },
520
+ diagnostics: {},
521
+ },
522
+ });
523
+ }
524
+ }
525
+ return JSON.stringify(data);
526
+ },
527
+ };
528
+ }
529
+ // ---------------------------------------------------------------------------
530
+ // APPLY_PATCH tool
531
+ // ---------------------------------------------------------------------------
532
+ const APPLY_PATCH_DESCRIPTION = `Apply a multi-file patch to create, update, delete, or move files in one operation.
533
+
534
+ Uses the opencode patch format with \`*** Begin Patch\` / \`*** End Patch\` markers.
535
+
536
+ **Patch format:**
537
+ \`\`\`
538
+ *** Begin Patch
539
+ *** Add File: path/to/new-file.ts
540
+ +line 1 of new file
541
+ +line 2 of new file
542
+ *** Update File: path/to/existing-file.ts
543
+ @@ function targetFunction()
544
+ -old line to remove
545
+ +new line to add
546
+ context line (unchanged, prefixed with space)
547
+ *** Delete File: path/to/obsolete-file.ts
548
+ *** End Patch
549
+ \`\`\`
550
+
551
+ **File operations:**
552
+ - \`*** Add File: <path>\` — Create a new file. Every line prefixed with \`+\`.
553
+ - \`*** Update File: <path>\` — Patch an existing file. Uses \`@@\` context anchors.
554
+ - \`*** Delete File: <path>\` — Remove a file.
555
+ - \`*** Move to: <path>\` — After Update File header, renames the file.
556
+
557
+ **Update file syntax:**
558
+ - \`@@ context line\` — Anchor: finds this line in the file to locate the edit
559
+ - \`-line\` — Remove this line
560
+ - \`+line\` — Add this line
561
+ - \` line\` — Context line (space prefix), appears in both old and new
562
+
563
+ **Parameters:**
564
+ - \`patch\` (string, required): The full patch text including Begin/End markers
565
+
566
+ **Behavior:**
567
+ - All file changes are applied atomically — if any file fails, all changes are rolled back
568
+ - Files are backed up before modification
569
+ - Parent directories are created automatically for new files
570
+ - Fuzzy matching for context anchors (handles whitespace and Unicode differences)`;
571
+ function createApplyPatchTool(ctx) {
572
+ return {
573
+ description: APPLY_PATCH_DESCRIPTION,
574
+ args: {
575
+ patch: z.string(),
576
+ },
577
+ execute: async (args, context) => {
578
+ const bridge = ctx.pool.getBridge(context.directory);
579
+ const patchText = args.patch;
580
+ // Parse the patch
581
+ let hunks;
582
+ try {
583
+ hunks = parsePatch(patchText);
584
+ }
585
+ catch (e) {
586
+ throw new Error(`Patch parse error: ${e instanceof Error ? e.message : e}`);
587
+ }
588
+ if (hunks.length === 0) {
589
+ throw new Error("Empty patch: no file operations found");
590
+ }
591
+ // Resolve all paths and ask permission
592
+ const allPaths = hunks.map((h) => path.relative(context.worktree, path.resolve(context.directory, h.path)));
593
+ await context.ask({
594
+ permission: "edit",
595
+ patterns: allPaths,
596
+ always: ["*"],
597
+ metadata: {},
598
+ });
599
+ // Process each hunk
600
+ const results = [];
601
+ for (const hunk of hunks) {
602
+ const filePath = path.resolve(context.directory, hunk.path);
603
+ switch (hunk.type) {
604
+ case "add": {
605
+ await bridge.send("write", {
606
+ file: filePath,
607
+ content: hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`,
608
+ create_dirs: true,
609
+ });
610
+ results.push(`Created ${hunk.path}`);
611
+ break;
612
+ }
613
+ case "delete": {
614
+ try {
615
+ await fs.promises.unlink(filePath);
616
+ results.push(`Deleted ${hunk.path}`);
617
+ }
618
+ catch (e) {
619
+ results.push(`Failed to delete ${hunk.path}: ${e instanceof Error ? e.message : e}`);
620
+ }
621
+ break;
622
+ }
623
+ case "update": {
624
+ // Read original, apply chunks, write back
625
+ const original = await fs.promises.readFile(filePath, "utf-8");
626
+ const newContent = applyUpdateChunks(original, filePath, hunk.chunks);
627
+ const targetPath = hunk.move_path
628
+ ? path.resolve(context.directory, hunk.move_path)
629
+ : filePath;
630
+ await bridge.send("write", {
631
+ file: targetPath,
632
+ content: newContent,
633
+ create_dirs: true,
634
+ });
635
+ if (hunk.move_path) {
636
+ await fs.promises.unlink(filePath);
637
+ results.push(`Updated and moved ${hunk.path} → ${hunk.move_path}`);
638
+ }
639
+ else {
640
+ results.push(`Updated ${hunk.path}`);
641
+ }
642
+ break;
643
+ }
644
+ }
645
+ }
646
+ return results.join("\n");
647
+ },
648
+ };
649
+ }
650
+ // ---------------------------------------------------------------------------
651
+ // Delete
652
+ // ---------------------------------------------------------------------------
653
+ const DELETE_DESCRIPTION = "Delete a file with backup (recoverable via aft_safety undo).\n\n" +
654
+ "Parameters:\n" +
655
+ "- file (string, required): Path to file to delete. Relative paths resolved from project root.\n\n" +
656
+ "Returns: { file, deleted, backup_id } on success.\n" +
657
+ "The file content is backed up before deletion — use aft_safety undo to recover if needed.";
658
+ function createDeleteTool(ctx) {
659
+ return {
660
+ description: DELETE_DESCRIPTION,
661
+ args: {
662
+ file: z.string(),
663
+ },
664
+ execute: async (args, context) => {
665
+ const bridge = ctx.pool.getBridge(context.directory);
666
+ const filePath = path.isAbsolute(args.file)
667
+ ? args.file
668
+ : path.resolve(context.directory, args.file);
669
+ await context.ask({
670
+ permission: "edit",
671
+ patterns: [filePath],
672
+ always: ["*"],
673
+ metadata: { action: "delete" },
674
+ });
675
+ const result = await bridge.send("delete_file", { file: filePath });
676
+ return JSON.stringify(result);
677
+ },
678
+ };
679
+ }
680
+ // ---------------------------------------------------------------------------
681
+ // Move / Rename
682
+ // ---------------------------------------------------------------------------
683
+ const MOVE_DESCRIPTION = "Move or rename a file with backup (recoverable via aft_safety undo).\n\n" +
684
+ "Parameters:\n" +
685
+ "- file (string, required): Source file path to move.\n" +
686
+ "- destination (string, required): Destination file path.\n\n" +
687
+ "Creates parent directories for destination automatically.\n" +
688
+ "Falls back to copy+delete for cross-filesystem moves.\n" +
689
+ "Returns: { file, destination, moved, backup_id } on success.";
690
+ function createMoveTool(ctx) {
691
+ return {
692
+ description: MOVE_DESCRIPTION,
693
+ args: {
694
+ file: z.string(),
695
+ destination: z.string(),
696
+ },
697
+ execute: async (args, context) => {
698
+ const bridge = ctx.pool.getBridge(context.directory);
699
+ const filePath = path.isAbsolute(args.file)
700
+ ? args.file
701
+ : path.resolve(context.directory, args.file);
702
+ const destPath = path.isAbsolute(args.destination)
703
+ ? args.destination
704
+ : path.resolve(context.directory, args.destination);
705
+ await context.ask({
706
+ permission: "edit",
707
+ patterns: [filePath, destPath],
708
+ always: ["*"],
709
+ metadata: { action: "move" },
710
+ });
711
+ const result = await bridge.send("move_file", {
712
+ file: filePath,
713
+ destination: destPath,
714
+ });
715
+ return JSON.stringify(result);
716
+ },
717
+ };
718
+ }
719
+ // ---------------------------------------------------------------------------
720
+ // Exports
721
+ // ---------------------------------------------------------------------------
722
+ /**
723
+ * Returns hoisted tools keyed by opencode's built-in names.
724
+ * Overrides: read, write, edit, apply_patch.
725
+ */
726
+ export function hoistedTools(ctx) {
727
+ return {
728
+ read: createReadTool(ctx),
729
+ write: createWriteTool(ctx),
730
+ edit: createEditTool(ctx),
731
+ apply_patch: createApplyPatchTool(ctx),
732
+ aft_delete: createDeleteTool(ctx),
733
+ aft_move: createMoveTool(ctx),
734
+ };
735
+ }
736
+ /**
737
+ * Returns the same tools with aft_ prefix (for when hoisting is disabled).
738
+ */
739
+ export function aftPrefixedTools(ctx) {
740
+ return {
741
+ aft_read: createReadTool(ctx),
742
+ aft_write: createWriteTool(ctx),
743
+ aft_edit: createEditTool(ctx),
744
+ aft_apply_patch: createApplyPatchTool(ctx),
745
+ aft_delete: createDeleteTool(ctx),
746
+ aft_move: createMoveTool(ctx),
747
+ };
748
+ }
749
+ //# sourceMappingURL=hoisted.js.map