aether-code 0.11.1 → 0.13.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/src/tools.js CHANGED
@@ -1,621 +1,803 @@
1
- // Tool implementations + JSON-schema definitions.
2
- //
3
- // Safety model:
4
- // - read_file, list_dir, search_files: auto-execute (read-only)
5
- // - write_file, edit_file: show diff, require y/n confirmation (or --yes flag)
6
- // - run_shell: show command, require y/n confirmation (or --yes flag)
7
- //
8
- // Path safety: every path is resolved against `cwd` and rejected if it
9
- // escapes `cwd` — unless the user explicitly passes --unsafe-paths.
10
-
11
- import fs from "node:fs";
12
- import path from "node:path";
13
- import readline from "node:readline";
14
- import { spawn } from "node:child_process";
15
- import { c } from "./render.js";
16
- import { unifiedDiff, summarizeWrite } from "./diff.js";
17
- import { getConfig } from "./config.js";
18
-
19
- /* ─────────────────────── Tool definitions (sent to model) ─────────────────────── */
20
-
21
- export const TOOL_DEFINITIONS = [
22
- {
23
- type: "function",
24
- function: {
25
- name: "read_file",
26
- description:
27
- "Read the contents of a file as UTF-8 text. Returns the file contents or an error if the file doesn't exist.",
28
- parameters: {
29
- type: "object",
30
- properties: {
31
- path: { type: "string", description: "Path relative to the working directory, or absolute." },
32
- },
33
- required: ["path"],
34
- },
35
- },
36
- },
37
- {
38
- type: "function",
39
- function: {
40
- name: "list_dir",
41
- description:
42
- "List the entries in a directory. Returns an array of {name, type: 'file'|'dir', size?: number}. Hidden files (starting with .) are excluded by default.",
43
- parameters: {
44
- type: "object",
45
- properties: {
46
- path: { type: "string", description: "Directory path." },
47
- include_hidden: { type: "boolean", description: "Include dotfiles. Default: false." },
48
- },
49
- required: ["path"],
50
- },
51
- },
52
- },
53
- {
54
- type: "function",
55
- function: {
56
- name: "search_files",
57
- description:
58
- "Recursively search for a regex pattern across files in a directory. Returns matching file paths and the matching line. Limited to 50 results.",
59
- parameters: {
60
- type: "object",
61
- properties: {
62
- path: { type: "string", description: "Directory to search." },
63
- pattern: { type: "string", description: "JavaScript-style regex (without slashes)." },
64
- glob: { type: "string", description: "Optional file-name glob filter, e.g. '*.ts'." },
65
- },
66
- required: ["path", "pattern"],
67
- },
68
- },
69
- },
70
- {
71
- type: "function",
72
- function: {
73
- name: "write_file",
74
- description:
75
- "Create or completely overwrite a file with the given content. The user will be shown a diff and may decline. If the parent directory doesn't exist, it will be created.",
76
- parameters: {
77
- type: "object",
78
- properties: {
79
- path: { type: "string", description: "File path." },
80
- content: { type: "string", description: "Full file content to write." },
81
- },
82
- required: ["path", "content"],
83
- },
84
- },
85
- },
86
- {
87
- type: "function",
88
- function: {
89
- name: "edit_file",
90
- description:
91
- "Replace exactly one occurrence of `find` with `replace` in an existing file. Use this for targeted edits instead of rewriting whole files. Fails if `find` is not found or appears more than once.",
92
- parameters: {
93
- type: "object",
94
- properties: {
95
- path: { type: "string", description: "File path." },
96
- find: { type: "string", description: "Exact text to replace (must appear exactly once)." },
97
- replace: { type: "string", description: "Text to substitute in." },
98
- },
99
- required: ["path", "find", "replace"],
100
- },
101
- },
102
- },
103
- {
104
- type: "function",
105
- function: {
106
- name: "run_shell",
107
- description:
108
- "Run a shell command and return its stdout, stderr, and exit code. The user will be shown the command and may decline. Used for builds, tests, package installs, git operations, etc.",
109
- parameters: {
110
- type: "object",
111
- properties: {
112
- command: { type: "string", description: "The shell command to run." },
113
- cwd: { type: "string", description: "Optional working directory (relative or absolute)." },
114
- },
115
- required: ["command"],
116
- },
117
- },
118
- },
119
- {
120
- type: "function",
121
- function: {
122
- name: "web_search",
123
- description:
124
- "Search the live web for a query and return a JSON array of {title, url, snippet} results. Use this to find current docs, recent libraries, API references, or anything that may have changed since training. ALWAYS prefer this over guessing at library APIs. Cost: ~3–8 credits per call.",
125
- parameters: {
126
- type: "object",
127
- properties: {
128
- query: { type: "string", description: "Plain-language search query." },
129
- max_results: { type: "number", description: "How many results to return (1–10, default 5)." },
130
- },
131
- required: ["query"],
132
- },
133
- },
134
- },
135
- {
136
- type: "function",
137
- function: {
138
- name: "web_fetch",
139
- description:
140
- "Fetch a URL and return its content as plain text (HTML scripts/styles stripped, tags removed, entities decoded). Use this after web_search to read the actual docs page. NEVER pass a URL you didn't get from a real source — only http:// or https:// is allowed. Caps response at 50 KB of text.",
141
- parameters: {
142
- type: "object",
143
- properties: {
144
- url: { type: "string", description: "Full http(s) URL to fetch." },
145
- },
146
- required: ["url"],
147
- },
148
- },
149
- },
150
- {
151
- type: "function",
152
- function: {
153
- name: "todo_write",
154
- description:
155
- "Replace the current todo list with a new state. Use this at the start of any task with 3+ steps to plan upfront, then call again to mark items 'in_progress' as you start them and 'completed' as you finish. Visible progress for the user; structural discipline for you. Status must be one of: 'pending', 'in_progress', 'completed'. Max 30 items per list.",
156
- parameters: {
157
- type: "object",
158
- properties: {
159
- todos: {
160
- type: "array",
161
- description: "Full replacement list (latest-wins semantics).",
162
- items: {
163
- type: "object",
164
- properties: {
165
- content: { type: "string", description: "Short imperative phrase, e.g. 'wire endpoint into UI'." },
166
- status: {
167
- type: "string",
168
- enum: ["pending", "in_progress", "completed"],
169
- description: "Task status.",
170
- },
171
- },
172
- required: ["content", "status"],
173
- },
174
- },
175
- },
176
- required: ["todos"],
177
- },
178
- },
179
- },
180
- ];
181
-
182
- /* ─────────────────────── Helpers ─────────────────────── */
183
-
184
- function resolveSafe(rel, opts) {
185
- const abs = path.isAbsolute(rel) ? path.normalize(rel) : path.resolve(opts.cwd, rel);
186
- if (!opts.unsafePaths) {
187
- const cwd = path.resolve(opts.cwd);
188
- if (!abs.startsWith(cwd + path.sep) && abs !== cwd) {
189
- throw new Error(
190
- `Refusing to touch path outside cwd: ${abs}\n Run with --unsafe-paths if you really mean this.`,
191
- );
192
- }
193
- }
194
- return abs;
195
- }
196
-
197
- function ask(question) {
198
- if (!process.stdin.isTTY) {
199
- return Promise.resolve("n"); // can't prompt in non-TTY; default no
200
- }
201
- return new Promise((resolve) => {
202
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
203
- rl.question(question, (answer) => {
204
- rl.close();
205
- resolve(answer.trim().toLowerCase());
206
- });
207
- });
208
- }
209
-
210
- async function confirm(question, autoYes) {
211
- if (autoYes) return true;
212
- const ans = await ask(`${question} ${c.dim("[y/N]: ")}`);
213
- return ans === "y" || ans === "yes";
214
- }
215
-
216
- /* ─────────────────────── Implementations ─────────────────────── */
217
-
218
- export async function executeTool(call, opts) {
219
- let args;
220
- try {
221
- args = JSON.parse(call.function.arguments || "{}");
222
- } catch (e) {
223
- return { ok: false, output: `Invalid JSON arguments: ${e.message}` };
224
- }
225
-
226
- const name = call.function.name;
227
- const handlers = {
228
- read_file: () => readFile(args, opts),
229
- list_dir: () => listDir(args, opts),
230
- search_files: () => searchFiles(args, opts),
231
- write_file: () => writeFile(args, opts),
232
- edit_file: () => editFile(args, opts),
233
- run_shell: () => runShell(args, opts),
234
- web_search: () => webSearch(args, opts),
235
- web_fetch: () => webFetch(args, opts),
236
- todo_write: () => todoWrite(args, opts),
237
- };
238
- const fn = handlers[name];
239
- if (!fn) {
240
- return { ok: false, output: `Unknown tool: ${name}` };
241
- }
242
- try {
243
- return await fn();
244
- } catch (e) {
245
- return { ok: false, output: `${name} failed: ${e.message}` };
246
- }
247
- }
248
-
249
- function readFile(args, opts) {
250
- if (typeof args.path !== "string") return { ok: false, output: "path is required" };
251
- const abs = resolveSafe(args.path, opts);
252
- const stat = fs.statSync(abs);
253
- if (stat.isDirectory()) return { ok: false, output: `${args.path} is a directory, not a file` };
254
- if (stat.size > 1_000_000) {
255
- return { ok: false, output: `File too large (${stat.size} bytes). Aether refuses to read >1MB at once.` };
256
- }
257
- const text = fs.readFileSync(abs, "utf8");
258
- return { ok: true, output: text };
259
- }
260
-
261
- function listDir(args, opts) {
262
- if (typeof args.path !== "string") return { ok: false, output: "path is required" };
263
- const abs = resolveSafe(args.path, opts);
264
- const entries = fs.readdirSync(abs, { withFileTypes: true });
265
- const results = [];
266
- for (const e of entries) {
267
- if (!args.include_hidden && e.name.startsWith(".")) continue;
268
- if (e.name === "node_modules" || e.name === ".git" || e.name === "dist") continue;
269
- let size = undefined;
270
- if (e.isFile()) {
271
- try { size = fs.statSync(path.join(abs, e.name)).size; } catch { /* skip */ }
272
- }
273
- results.push({
274
- name: e.name,
275
- type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other",
276
- size,
277
- });
278
- }
279
- results.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
280
- return { ok: true, output: JSON.stringify(results, null, 2) };
281
- }
282
-
283
- function searchFiles(args, opts) {
284
- if (typeof args.path !== "string" || typeof args.pattern !== "string") {
285
- return { ok: false, output: "path and pattern are required" };
286
- }
287
- let regex;
288
- try { regex = new RegExp(args.pattern); } catch (e) {
289
- return { ok: false, output: `Invalid regex: ${e.message}` };
290
- }
291
- const root = resolveSafe(args.path, opts);
292
- const matches = [];
293
- const globRe = args.glob ? new RegExp("^" + args.glob.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$") : null;
294
-
295
- function walk(dir) {
296
- if (matches.length >= 50) return;
297
- let entries;
298
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
299
- for (const e of entries) {
300
- if (matches.length >= 50) return;
301
- if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue;
302
- const full = path.join(dir, e.name);
303
- if (e.isDirectory()) {
304
- walk(full);
305
- } else if (e.isFile()) {
306
- if (globRe && !globRe.test(e.name)) continue;
307
- let content;
308
- try {
309
- const stat = fs.statSync(full);
310
- if (stat.size > 500_000) continue;
311
- content = fs.readFileSync(full, "utf8");
312
- } catch { continue; }
313
- const lines = content.split("\n");
314
- for (let i = 0; i < lines.length; i++) {
315
- if (regex.test(lines[i])) {
316
- matches.push({ file: path.relative(opts.cwd, full), line: i + 1, text: lines[i].slice(0, 300) });
317
- if (matches.length >= 50) return;
318
- }
319
- }
320
- }
321
- }
322
- }
323
- walk(root);
324
- return { ok: true, output: JSON.stringify(matches, null, 2) };
325
- }
326
-
327
- async function writeFile(args, opts) {
328
- if (typeof args.path !== "string" || typeof args.content !== "string") {
329
- return { ok: false, output: "path and content are required" };
330
- }
331
- const abs = resolveSafe(args.path, opts);
332
- const exists = fs.existsSync(abs);
333
- const oldContent = exists ? fs.readFileSync(abs, "utf8") : null;
334
- if (exists && oldContent === args.content) {
335
- return { ok: true, output: `(no change — file already matches)` };
336
- }
337
- // Show diff + confirm
338
- console.log("");
339
- console.log(summarizeWrite(oldContent, args.content, path.relative(opts.cwd, abs)));
340
- console.log(unifiedDiff(oldContent ?? "", args.content, path.relative(opts.cwd, abs)));
341
- const approved = await confirm(c.yellow("Apply this write?"), opts.autoYes);
342
- if (!approved) {
343
- return { ok: false, output: "User declined the write." };
344
- }
345
- fs.mkdirSync(path.dirname(abs), { recursive: true });
346
- fs.writeFileSync(abs, args.content, "utf8");
347
- return { ok: true, output: `Wrote ${args.content.length} bytes to ${path.relative(opts.cwd, abs)}` };
348
- }
349
-
350
- async function editFile(args, opts) {
351
- if (typeof args.path !== "string" || typeof args.find !== "string" || typeof args.replace !== "string") {
352
- return { ok: false, output: "path, find, replace are required" };
353
- }
354
- const abs = resolveSafe(args.path, opts);
355
- if (!fs.existsSync(abs)) return { ok: false, output: `File not found: ${args.path}` };
356
- const oldContent = fs.readFileSync(abs, "utf8");
357
- const occurrences = oldContent.split(args.find).length - 1;
358
- if (occurrences === 0) {
359
- return { ok: false, output: `\`find\` text not found in ${args.path}. Tip: read the file first to copy exact characters.` };
360
- }
361
- if (occurrences > 1) {
362
- return {
363
- ok: false,
364
- output: `\`find\` text appears ${occurrences} times — must be unique. Add more context to disambiguate.`,
365
- };
366
- }
367
- const newContent = oldContent.replace(args.find, args.replace);
368
- console.log("");
369
- console.log(c.dim(`edit ${path.relative(opts.cwd, abs)}`));
370
- console.log(unifiedDiff(oldContent, newContent, path.relative(opts.cwd, abs)));
371
- const approved = await confirm(c.yellow("Apply this edit?"), opts.autoYes);
372
- if (!approved) return { ok: false, output: "User declined the edit." };
373
- fs.writeFileSync(abs, newContent, "utf8");
374
- return { ok: true, output: `Edited ${path.relative(opts.cwd, abs)}` };
375
- }
376
-
377
- /* ─────────────────────── Web tools ─────────────────────── */
378
-
379
- async function webSearch(args, opts) {
380
- void opts;
381
- if (typeof args.query !== "string") return { ok: false, output: "query is required" };
382
- const { apiKey, baseUrl } = getConfig();
383
- if (!apiKey) {
384
- return { ok: false, output: "Web search requires AETHER_API_KEY. Set it and try again." };
385
- }
386
- const max = Number.isInteger(args.max_results) ? Math.min(10, Math.max(1, args.max_results)) : 5;
387
- let res;
388
- try {
389
- res = await fetch(`${baseUrl}/api/v1/web-search`, {
390
- method: "POST",
391
- headers: {
392
- "Content-Type": "application/json",
393
- Authorization: `Bearer ${apiKey}`,
394
- "User-Agent": "aether-code/web-search",
395
- },
396
- body: JSON.stringify({ query: args.query, max_results: max }),
397
- });
398
- } catch (e) {
399
- return { ok: false, output: `web_search network error: ${e.message}` };
400
- }
401
- let data = null;
402
- try { data = await res.json(); } catch { /* non-JSON */ }
403
- if (!res.ok) {
404
- return { ok: false, output: data?.error || `web_search HTTP ${res.status}` };
405
- }
406
- // Hand the model just the array — that's all it needs to decide which URL to fetch.
407
- return { ok: true, output: JSON.stringify(data.results ?? [], null, 2) };
408
- }
409
-
410
- // Bounded fetch with a fixed timeout + size cap. Strips scripts/styles, removes
411
- // tags, decodes common HTML entities. Not a full HTML parser; good enough for
412
- // reading docs pages, GitHub READMEs, MDN, Stack Overflow answers, etc.
413
- async function webFetch(args, opts) {
414
- void opts;
415
- if (typeof args.url !== "string") return { ok: false, output: "url is required" };
416
- if (!/^https?:\/\//i.test(args.url)) {
417
- return { ok: false, output: "Only http:// and https:// URLs are allowed." };
418
- }
419
- const controller = new AbortController();
420
- const timeout = setTimeout(() => controller.abort(), 15_000);
421
- let res;
422
- try {
423
- res = await fetch(args.url, {
424
- signal: controller.signal,
425
- redirect: "follow",
426
- headers: {
427
- // Looking like a normal browser dodges many anti-bot pages.
428
- "User-Agent":
429
- "Mozilla/5.0 (compatible; aether-code/0.7) Gecko/20100101 Firefox/130.0",
430
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
431
- "Accept-Language": "en-US,en;q=0.9",
432
- },
433
- });
434
- } catch (e) {
435
- clearTimeout(timeout);
436
- return { ok: false, output: `web_fetch error: ${e.name === "AbortError" ? "timed out after 15s" : e.message}` };
437
- }
438
- clearTimeout(timeout);
439
- if (!res.ok) {
440
- return { ok: false, output: `web_fetch HTTP ${res.status} ${res.statusText}` };
441
- }
442
- // Cap at 2 MB of raw HTML before stripping — page might be huge.
443
- const reader = res.body?.getReader();
444
- if (!reader) return { ok: false, output: "Response had no body." };
445
- const chunks = [];
446
- let total = 0;
447
- while (true) {
448
- const { value, done } = await reader.read();
449
- if (done) break;
450
- total += value.length;
451
- if (total > 2_000_000) {
452
- reader.cancel();
453
- break;
454
- }
455
- chunks.push(value);
456
- }
457
- const html = new TextDecoder("utf-8", { fatal: false }).decode(
458
- Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))),
459
- );
460
- const text = htmlToText(html);
461
- // Final cap on what we hand to the model so a single fetch doesn't blow the context.
462
- const capped = text.length > 50_000 ? text.slice(0, 50_000) + "\n…(truncated; page was longer)" : text;
463
- return { ok: true, output: capped };
464
- }
465
-
466
- export function htmlToText(html) {
467
- let s = html;
468
- // Drop script/style blocks entirely.
469
- s = s.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, " ");
470
- s = s.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, " ");
471
- s = s.replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, " ");
472
- // Preserve paragraph + heading breaks.
473
- s = s.replace(/<\/(p|div|h[1-6]|li|tr|br)\s*>/gi, "\n");
474
- s = s.replace(/<br\s*\/?>/gi, "\n");
475
- // Strip remaining tags.
476
- s = s.replace(/<[^>]+>/g, "");
477
- // Decode common HTML entities (covers ~95% of real-world cases).
478
- const entities = {
479
- "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'",
480
- "&apos;": "'", "&nbsp;": " ", "&mdash;": "—", "&ndash;": "–",
481
- "&hellip;": "…", "&copy;": "©", "&reg;": "®", "&trade;": "™",
482
- };
483
- for (const [ent, ch] of Object.entries(entities)) {
484
- s = s.split(ent).join(ch);
485
- }
486
- s = s.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)));
487
- s = s.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)));
488
- // Collapse whitespace.
489
- s = s.replace(/[ \t]+/g, " ");
490
- s = s.replace(/\n\s*\n\s*\n+/g, "\n\n");
491
- return s.trim();
492
- }
493
-
494
- async function runShell(args, opts) {
495
- if (typeof args.command !== "string") return { ok: false, output: "command is required" };
496
- const cwd = args.cwd ? resolveSafe(args.cwd, opts) : opts.cwd;
497
- console.log("");
498
- console.log(c.yellow("$ ") + c.bold(args.command) + (args.cwd ? c.dim(` (cwd: ${args.cwd})`) : ""));
499
- const approved = await confirm(c.yellow("Run this command?"), opts.autoYes);
500
- if (!approved) return { ok: false, output: "User declined the command." };
501
-
502
- return new Promise((resolve) => {
503
- const child = spawn(args.command, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
504
- let stdout = "";
505
- let stderr = "";
506
- let killed = false;
507
- const timeout = setTimeout(() => {
508
- killed = true;
509
- child.kill("SIGTERM");
510
- }, 120_000); // 2-minute hard cap per command
511
-
512
- child.stdout.on("data", (d) => {
513
- const s = d.toString();
514
- stdout += s;
515
- if (stdout.length < 80_000) process.stdout.write(c.dim(s));
516
- });
517
- child.stderr.on("data", (d) => {
518
- const s = d.toString();
519
- stderr += s;
520
- if (stderr.length < 80_000) process.stderr.write(c.dim(s));
521
- });
522
- child.on("close", (code) => {
523
- clearTimeout(timeout);
524
- // Truncate huge outputs before sending back to the model
525
- const truncate = (t) => (t.length > 20_000 ? t.slice(0, 20_000) + "\n…(truncated)" : t);
526
- const out = JSON.stringify(
527
- {
528
- exit_code: code,
529
- killed,
530
- stdout: truncate(stdout),
531
- stderr: truncate(stderr),
532
- },
533
- null,
534
- 2,
535
- );
536
- resolve({ ok: code === 0 && !killed, output: out });
537
- });
538
- });
539
- }
540
-
541
- /* ─────────────────────── todo_write ─────────────────────── */
542
-
543
- // Module-level singleton holding the current todo list for this CLI session.
544
- // Latest-wins: each todo_write call replaces the entire list. The model
545
- // passes the full new state every time, mirroring what Claude Code's
546
- // TodoWrite does. Simpler than incremental ops; gives the model full
547
- // control over ordering and renames.
548
- const VALID_TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
549
- const MAX_TODOS = 30;
550
- let todoState = [];
551
-
552
- // Test-only escape hatches — exported so the test suite can reset state
553
- // between cases. Production code never touches these.
554
- export function __resetTodoState() {
555
- todoState = [];
556
- }
557
- export function __getTodoState() {
558
- return todoState.map((t) => ({ ...t }));
559
- }
560
-
561
- function todoWrite(args, opts) {
562
- void opts;
563
- if (!Array.isArray(args.todos)) {
564
- return { ok: false, output: "todos must be an array" };
565
- }
566
- if (args.todos.length > MAX_TODOS) {
567
- return {
568
- ok: false,
569
- output: `too many todos (${args.todos.length}) — max ${MAX_TODOS}. Keep the plan focused.`,
570
- };
571
- }
572
- // Validate every item BEFORE mutating state — we don't want a partial write.
573
- for (let i = 0; i < args.todos.length; i++) {
574
- const t = args.todos[i];
575
- if (!t || typeof t !== "object") {
576
- return { ok: false, output: `todo[${i}] must be an object` };
577
- }
578
- if (typeof t.content !== "string" || t.content.trim().length === 0) {
579
- return { ok: false, output: `todo[${i}] needs a non-empty content string` };
580
- }
581
- if (!VALID_TODO_STATUSES.has(t.status)) {
582
- return {
583
- ok: false,
584
- output: `todo[${i}] invalid status: "${t.status}" must be pending, in_progress, or completed`,
585
- };
586
- }
587
- }
588
- todoState = args.todos.map((t) => ({
589
- content: t.content.trim(),
590
- status: t.status,
591
- }));
592
- renderTodos(todoState);
593
- const counts = { pending: 0, in_progress: 0, completed: 0 };
594
- for (const t of todoState) counts[t.status]++;
595
- return {
596
- ok: true,
597
- output: `Todos updated: ${counts.pending} pending, ${counts.in_progress} in_progress, ${counts.completed} completed.`,
598
- };
599
- }
600
-
601
- function renderTodos(todos) {
602
- if (!process.stdout.isTTY) return; // skip render in non-TTY (CI, piped, tests)
603
- console.log("");
604
- console.log(c.dim("─── Plan ───"));
605
- for (const t of todos) {
606
- const icon =
607
- t.status === "completed"
608
- ? c.green("✓")
609
- : t.status === "in_progress"
610
- ? c.yellow("")
611
- : c.dim("○");
612
- const text =
613
- t.status === "completed"
614
- ? c.dim(t.content)
615
- : t.status === "in_progress"
616
- ? c.bold(t.content)
617
- : t.content;
618
- console.log(` ${icon} ${text}`);
619
- }
620
- console.log("");
621
- }
1
+ // Tool implementations + JSON-schema definitions.
2
+ //
3
+ // Safety model:
4
+ // - read_file, list_dir, search_files: auto-execute (read-only)
5
+ // - write_file, edit_file: show diff, require y/n confirmation (or --yes flag)
6
+ // - run_shell: show command, require y/n confirmation (or --yes flag)
7
+ //
8
+ // Path safety: every path is resolved against `cwd` and rejected if it
9
+ // escapes `cwd` — unless the user explicitly passes --unsafe-paths.
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import readline from "node:readline";
14
+ import { spawn } from "node:child_process";
15
+ import { c } from "./render.js";
16
+ import { unifiedDiff, summarizeWrite } from "./diff.js";
17
+ import { getConfig } from "./config.js";
18
+
19
+ /* ─────────────────────── Tool definitions (sent to model) ─────────────────────── */
20
+
21
+ export const TOOL_DEFINITIONS = [
22
+ {
23
+ type: "function",
24
+ function: {
25
+ name: "read_file",
26
+ description:
27
+ "Read the contents of a file as UTF-8 text. Returns the file contents or an error if the file doesn't exist.",
28
+ parameters: {
29
+ type: "object",
30
+ properties: {
31
+ path: { type: "string", description: "Path relative to the working directory, or absolute." },
32
+ },
33
+ required: ["path"],
34
+ },
35
+ },
36
+ },
37
+ {
38
+ type: "function",
39
+ function: {
40
+ name: "list_dir",
41
+ description:
42
+ "List the entries in a directory. Returns an array of {name, type: 'file'|'dir', size?: number}. Hidden files (starting with .) are excluded by default.",
43
+ parameters: {
44
+ type: "object",
45
+ properties: {
46
+ path: { type: "string", description: "Directory path." },
47
+ include_hidden: { type: "boolean", description: "Include dotfiles. Default: false." },
48
+ },
49
+ required: ["path"],
50
+ },
51
+ },
52
+ },
53
+ {
54
+ type: "function",
55
+ function: {
56
+ name: "search_files",
57
+ description:
58
+ "Recursively search for a regex pattern across files in a directory. Returns matching file paths and the matching line. Limited to 50 results.",
59
+ parameters: {
60
+ type: "object",
61
+ properties: {
62
+ path: { type: "string", description: "Directory to search." },
63
+ pattern: { type: "string", description: "JavaScript-style regex (without slashes)." },
64
+ glob: { type: "string", description: "Optional file-name glob filter, e.g. '*.ts'." },
65
+ },
66
+ required: ["path", "pattern"],
67
+ },
68
+ },
69
+ },
70
+ {
71
+ type: "function",
72
+ function: {
73
+ name: "glob_files",
74
+ description:
75
+ "Find files by path pattern (no content search). Returns matching file paths sorted by most-recently-modified. Use this to locate files by name/extension/location, e.g. '**/*.ts', 'src/**/*.test.js', 'package.json'. Faster and clearer than search_files when you only need to find files, not grep their contents.",
76
+ parameters: {
77
+ type: "object",
78
+ properties: {
79
+ pattern: { type: "string", description: "Glob pattern. Supports ** (any depth), * (within a path segment), ?. e.g. 'src/**/*.js'." },
80
+ path: { type: "string", description: "Directory to search from. Default: the working directory." },
81
+ },
82
+ required: ["pattern"],
83
+ },
84
+ },
85
+ },
86
+ {
87
+ type: "function",
88
+ function: {
89
+ name: "write_file",
90
+ description:
91
+ "Create or completely overwrite a file with the given content. The user will be shown a diff and may decline. If the parent directory doesn't exist, it will be created.",
92
+ parameters: {
93
+ type: "object",
94
+ properties: {
95
+ path: { type: "string", description: "File path." },
96
+ content: { type: "string", description: "Full file content to write." },
97
+ },
98
+ required: ["path", "content"],
99
+ },
100
+ },
101
+ },
102
+ {
103
+ type: "function",
104
+ function: {
105
+ name: "edit_file",
106
+ description:
107
+ "Replace occurrences of `find` with `replace` in an existing file. Use this for targeted edits instead of rewriting whole files. By default replaces exactly one occurrence and fails if `find` is missing or appears more than once. Set `replace_all: true` to replace every occurrence.",
108
+ parameters: {
109
+ type: "object",
110
+ properties: {
111
+ path: { type: "string", description: "File path." },
112
+ find: { type: "string", description: "Exact text to replace. Must be unique unless replace_all is true." },
113
+ replace: { type: "string", description: "Text to substitute in." },
114
+ replace_all: { type: "boolean", description: "Replace ALL occurrences instead of requiring a unique match. Default: false." },
115
+ },
116
+ required: ["path", "find", "replace"],
117
+ },
118
+ },
119
+ },
120
+ {
121
+ type: "function",
122
+ function: {
123
+ name: "run_shell",
124
+ description:
125
+ "Run a shell command and return its stdout, stderr, and exit code. The user will be shown the command and may decline. Used for builds, tests, package installs, git operations, etc.",
126
+ parameters: {
127
+ type: "object",
128
+ properties: {
129
+ command: { type: "string", description: "The shell command to run." },
130
+ cwd: { type: "string", description: "Optional working directory (relative or absolute)." },
131
+ },
132
+ required: ["command"],
133
+ },
134
+ },
135
+ },
136
+ {
137
+ type: "function",
138
+ function: {
139
+ name: "web_search",
140
+ description:
141
+ "Search the live web for a query and return a JSON array of {title, url, snippet} results. Use this to find current docs, recent libraries, API references, or anything that may have changed since training. ALWAYS prefer this over guessing at library APIs. Cost: ~3–8 credits per call.",
142
+ parameters: {
143
+ type: "object",
144
+ properties: {
145
+ query: { type: "string", description: "Plain-language search query." },
146
+ max_results: { type: "number", description: "How many results to return (1–10, default 5)." },
147
+ },
148
+ required: ["query"],
149
+ },
150
+ },
151
+ },
152
+ {
153
+ type: "function",
154
+ function: {
155
+ name: "web_fetch",
156
+ description:
157
+ "Fetch a URL and return its content as plain text (HTML scripts/styles stripped, tags removed, entities decoded). Use this after web_search to read the actual docs page. NEVER pass a URL you didn't get from a real source — only http:// or https:// is allowed. Caps response at 50 KB of text.",
158
+ parameters: {
159
+ type: "object",
160
+ properties: {
161
+ url: { type: "string", description: "Full http(s) URL to fetch." },
162
+ },
163
+ required: ["url"],
164
+ },
165
+ },
166
+ },
167
+ {
168
+ type: "function",
169
+ function: {
170
+ name: "todo_write",
171
+ description:
172
+ "Replace the current todo list with a new state. Use this at the start of any task with 3+ steps to plan upfront, then call again to mark items 'in_progress' as you start them and 'completed' as you finish. Visible progress for the user; structural discipline for you. Status must be one of: 'pending', 'in_progress', 'completed'. Max 30 items per list.",
173
+ parameters: {
174
+ type: "object",
175
+ properties: {
176
+ todos: {
177
+ type: "array",
178
+ description: "Full replacement list (latest-wins semantics).",
179
+ items: {
180
+ type: "object",
181
+ properties: {
182
+ content: { type: "string", description: "Short imperative phrase, e.g. 'wire endpoint into UI'." },
183
+ status: {
184
+ type: "string",
185
+ enum: ["pending", "in_progress", "completed"],
186
+ description: "Task status.",
187
+ },
188
+ },
189
+ required: ["content", "status"],
190
+ },
191
+ },
192
+ },
193
+ required: ["todos"],
194
+ },
195
+ },
196
+ },
197
+ ];
198
+
199
+ /* ─────────────────────── Argument validation ─────────────────────── */
200
+
201
+ const TOOL_SCHEMAS = Object.fromEntries(
202
+ TOOL_DEFINITIONS.map((t) => [t.function.name, t.function.parameters]),
203
+ );
204
+
205
+ function typeMatches(val, t) {
206
+ switch (t) {
207
+ case "string": return typeof val === "string";
208
+ case "number": return typeof val === "number";
209
+ case "integer": return typeof val === "number" && Number.isInteger(val);
210
+ case "boolean": return typeof val === "boolean";
211
+ case "object": return typeof val === "object" && val !== null && !Array.isArray(val);
212
+ case "array": return Array.isArray(val);
213
+ default: return true;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Validate parsed tool arguments against the tool's JSON schema BEFORE the
219
+ * handler runs. Returns an error string the model can act on, or null if valid.
220
+ * Catches malformed/partial args from the model that would otherwise surface as
221
+ * cryptic downstream errors.
222
+ */
223
+ export function validateToolArgs(name, args) {
224
+ const schema = TOOL_SCHEMAS[name];
225
+ if (!schema) return `Unknown tool: ${name}`;
226
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
227
+ return `Arguments for ${name} must be a JSON object.`;
228
+ }
229
+ for (const req of schema.required ?? []) {
230
+ if (args[req] === undefined || args[req] === null) {
231
+ return `Missing required argument "${req}" for ${name}.`;
232
+ }
233
+ }
234
+ for (const [key, val] of Object.entries(args)) {
235
+ const prop = schema.properties?.[key];
236
+ if (!prop || val === undefined || val === null) continue;
237
+ if (prop.type && !typeMatches(val, prop.type)) {
238
+ return `Argument "${key}" for ${name} must be of type ${prop.type}.`;
239
+ }
240
+ }
241
+ return null;
242
+ }
243
+
244
+ /* ─────────────────────── Glob + ignore helpers ─────────────────────── */
245
+
246
+ const ALWAYS_SKIP = new Set([".git", "node_modules", "dist"]);
247
+ const MAX_SEARCH_MATCHES = 200;
248
+ const MAX_GLOB_RESULTS = 300;
249
+
250
+ // Convert a glob ('**', '*', '?') to an anchored regex over a POSIX-style
251
+ // relative path. ** spans directory separators; * does not.
252
+ export function globToRegExp(glob) {
253
+ let re = "";
254
+ for (let i = 0; i < glob.length; i++) {
255
+ const ch = glob[i];
256
+ if (ch === "*") {
257
+ if (glob[i + 1] === "*") {
258
+ re += ".*";
259
+ i++;
260
+ if (glob[i + 1] === "/") i++; // collapse '**/' so it can also match zero dirs
261
+ } else {
262
+ re += "[^/]*";
263
+ }
264
+ } else if (ch === "?") {
265
+ re += "[^/]";
266
+ } else if ("\\^$+.()|{}[]".includes(ch)) {
267
+ re += "\\" + ch;
268
+ } else {
269
+ re += ch;
270
+ }
271
+ }
272
+ return new RegExp("^" + re + "$");
273
+ }
274
+
275
+ // Parse a .gitignore at `root` into a matcher. Pragmatic subset of gitignore
276
+ // semantics: blank/comment lines ignored; trailing '/' = directory-only;
277
+ // leading '/' = root-anchored; '*'/'?' globs supported.
278
+ function loadIgnore(root) {
279
+ let lines = [];
280
+ try { lines = fs.readFileSync(path.join(root, ".gitignore"), "utf8").split("\n"); } catch { /* none */ }
281
+ const rules = [];
282
+ for (const raw of lines) {
283
+ const line = raw.trim();
284
+ if (!line || line.startsWith("#")) continue;
285
+ let pat = line;
286
+ const dirOnly = pat.endsWith("/");
287
+ if (dirOnly) pat = pat.slice(0, -1);
288
+ const anchored = pat.startsWith("/");
289
+ if (anchored) pat = pat.slice(1);
290
+ rules.push({ re: globToRegExp(pat), anchored, dirOnly, base: !pat.includes("/") });
291
+ }
292
+ return (relPath, isDir) => {
293
+ const norm = relPath.split(path.sep).join("/");
294
+ const baseName = norm.split("/").pop();
295
+ for (const r of rules) {
296
+ if (r.dirOnly && !isDir) continue;
297
+ if (r.base) { if (r.re.test(baseName)) return true; }
298
+ else if (r.anchored) { if (r.re.test(norm)) return true; }
299
+ else if (r.re.test(norm) || r.re.test(baseName)) return true;
300
+ }
301
+ return false;
302
+ };
303
+ }
304
+
305
+ /* ─────────────────────── Helpers ─────────────────────── */
306
+
307
+ function resolveSafe(rel, opts) {
308
+ const abs = path.isAbsolute(rel) ? path.normalize(rel) : path.resolve(opts.cwd, rel);
309
+ if (!opts.unsafePaths) {
310
+ const cwd = path.resolve(opts.cwd);
311
+ if (!abs.startsWith(cwd + path.sep) && abs !== cwd) {
312
+ throw new Error(
313
+ `Refusing to touch path outside cwd: ${abs}\n Run with --unsafe-paths if you really mean this.`,
314
+ );
315
+ }
316
+ }
317
+ return abs;
318
+ }
319
+
320
+ function ask(question) {
321
+ if (!process.stdin.isTTY) {
322
+ return Promise.resolve("n"); // can't prompt in non-TTY; default no
323
+ }
324
+ return new Promise((resolve) => {
325
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
326
+ rl.question(question, (answer) => {
327
+ rl.close();
328
+ resolve(answer.trim().toLowerCase());
329
+ });
330
+ });
331
+ }
332
+
333
+ async function confirm(question, autoYes) {
334
+ if (autoYes) return true;
335
+ const ans = await ask(`${question} ${c.dim("[y/N]: ")}`);
336
+ return ans === "y" || ans === "yes";
337
+ }
338
+
339
+ /* ─────────────────────── Implementations ─────────────────────── */
340
+
341
+ export async function executeTool(call, opts) {
342
+ let args;
343
+ try {
344
+ args = JSON.parse(call.function.arguments || "{}");
345
+ } catch (e) {
346
+ return { ok: false, output: `Invalid JSON arguments: ${e.message}` };
347
+ }
348
+
349
+ const name = call.function.name;
350
+ const validationError = validateToolArgs(name, args);
351
+ if (validationError) {
352
+ return { ok: false, output: validationError };
353
+ }
354
+ const handlers = {
355
+ read_file: () => readFile(args, opts),
356
+ list_dir: () => listDir(args, opts),
357
+ search_files: () => searchFiles(args, opts),
358
+ glob_files: () => globFiles(args, opts),
359
+ write_file: () => writeFile(args, opts),
360
+ edit_file: () => editFile(args, opts),
361
+ run_shell: () => runShell(args, opts),
362
+ web_search: () => webSearch(args, opts),
363
+ web_fetch: () => webFetch(args, opts),
364
+ todo_write: () => todoWrite(args, opts),
365
+ };
366
+ const fn = handlers[name];
367
+ if (!fn) {
368
+ return { ok: false, output: `Unknown tool: ${name}` };
369
+ }
370
+ try {
371
+ return await fn();
372
+ } catch (e) {
373
+ return { ok: false, output: `${name} failed: ${e.message}` };
374
+ }
375
+ }
376
+
377
+ function readFile(args, opts) {
378
+ if (typeof args.path !== "string") return { ok: false, output: "path is required" };
379
+ const abs = resolveSafe(args.path, opts);
380
+ const stat = fs.statSync(abs);
381
+ if (stat.isDirectory()) return { ok: false, output: `${args.path} is a directory, not a file` };
382
+ if (stat.size > 1_000_000) {
383
+ return { ok: false, output: `File too large (${stat.size} bytes). Aether refuses to read >1MB at once.` };
384
+ }
385
+ const text = fs.readFileSync(abs, "utf8");
386
+ return { ok: true, output: text };
387
+ }
388
+
389
+ function listDir(args, opts) {
390
+ if (typeof args.path !== "string") return { ok: false, output: "path is required" };
391
+ const abs = resolveSafe(args.path, opts);
392
+ const entries = fs.readdirSync(abs, { withFileTypes: true });
393
+ const results = [];
394
+ for (const e of entries) {
395
+ if (!args.include_hidden && e.name.startsWith(".")) continue;
396
+ if (e.name === "node_modules" || e.name === ".git" || e.name === "dist") continue;
397
+ let size = undefined;
398
+ if (e.isFile()) {
399
+ try { size = fs.statSync(path.join(abs, e.name)).size; } catch { /* skip */ }
400
+ }
401
+ results.push({
402
+ name: e.name,
403
+ type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other",
404
+ size,
405
+ });
406
+ }
407
+ results.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
408
+ return { ok: true, output: JSON.stringify(results, null, 2) };
409
+ }
410
+
411
+ function searchFiles(args, opts) {
412
+ if (typeof args.path !== "string" || typeof args.pattern !== "string") {
413
+ return { ok: false, output: "path and pattern are required" };
414
+ }
415
+ let regex;
416
+ try { regex = new RegExp(args.pattern); } catch (e) {
417
+ return { ok: false, output: `Invalid regex: ${e.message}` };
418
+ }
419
+ const root = resolveSafe(args.path, opts);
420
+ const isIgnored = loadIgnore(path.resolve(opts.cwd));
421
+ const matches = [];
422
+ const globRe = args.glob ? globToRegExp(args.glob) : null;
423
+ let truncated = false;
424
+
425
+ function walk(dir) {
426
+ if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
427
+ let entries;
428
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
429
+ for (const e of entries) {
430
+ if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
431
+ if (e.name.startsWith(".") || ALWAYS_SKIP.has(e.name)) continue;
432
+ const full = path.join(dir, e.name);
433
+ const rel = path.relative(opts.cwd, full);
434
+ if (isIgnored(rel, e.isDirectory())) continue;
435
+ if (e.isDirectory()) {
436
+ walk(full);
437
+ } else if (e.isFile()) {
438
+ if (globRe && !globRe.test(e.name)) continue;
439
+ let content;
440
+ try {
441
+ const stat = fs.statSync(full);
442
+ if (stat.size > 500_000) continue;
443
+ content = fs.readFileSync(full, "utf8");
444
+ } catch { continue; }
445
+ const lines = content.split("\n");
446
+ for (let i = 0; i < lines.length; i++) {
447
+ if (regex.test(lines[i])) {
448
+ matches.push({ file: rel, line: i + 1, text: lines[i].slice(0, 300) });
449
+ if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
450
+ }
451
+ }
452
+ }
453
+ }
454
+ }
455
+ walk(root);
456
+ const payload = { matches };
457
+ if (truncated) {
458
+ payload.truncated = true;
459
+ payload.note = `Showing the first ${MAX_SEARCH_MATCHES} matches — refine the pattern or pass a 'glob' filter to narrow.`;
460
+ }
461
+ return { ok: true, output: JSON.stringify(payload, null, 2) };
462
+ }
463
+
464
+ function globFiles(args, opts) {
465
+ if (typeof args.pattern !== "string") return { ok: false, output: "pattern is required" };
466
+ const root = resolveSafe(typeof args.path === "string" ? args.path : ".", opts);
467
+ const re = globToRegExp(args.pattern);
468
+ const isIgnored = loadIgnore(path.resolve(opts.cwd));
469
+ const found = [];
470
+ let truncated = false;
471
+
472
+ function walk(dir) {
473
+ if (found.length >= MAX_GLOB_RESULTS) { truncated = true; return; }
474
+ let entries;
475
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
476
+ for (const e of entries) {
477
+ if (found.length >= MAX_GLOB_RESULTS) { truncated = true; return; }
478
+ if (e.name.startsWith(".") || ALWAYS_SKIP.has(e.name)) continue;
479
+ const full = path.join(dir, e.name);
480
+ const rel = path.relative(opts.cwd, full);
481
+ if (isIgnored(rel, e.isDirectory())) continue;
482
+ if (e.isDirectory()) {
483
+ walk(full);
484
+ } else if (e.isFile()) {
485
+ const relToRoot = path.relative(root, full).split(path.sep).join("/");
486
+ if (re.test(relToRoot)) {
487
+ let mtime = 0;
488
+ try { mtime = fs.statSync(full).mtimeMs; } catch { /* ignore */ }
489
+ found.push({ path: rel, mtime });
490
+ }
491
+ }
492
+ }
493
+ }
494
+ walk(root);
495
+ found.sort((a, b) => b.mtime - a.mtime); // most-recently-modified first
496
+ const payload = { files: found.map((f) => f.path) };
497
+ if (truncated) {
498
+ payload.truncated = true;
499
+ payload.note = `Showing the first ${MAX_GLOB_RESULTS} files — narrow the pattern.`;
500
+ }
501
+ return { ok: true, output: JSON.stringify(payload, null, 2) };
502
+ }
503
+
504
+ async function writeFile(args, opts) {
505
+ if (typeof args.path !== "string" || typeof args.content !== "string") {
506
+ return { ok: false, output: "path and content are required" };
507
+ }
508
+ const abs = resolveSafe(args.path, opts);
509
+ const exists = fs.existsSync(abs);
510
+ const oldContent = exists ? fs.readFileSync(abs, "utf8") : null;
511
+ if (exists && oldContent === args.content) {
512
+ return { ok: true, output: `(no change — file already matches)` };
513
+ }
514
+ // Show diff + confirm
515
+ console.log("");
516
+ console.log(summarizeWrite(oldContent, args.content, path.relative(opts.cwd, abs)));
517
+ console.log(unifiedDiff(oldContent ?? "", args.content, path.relative(opts.cwd, abs)));
518
+ const approved = await confirm(c.yellow("Apply this write?"), opts.autoYes);
519
+ if (!approved) {
520
+ return { ok: false, output: "User declined the write." };
521
+ }
522
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
523
+ fs.writeFileSync(abs, args.content, "utf8");
524
+ return { ok: true, output: `Wrote ${args.content.length} bytes to ${path.relative(opts.cwd, abs)}` };
525
+ }
526
+
527
+ async function editFile(args, opts) {
528
+ if (typeof args.path !== "string" || typeof args.find !== "string" || typeof args.replace !== "string") {
529
+ return { ok: false, output: "path, find, replace are required" };
530
+ }
531
+ const abs = resolveSafe(args.path, opts);
532
+ if (!fs.existsSync(abs)) return { ok: false, output: `File not found: ${args.path}` };
533
+ const oldContent = fs.readFileSync(abs, "utf8");
534
+ const occurrences = oldContent.split(args.find).length - 1;
535
+ if (occurrences === 0) {
536
+ return { ok: false, output: `\`find\` text not found in ${args.path}. Tip: read the file first to copy exact characters.` };
537
+ }
538
+ if (!args.replace_all && occurrences > 1) {
539
+ return {
540
+ ok: false,
541
+ output: `\`find\` text appears ${occurrences} times — must be unique. Add more context to disambiguate, or set replace_all: true to replace all ${occurrences}.`,
542
+ };
543
+ }
544
+ // split/join replaces every occurrence without regex-escaping pitfalls; for
545
+ // the default single-edit path occurrences === 1 so it's equivalent.
546
+ const newContent = args.replace_all
547
+ ? oldContent.split(args.find).join(args.replace)
548
+ : oldContent.replace(args.find, args.replace);
549
+ const rel = path.relative(opts.cwd, abs);
550
+ console.log("");
551
+ console.log(c.dim(`edit ${rel}${args.replace_all ? ` (${occurrences} occurrences)` : ""}`));
552
+ console.log(unifiedDiff(oldContent, newContent, rel));
553
+ const approved = await confirm(c.yellow("Apply this edit?"), opts.autoYes);
554
+ if (!approved) return { ok: false, output: "User declined the edit." };
555
+ fs.writeFileSync(abs, newContent, "utf8");
556
+ return { ok: true, output: `Edited ${rel}${args.replace_all && occurrences > 1 ? ` (${occurrences} replacements)` : ""}` };
557
+ }
558
+
559
+ /* ─────────────────────── Web tools ─────────────────────── */
560
+
561
+ async function webSearch(args, opts) {
562
+ void opts;
563
+ if (typeof args.query !== "string") return { ok: false, output: "query is required" };
564
+ const { apiKey, baseUrl } = getConfig();
565
+ if (!apiKey) {
566
+ return { ok: false, output: "Web search requires AETHER_API_KEY. Set it and try again." };
567
+ }
568
+ const max = Number.isInteger(args.max_results) ? Math.min(10, Math.max(1, args.max_results)) : 5;
569
+ let res;
570
+ try {
571
+ res = await fetch(`${baseUrl}/api/v1/web-search`, {
572
+ method: "POST",
573
+ headers: {
574
+ "Content-Type": "application/json",
575
+ Authorization: `Bearer ${apiKey}`,
576
+ "User-Agent": "aether-code/web-search",
577
+ },
578
+ body: JSON.stringify({ query: args.query, max_results: max }),
579
+ });
580
+ } catch (e) {
581
+ return { ok: false, output: `web_search network error: ${e.message}` };
582
+ }
583
+ let data = null;
584
+ try { data = await res.json(); } catch { /* non-JSON */ }
585
+ if (!res.ok) {
586
+ return { ok: false, output: data?.error || `web_search HTTP ${res.status}` };
587
+ }
588
+ // Hand the model just the array — that's all it needs to decide which URL to fetch.
589
+ return { ok: true, output: JSON.stringify(data.results ?? [], null, 2) };
590
+ }
591
+
592
+ // Bounded fetch with a fixed timeout + size cap. Strips scripts/styles, removes
593
+ // tags, decodes common HTML entities. Not a full HTML parser; good enough for
594
+ // reading docs pages, GitHub READMEs, MDN, Stack Overflow answers, etc.
595
+ async function webFetch(args, opts) {
596
+ void opts;
597
+ if (typeof args.url !== "string") return { ok: false, output: "url is required" };
598
+ if (!/^https?:\/\//i.test(args.url)) {
599
+ return { ok: false, output: "Only http:// and https:// URLs are allowed." };
600
+ }
601
+ const controller = new AbortController();
602
+ const timeout = setTimeout(() => controller.abort(), 15_000);
603
+ let res;
604
+ try {
605
+ res = await fetch(args.url, {
606
+ signal: controller.signal,
607
+ redirect: "follow",
608
+ headers: {
609
+ // Looking like a normal browser dodges many anti-bot pages.
610
+ "User-Agent":
611
+ "Mozilla/5.0 (compatible; aether-code/0.7) Gecko/20100101 Firefox/130.0",
612
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
613
+ "Accept-Language": "en-US,en;q=0.9",
614
+ },
615
+ });
616
+ } catch (e) {
617
+ clearTimeout(timeout);
618
+ return { ok: false, output: `web_fetch error: ${e.name === "AbortError" ? "timed out after 15s" : e.message}` };
619
+ }
620
+ clearTimeout(timeout);
621
+ if (!res.ok) {
622
+ return { ok: false, output: `web_fetch HTTP ${res.status} ${res.statusText}` };
623
+ }
624
+ // Cap at 2 MB of raw HTML before stripping — page might be huge.
625
+ const reader = res.body?.getReader();
626
+ if (!reader) return { ok: false, output: "Response had no body." };
627
+ const chunks = [];
628
+ let total = 0;
629
+ while (true) {
630
+ const { value, done } = await reader.read();
631
+ if (done) break;
632
+ total += value.length;
633
+ if (total > 2_000_000) {
634
+ reader.cancel();
635
+ break;
636
+ }
637
+ chunks.push(value);
638
+ }
639
+ const html = new TextDecoder("utf-8", { fatal: false }).decode(
640
+ Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))),
641
+ );
642
+ const text = htmlToText(html);
643
+ // Final cap on what we hand to the model so a single fetch doesn't blow the context.
644
+ const capped = text.length > 50_000 ? text.slice(0, 50_000) + "\n…(truncated; page was longer)" : text;
645
+ return { ok: true, output: capped };
646
+ }
647
+
648
+ export function htmlToText(html) {
649
+ let s = html;
650
+ // Drop script/style blocks entirely.
651
+ s = s.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, " ");
652
+ s = s.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, " ");
653
+ s = s.replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, " ");
654
+ // Preserve paragraph + heading breaks.
655
+ s = s.replace(/<\/(p|div|h[1-6]|li|tr|br)\s*>/gi, "\n");
656
+ s = s.replace(/<br\s*\/?>/gi, "\n");
657
+ // Strip remaining tags.
658
+ s = s.replace(/<[^>]+>/g, "");
659
+ // Decode common HTML entities (covers ~95% of real-world cases).
660
+ const entities = {
661
+ "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'",
662
+ "&apos;": "'", "&nbsp;": " ", "&mdash;": "—", "&ndash;": "–",
663
+ "&hellip;": "…", "&copy;": "©", "&reg;": "®", "&trade;": "™",
664
+ };
665
+ for (const [ent, ch] of Object.entries(entities)) {
666
+ s = s.split(ent).join(ch);
667
+ }
668
+ s = s.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)));
669
+ s = s.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)));
670
+ // Collapse whitespace.
671
+ s = s.replace(/[ \t]+/g, " ");
672
+ s = s.replace(/\n\s*\n\s*\n+/g, "\n\n");
673
+ return s.trim();
674
+ }
675
+
676
+ async function runShell(args, opts) {
677
+ if (typeof args.command !== "string") return { ok: false, output: "command is required" };
678
+ const cwd = args.cwd ? resolveSafe(args.cwd, opts) : opts.cwd;
679
+ console.log("");
680
+ console.log(c.yellow("$ ") + c.bold(args.command) + (args.cwd ? c.dim(` (cwd: ${args.cwd})`) : ""));
681
+ const approved = await confirm(c.yellow("Run this command?"), opts.autoYes);
682
+ if (!approved) return { ok: false, output: "User declined the command." };
683
+
684
+ return new Promise((resolve) => {
685
+ const child = spawn(args.command, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
686
+ let stdout = "";
687
+ let stderr = "";
688
+ let killed = false;
689
+ const timeout = setTimeout(() => {
690
+ killed = true;
691
+ child.kill("SIGTERM");
692
+ }, 120_000); // 2-minute hard cap per command
693
+
694
+ child.stdout.on("data", (d) => {
695
+ const s = d.toString();
696
+ stdout += s;
697
+ if (stdout.length < 80_000) process.stdout.write(c.dim(s));
698
+ });
699
+ child.stderr.on("data", (d) => {
700
+ const s = d.toString();
701
+ stderr += s;
702
+ if (stderr.length < 80_000) process.stderr.write(c.dim(s));
703
+ });
704
+ child.on("close", (code) => {
705
+ clearTimeout(timeout);
706
+ // Truncate huge outputs before sending back to the model
707
+ const truncate = (t) => (t.length > 20_000 ? t.slice(0, 20_000) + "\n…(truncated)" : t);
708
+ const out = JSON.stringify(
709
+ {
710
+ exit_code: code,
711
+ killed,
712
+ stdout: truncate(stdout),
713
+ stderr: truncate(stderr),
714
+ },
715
+ null,
716
+ 2,
717
+ );
718
+ resolve({ ok: code === 0 && !killed, output: out });
719
+ });
720
+ });
721
+ }
722
+
723
+ /* ─────────────────────── todo_write ─────────────────────── */
724
+
725
+ // Module-level singleton holding the current todo list for this CLI session.
726
+ // Latest-wins: each todo_write call replaces the entire list. The model
727
+ // passes the full new state every time, mirroring what Claude Code's
728
+ // TodoWrite does. Simpler than incremental ops; gives the model full
729
+ // control over ordering and renames.
730
+ const VALID_TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
731
+ const MAX_TODOS = 30;
732
+ let todoState = [];
733
+
734
+ // Test-only escape hatches — exported so the test suite can reset state
735
+ // between cases. Production code never touches these.
736
+ export function __resetTodoState() {
737
+ todoState = [];
738
+ }
739
+ export function __getTodoState() {
740
+ return todoState.map((t) => ({ ...t }));
741
+ }
742
+
743
+ function todoWrite(args, opts) {
744
+ void opts;
745
+ if (!Array.isArray(args.todos)) {
746
+ return { ok: false, output: "todos must be an array" };
747
+ }
748
+ if (args.todos.length > MAX_TODOS) {
749
+ return {
750
+ ok: false,
751
+ output: `too many todos (${args.todos.length}) — max ${MAX_TODOS}. Keep the plan focused.`,
752
+ };
753
+ }
754
+ // Validate every item BEFORE mutating state — we don't want a partial write.
755
+ for (let i = 0; i < args.todos.length; i++) {
756
+ const t = args.todos[i];
757
+ if (!t || typeof t !== "object") {
758
+ return { ok: false, output: `todo[${i}] must be an object` };
759
+ }
760
+ if (typeof t.content !== "string" || t.content.trim().length === 0) {
761
+ return { ok: false, output: `todo[${i}] needs a non-empty content string` };
762
+ }
763
+ if (!VALID_TODO_STATUSES.has(t.status)) {
764
+ return {
765
+ ok: false,
766
+ output: `todo[${i}] invalid status: "${t.status}" — must be pending, in_progress, or completed`,
767
+ };
768
+ }
769
+ }
770
+ todoState = args.todos.map((t) => ({
771
+ content: t.content.trim(),
772
+ status: t.status,
773
+ }));
774
+ renderTodos(todoState);
775
+ const counts = { pending: 0, in_progress: 0, completed: 0 };
776
+ for (const t of todoState) counts[t.status]++;
777
+ return {
778
+ ok: true,
779
+ output: `Todos updated: ${counts.pending} pending, ${counts.in_progress} in_progress, ${counts.completed} completed.`,
780
+ };
781
+ }
782
+
783
+ function renderTodos(todos) {
784
+ if (!process.stdout.isTTY) return; // skip render in non-TTY (CI, piped, tests)
785
+ console.log("");
786
+ console.log(c.dim("─── Plan ───"));
787
+ for (const t of todos) {
788
+ const icon =
789
+ t.status === "completed"
790
+ ? c.green("✓")
791
+ : t.status === "in_progress"
792
+ ? c.yellow("▶")
793
+ : c.dim("○");
794
+ const text =
795
+ t.status === "completed"
796
+ ? c.dim(t.content)
797
+ : t.status === "in_progress"
798
+ ? c.bold(t.content)
799
+ : t.content;
800
+ console.log(` ${icon} ${text}`);
801
+ }
802
+ console.log("");
803
+ }