@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.
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -3
- package/dist/index.js.map +1 -1
- package/dist/metadata-store.d.ts +29 -0
- package/dist/metadata-store.d.ts.map +1 -0
- package/dist/metadata-store.js +53 -0
- package/dist/metadata-store.js.map +1 -0
- package/dist/patch-parser.d.ts +33 -0
- package/dist/patch-parser.d.ts.map +1 -0
- package/dist/patch-parser.js +237 -0
- package/dist/patch-parser.js.map +1 -0
- package/dist/tools/ast.d.ts.map +1 -1
- package/dist/tools/ast.js +159 -62
- package/dist/tools/ast.js.map +1 -1
- package/dist/tools/editing.d.ts +3 -2
- package/dist/tools/editing.d.ts.map +1 -1
- package/dist/tools/editing.js +4 -146
- package/dist/tools/editing.js.map +1 -1
- package/dist/tools/hoisted.d.ts +26 -0
- package/dist/tools/hoisted.d.ts.map +1 -0
- package/dist/tools/hoisted.js +749 -0
- package/dist/tools/hoisted.js.map +1 -0
- package/dist/tools/imports.d.ts.map +1 -1
- package/dist/tools/imports.js +15 -5
- package/dist/tools/imports.js.map +1 -1
- package/dist/tools/lsp.d.ts.map +1 -1
- package/dist/tools/lsp.js +25 -108
- package/dist/tools/lsp.js.map +1 -1
- package/dist/tools/navigation.d.ts.map +1 -1
- package/dist/tools/navigation.js +9 -3
- package/dist/tools/navigation.js.map +1 -1
- package/dist/tools/reading.d.ts +2 -1
- package/dist/tools/reading.d.ts.map +1 -1
- package/dist/tools/reading.js +7 -70
- package/dist/tools/reading.js.map +1 -1
- package/dist/tools/refactoring.d.ts.map +1 -1
- package/dist/tools/refactoring.js +16 -5
- package/dist/tools/refactoring.js.map +1 -1
- package/dist/tools/safety.d.ts.map +1 -1
- package/dist/tools/safety.js +11 -6
- package/dist/tools/safety.js.map +1 -1
- package/dist/tools/structure.d.ts.map +1 -1
- package/dist/tools/structure.js +22 -7
- package/dist/tools/structure.js.map +1 -1
- 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
|