@cortexkit/aft-opencode 0.2.0 → 0.4.1
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/bridge.d.ts +8 -0
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +45 -2
- package/dist/bridge.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +17 -0
- package/dist/config.js.map +1 -1
- package/dist/downloader.d.ts.map +1 -1
- package/dist/downloader.js +51 -15
- package/dist/downloader.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +73 -15
- 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/normalize-schemas.d.ts +16 -0
- package/dist/normalize-schemas.d.ts.map +1 -0
- package/dist/normalize-schemas.js +45 -0
- package/dist/normalize-schemas.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 +247 -0
- package/dist/patch-parser.js.map +1 -0
- package/dist/platform.d.ts +21 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +31 -0
- package/dist/platform.js.map +1 -0
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +14 -5
- package/dist/pool.js.map +1 -1
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +6 -9
- package/dist/resolver.js.map +1 -1
- package/dist/tools/ast.d.ts.map +1 -1
- package/dist/tools/ast.js +179 -89
- package/dist/tools/ast.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 +852 -0
- package/dist/tools/hoisted.js.map +1 -0
- package/dist/tools/imports.d.ts.map +1 -1
- package/dist/tools/imports.js +41 -21
- package/dist/tools/imports.js.map +1 -1
- package/dist/tools/lsp.d.ts +1 -2
- package/dist/tools/lsp.d.ts.map +1 -1
- package/dist/tools/lsp.js +45 -110
- package/dist/tools/lsp.js.map +1 -1
- package/dist/tools/navigation.d.ts.map +1 -1
- package/dist/tools/navigation.js +25 -10
- package/dist/tools/navigation.js.map +1 -1
- package/dist/tools/permissions.d.ts +8 -0
- package/dist/tools/permissions.d.ts.map +1 -0
- package/dist/tools/permissions.js +50 -0
- package/dist/tools/permissions.js.map +1 -0
- package/dist/tools/reading.d.ts +1 -1
- package/dist/tools/reading.d.ts.map +1 -1
- package/dist/tools/reading.js +170 -54
- package/dist/tools/reading.js.map +1 -1
- package/dist/tools/refactoring.d.ts.map +1 -1
- package/dist/tools/refactoring.js +73 -24
- package/dist/tools/refactoring.js.map +1 -1
- package/dist/tools/safety.d.ts.map +1 -1
- package/dist/tools/safety.js +38 -11
- package/dist/tools/safety.js.map +1 -1
- package/dist/tools/structure.d.ts.map +1 -1
- package/dist/tools/structure.js +70 -20
- package/dist/tools/structure.js.map +1 -1
- package/package.json +7 -7
- package/dist/tools/editing.d.ts +0 -7
- package/dist/tools/editing.d.ts.map +0 -1
- package/dist/tools/editing.js +0 -150
- package/dist/tools/editing.js.map +0 -1
|
@@ -0,0 +1,852 @@
|
|
|
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
|
+
// Tool descriptions focus on behavior, modes, and return values.
|
|
63
|
+
// Parameter docs live in Zod .describe() and reach the LLM via JSON Schema.
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const READ_DESCRIPTION = `Read file contents or list directory entries.
|
|
66
|
+
|
|
67
|
+
Use either startLine/endLine OR offset/limit to read a section of a file.
|
|
68
|
+
|
|
69
|
+
Behavior:
|
|
70
|
+
- Returns line-numbered content (e.g., "1: const x = 1")
|
|
71
|
+
- Lines longer than 2000 characters are truncated
|
|
72
|
+
- Output capped at 50KB
|
|
73
|
+
- Binary files are auto-detected and return a size-only message
|
|
74
|
+
- Image files (.png, .jpg, .gif, .webp, etc.) and PDFs return a metadata string (format, size, path) — no file content is returned
|
|
75
|
+
- Directories return sorted entries with trailing / for subdirectories
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
Read full file: { "filePath": "src/app.ts" }
|
|
79
|
+
Read lines 50-100: { "filePath": "src/app.ts", "startLine": 50, "endLine": 100 }
|
|
80
|
+
Read 30 lines from line 200: { "filePath": "src/app.ts", "offset": 200, "limit": 30 }
|
|
81
|
+
List directory: { "filePath": "src/" }
|
|
82
|
+
|
|
83
|
+
Returns: Line-numbered file content string. For directories: newline-joined sorted entries. For binary files: size/message string.`;
|
|
84
|
+
/**
|
|
85
|
+
* Creates the simple read tool. Registers as "read" when hoisted, "aft_read" when not.
|
|
86
|
+
*/
|
|
87
|
+
export function createReadTool(ctx) {
|
|
88
|
+
return {
|
|
89
|
+
description: READ_DESCRIPTION,
|
|
90
|
+
args: {
|
|
91
|
+
filePath: z
|
|
92
|
+
.string()
|
|
93
|
+
.describe("Path to file or directory (absolute or relative to project root)"),
|
|
94
|
+
startLine: z.number().optional().describe("1-based line to start reading from"),
|
|
95
|
+
endLine: z.number().optional().describe("1-based line to stop reading at (inclusive)"),
|
|
96
|
+
limit: z.number().optional().describe("Max lines to return (default: 2000)"),
|
|
97
|
+
offset: z
|
|
98
|
+
.number()
|
|
99
|
+
.optional()
|
|
100
|
+
.describe("1-based line number to start reading from (use with limit). Ignored if startLine is provided"),
|
|
101
|
+
},
|
|
102
|
+
execute: async (args, context) => {
|
|
103
|
+
const bridge = ctx.pool.getBridge(context.directory);
|
|
104
|
+
const file = args.filePath;
|
|
105
|
+
// Resolve relative paths
|
|
106
|
+
const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
|
|
107
|
+
// Permission check
|
|
108
|
+
await context.ask({
|
|
109
|
+
permission: "read",
|
|
110
|
+
patterns: [filePath],
|
|
111
|
+
always: ["*"],
|
|
112
|
+
metadata: {},
|
|
113
|
+
});
|
|
114
|
+
// Image/PDF detection — return metadata for UI preview
|
|
115
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
116
|
+
const mimeMap = {
|
|
117
|
+
".png": "image/png",
|
|
118
|
+
".jpg": "image/jpeg",
|
|
119
|
+
".jpeg": "image/jpeg",
|
|
120
|
+
".gif": "image/gif",
|
|
121
|
+
".webp": "image/webp",
|
|
122
|
+
".bmp": "image/bmp",
|
|
123
|
+
".ico": "image/x-icon",
|
|
124
|
+
".tiff": "image/tiff",
|
|
125
|
+
".tif": "image/tiff",
|
|
126
|
+
".avif": "image/avif",
|
|
127
|
+
".heic": "image/heic",
|
|
128
|
+
".heif": "image/heif",
|
|
129
|
+
".pdf": "application/pdf",
|
|
130
|
+
};
|
|
131
|
+
const mime = mimeMap[ext];
|
|
132
|
+
if (mime) {
|
|
133
|
+
const isImage = mime.startsWith("image/");
|
|
134
|
+
const label = isImage ? "Image" : "PDF";
|
|
135
|
+
let fileSize = 0;
|
|
136
|
+
try {
|
|
137
|
+
const stat = await import("node:fs/promises").then((fs) => fs.stat(filePath));
|
|
138
|
+
fileSize = stat.size;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
143
|
+
const sizeStr = fileSize > 1024 * 1024
|
|
144
|
+
? `${(fileSize / (1024 * 1024)).toFixed(1)}MB`
|
|
145
|
+
: fileSize > 1024
|
|
146
|
+
? `${(fileSize / 1024).toFixed(0)}KB`
|
|
147
|
+
: `${fileSize} bytes`;
|
|
148
|
+
const msg = `${label} read successfully`;
|
|
149
|
+
const imgCallID = getCallID(context);
|
|
150
|
+
if (imgCallID) {
|
|
151
|
+
storeToolMetadata(context.sessionID, imgCallID, {
|
|
152
|
+
title: path.relative(context.worktree, filePath),
|
|
153
|
+
metadata: {
|
|
154
|
+
preview: msg,
|
|
155
|
+
filepath: filePath,
|
|
156
|
+
isImage,
|
|
157
|
+
isPdf: mime === "application/pdf",
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return `${msg} (${ext.slice(1).toUpperCase()}, ${sizeStr}). File: ${filePath}`;
|
|
162
|
+
}
|
|
163
|
+
// Normalize offset/limit to startLine/endLine (backward compat with opencode's read)
|
|
164
|
+
let startLine = args.startLine;
|
|
165
|
+
let endLine = args.endLine;
|
|
166
|
+
if (startLine === undefined && args.offset !== undefined) {
|
|
167
|
+
startLine = args.offset;
|
|
168
|
+
if (args.limit !== undefined) {
|
|
169
|
+
endLine = Number(args.offset) + Number(args.limit) - 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Always use Rust read command — simple file reading only
|
|
173
|
+
const params = { file: filePath };
|
|
174
|
+
if (startLine !== undefined)
|
|
175
|
+
params.start_line = startLine;
|
|
176
|
+
if (endLine !== undefined)
|
|
177
|
+
params.end_line = endLine;
|
|
178
|
+
// Only send limit if we did NOT convert offset to startLine/endLine
|
|
179
|
+
if (args.limit !== undefined && args.offset === undefined)
|
|
180
|
+
params.limit = args.limit;
|
|
181
|
+
const data = await bridge.send("read", params);
|
|
182
|
+
const readCallID = getCallID(context);
|
|
183
|
+
// Directory response
|
|
184
|
+
if (data.entries) {
|
|
185
|
+
if (readCallID) {
|
|
186
|
+
const dp = relativeToWorktree(filePath, context.worktree) || file;
|
|
187
|
+
storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
|
|
188
|
+
}
|
|
189
|
+
return data.entries.join("\n");
|
|
190
|
+
}
|
|
191
|
+
// Binary response
|
|
192
|
+
if (data.binary) {
|
|
193
|
+
if (readCallID) {
|
|
194
|
+
const dp = relativeToWorktree(filePath, context.worktree) || file;
|
|
195
|
+
storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
|
|
196
|
+
}
|
|
197
|
+
return data.message;
|
|
198
|
+
}
|
|
199
|
+
// File content — already line-numbered from Rust
|
|
200
|
+
if (readCallID) {
|
|
201
|
+
const dp = relativeToWorktree(filePath, context.worktree) || file;
|
|
202
|
+
storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
|
|
203
|
+
}
|
|
204
|
+
let output = data.content;
|
|
205
|
+
// Add navigation hint if truncated
|
|
206
|
+
if (data.truncated) {
|
|
207
|
+
output += `\n(Showing lines ${data.start_line}-${data.end_line} of ${data.total_lines}. Use startLine/endLine to read other sections.)`;
|
|
208
|
+
}
|
|
209
|
+
return output;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// WRITE tool
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
function getWriteDescription(editToolName) {
|
|
217
|
+
return `Write content to a file, creating it (and parent directories) if needed.
|
|
218
|
+
|
|
219
|
+
Automatically creates parent directories. Backs up existing files before overwriting.
|
|
220
|
+
If the project has a formatter configured (biome, prettier, rustfmt, etc.), the file
|
|
221
|
+
is auto-formatted after writing. Returns inline LSP diagnostics when available.
|
|
222
|
+
|
|
223
|
+
**Behavior:**
|
|
224
|
+
- Creates parent directories automatically (no need to mkdir first)
|
|
225
|
+
- Existing files are backed up before overwriting (recoverable via aft_safety undo)
|
|
226
|
+
- Auto-formats using project formatter if configured (biome.json, .prettierrc, etc.)
|
|
227
|
+
- Returns LSP error-level diagnostics inline if type errors are introduced
|
|
228
|
+
- Use this for creating new files or completely replacing file contents
|
|
229
|
+
- For partial edits (find/replace), use the \`${editToolName}\` tool instead
|
|
230
|
+
|
|
231
|
+
Returns: Status message string (for example: "Created new file. Auto-formatted.") with optional inline LSP error lines.`;
|
|
232
|
+
}
|
|
233
|
+
function createWriteTool(ctx, editToolName = "edit") {
|
|
234
|
+
return {
|
|
235
|
+
description: getWriteDescription(editToolName),
|
|
236
|
+
args: {
|
|
237
|
+
filePath: z
|
|
238
|
+
.string()
|
|
239
|
+
.describe("Path to the file to write (absolute or relative to project root)"),
|
|
240
|
+
content: z.string().describe("The full content to write to the file"),
|
|
241
|
+
},
|
|
242
|
+
execute: async (args, context) => {
|
|
243
|
+
const bridge = ctx.pool.getBridge(context.directory);
|
|
244
|
+
const file = args.filePath;
|
|
245
|
+
const content = args.content;
|
|
246
|
+
const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
|
|
247
|
+
const relPath = path.relative(context.worktree, filePath);
|
|
248
|
+
// Permission check
|
|
249
|
+
await context.ask({
|
|
250
|
+
permission: "edit",
|
|
251
|
+
patterns: [relPath],
|
|
252
|
+
always: ["*"],
|
|
253
|
+
metadata: { filepath: filePath },
|
|
254
|
+
});
|
|
255
|
+
const data = await bridge.send("write", {
|
|
256
|
+
file: filePath,
|
|
257
|
+
content,
|
|
258
|
+
create_dirs: true,
|
|
259
|
+
diagnostics: true,
|
|
260
|
+
include_diff: true,
|
|
261
|
+
});
|
|
262
|
+
let output = data.created ? "Created new file." : "File updated.";
|
|
263
|
+
if (data.formatted)
|
|
264
|
+
output += " Auto-formatted.";
|
|
265
|
+
// Append inline diagnostics if present
|
|
266
|
+
const diags = data.lsp_diagnostics;
|
|
267
|
+
if (diags && diags.length > 0) {
|
|
268
|
+
const errors = diags.filter((d) => d.severity === "error");
|
|
269
|
+
if (errors.length > 0) {
|
|
270
|
+
output += "\n\nLSP errors detected, please fix:\n";
|
|
271
|
+
for (const d of errors) {
|
|
272
|
+
output += ` Line ${d.line}: ${d.message}\n`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Store metadata for tool.execute.after hook (fromPlugin overwrites context.metadata)
|
|
277
|
+
const diff = data.diff;
|
|
278
|
+
const callID = getCallID(context);
|
|
279
|
+
if (callID) {
|
|
280
|
+
const dp = relativeToWorktree(filePath, context.worktree);
|
|
281
|
+
const beforeContent = diff?.before ?? "";
|
|
282
|
+
const afterContent = diff?.after ?? content;
|
|
283
|
+
storeToolMetadata(context.sessionID, callID, {
|
|
284
|
+
title: dp,
|
|
285
|
+
metadata: {
|
|
286
|
+
diff: buildUnifiedDiff(filePath, beforeContent, afterContent),
|
|
287
|
+
filediff: {
|
|
288
|
+
file: filePath,
|
|
289
|
+
before: beforeContent,
|
|
290
|
+
after: afterContent,
|
|
291
|
+
additions: diff?.additions ?? 0,
|
|
292
|
+
deletions: diff?.deletions ?? 0,
|
|
293
|
+
},
|
|
294
|
+
diagnostics: {},
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return output;
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// EDIT tool
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
function getEditDescription(writeToolName) {
|
|
306
|
+
return `Edit a file by finding and replacing text, or by targeting named symbols.
|
|
307
|
+
|
|
308
|
+
**Modes** (determined by which parameters you provide):
|
|
309
|
+
|
|
310
|
+
Mode priority: operations > edits > symbol (without oldString) > oldString (find/replace) > content-only (${writeToolName})
|
|
311
|
+
|
|
312
|
+
1. **Multi-file transaction** — pass \`operations\` array
|
|
313
|
+
Edits across multiple files with checkpoint-based rollback on failure.
|
|
314
|
+
Each operation: \`{ "file": "path", "command": "edit_match" | "write", ... }\`.
|
|
315
|
+
For \`edit_match\`: include \`match\`, \`replacement\`. For \`write\`: include \`content\`.
|
|
316
|
+
Example: \`{ "operations": [{ "file": "a.ts", "command": "edit_match", "match": "old", "replacement": "new" }, { "file": "b.ts", "command": "write", "content": "..." }] }\`
|
|
317
|
+
|
|
318
|
+
2. **Batch edits** — pass \`filePath\` + \`edits\` array
|
|
319
|
+
Multiple edits in one file atomically. Each edit is either:
|
|
320
|
+
- \`{ "oldString": "old", "newString": "new" }\` — find/replace
|
|
321
|
+
- \`{ "startLine": 5, "endLine": 7, "content": "new lines" }\` — replace line range (1-based, both inclusive)
|
|
322
|
+
Set content to empty string to delete lines.
|
|
323
|
+
|
|
324
|
+
3. **Symbol replace** — pass \`filePath\` + \`symbol\` + \`content\`
|
|
325
|
+
Replaces an entire named symbol (function, class, type) with new content.
|
|
326
|
+
Includes decorators, attributes, and doc comments in the replacement range.
|
|
327
|
+
**Important:** You must NOT provide \`oldString\` when using symbol mode — if present, the tool silently falls back to find/replace mode.
|
|
328
|
+
Example: \`{ "filePath": "src/app.ts", "symbol": "handleRequest", "content": "function handleRequest() { ... }" }\`
|
|
329
|
+
|
|
330
|
+
4. **Find and replace** — pass \`filePath\` + \`oldString\` + \`newString\`
|
|
331
|
+
Finds the exact text in \`oldString\` and replaces it with \`newString\`.
|
|
332
|
+
Supports fuzzy matching (handles whitespace differences automatically).
|
|
333
|
+
If multiple matches exist, specify which one with \`occurrence\` or use \`replaceAll: true\`.
|
|
334
|
+
Example: \`{ "filePath": "src/app.ts", "oldString": "const x = 1", "newString": "const x = 2" }\`
|
|
335
|
+
|
|
336
|
+
5. **Replace all occurrences** — add \`replaceAll: true\`
|
|
337
|
+
Replaces every occurrence of \`oldString\` in the file.
|
|
338
|
+
Example: \`{ "filePath": "src/app.ts", "oldString": "oldName", "newString": "newName", "replaceAll": true }\`
|
|
339
|
+
|
|
340
|
+
6. **Select specific occurrence** — add \`occurrence: N\` (0-indexed)
|
|
341
|
+
When multiple matches exist, select the Nth one (0 = first, 1 = second, etc.).
|
|
342
|
+
Example: \`{ "filePath": "src/app.ts", "oldString": "TODO", "newString": "DONE", "occurrence": 0 }\`
|
|
343
|
+
|
|
344
|
+
Note: Modes 5 and 6 are options on mode 4 (find/replace) — they require \`oldString\`.
|
|
345
|
+
|
|
346
|
+
**Behavior:**
|
|
347
|
+
- Backs up files before editing (recoverable via aft_safety undo)
|
|
348
|
+
- Auto-formats using project formatter if configured
|
|
349
|
+
- Tree-sitter syntax validation on all edits
|
|
350
|
+
- Symbol replace includes decorators, attributes, and doc comments in range
|
|
351
|
+
- LSP error-level diagnostics are returned automatically after non-dry-run edits
|
|
352
|
+
|
|
353
|
+
Returns: JSON string for the selected edit mode. Dry runs return diff data; non-dry-run edits may append inline LSP error lines.
|
|
354
|
+
|
|
355
|
+
Common response fields: success (boolean), diff (object with before/after), backup_id (string), syntax_valid (boolean). Exact fields vary by mode.`;
|
|
356
|
+
// Note: The Returns section intentionally stays high-level because per-mode JSON shapes
|
|
357
|
+
// vary by Rust command and documenting each would bloat the description for minimal gain.
|
|
358
|
+
// Agents can parse the JSON response generically — key fields include 'success' and 'diff'.
|
|
359
|
+
}
|
|
360
|
+
function createEditTool(ctx, writeToolName = "write") {
|
|
361
|
+
return {
|
|
362
|
+
description: getEditDescription(writeToolName),
|
|
363
|
+
args: {
|
|
364
|
+
filePath: z
|
|
365
|
+
.string()
|
|
366
|
+
.optional()
|
|
367
|
+
.describe("Path to the file to edit (absolute or relative to project root). Required for all modes except 'operations' multi-file transactions"),
|
|
368
|
+
oldString: z.string().optional().describe("Text to find (exact match, with fuzzy fallback)"),
|
|
369
|
+
newString: z
|
|
370
|
+
.string()
|
|
371
|
+
.optional()
|
|
372
|
+
.describe("Text to replace with (omit or set to empty string to delete the matched text)"),
|
|
373
|
+
replaceAll: z.boolean().optional().describe("Replace all occurrences"),
|
|
374
|
+
occurrence: z
|
|
375
|
+
.number()
|
|
376
|
+
.optional()
|
|
377
|
+
.describe("0-indexed occurrence to replace when multiple matches exist"),
|
|
378
|
+
symbol: z.string().optional().describe("Named symbol to replace (function, class, type)"),
|
|
379
|
+
content: z.string().optional().describe("New content for symbol replace or file write"),
|
|
380
|
+
edits: z
|
|
381
|
+
.array(z.record(z.string(), z.unknown()))
|
|
382
|
+
.optional()
|
|
383
|
+
.describe("Batch edits — array of { oldString: string, newString: string } or { startLine: number (1-based), endLine: number (1-based, inclusive), content: string }"),
|
|
384
|
+
operations: z
|
|
385
|
+
.array(z.record(z.string(), z.unknown()))
|
|
386
|
+
.optional()
|
|
387
|
+
.describe("Transaction — array of { file: string, command: 'edit_match' | 'write', match?: string, replacement?: string, content?: string } for multi-file edits with rollback. Note: uses 'file'/'match'/'replacement' (not filePath/oldString/newString)"),
|
|
388
|
+
dryRun: z
|
|
389
|
+
.boolean()
|
|
390
|
+
.optional()
|
|
391
|
+
.describe("Preview changes without applying (returns diff, default: false)"),
|
|
392
|
+
},
|
|
393
|
+
execute: async (args, context) => {
|
|
394
|
+
const bridge = ctx.pool.getBridge(context.directory);
|
|
395
|
+
// Transaction mode — multi-file
|
|
396
|
+
if (Array.isArray(args.operations)) {
|
|
397
|
+
const ops = args.operations;
|
|
398
|
+
const files = ops.map((op) => op.file).filter(Boolean);
|
|
399
|
+
await context.ask({
|
|
400
|
+
permission: "edit",
|
|
401
|
+
patterns: files.map((f) => path.relative(context.worktree, path.resolve(context.directory, f))),
|
|
402
|
+
always: ["*"],
|
|
403
|
+
metadata: {},
|
|
404
|
+
});
|
|
405
|
+
const resolvedOps = ops.map((op) => ({
|
|
406
|
+
...op,
|
|
407
|
+
file: path.isAbsolute(op.file)
|
|
408
|
+
? op.file
|
|
409
|
+
: path.resolve(context.directory, op.file),
|
|
410
|
+
}));
|
|
411
|
+
const params = { operations: resolvedOps };
|
|
412
|
+
params.dry_run = args.dryRun === true;
|
|
413
|
+
const data = await bridge.send("transaction", params);
|
|
414
|
+
return JSON.stringify(data);
|
|
415
|
+
}
|
|
416
|
+
const file = args.filePath;
|
|
417
|
+
if (!file)
|
|
418
|
+
throw new Error("'filePath' parameter is required");
|
|
419
|
+
const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
|
|
420
|
+
const relPath = path.relative(context.worktree, filePath);
|
|
421
|
+
await context.ask({
|
|
422
|
+
permission: "edit",
|
|
423
|
+
patterns: [relPath],
|
|
424
|
+
always: ["*"],
|
|
425
|
+
metadata: { filepath: filePath },
|
|
426
|
+
});
|
|
427
|
+
const params = { file: filePath };
|
|
428
|
+
// Route to appropriate Rust command
|
|
429
|
+
let command;
|
|
430
|
+
if (Array.isArray(args.edits)) {
|
|
431
|
+
// Batch mode — translate camelCase to snake_case for Rust
|
|
432
|
+
command = "batch";
|
|
433
|
+
params.edits = args.edits.map((edit) => {
|
|
434
|
+
const translated = {};
|
|
435
|
+
for (const [key, value] of Object.entries(edit)) {
|
|
436
|
+
if (key === "oldString")
|
|
437
|
+
translated.match = value;
|
|
438
|
+
else if (key === "newString")
|
|
439
|
+
translated.replacement = value;
|
|
440
|
+
else if (key === "startLine")
|
|
441
|
+
translated.line_start = value;
|
|
442
|
+
else if (key === "endLine")
|
|
443
|
+
translated.line_end = value;
|
|
444
|
+
else
|
|
445
|
+
translated[key] = value;
|
|
446
|
+
}
|
|
447
|
+
return translated;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
else if (typeof args.symbol === "string" &&
|
|
451
|
+
typeof args.oldString !== "string" &&
|
|
452
|
+
args.content !== undefined) {
|
|
453
|
+
// Symbol replace — only when content is provided and oldString is NOT present
|
|
454
|
+
// (agents often pass symbol as "what to search for", not "replace whole symbol")
|
|
455
|
+
command = "edit_symbol";
|
|
456
|
+
params.symbol = args.symbol;
|
|
457
|
+
params.operation = "replace";
|
|
458
|
+
params.content = args.content;
|
|
459
|
+
}
|
|
460
|
+
else if (typeof args.oldString === "string") {
|
|
461
|
+
// Find/replace mode — default newString to "" (deletion) if not provided
|
|
462
|
+
command = "edit_match";
|
|
463
|
+
params.match = args.oldString;
|
|
464
|
+
params.replacement = args.newString ?? "";
|
|
465
|
+
if (args.replaceAll !== undefined)
|
|
466
|
+
params.replace_all = args.replaceAll;
|
|
467
|
+
if (args.occurrence !== undefined)
|
|
468
|
+
params.occurrence = args.occurrence;
|
|
469
|
+
}
|
|
470
|
+
else if (typeof args.content === "string") {
|
|
471
|
+
// Write mode
|
|
472
|
+
command = "write";
|
|
473
|
+
params.content = args.content;
|
|
474
|
+
params.create_dirs = true;
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
throw new Error("Provide 'oldString' + 'newString', 'symbol' + 'content', 'edits' array, or 'content' for write");
|
|
478
|
+
}
|
|
479
|
+
if (args.dryRun)
|
|
480
|
+
params.dry_run = true;
|
|
481
|
+
if (!args.dryRun)
|
|
482
|
+
params.diagnostics = true;
|
|
483
|
+
// Request diff from Rust for UI metadata (avoids extra file reads in TS)
|
|
484
|
+
if (!args.dryRun)
|
|
485
|
+
params.include_diff = true;
|
|
486
|
+
const data = await bridge.send(command, params);
|
|
487
|
+
// Store metadata for tool.execute.after hook (fromPlugin overwrites context.metadata)
|
|
488
|
+
if (!args.dryRun && data.success && data.diff) {
|
|
489
|
+
const diff = data.diff;
|
|
490
|
+
const callID = getCallID(context);
|
|
491
|
+
if (callID) {
|
|
492
|
+
const dp = relativeToWorktree(filePath, context.worktree);
|
|
493
|
+
const beforeContent = diff.before ?? "";
|
|
494
|
+
const afterContent = diff.after ?? "";
|
|
495
|
+
storeToolMetadata(context.sessionID, callID, {
|
|
496
|
+
title: dp,
|
|
497
|
+
metadata: {
|
|
498
|
+
diff: buildUnifiedDiff(filePath, beforeContent, afterContent),
|
|
499
|
+
filediff: {
|
|
500
|
+
file: filePath,
|
|
501
|
+
before: beforeContent,
|
|
502
|
+
after: afterContent,
|
|
503
|
+
additions: diff.additions ?? 0,
|
|
504
|
+
deletions: diff.deletions ?? 0,
|
|
505
|
+
},
|
|
506
|
+
diagnostics: {},
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
let result = JSON.stringify(data);
|
|
512
|
+
// Append inline diagnostics to output (matching write tool pattern)
|
|
513
|
+
if (!args.dryRun) {
|
|
514
|
+
const diags = data.lsp_diagnostics;
|
|
515
|
+
if (diags && diags.length > 0) {
|
|
516
|
+
const errors = diags.filter((d) => d.severity === "error");
|
|
517
|
+
if (errors.length > 0) {
|
|
518
|
+
const diagLines = errors.map((d) => ` Line ${d.line}: ${d.message}`).join("\n");
|
|
519
|
+
result += `\n\nLSP errors detected, please fix:\n${diagLines}`;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return result;
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
// APPLY_PATCH tool
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
const APPLY_PATCH_DESCRIPTION = `Apply a multi-file patch to create, update, delete, or move files in one operation.
|
|
531
|
+
|
|
532
|
+
Uses the opencode patch format with \`*** Begin Patch\` / \`*** End Patch\` markers.
|
|
533
|
+
|
|
534
|
+
**Patch format:**
|
|
535
|
+
\`\`\`
|
|
536
|
+
*** Begin Patch
|
|
537
|
+
*** Add File: path/to/new-file.ts
|
|
538
|
+
+line 1 of new file
|
|
539
|
+
+line 2 of new file
|
|
540
|
+
*** Update File: path/to/existing-file.ts
|
|
541
|
+
@@ function targetFunction()
|
|
542
|
+
-old line to remove
|
|
543
|
+
+new line to add
|
|
544
|
+
context line (unchanged, prefixed with space)
|
|
545
|
+
*** Update File: path/to/old-name.ts
|
|
546
|
+
*** Move to: path/to/new-name.ts
|
|
547
|
+
@@ import { foo }
|
|
548
|
+
-import { foo } from './old'
|
|
549
|
+
+import { foo } from './new'
|
|
550
|
+
*** Delete File: path/to/obsolete-file.ts
|
|
551
|
+
*** End Patch
|
|
552
|
+
\`\`\`
|
|
553
|
+
|
|
554
|
+
**File operations:**
|
|
555
|
+
- \`*** Add File: <path>\` — Create a new file. Every line prefixed with \`+\`.
|
|
556
|
+
- \`*** Update File: <path>\` — Patch an existing file. Uses \`@@\` context anchors.
|
|
557
|
+
- \`*** Delete File: <path>\` — Remove a file.
|
|
558
|
+
- \`*** Move to: <path>\` — After Update File header, renames the file.
|
|
559
|
+
|
|
560
|
+
**Update file syntax:**
|
|
561
|
+
- \`@@ context line\` — Anchor: finds this line in the file to locate the edit
|
|
562
|
+
- \`-line\` — Remove this line
|
|
563
|
+
- \`+line\` — Add this line
|
|
564
|
+
- \` line\` — Context line (space prefix), appears in both old and new
|
|
565
|
+
|
|
566
|
+
**Behavior:**
|
|
567
|
+
- All file changes are applied with checkpoint-based rollback — if any file fails, previous changes are rolled back (best-effort)
|
|
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
|
+
|
|
572
|
+
Returns: Status message string listing created, updated, moved, deleted, or failed file operations. May include inline LSP errors if type errors are introduced by the patch.`;
|
|
573
|
+
function createApplyPatchTool(ctx) {
|
|
574
|
+
return {
|
|
575
|
+
description: APPLY_PATCH_DESCRIPTION,
|
|
576
|
+
args: {
|
|
577
|
+
patchText: z.string().describe("The full patch text including Begin/End markers"),
|
|
578
|
+
},
|
|
579
|
+
execute: async (args, context) => {
|
|
580
|
+
const bridge = ctx.pool.getBridge(context.directory);
|
|
581
|
+
const patchText = args.patchText;
|
|
582
|
+
if (!patchText)
|
|
583
|
+
throw new Error("'patchText' is required");
|
|
584
|
+
// Parse the patch
|
|
585
|
+
let hunks;
|
|
586
|
+
try {
|
|
587
|
+
hunks = parsePatch(patchText);
|
|
588
|
+
}
|
|
589
|
+
catch (e) {
|
|
590
|
+
throw new Error(`Patch parse error: ${e instanceof Error ? e.message : e}`);
|
|
591
|
+
}
|
|
592
|
+
if (hunks.length === 0) {
|
|
593
|
+
throw new Error("Empty patch: no file operations found");
|
|
594
|
+
}
|
|
595
|
+
// Resolve all paths and ask permission
|
|
596
|
+
const allPaths = hunks.map((h) => path.relative(context.worktree, path.resolve(context.directory, h.path)));
|
|
597
|
+
await context.ask({
|
|
598
|
+
permission: "edit",
|
|
599
|
+
patterns: allPaths,
|
|
600
|
+
always: ["*"],
|
|
601
|
+
metadata: {},
|
|
602
|
+
});
|
|
603
|
+
// Checkpoint all affected files for atomic rollback
|
|
604
|
+
const checkpointName = `apply_patch_${Date.now()}`;
|
|
605
|
+
try {
|
|
606
|
+
await bridge.send("checkpoint", {
|
|
607
|
+
name: checkpointName,
|
|
608
|
+
files: allPaths.map((p) => path.resolve(context.directory, p)),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// Checkpoint failure is non-fatal — proceed without rollback protection
|
|
613
|
+
}
|
|
614
|
+
// Process each hunk, track diffs for metadata
|
|
615
|
+
const results = [];
|
|
616
|
+
let combinedBefore = "";
|
|
617
|
+
let combinedAfter = "";
|
|
618
|
+
let patchFailed = false;
|
|
619
|
+
for (const hunk of hunks) {
|
|
620
|
+
const filePath = path.resolve(context.directory, hunk.path);
|
|
621
|
+
switch (hunk.type) {
|
|
622
|
+
case "add": {
|
|
623
|
+
try {
|
|
624
|
+
await bridge.send("write", {
|
|
625
|
+
file: filePath,
|
|
626
|
+
content: hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`,
|
|
627
|
+
create_dirs: true,
|
|
628
|
+
diagnostics: true,
|
|
629
|
+
});
|
|
630
|
+
combinedAfter += hunk.contents;
|
|
631
|
+
results.push(`Created ${hunk.path}`);
|
|
632
|
+
}
|
|
633
|
+
catch (e) {
|
|
634
|
+
patchFailed = true;
|
|
635
|
+
results.push(`Failed to create ${hunk.path}: ${e instanceof Error ? e.message : e}`);
|
|
636
|
+
}
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
case "delete": {
|
|
640
|
+
try {
|
|
641
|
+
const before = await fs.promises.readFile(filePath, "utf-8").catch(() => "");
|
|
642
|
+
await bridge.send("delete_file", { file: filePath });
|
|
643
|
+
combinedBefore += before;
|
|
644
|
+
results.push(`Deleted ${hunk.path}`);
|
|
645
|
+
}
|
|
646
|
+
catch (e) {
|
|
647
|
+
patchFailed = true;
|
|
648
|
+
results.push(`Failed to delete ${hunk.path}: ${e instanceof Error ? e.message : e}`);
|
|
649
|
+
}
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
case "update": {
|
|
653
|
+
try {
|
|
654
|
+
// Read original, apply chunks, write back
|
|
655
|
+
const original = await fs.promises.readFile(filePath, "utf-8");
|
|
656
|
+
const newContent = applyUpdateChunks(original, filePath, hunk.chunks);
|
|
657
|
+
const targetPath = hunk.move_path
|
|
658
|
+
? path.resolve(context.directory, hunk.move_path)
|
|
659
|
+
: filePath;
|
|
660
|
+
const writeResult = await bridge.send("write", {
|
|
661
|
+
file: targetPath,
|
|
662
|
+
content: newContent,
|
|
663
|
+
create_dirs: true,
|
|
664
|
+
diagnostics: true,
|
|
665
|
+
});
|
|
666
|
+
// Collect diagnostics from this file
|
|
667
|
+
const diags = writeResult.lsp_diagnostics;
|
|
668
|
+
if (diags && diags.length > 0) {
|
|
669
|
+
const errors = diags.filter((d) => d.severity === "error");
|
|
670
|
+
if (errors.length > 0) {
|
|
671
|
+
const relPath = path.relative(context.worktree, targetPath);
|
|
672
|
+
const diagLines = errors.map((d) => ` Line ${d.line}: ${d.message}`).join("\n");
|
|
673
|
+
results.push(`\nLSP errors detected in ${relPath}, please fix:\n${diagLines}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// Track diff for metadata
|
|
677
|
+
combinedBefore += original;
|
|
678
|
+
combinedAfter += newContent;
|
|
679
|
+
if (hunk.move_path) {
|
|
680
|
+
await bridge.send("delete_file", { file: filePath });
|
|
681
|
+
results.push(`Updated and moved ${hunk.path} → ${hunk.move_path}`);
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
results.push(`Updated ${hunk.path}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
catch (e) {
|
|
688
|
+
patchFailed = true;
|
|
689
|
+
results.push(`Failed to update ${hunk.path}: ${e instanceof Error ? e.message : e}`);
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// On failure, restore checkpoint to undo partial changes
|
|
697
|
+
if (patchFailed) {
|
|
698
|
+
try {
|
|
699
|
+
await bridge.send("restore_checkpoint", { name: checkpointName });
|
|
700
|
+
results.push("Patch failed — restored files to pre-patch state.");
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
results.push("Patch failed — checkpoint restore also failed, files may be inconsistent.");
|
|
704
|
+
}
|
|
705
|
+
return results.join("\n");
|
|
706
|
+
}
|
|
707
|
+
// Store metadata for tool.execute.after hook (match opencode built-in format)
|
|
708
|
+
const callID = getCallID(context);
|
|
709
|
+
if (callID) {
|
|
710
|
+
// Build per-file metadata matching opencode's files array
|
|
711
|
+
const files = hunks.map((h) => {
|
|
712
|
+
const relPath = path.relative(context.worktree, path.resolve(context.directory, h.path));
|
|
713
|
+
return {
|
|
714
|
+
filePath: path.resolve(context.directory, h.path),
|
|
715
|
+
relativePath: relPath,
|
|
716
|
+
type: h.type,
|
|
717
|
+
};
|
|
718
|
+
});
|
|
719
|
+
// Build title matching built-in: "Success. Updated the following files:\nM path/to/file.ts"
|
|
720
|
+
const fileList = files
|
|
721
|
+
.map((f) => {
|
|
722
|
+
const prefix = f.type === "add" ? "A" : f.type === "delete" ? "D" : "M";
|
|
723
|
+
return `${prefix} ${f.relativePath}`;
|
|
724
|
+
})
|
|
725
|
+
.join("\n");
|
|
726
|
+
const title = `Success. Updated the following files:\n${fileList}`;
|
|
727
|
+
storeToolMetadata(context.sessionID, callID, {
|
|
728
|
+
title,
|
|
729
|
+
metadata: {
|
|
730
|
+
diff: buildUnifiedDiff(files.length === 1 ? files[0].filePath : "patch", combinedBefore, combinedAfter),
|
|
731
|
+
files,
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
return results.join("\n");
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
// Delete
|
|
741
|
+
// ---------------------------------------------------------------------------
|
|
742
|
+
const DELETE_DESCRIPTION = "Delete a file with backup (recoverable via aft_safety undo).\n\n" +
|
|
743
|
+
"Returns: { file, deleted, backup_id } on success.\n" +
|
|
744
|
+
"The file content is backed up before deletion — use aft_safety undo to recover if needed.";
|
|
745
|
+
function createDeleteTool(ctx) {
|
|
746
|
+
return {
|
|
747
|
+
description: DELETE_DESCRIPTION,
|
|
748
|
+
args: {
|
|
749
|
+
filePath: z
|
|
750
|
+
.string()
|
|
751
|
+
.describe("Path to file to delete (absolute or relative to project root)"),
|
|
752
|
+
},
|
|
753
|
+
execute: async (args, context) => {
|
|
754
|
+
const bridge = ctx.pool.getBridge(context.directory);
|
|
755
|
+
const filePath = path.isAbsolute(args.filePath)
|
|
756
|
+
? args.filePath
|
|
757
|
+
: path.resolve(context.directory, args.filePath);
|
|
758
|
+
await context.ask({
|
|
759
|
+
permission: "edit",
|
|
760
|
+
patterns: [filePath],
|
|
761
|
+
always: ["*"],
|
|
762
|
+
metadata: { action: "delete" },
|
|
763
|
+
});
|
|
764
|
+
const result = await bridge.send("delete_file", { file: filePath });
|
|
765
|
+
return JSON.stringify(result);
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
// Move / Rename
|
|
771
|
+
// ---------------------------------------------------------------------------
|
|
772
|
+
const MOVE_DESCRIPTION = "Move or rename a file with backup (recoverable via aft_safety undo).\n\n" +
|
|
773
|
+
"Creates parent directories for destination automatically.\n" +
|
|
774
|
+
"Falls back to copy+delete for cross-filesystem moves.\n" +
|
|
775
|
+
"Returns: { file, destination, moved, backup_id } on success.\n\n" +
|
|
776
|
+
"Note: This moves/renames files at the OS level. To move a code symbol (function, class) to another file while updating all imports, use aft_refactor with op='move' instead.";
|
|
777
|
+
function createMoveTool(ctx) {
|
|
778
|
+
return {
|
|
779
|
+
description: MOVE_DESCRIPTION,
|
|
780
|
+
args: {
|
|
781
|
+
filePath: z
|
|
782
|
+
.string()
|
|
783
|
+
.describe("Source file path to move (absolute or relative to project root)"),
|
|
784
|
+
destination: z
|
|
785
|
+
.string()
|
|
786
|
+
.describe("Destination file path (absolute or relative to project root)"),
|
|
787
|
+
},
|
|
788
|
+
execute: async (args, context) => {
|
|
789
|
+
const bridge = ctx.pool.getBridge(context.directory);
|
|
790
|
+
const filePath = path.isAbsolute(args.filePath)
|
|
791
|
+
? args.filePath
|
|
792
|
+
: path.resolve(context.directory, args.filePath);
|
|
793
|
+
const destPath = path.isAbsolute(args.destination)
|
|
794
|
+
? args.destination
|
|
795
|
+
: path.resolve(context.directory, args.destination);
|
|
796
|
+
await context.ask({
|
|
797
|
+
permission: "edit",
|
|
798
|
+
patterns: [filePath, destPath],
|
|
799
|
+
always: ["*"],
|
|
800
|
+
metadata: { action: "move" },
|
|
801
|
+
});
|
|
802
|
+
const result = await bridge.send("move_file", {
|
|
803
|
+
file: filePath,
|
|
804
|
+
destination: destPath,
|
|
805
|
+
});
|
|
806
|
+
return JSON.stringify(result);
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
// Exports
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
/**
|
|
814
|
+
* Returns hoisted tools keyed by opencode's built-in names.
|
|
815
|
+
* Overrides: read, write, edit, apply_patch.
|
|
816
|
+
*/
|
|
817
|
+
export function hoistedTools(ctx) {
|
|
818
|
+
return {
|
|
819
|
+
read: createReadTool(ctx),
|
|
820
|
+
write: createWriteTool(ctx, "edit"),
|
|
821
|
+
edit: createEditTool(ctx, "write"),
|
|
822
|
+
apply_patch: createApplyPatchTool(ctx),
|
|
823
|
+
aft_delete: createDeleteTool(ctx),
|
|
824
|
+
aft_move: createMoveTool(ctx),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Returns the same tools with aft_ prefix (for when hoisting is disabled).
|
|
829
|
+
*/
|
|
830
|
+
export function aftPrefixedTools(ctx) {
|
|
831
|
+
const aftEditTool = createEditTool(ctx, "aft_write");
|
|
832
|
+
return {
|
|
833
|
+
aft_read: createReadTool(ctx),
|
|
834
|
+
aft_write: createWriteTool(ctx, "aft_edit"),
|
|
835
|
+
aft_edit: {
|
|
836
|
+
...aftEditTool,
|
|
837
|
+
execute: async (args, context) => {
|
|
838
|
+
const argRecord = args;
|
|
839
|
+
const normalizedArgs = argRecord.mode !== undefined &&
|
|
840
|
+
argRecord.filePath === undefined &&
|
|
841
|
+
typeof argRecord.file === "string"
|
|
842
|
+
? { ...argRecord, filePath: argRecord.file }
|
|
843
|
+
: argRecord;
|
|
844
|
+
return aftEditTool.execute(normalizedArgs, context);
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
aft_apply_patch: createApplyPatchTool(ctx),
|
|
848
|
+
aft_delete: createDeleteTool(ctx),
|
|
849
|
+
aft_move: createMoveTool(ctx),
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
//# sourceMappingURL=hoisted.js.map
|