aether-code 0.42.0 → 0.43.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,1288 +1,1288 @@
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 { promptChoice } from "./menu.js";
17
- import { unifiedDiff, summarizeWrite } from "./diff.js";
18
- import { getConfig } from "./config.js";
19
- import { getVersion } from "./version.js";
20
-
21
- /* ─────────────────────── Tool definitions (sent to model) ─────────────────────── */
22
-
23
- export const TOOL_DEFINITIONS = [
24
- {
25
- type: "function",
26
- function: {
27
- name: "read_file",
28
- description:
29
- "Read the contents of a file as UTF-8 text. Returns the file contents or an error if the file doesn't exist.",
30
- parameters: {
31
- type: "object",
32
- properties: {
33
- path: { type: "string", description: "Path relative to the working directory, or absolute." },
34
- },
35
- required: ["path"],
36
- },
37
- },
38
- },
39
- {
40
- type: "function",
41
- function: {
42
- name: "list_dir",
43
- description:
44
- "List the entries in a directory. Returns an array of {name, type: 'file'|'dir', size?: number}. Hidden files (starting with .) are excluded by default.",
45
- parameters: {
46
- type: "object",
47
- properties: {
48
- path: { type: "string", description: "Directory path." },
49
- include_hidden: { type: "boolean", description: "Include dotfiles. Default: false." },
50
- },
51
- required: ["path"],
52
- },
53
- },
54
- },
55
- {
56
- type: "function",
57
- function: {
58
- name: "search_files",
59
- description:
60
- "Recursively search for a regex pattern across files in a directory. Returns matching file paths and the matching line. Limited to 50 results.",
61
- parameters: {
62
- type: "object",
63
- properties: {
64
- path: { type: "string", description: "Directory to search." },
65
- pattern: { type: "string", description: "JavaScript-style regex (without slashes)." },
66
- glob: { type: "string", description: "Optional file-name glob filter, e.g. '*.ts'." },
67
- },
68
- required: ["path", "pattern"],
69
- },
70
- },
71
- },
72
- {
73
- type: "function",
74
- function: {
75
- name: "glob_files",
76
- description:
77
- "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.",
78
- parameters: {
79
- type: "object",
80
- properties: {
81
- pattern: { type: "string", description: "Glob pattern. Supports ** (any depth), * (within a path segment), ?. e.g. 'src/**/*.js'." },
82
- path: { type: "string", description: "Directory to search from. Default: the working directory." },
83
- },
84
- required: ["pattern"],
85
- },
86
- },
87
- },
88
- {
89
- type: "function",
90
- function: {
91
- name: "write_file",
92
- description:
93
- "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.",
94
- parameters: {
95
- type: "object",
96
- properties: {
97
- path: { type: "string", description: "File path." },
98
- content: { type: "string", description: "Full file content to write." },
99
- },
100
- required: ["path", "content"],
101
- },
102
- },
103
- },
104
- {
105
- type: "function",
106
- function: {
107
- name: "edit_file",
108
- description:
109
- "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.",
110
- parameters: {
111
- type: "object",
112
- properties: {
113
- path: { type: "string", description: "File path." },
114
- find: { type: "string", description: "Exact text to replace. Must be unique unless replace_all is true." },
115
- replace: { type: "string", description: "Text to substitute in." },
116
- replace_all: { type: "boolean", description: "Replace ALL occurrences instead of requiring a unique match. Default: false." },
117
- },
118
- required: ["path", "find", "replace"],
119
- },
120
- },
121
- },
122
- {
123
- type: "function",
124
- function: {
125
- name: "run_shell",
126
- description:
127
- "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.",
128
- parameters: {
129
- type: "object",
130
- properties: {
131
- command: { type: "string", description: "The shell command to run." },
132
- cwd: { type: "string", description: "Optional working directory (relative or absolute)." },
133
- },
134
- required: ["command"],
135
- },
136
- },
137
- },
138
- {
139
- type: "function",
140
- function: {
141
- name: "web_search",
142
- description:
143
- "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.",
144
- parameters: {
145
- type: "object",
146
- properties: {
147
- query: { type: "string", description: "Plain-language search query." },
148
- max_results: { type: "number", description: "How many results to return (1–10, default 5)." },
149
- },
150
- required: ["query"],
151
- },
152
- },
153
- },
154
- {
155
- type: "function",
156
- function: {
157
- name: "web_fetch",
158
- description:
159
- "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.",
160
- parameters: {
161
- type: "object",
162
- properties: {
163
- url: { type: "string", description: "Full http(s) URL to fetch." },
164
- },
165
- required: ["url"],
166
- },
167
- },
168
- },
169
- {
170
- type: "function",
171
- function: {
172
- name: "todo_write",
173
- description:
174
- "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.",
175
- parameters: {
176
- type: "object",
177
- properties: {
178
- todos: {
179
- type: "array",
180
- description: "Full replacement list (latest-wins semantics).",
181
- items: {
182
- type: "object",
183
- properties: {
184
- content: { type: "string", description: "Short imperative phrase, e.g. 'wire endpoint into UI'." },
185
- status: {
186
- type: "string",
187
- enum: ["pending", "in_progress", "completed"],
188
- description: "Task status.",
189
- },
190
- },
191
- required: ["content", "status"],
192
- },
193
- },
194
- },
195
- required: ["todos"],
196
- },
197
- },
198
- },
199
- {
200
- type: "function",
201
- function: {
202
- name: "ask_user",
203
- description:
204
- "Ask the user a single multiple-choice question and wait for their answer. Use this ONLY when the request is genuinely ambiguous AND the answer materially changes what you build — e.g. which platform/framework/language, the scope, or an irreversible decision. Ask BEFORE building when a wrong assumption would waste real work. Give 2–5 concrete options, each with a short description; put the best default first. Do NOT use it for trivial choices, things you can infer, or to confirm permission to act — just build when the task is clear.",
205
- parameters: {
206
- type: "object",
207
- properties: {
208
- question: { type: "string", description: "The single, focused question to ask." },
209
- options: {
210
- type: "array",
211
- description: "2–5 options. Each is {label, description}. Best default first.",
212
- items: {
213
- type: "object",
214
- properties: {
215
- label: { type: "string", description: "Short option label." },
216
- description: { type: "string", description: "One-line explanation of this option." },
217
- },
218
- required: ["label"],
219
- },
220
- },
221
- },
222
- required: ["question", "options"],
223
- },
224
- },
225
- },
226
- /* ─────────────────────── Git tools ─────────────────────── */
227
- {
228
- type: "function",
229
- function: {
230
- name: "git_status",
231
- description:
232
- "Show the working tree status — staged, unstaged, and untracked files. Auto-executes (read-only). Use this before committing to see what's changed.",
233
- parameters: {
234
- type: "object",
235
- properties: {},
236
- },
237
- },
238
- },
239
- {
240
- type: "function",
241
- function: {
242
- name: "git_diff",
243
- description:
244
- "Show a unified diff of changes. By default shows unstaged changes. Set staged=true for staged changes, or pass base/head for branch comparisons (e.g. base='main' head='HEAD'). Optionally limit to specific file(s).",
245
- parameters: {
246
- type: "object",
247
- properties: {
248
- staged: { type: "boolean", description: "Show staged (--cached) diff instead of unstaged. Default: false." },
249
- base: { type: "string", description: "Base ref for comparison (e.g. 'main', 'HEAD~3'). When set, shows diff between base and head." },
250
- head: { type: "string", description: "Head ref for comparison. Default: 'HEAD' when base is set." },
251
- files: {
252
- type: "array",
253
- description: "Limit diff to these file paths.",
254
- items: { type: "string" },
255
- },
256
- },
257
- },
258
- },
259
- },
260
- {
261
- type: "function",
262
- function: {
263
- name: "git_log",
264
- description:
265
- "Show recent commit history with hash, author, date, and message. Defaults to 15 commits. Use this to understand what's been done recently, find commit hashes, or check the commit style before committing.",
266
- parameters: {
267
- type: "object",
268
- properties: {
269
- count: { type: "number", description: "Number of commits to show (1-50). Default: 15." },
270
- oneline: { type: "boolean", description: "Compact one-line format. Default: false." },
271
- file: { type: "string", description: "Show only commits that touched this file." },
272
- branch: { type: "string", description: "Show commits on this branch instead of HEAD." },
273
- },
274
- },
275
- },
276
- },
277
- {
278
- type: "function",
279
- function: {
280
- name: "git_commit",
281
- description:
282
- "Stage files and create a commit. If no files are specified, stages all modified/new files (git add -A). The user will see what's being committed and confirm. Use git_status first to see what's changed, and git_log to match the commit message style.",
283
- parameters: {
284
- type: "object",
285
- properties: {
286
- message: { type: "string", description: "Commit message. Be concise — focus on WHY, not WHAT." },
287
- files: {
288
- type: "array",
289
- description: "Specific files to stage. If omitted, stages everything (git add -A).",
290
- items: { type: "string" },
291
- },
292
- },
293
- required: ["message"],
294
- },
295
- },
296
- },
297
- {
298
- type: "function",
299
- function: {
300
- name: "git_branch",
301
- description:
302
- "Create and/or switch to a branch. If the branch exists, switches to it. If it doesn't, creates it from the current HEAD (or from 'base' if specified) and switches to it.",
303
- parameters: {
304
- type: "object",
305
- properties: {
306
- name: { type: "string", description: "Branch name to create or switch to." },
307
- base: { type: "string", description: "Create the branch from this ref instead of HEAD." },
308
- },
309
- required: ["name"],
310
- },
311
- },
312
- },
313
- {
314
- type: "function",
315
- function: {
316
- name: "git_push",
317
- description:
318
- "Push the current branch to the remote. Sets upstream tracking (-u origin) on first push. The user will confirm before pushing.",
319
- parameters: {
320
- type: "object",
321
- properties: {
322
- force: { type: "boolean", description: "Force push (--force-with-lease). Use only when explicitly asked. Default: false." },
323
- },
324
- },
325
- },
326
- },
327
- {
328
- type: "function",
329
- function: {
330
- name: "git_create_pr",
331
- description:
332
- "Create a GitHub pull request using the `gh` CLI. Requires `gh` to be installed and authenticated. Pushes the branch first if needed. The user will confirm before creating.",
333
- parameters: {
334
- type: "object",
335
- properties: {
336
- title: { type: "string", description: "PR title. Keep under 72 chars." },
337
- body: { type: "string", description: "PR body/description. Markdown supported." },
338
- base: { type: "string", description: "Target branch to merge into. Default: repo default (usually main)." },
339
- draft: { type: "boolean", description: "Create as a draft PR. Default: false." },
340
- },
341
- required: ["title", "body"],
342
- },
343
- },
344
- },
345
- ];
346
-
347
- /* ─────────────────────── Argument validation ─────────────────────── */
348
-
349
- const TOOL_SCHEMAS = Object.fromEntries(
350
- TOOL_DEFINITIONS.map((t) => [t.function.name, t.function.parameters]),
351
- );
352
-
353
- function typeMatches(val, t) {
354
- switch (t) {
355
- case "string": return typeof val === "string";
356
- case "number": return typeof val === "number";
357
- case "integer": return typeof val === "number" && Number.isInteger(val);
358
- case "boolean": return typeof val === "boolean";
359
- case "object": return typeof val === "object" && val !== null && !Array.isArray(val);
360
- case "array": return Array.isArray(val);
361
- default: return true;
362
- }
363
- }
364
-
365
- /**
366
- * Validate parsed tool arguments against the tool's JSON schema BEFORE the
367
- * handler runs. Returns an error string the model can act on, or null if valid.
368
- * Catches malformed/partial args from the model that would otherwise surface as
369
- * cryptic downstream errors.
370
- */
371
- export function validateToolArgs(name, args) {
372
- const schema = TOOL_SCHEMAS[name];
373
- if (!schema) return `Unknown tool: ${name}`;
374
- if (typeof args !== "object" || args === null || Array.isArray(args)) {
375
- return `Arguments for ${name} must be a JSON object.`;
376
- }
377
- for (const req of schema.required ?? []) {
378
- if (args[req] === undefined || args[req] === null) {
379
- return `Missing required argument "${req}" for ${name}.`;
380
- }
381
- }
382
- for (const [key, val] of Object.entries(args)) {
383
- const prop = schema.properties?.[key];
384
- if (!prop || val === undefined || val === null) continue;
385
- if (prop.type && !typeMatches(val, prop.type)) {
386
- return `Argument "${key}" for ${name} must be of type ${prop.type}.`;
387
- }
388
- }
389
- return null;
390
- }
391
-
392
- /* ─────────────────────── Glob + ignore helpers ─────────────────────── */
393
-
394
- const ALWAYS_SKIP = new Set([".git", "node_modules", "dist"]);
395
- const MAX_SEARCH_MATCHES = 200;
396
- const MAX_GLOB_RESULTS = 300;
397
-
398
- // Convert a glob ('**', '*', '?') to an anchored regex over a POSIX-style
399
- // relative path. ** spans directory separators; * does not.
400
- export function globToRegExp(glob) {
401
- let re = "";
402
- let braceDepth = 0;
403
- for (let i = 0; i < glob.length; i++) {
404
- const ch = glob[i];
405
- if (ch === "*") {
406
- if (glob[i + 1] === "*") {
407
- re += ".*";
408
- i++;
409
- if (glob[i + 1] === "/") i++; // collapse '**/' so it can also match zero dirs
410
- } else {
411
- re += "[^/]*";
412
- }
413
- } else if (ch === "?") {
414
- re += "[^/]";
415
- } else if (ch === "{") {
416
- // Brace expansion: {js,ts} -> (js|ts). Common in .gitignore (*.{js,ts});
417
- // treating it literally silently failed to ignore those files.
418
- re += "(";
419
- braceDepth++;
420
- } else if (ch === "}" && braceDepth > 0) {
421
- re += ")";
422
- braceDepth--;
423
- } else if (ch === "," && braceDepth > 0) {
424
- re += "|";
425
- } else if ("\\^$+.()|{}[]".includes(ch)) {
426
- re += "\\" + ch;
427
- } else {
428
- re += ch;
429
- }
430
- }
431
- return new RegExp("^" + re + "$");
432
- }
433
-
434
- // Parse a .gitignore at `root` into a matcher. Pragmatic subset of gitignore
435
- // semantics: blank/comment lines ignored; trailing '/' = directory-only;
436
- // leading '/' = root-anchored; '*'/'?' globs supported.
437
- function loadIgnore(root) {
438
- let lines = [];
439
- try { lines = fs.readFileSync(path.join(root, ".gitignore"), "utf8").split("\n"); } catch { /* none */ }
440
- const rules = [];
441
- for (const raw of lines) {
442
- const line = raw.trim();
443
- if (!line || line.startsWith("#")) continue;
444
- let pat = line;
445
- const dirOnly = pat.endsWith("/");
446
- if (dirOnly) pat = pat.slice(0, -1);
447
- const anchored = pat.startsWith("/");
448
- if (anchored) pat = pat.slice(1);
449
- rules.push({ re: globToRegExp(pat), anchored, dirOnly, base: !pat.includes("/") });
450
- }
451
- return (relPath, isDir) => {
452
- const norm = relPath.split(path.sep).join("/");
453
- const baseName = norm.split("/").pop();
454
- for (const r of rules) {
455
- if (r.dirOnly && !isDir) continue;
456
- if (r.base) { if (r.re.test(baseName)) return true; }
457
- else if (r.anchored) { if (r.re.test(norm)) return true; }
458
- else if (r.re.test(norm) || r.re.test(baseName)) return true;
459
- }
460
- return false;
461
- };
462
- }
463
-
464
- /* ─────────────────────── Helpers ─────────────────────── */
465
-
466
- function resolveSafe(rel, opts) {
467
- const abs = path.isAbsolute(rel) ? path.normalize(rel) : path.resolve(opts.cwd, rel);
468
- if (!opts.unsafePaths) {
469
- const cwd = path.resolve(opts.cwd);
470
- // Use path.relative (not startsWith) so Windows drive-relative inputs like
471
- // "C:evil.txt" — which path.isAbsolute() reports as NON-absolute and
472
- // path.resolve() may anchor to a drive's own cwd outside the project — are
473
- // caught. A path is inside cwd only if its relative form doesn't climb out
474
- // (no leading "..") and isn't itself absolute (different drive/root).
475
- const relToCwd = path.relative(cwd, abs);
476
- const escapes = relToCwd === ".." || relToCwd.startsWith(".." + path.sep) || path.isAbsolute(relToCwd);
477
- if (abs !== cwd && escapes) {
478
- throw new Error(
479
- `Refusing to touch path outside cwd: ${abs}\n Run with --unsafe-paths if you really mean this.`,
480
- );
481
- }
482
- }
483
- return abs;
484
- }
485
-
486
- function ask(question) {
487
- if (!process.stdin.isTTY) {
488
- return Promise.resolve("n"); // can't prompt in non-TTY; default no
489
- }
490
- return new Promise((resolve) => {
491
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
492
- rl.question(question, (answer) => {
493
- rl.close();
494
- resolve(answer.trim().toLowerCase());
495
- });
496
- });
497
- }
498
-
499
- async function confirm(question, autoYes) {
500
- if (autoYes) return true;
501
- const ans = await ask(`${question} ${c.dim("[y/N]: ")}`);
502
- return ans === "y" || ans === "yes";
503
- }
504
-
505
- /* ─────────────────────── Implementations ─────────────────────── */
506
-
507
- export async function executeTool(call, opts) {
508
- let args;
509
- try {
510
- args = JSON.parse(call.function.arguments || "{}");
511
- } catch (e) {
512
- return { ok: false, output: `Invalid JSON arguments: ${e.message}` };
513
- }
514
-
515
- const name = call.function.name;
516
- const validationError = validateToolArgs(name, args);
517
- if (validationError) {
518
- return { ok: false, output: validationError };
519
- }
520
- const handlers = {
521
- read_file: () => readFile(args, opts),
522
- list_dir: () => listDir(args, opts),
523
- search_files: () => searchFiles(args, opts),
524
- glob_files: () => globFiles(args, opts),
525
- write_file: () => writeFile(args, opts),
526
- edit_file: () => editFile(args, opts),
527
- run_shell: () => runShell(args, opts),
528
- web_search: () => webSearch(args, opts),
529
- web_fetch: () => webFetch(args, opts),
530
- todo_write: () => todoWrite(args, opts),
531
- ask_user: () => askUser(args, opts),
532
- git_status: () => gitStatus(args, opts),
533
- git_diff: () => gitDiff(args, opts),
534
- git_log: () => gitLog(args, opts),
535
- git_commit: () => gitCommit(args, opts),
536
- git_branch: () => gitBranch(args, opts),
537
- git_push: () => gitPush(args, opts),
538
- git_create_pr: () => gitCreatePr(args, opts),
539
- };
540
- const fn = handlers[name];
541
- if (!fn) {
542
- return { ok: false, output: `Unknown tool: ${name}` };
543
- }
544
- try {
545
- return await fn();
546
- } catch (e) {
547
- return { ok: false, output: `${name} failed: ${e.message}` };
548
- }
549
- }
550
-
551
- // Ask the user a multiple-choice question and block on their answer. Not a
552
- // permission gate — it runs even in skip-permissions mode, because the model is
553
- // explicitly requesting input. In a non-TTY the menu auto-picks the first
554
- // option so scripted runs don't hang.
555
- async function askUser(args, _opts) {
556
- const options = Array.isArray(args.options) ? args.options : [];
557
- if (options.length === 0) {
558
- return { ok: false, output: "ask_user requires a non-empty options array" };
559
- }
560
- const choice = await promptChoice({ question: String(args.question || "Which option?"), options });
561
- if (!choice) {
562
- return { ok: true, output: "The user cancelled the question. Proceed with your best judgment." };
563
- }
564
- const extra = choice.description ? ` (${choice.description})` : "";
565
- return { ok: true, output: `The user chose: ${choice.label}${extra}` };
566
- }
567
-
568
- function readFile(args, opts) {
569
- if (typeof args.path !== "string") return { ok: false, output: "path is required" };
570
- const abs = resolveSafe(args.path, opts);
571
- let stat;
572
- try { stat = fs.statSync(abs); } catch (e) {
573
- return { ok: false, output: `Cannot access ${args.path}: ${e.code || e.message}` };
574
- }
575
- if (stat.isDirectory()) return { ok: false, output: `${args.path} is a directory, not a file` };
576
- // Block non-regular files (named pipes, sockets, devices) — reading them can
577
- // hang forever (no timeout on readFileSync).
578
- if (!stat.isFile()) return { ok: false, output: `${args.path} is not a regular file.` };
579
- if (stat.size > 1_000_000) {
580
- return { ok: false, output: `File too large (${stat.size} bytes). Aether refuses to read >1MB at once.` };
581
- }
582
- try {
583
- return { ok: true, output: fs.readFileSync(abs, "utf8") };
584
- } catch (e) {
585
- return { ok: false, output: `Cannot read ${args.path}: ${e.code || e.message}` };
586
- }
587
- }
588
-
589
- function listDir(args, opts) {
590
- if (typeof args.path !== "string") return { ok: false, output: "path is required" };
591
- const abs = resolveSafe(args.path, opts);
592
- const entries = fs.readdirSync(abs, { withFileTypes: true });
593
- const results = [];
594
- for (const e of entries) {
595
- if (!args.include_hidden && e.name.startsWith(".")) continue;
596
- if (e.name === "node_modules" || e.name === ".git" || e.name === "dist") continue;
597
- let size = undefined;
598
- if (e.isFile()) {
599
- try { size = fs.statSync(path.join(abs, e.name)).size; } catch { /* skip */ }
600
- }
601
- results.push({
602
- name: e.name,
603
- type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other",
604
- size,
605
- });
606
- }
607
- results.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
608
- return { ok: true, output: JSON.stringify(results, null, 2) };
609
- }
610
-
611
- function searchFiles(args, opts) {
612
- if (typeof args.path !== "string" || typeof args.pattern !== "string") {
613
- return { ok: false, output: "path and pattern are required" };
614
- }
615
- let regex;
616
- try { regex = new RegExp(args.pattern); } catch (e) {
617
- return { ok: false, output: `Invalid regex: ${e.message}` };
618
- }
619
- const root = resolveSafe(args.path, opts);
620
- const isIgnored = loadIgnore(path.resolve(opts.cwd));
621
- const matches = [];
622
- const globRe = args.glob ? globToRegExp(args.glob) : null;
623
- let truncated = false;
624
-
625
- function walk(dir) {
626
- if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
627
- let entries;
628
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
629
- for (const e of entries) {
630
- if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
631
- if (e.name.startsWith(".") || ALWAYS_SKIP.has(e.name)) continue;
632
- const full = path.join(dir, e.name);
633
- const rel = path.relative(opts.cwd, full);
634
- if (isIgnored(rel, e.isDirectory())) continue;
635
- if (e.isDirectory()) {
636
- walk(full);
637
- } else if (e.isFile()) {
638
- if (globRe && !globRe.test(e.name)) continue;
639
- let content;
640
- try {
641
- const stat = fs.statSync(full);
642
- if (stat.size > 500_000) continue;
643
- content = fs.readFileSync(full, "utf8");
644
- } catch { continue; }
645
- const lines = content.split("\n");
646
- for (let i = 0; i < lines.length; i++) {
647
- // Skip very long lines: the pattern is model-supplied and a
648
- // pathological regex on a long line can backtrack catastrophically
649
- // (ReDoS) and freeze the single-threaded process.
650
- if (lines[i].length > 2000) continue;
651
- if (regex.test(lines[i])) {
652
- matches.push({ file: rel, line: i + 1, text: lines[i].slice(0, 300) });
653
- if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
654
- }
655
- }
656
- }
657
- }
658
- }
659
- walk(root);
660
- const payload = { matches };
661
- if (truncated) {
662
- payload.truncated = true;
663
- payload.note = `Showing the first ${MAX_SEARCH_MATCHES} matches — refine the pattern or pass a 'glob' filter to narrow.`;
664
- }
665
- return { ok: true, output: JSON.stringify(payload, null, 2) };
666
- }
667
-
668
- function globFiles(args, opts) {
669
- if (typeof args.pattern !== "string") return { ok: false, output: "pattern is required" };
670
- const root = resolveSafe(typeof args.path === "string" ? args.path : ".", opts);
671
- const re = globToRegExp(args.pattern);
672
- const isIgnored = loadIgnore(path.resolve(opts.cwd));
673
- const found = [];
674
- let truncated = false;
675
-
676
- function walk(dir) {
677
- if (found.length >= MAX_GLOB_RESULTS) { truncated = true; return; }
678
- let entries;
679
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
680
- for (const e of entries) {
681
- if (found.length >= MAX_GLOB_RESULTS) { truncated = true; return; }
682
- if (e.name.startsWith(".") || ALWAYS_SKIP.has(e.name)) continue;
683
- const full = path.join(dir, e.name);
684
- const rel = path.relative(opts.cwd, full);
685
- if (isIgnored(rel, e.isDirectory())) continue;
686
- if (e.isDirectory()) {
687
- walk(full);
688
- } else if (e.isFile()) {
689
- const relToRoot = path.relative(root, full).split(path.sep).join("/");
690
- if (re.test(relToRoot)) {
691
- let mtime = 0;
692
- try { mtime = fs.statSync(full).mtimeMs; } catch { /* ignore */ }
693
- found.push({ path: rel, mtime });
694
- }
695
- }
696
- }
697
- }
698
- walk(root);
699
- found.sort((a, b) => b.mtime - a.mtime); // most-recently-modified first
700
- const payload = { files: found.map((f) => f.path) };
701
- if (truncated) {
702
- payload.truncated = true;
703
- payload.note = `Showing the first ${MAX_GLOB_RESULTS} files — narrow the pattern.`;
704
- }
705
- return { ok: true, output: JSON.stringify(payload, null, 2) };
706
- }
707
-
708
- async function writeFile(args, opts) {
709
- if (typeof args.path !== "string" || typeof args.content !== "string") {
710
- return { ok: false, output: "path and content are required" };
711
- }
712
- const abs = resolveSafe(args.path, opts);
713
- const exists = fs.existsSync(abs);
714
- const oldContent = exists ? fs.readFileSync(abs, "utf8") : null;
715
- if (exists && oldContent === args.content) {
716
- return { ok: true, output: `(no change — file already matches)` };
717
- }
718
- // Show diff + confirm (the "● write <file>" label already spaced this block)
719
- console.log(summarizeWrite(oldContent, args.content, path.relative(opts.cwd, abs)));
720
- console.log(unifiedDiff(oldContent ?? "", args.content, path.relative(opts.cwd, abs)));
721
- const approved = await confirm(c.yellow("Apply this write?"), opts.autoYes);
722
- if (!approved) {
723
- return { ok: false, output: "User declined the write." };
724
- }
725
- fs.mkdirSync(path.dirname(abs), { recursive: true });
726
- fs.writeFileSync(abs, args.content, "utf8");
727
- return { ok: true, output: `Wrote ${args.content.length} bytes to ${path.relative(opts.cwd, abs)}` };
728
- }
729
-
730
- async function editFile(args, opts) {
731
- if (typeof args.path !== "string" || typeof args.find !== "string" || typeof args.replace !== "string") {
732
- return { ok: false, output: "path, find, replace are required" };
733
- }
734
- const abs = resolveSafe(args.path, opts);
735
- if (!fs.existsSync(abs)) {
736
- return {
737
- ok: false,
738
- output: `File not found: ${args.path}. It doesn't exist yet — use write_file to CREATE it (edit_file only modifies existing files). Do not retry edit_file on this path.`,
739
- };
740
- }
741
- const oldContent = fs.readFileSync(abs, "utf8");
742
- const occurrences = oldContent.split(args.find).length - 1;
743
- if (occurrences === 0) {
744
- return { ok: false, output: `\`find\` text not found in ${args.path}. Tip: read the file first to copy exact characters.` };
745
- }
746
- if (!args.replace_all && occurrences > 1) {
747
- return {
748
- ok: false,
749
- output: `\`find\` text appears ${occurrences} times — must be unique. Add more context to disambiguate, or set replace_all: true to replace all ${occurrences}.`,
750
- };
751
- }
752
- // split/join for BOTH paths: String.replace(str, str) would interpret special
753
- // patterns in the replacement ($&, $`, $', $$) and silently corrupt code that
754
- // contains them (regex, jQuery $, template strings). split/join is literal.
755
- // On the single-edit path occurrences === 1, so it's equivalent + safe.
756
- const newContent = oldContent.split(args.find).join(args.replace);
757
- const rel = path.relative(opts.cwd, abs);
758
- console.log(c.dim(`edit ${rel}${args.replace_all ? ` (${occurrences} occurrences)` : ""}`));
759
- console.log(unifiedDiff(oldContent, newContent, rel));
760
- const approved = await confirm(c.yellow("Apply this edit?"), opts.autoYes);
761
- if (!approved) return { ok: false, output: "User declined the edit." };
762
- fs.writeFileSync(abs, newContent, "utf8");
763
- return { ok: true, output: `Edited ${rel}${args.replace_all && occurrences > 1 ? ` (${occurrences} replacements)` : ""}` };
764
- }
765
-
766
- /* ─────────────────────── Web tools ─────────────────────── */
767
-
768
- async function webSearch(args, opts) {
769
- void opts;
770
- if (typeof args.query !== "string") return { ok: false, output: "query is required" };
771
- const { apiKey, baseUrl } = getConfig();
772
- if (!apiKey) {
773
- return { ok: false, output: "Web search requires AETHER_API_KEY. Set it and try again." };
774
- }
775
- const max = Number.isInteger(args.max_results) ? Math.min(10, Math.max(1, args.max_results)) : 5;
776
- const maxRetries = 2;
777
- const baseDelay = 500;
778
- let res;
779
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
780
- try {
781
- res = await fetch(`${baseUrl}/api/v1/web-search`, {
782
- method: "POST",
783
- headers: {
784
- "Content-Type": "application/json",
785
- Authorization: `Bearer ${apiKey}`,
786
- "User-Agent": `aether-code/${getVersion()}`,
787
- },
788
- body: JSON.stringify({ query: args.query, max_results: max }),
789
- });
790
- if (res.status >= 500 && attempt < maxRetries) {
791
- res.body?.cancel().catch(() => {});
792
- await new Promise((r) => setTimeout(r, baseDelay * 2 ** attempt));
793
- continue;
794
- }
795
- break;
796
- } catch (e) {
797
- if (attempt < maxRetries) {
798
- await new Promise((r) => setTimeout(r, baseDelay * 2 ** attempt));
799
- continue;
800
- }
801
- return { ok: false, output: `web_search network error: ${e.message}` };
802
- }
803
- }
804
- let data = null;
805
- try { data = await res.json(); } catch { /* non-JSON */ }
806
- if (!res.ok) {
807
- return { ok: false, output: data?.error || `web_search HTTP ${res.status}` };
808
- }
809
- return { ok: true, output: JSON.stringify(data.results ?? [], null, 2) };
810
- }
811
-
812
- // Bounded fetch with a fixed timeout + size cap. Strips scripts/styles, removes
813
- // tags, decodes common HTML entities. Not a full HTML parser; good enough for
814
- // reading docs pages, GitHub READMEs, MDN, Stack Overflow answers, etc.
815
- // SSRF guard: refuse loopback / link-local / private hosts so web_fetch can't
816
- // be steered (directly or via a redirect) at localhost or cloud-metadata
817
- // (169.254.169.254) or an internal service.
818
- function isBlockedHost(hostname) {
819
- const h = (hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
820
- if (!h || h === "localhost" || h === "0.0.0.0" || h === "::1" || h === "::") return true;
821
- if (h.endsWith(".localhost") || h.endsWith(".internal") || h.endsWith(".local")) return true;
822
- if (/^127\./.test(h)) return true; // loopback
823
- if (/^10\./.test(h)) return true; // private A
824
- if (/^192\.168\./.test(h)) return true; // private C
825
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; // private B
826
- if (/^169\.254\./.test(h)) return true; // link-local incl. cloud metadata
827
- if (/^(fe80:|fc|fd|::ffff:)/.test(h)) return true; // IPv6 link-local / ULA / mapped
828
- return false;
829
- }
830
-
831
- async function webFetch(args, opts) {
832
- void opts;
833
- if (typeof args.url !== "string") return { ok: false, output: "url is required" };
834
- if (!/^https?:\/\//i.test(args.url)) {
835
- return { ok: false, output: "Only http:// and https:// URLs are allowed." };
836
- }
837
- let initialHost;
838
- try { initialHost = new URL(args.url).hostname; } catch { return { ok: false, output: "Invalid URL." }; }
839
- if (isBlockedHost(initialHost)) {
840
- return { ok: false, output: "web_fetch blocked: refusing to fetch a private/internal/loopback address." };
841
- }
842
- const controller = new AbortController();
843
- const timeout = setTimeout(() => controller.abort(), 15_000);
844
- let res;
845
- try {
846
- res = await fetch(args.url, {
847
- signal: controller.signal,
848
- redirect: "follow",
849
- headers: {
850
- // Looking like a normal browser dodges many anti-bot pages.
851
- "User-Agent":
852
- `Mozilla/5.0 (compatible; aether-code/${getVersion()}) Gecko/20100101 Firefox/130.0`,
853
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
854
- "Accept-Language": "en-US,en;q=0.9",
855
- },
856
- });
857
- } catch (e) {
858
- clearTimeout(timeout);
859
- return { ok: false, output: `web_fetch error: ${e.name === "AbortError" ? "timed out after 15s" : e.message}` };
860
- }
861
- clearTimeout(timeout);
862
- // Re-check the FINAL url after any redirects — a public URL can 302 to an
863
- // internal target, which the initial-URL check wouldn't catch.
864
- try {
865
- if (isBlockedHost(new URL(res.url).hostname)) {
866
- res.body?.cancel().catch(() => {});
867
- return { ok: false, output: "web_fetch blocked: redirect to a private/internal address." };
868
- }
869
- } catch { /* res.url unparseable — fall through */ }
870
- if (!res.ok) {
871
- return { ok: false, output: `web_fetch HTTP ${res.status} ${res.statusText}` };
872
- }
873
- // Cap at 2 MB of raw HTML before stripping — page might be huge.
874
- const reader = res.body?.getReader();
875
- if (!reader) return { ok: false, output: "Response had no body." };
876
- const chunks = [];
877
- let total = 0;
878
- while (true) {
879
- const { value, done } = await reader.read();
880
- if (done) break;
881
- total += value.length;
882
- if (total > 2_000_000) {
883
- reader.cancel();
884
- break;
885
- }
886
- chunks.push(value);
887
- }
888
- const html = new TextDecoder("utf-8", { fatal: false }).decode(
889
- Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))),
890
- );
891
- const text = htmlToText(html);
892
- // Final cap on what we hand to the model so a single fetch doesn't blow the context.
893
- const capped = text.length > 50_000 ? text.slice(0, 50_000) + "\n…(truncated; page was longer)" : text;
894
- return { ok: true, output: capped };
895
- }
896
-
897
- export function htmlToText(html) {
898
- let s = html;
899
- // Drop script/style blocks entirely.
900
- s = s.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, " ");
901
- s = s.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, " ");
902
- s = s.replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, " ");
903
- // Preserve paragraph + heading breaks.
904
- s = s.replace(/<\/(p|div|h[1-6]|li|tr|br)\s*>/gi, "\n");
905
- s = s.replace(/<br\s*\/?>/gi, "\n");
906
- // Strip remaining tags.
907
- s = s.replace(/<[^>]+>/g, "");
908
- // Decode common HTML entities (covers ~95% of real-world cases).
909
- const entities = {
910
- "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'",
911
- "&apos;": "'", "&nbsp;": " ", "&mdash;": "—", "&ndash;": "–",
912
- "&hellip;": "…", "&copy;": "©", "&reg;": "®", "&trade;": "™",
913
- };
914
- for (const [ent, ch] of Object.entries(entities)) {
915
- s = s.split(ent).join(ch);
916
- }
917
- s = s.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)));
918
- s = s.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)));
919
- // Collapse whitespace.
920
- s = s.replace(/[ \t]+/g, " ");
921
- s = s.replace(/\n\s*\n\s*\n+/g, "\n\n");
922
- return s.trim();
923
- }
924
-
925
- const CATASTROPHIC_CMD_RE = new RegExp(
926
- [
927
- String.raw`rm\s+-(r|f|rf|fr)\w*\s+[/~](?!\S*node_modules)(?!\S*dist)(?!\S*\.next)(?!\S*build)`,
928
- String.raw`del\s+/[sfq]+\s+[cC]:\\`,
929
- String.raw`format\s+[a-zA-Z]:`,
930
- String.raw`mkfs\.`,
931
- String.raw`dd\s+.*of=/dev/[sh]d`,
932
- String.raw`:\(\)\s*\{\s*:\|:`,
933
- String.raw`>\s*/dev/[sh]d`,
934
- String.raw`rd\s+/[sq]+\s+[cC]:\\`,
935
- ].join("|"),
936
- );
937
-
938
- async function runShell(args, opts) {
939
- if (typeof args.command !== "string") return { ok: false, output: "command is required" };
940
- const cwd = args.cwd ? resolveSafe(args.cwd, opts) : opts.cwd;
941
- console.log(c.yellow("$ ") + c.bold(args.command) + (args.cwd ? c.dim(` (cwd: ${args.cwd})`) : ""));
942
- const forceReview = CATASTROPHIC_CMD_RE.test(args.command);
943
- if (forceReview) {
944
- console.log(c.red(c.bold(" ⚠ DESTRUCTIVE COMMAND DETECTED")) + c.gray(" — requires manual confirmation"));
945
- }
946
- const approved = await confirm(c.yellow("Run this command?"), forceReview ? false : opts.autoYes);
947
- if (!approved) return { ok: false, output: "User declined the command." };
948
-
949
- return new Promise((resolve) => {
950
- const child = spawn(args.command, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
951
- let stdout = "";
952
- let stderr = "";
953
- let killed = false;
954
- const timeout = setTimeout(() => {
955
- killed = true;
956
- child.kill("SIGTERM");
957
- }, 120_000); // 2-minute hard cap per command
958
-
959
- child.stdout.on("data", (d) => {
960
- const s = d.toString();
961
- stdout += s;
962
- if (stdout.length < 80_000) process.stdout.write(c.dim(s));
963
- });
964
- child.stderr.on("data", (d) => {
965
- const s = d.toString();
966
- stderr += s;
967
- if (stderr.length < 80_000) process.stderr.write(c.dim(s));
968
- });
969
- child.on("close", (code) => {
970
- clearTimeout(timeout);
971
- // Truncate huge outputs before sending back to the model
972
- const truncate = (t) => (t.length > 20_000 ? t.slice(0, 20_000) + "\n…(truncated)" : t);
973
- const out = JSON.stringify(
974
- {
975
- exit_code: code,
976
- killed,
977
- stdout: truncate(stdout),
978
- stderr: truncate(stderr),
979
- },
980
- null,
981
- 2,
982
- );
983
- resolve({ ok: code === 0 && !killed, output: out });
984
- });
985
- });
986
- }
987
-
988
- /* ─────────────────────── Git tools ─────────────────────── */
989
-
990
- function runGit(gitArgs, cwd, timeoutMs = 30_000) {
991
- return new Promise((resolve) => {
992
- const child = spawn("git", gitArgs, { cwd, stdio: ["ignore", "pipe", "pipe"] });
993
- let stdout = "";
994
- let stderr = "";
995
- let killed = false;
996
- const timer = setTimeout(() => { killed = true; child.kill("SIGTERM"); }, timeoutMs);
997
- child.stdout.on("data", (d) => { stdout += d.toString(); });
998
- child.stderr.on("data", (d) => { stderr += d.toString(); });
999
- child.on("close", (code) => {
1000
- clearTimeout(timer);
1001
- const truncate = (t) => (t.length > 30_000 ? t.slice(0, 30_000) + "\n…(truncated)" : t);
1002
- resolve({ code, killed, stdout: truncate(stdout), stderr: truncate(stderr) });
1003
- });
1004
- child.on("error", (err) => {
1005
- clearTimeout(timer);
1006
- resolve({ code: 1, killed: false, stdout: "", stderr: `git spawn error: ${err.message}` });
1007
- });
1008
- });
1009
- }
1010
-
1011
- async function gitStatus(_args, opts) {
1012
- const r = await runGit(["status", "--short", "--branch", "-u"], opts.cwd);
1013
- if (r.code !== 0) return { ok: false, output: r.stderr || "git status failed" };
1014
- const lines = r.stdout.trim().split("\n");
1015
- const branchLine = lines[0] || "";
1016
- const files = lines.slice(1).filter((l) => l.trim());
1017
- const staged = [];
1018
- const unstaged = [];
1019
- const untracked = [];
1020
- for (const line of files) {
1021
- const x = line[0];
1022
- const y = line[1];
1023
- const name = line.slice(3);
1024
- if (x === "?") { untracked.push(name); continue; }
1025
- if (x !== " " && x !== "?") staged.push({ status: x, file: name });
1026
- if (y !== " " && y !== "?") unstaged.push({ status: y, file: name });
1027
- }
1028
- const result = {
1029
- branch: branchLine.replace(/^## /, ""),
1030
- staged,
1031
- unstaged,
1032
- untracked,
1033
- clean: staged.length === 0 && unstaged.length === 0 && untracked.length === 0,
1034
- };
1035
- return { ok: true, output: JSON.stringify(result, null, 2) };
1036
- }
1037
-
1038
- async function gitDiff(args, opts) {
1039
- const gitArgs = ["diff", "--stat", "--patch"];
1040
- if (args.staged) gitArgs.push("--cached");
1041
- if (args.base) {
1042
- const head = args.head || "HEAD";
1043
- gitArgs.length = 0;
1044
- gitArgs.push("diff", args.base + "..." + head, "--stat", "--patch");
1045
- }
1046
- if (Array.isArray(args.files) && args.files.length > 0) {
1047
- gitArgs.push("--");
1048
- for (const f of args.files) gitArgs.push(f);
1049
- }
1050
- const r = await runGit(gitArgs, opts.cwd, 60_000);
1051
- if (r.code !== 0) return { ok: false, output: r.stderr || "git diff failed" };
1052
- if (!r.stdout.trim()) return { ok: true, output: "(no changes)" };
1053
- return { ok: true, output: r.stdout };
1054
- }
1055
-
1056
- async function gitLog(args, opts) {
1057
- const count = Math.min(50, Math.max(1, args.count ?? 15));
1058
- const gitArgs = ["log", `-${count}`];
1059
- if (args.oneline) {
1060
- gitArgs.push("--oneline");
1061
- } else {
1062
- gitArgs.push("--format=%h %an (%ar)%n %s%n");
1063
- }
1064
- if (args.branch) gitArgs.push(args.branch);
1065
- if (args.file) { gitArgs.push("--"); gitArgs.push(args.file); }
1066
- const r = await runGit(gitArgs, opts.cwd);
1067
- if (r.code !== 0) return { ok: false, output: r.stderr || "git log failed" };
1068
- if (!r.stdout.trim()) return { ok: true, output: "(no commits)" };
1069
- return { ok: true, output: r.stdout };
1070
- }
1071
-
1072
- async function gitCommit(args, opts) {
1073
- if (typeof args.message !== "string" || !args.message.trim()) {
1074
- return { ok: false, output: "commit message is required" };
1075
- }
1076
- // Stage files
1077
- const filesToStage = Array.isArray(args.files) && args.files.length > 0 ? args.files : null;
1078
- if (filesToStage) {
1079
- const addR = await runGit(["add", "--", ...filesToStage], opts.cwd);
1080
- if (addR.code !== 0) return { ok: false, output: `git add failed: ${addR.stderr}` };
1081
- } else {
1082
- const addR = await runGit(["add", "-A"], opts.cwd);
1083
- if (addR.code !== 0) return { ok: false, output: `git add -A failed: ${addR.stderr}` };
1084
- }
1085
- // Show what's staged
1086
- const staged = await runGit(["diff", "--cached", "--stat"], opts.cwd);
1087
- if (!staged.stdout.trim()) {
1088
- return { ok: false, output: "Nothing to commit — no staged changes after git add." };
1089
- }
1090
- console.log(c.dim(staged.stdout));
1091
- console.log(c.cyan("commit message: ") + c.bold(args.message));
1092
- const approved = await confirm(c.yellow("Create this commit?"), opts.autoYes);
1093
- if (!approved) {
1094
- await runGit(["reset", "HEAD"], opts.cwd);
1095
- return { ok: false, output: "User declined the commit. Staged changes have been unstaged." };
1096
- }
1097
- const commitR = await runGit(["commit", "-m", args.message], opts.cwd);
1098
- if (commitR.code !== 0) return { ok: false, output: `git commit failed: ${commitR.stderr}` };
1099
- // Get the commit hash for confirmation
1100
- const hashR = await runGit(["rev-parse", "--short", "HEAD"], opts.cwd);
1101
- const hash = hashR.stdout.trim();
1102
- return { ok: true, output: `Committed ${hash}: ${args.message}\n${commitR.stdout}` };
1103
- }
1104
-
1105
- async function gitBranch(args, opts) {
1106
- if (typeof args.name !== "string" || !args.name.trim()) {
1107
- return { ok: false, output: "branch name is required" };
1108
- }
1109
- // Check if branch exists
1110
- const checkR = await runGit(["rev-parse", "--verify", args.name], opts.cwd);
1111
- if (checkR.code === 0) {
1112
- // Branch exists, switch to it
1113
- const switchR = await runGit(["checkout", args.name], opts.cwd);
1114
- if (switchR.code !== 0) return { ok: false, output: `git checkout failed: ${switchR.stderr}` };
1115
- return { ok: true, output: `Switched to existing branch '${args.name}'` };
1116
- }
1117
- // Create new branch
1118
- const createArgs = ["checkout", "-b", args.name];
1119
- if (args.base) createArgs.push(args.base);
1120
- const createR = await runGit(createArgs, opts.cwd);
1121
- if (createR.code !== 0) return { ok: false, output: `git checkout -b failed: ${createR.stderr}` };
1122
- return { ok: true, output: `Created and switched to new branch '${args.name}'${args.base ? ` from ${args.base}` : ""}` };
1123
- }
1124
-
1125
- async function gitPush(args, opts) {
1126
- // Get current branch name
1127
- const branchR = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], opts.cwd);
1128
- if (branchR.code !== 0) return { ok: false, output: `Cannot determine current branch: ${branchR.stderr}` };
1129
- const branch = branchR.stdout.trim();
1130
- if (!branch || branch === "HEAD") {
1131
- return { ok: false, output: "Cannot push — HEAD is detached. Checkout a branch first." };
1132
- }
1133
- const pushArgs = ["push", "-u", "origin", branch];
1134
- if (args.force) pushArgs.splice(1, 0, "--force-with-lease");
1135
- console.log(c.yellow("$ ") + c.bold(`git ${pushArgs.join(" ")}`));
1136
- const approved = await confirm(c.yellow(`Push '${branch}' to origin?`), opts.autoYes);
1137
- if (!approved) return { ok: false, output: "User declined the push." };
1138
- const r = await runGit(pushArgs, opts.cwd, 60_000);
1139
- if (r.code !== 0) return { ok: false, output: `git push failed: ${r.stderr}` };
1140
- return { ok: true, output: `Pushed '${branch}' to origin.\n${r.stderr}${r.stdout}`.trim() };
1141
- }
1142
-
1143
- async function gitCreatePr(args, opts) {
1144
- if (typeof args.title !== "string" || !args.title.trim()) {
1145
- return { ok: false, output: "PR title is required" };
1146
- }
1147
- if (typeof args.body !== "string") {
1148
- return { ok: false, output: "PR body is required" };
1149
- }
1150
- // Check if gh CLI is available
1151
- const ghCheck = await runGit(["--version"], opts.cwd);
1152
- void ghCheck;
1153
- const ghVerify = new Promise((resolve) => {
1154
- const child = spawn("gh", ["--version"], { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
1155
- let out = "";
1156
- child.stdout.on("data", (d) => { out += d.toString(); });
1157
- child.on("close", (code) => resolve({ code, stdout: out }));
1158
- child.on("error", () => resolve({ code: 1, stdout: "" }));
1159
- });
1160
- const ghResult = await ghVerify;
1161
- if (ghResult.code !== 0) {
1162
- return { ok: false, output: "GitHub CLI (gh) is not installed or not in PATH. Install it: https://cli.github.com" };
1163
- }
1164
- // Push branch first if needed
1165
- const branchR = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], opts.cwd);
1166
- const branch = branchR.stdout.trim();
1167
- const trackR = await runGit(["config", "--get", `branch.${branch}.remote`], opts.cwd);
1168
- if (trackR.code !== 0 || !trackR.stdout.trim()) {
1169
- console.log(c.gray(`Branch '${branch}' has no remote — pushing first...`));
1170
- const pushR = await runGit(["push", "-u", "origin", branch], opts.cwd, 60_000);
1171
- if (pushR.code !== 0) return { ok: false, output: `Failed to push branch: ${pushR.stderr}` };
1172
- console.log(c.gray("Pushed."));
1173
- }
1174
- // Build gh pr create command
1175
- console.log(c.cyan("PR title: ") + c.bold(args.title));
1176
- const bodyPreview = args.body.length > 200 ? args.body.slice(0, 197) + "..." : args.body;
1177
- console.log(c.gray(bodyPreview));
1178
- if (args.base) console.log(c.gray(`base: ${args.base}`));
1179
- if (args.draft) console.log(c.gray("(draft PR)"));
1180
- const approved = await confirm(c.yellow("Create this pull request?"), opts.autoYes);
1181
- if (!approved) return { ok: false, output: "User declined the PR." };
1182
- const ghArgs = ["pr", "create", "--title", args.title, "--body", args.body];
1183
- if (args.base) { ghArgs.push("--base"); ghArgs.push(args.base); }
1184
- if (args.draft) ghArgs.push("--draft");
1185
- return new Promise((resolve) => {
1186
- const child = spawn("gh", ghArgs, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
1187
- let stdout = "";
1188
- let stderr = "";
1189
- const timer = setTimeout(() => child.kill("SIGTERM"), 30_000);
1190
- child.stdout.on("data", (d) => { stdout += d.toString(); });
1191
- child.stderr.on("data", (d) => { stderr += d.toString(); });
1192
- child.on("close", (code) => {
1193
- clearTimeout(timer);
1194
- if (code !== 0) {
1195
- resolve({ ok: false, output: `gh pr create failed (exit ${code}): ${stderr || stdout}` });
1196
- } else {
1197
- const prUrl = stdout.trim();
1198
- resolve({ ok: true, output: `Pull request created: ${prUrl}\n${stderr}`.trim() });
1199
- }
1200
- });
1201
- child.on("error", (err) => {
1202
- clearTimeout(timer);
1203
- resolve({ ok: false, output: `gh spawn error: ${err.message}` });
1204
- });
1205
- });
1206
- }
1207
-
1208
- /* ─────────────────────── todo_write ─────────────────────── */
1209
-
1210
- // Module-level singleton holding the current todo list for this CLI session.
1211
- // Latest-wins: each todo_write call replaces the entire list. The model
1212
- // passes the full new state every time, mirroring what Claude Code's
1213
- // TodoWrite does. Simpler than incremental ops; gives the model full
1214
- // control over ordering and renames.
1215
- const VALID_TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
1216
- const MAX_TODOS = 30;
1217
- let todoState = [];
1218
-
1219
- // Test-only escape hatches — exported so the test suite can reset state
1220
- // between cases. Production code never touches these.
1221
- export function __resetTodoState() {
1222
- todoState = [];
1223
- }
1224
- export function __getTodoState() {
1225
- return todoState.map((t) => ({ ...t }));
1226
- }
1227
-
1228
- function todoWrite(args, opts) {
1229
- void opts;
1230
- if (!Array.isArray(args.todos)) {
1231
- return { ok: false, output: "todos must be an array" };
1232
- }
1233
- if (args.todos.length > MAX_TODOS) {
1234
- return {
1235
- ok: false,
1236
- output: `too many todos (${args.todos.length}) — max ${MAX_TODOS}. Keep the plan focused.`,
1237
- };
1238
- }
1239
- // Validate every item BEFORE mutating state — we don't want a partial write.
1240
- for (let i = 0; i < args.todos.length; i++) {
1241
- const t = args.todos[i];
1242
- if (!t || typeof t !== "object") {
1243
- return { ok: false, output: `todo[${i}] must be an object` };
1244
- }
1245
- if (typeof t.content !== "string" || t.content.trim().length === 0) {
1246
- return { ok: false, output: `todo[${i}] needs a non-empty content string` };
1247
- }
1248
- if (!VALID_TODO_STATUSES.has(t.status)) {
1249
- return {
1250
- ok: false,
1251
- output: `todo[${i}] invalid status: "${t.status}" — must be pending, in_progress, or completed`,
1252
- };
1253
- }
1254
- }
1255
- todoState = args.todos.map((t) => ({
1256
- content: t.content.trim(),
1257
- status: t.status,
1258
- }));
1259
- renderTodos(todoState);
1260
- const counts = { pending: 0, in_progress: 0, completed: 0 };
1261
- for (const t of todoState) counts[t.status]++;
1262
- return {
1263
- ok: true,
1264
- output: `Todos updated: ${counts.pending} pending, ${counts.in_progress} in_progress, ${counts.completed} completed.`,
1265
- };
1266
- }
1267
-
1268
- function renderTodos(todos) {
1269
- if (!process.stdout.isTTY) return; // skip render in non-TTY (CI, piped, tests)
1270
- console.log("");
1271
- console.log(c.cyan("●") + " " + c.bold("Plan"));
1272
- for (const t of todos) {
1273
- const icon =
1274
- t.status === "completed"
1275
- ? c.green("●")
1276
- : t.status === "in_progress"
1277
- ? c.yellow("→")
1278
- : c.dim("·");
1279
- const text =
1280
- t.status === "completed"
1281
- ? c.dim(t.content)
1282
- : t.status === "in_progress"
1283
- ? c.bold(t.content)
1284
- : c.gray(t.content);
1285
- console.log(` ${icon} ${text}`);
1286
- }
1287
- console.log("");
1288
- }
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 { promptChoice } from "./menu.js";
17
+ import { unifiedDiff, summarizeWrite } from "./diff.js";
18
+ import { getConfig } from "./config.js";
19
+ import { getVersion } from "./version.js";
20
+
21
+ /* ─────────────────────── Tool definitions (sent to model) ─────────────────────── */
22
+
23
+ export const TOOL_DEFINITIONS = [
24
+ {
25
+ type: "function",
26
+ function: {
27
+ name: "read_file",
28
+ description:
29
+ "Read the contents of a file as UTF-8 text. Returns the file contents or an error if the file doesn't exist.",
30
+ parameters: {
31
+ type: "object",
32
+ properties: {
33
+ path: { type: "string", description: "Path relative to the working directory, or absolute." },
34
+ },
35
+ required: ["path"],
36
+ },
37
+ },
38
+ },
39
+ {
40
+ type: "function",
41
+ function: {
42
+ name: "list_dir",
43
+ description:
44
+ "List the entries in a directory. Returns an array of {name, type: 'file'|'dir', size?: number}. Hidden files (starting with .) are excluded by default.",
45
+ parameters: {
46
+ type: "object",
47
+ properties: {
48
+ path: { type: "string", description: "Directory path." },
49
+ include_hidden: { type: "boolean", description: "Include dotfiles. Default: false." },
50
+ },
51
+ required: ["path"],
52
+ },
53
+ },
54
+ },
55
+ {
56
+ type: "function",
57
+ function: {
58
+ name: "search_files",
59
+ description:
60
+ "Recursively search for a regex pattern across files in a directory. Returns matching file paths and the matching line. Limited to 50 results.",
61
+ parameters: {
62
+ type: "object",
63
+ properties: {
64
+ path: { type: "string", description: "Directory to search." },
65
+ pattern: { type: "string", description: "JavaScript-style regex (without slashes)." },
66
+ glob: { type: "string", description: "Optional file-name glob filter, e.g. '*.ts'." },
67
+ },
68
+ required: ["path", "pattern"],
69
+ },
70
+ },
71
+ },
72
+ {
73
+ type: "function",
74
+ function: {
75
+ name: "glob_files",
76
+ description:
77
+ "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.",
78
+ parameters: {
79
+ type: "object",
80
+ properties: {
81
+ pattern: { type: "string", description: "Glob pattern. Supports ** (any depth), * (within a path segment), ?. e.g. 'src/**/*.js'." },
82
+ path: { type: "string", description: "Directory to search from. Default: the working directory." },
83
+ },
84
+ required: ["pattern"],
85
+ },
86
+ },
87
+ },
88
+ {
89
+ type: "function",
90
+ function: {
91
+ name: "write_file",
92
+ description:
93
+ "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.",
94
+ parameters: {
95
+ type: "object",
96
+ properties: {
97
+ path: { type: "string", description: "File path." },
98
+ content: { type: "string", description: "Full file content to write." },
99
+ },
100
+ required: ["path", "content"],
101
+ },
102
+ },
103
+ },
104
+ {
105
+ type: "function",
106
+ function: {
107
+ name: "edit_file",
108
+ description:
109
+ "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.",
110
+ parameters: {
111
+ type: "object",
112
+ properties: {
113
+ path: { type: "string", description: "File path." },
114
+ find: { type: "string", description: "Exact text to replace. Must be unique unless replace_all is true." },
115
+ replace: { type: "string", description: "Text to substitute in." },
116
+ replace_all: { type: "boolean", description: "Replace ALL occurrences instead of requiring a unique match. Default: false." },
117
+ },
118
+ required: ["path", "find", "replace"],
119
+ },
120
+ },
121
+ },
122
+ {
123
+ type: "function",
124
+ function: {
125
+ name: "run_shell",
126
+ description:
127
+ "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.",
128
+ parameters: {
129
+ type: "object",
130
+ properties: {
131
+ command: { type: "string", description: "The shell command to run." },
132
+ cwd: { type: "string", description: "Optional working directory (relative or absolute)." },
133
+ },
134
+ required: ["command"],
135
+ },
136
+ },
137
+ },
138
+ {
139
+ type: "function",
140
+ function: {
141
+ name: "web_search",
142
+ description:
143
+ "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.",
144
+ parameters: {
145
+ type: "object",
146
+ properties: {
147
+ query: { type: "string", description: "Plain-language search query." },
148
+ max_results: { type: "number", description: "How many results to return (1–10, default 5)." },
149
+ },
150
+ required: ["query"],
151
+ },
152
+ },
153
+ },
154
+ {
155
+ type: "function",
156
+ function: {
157
+ name: "web_fetch",
158
+ description:
159
+ "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.",
160
+ parameters: {
161
+ type: "object",
162
+ properties: {
163
+ url: { type: "string", description: "Full http(s) URL to fetch." },
164
+ },
165
+ required: ["url"],
166
+ },
167
+ },
168
+ },
169
+ {
170
+ type: "function",
171
+ function: {
172
+ name: "todo_write",
173
+ description:
174
+ "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.",
175
+ parameters: {
176
+ type: "object",
177
+ properties: {
178
+ todos: {
179
+ type: "array",
180
+ description: "Full replacement list (latest-wins semantics).",
181
+ items: {
182
+ type: "object",
183
+ properties: {
184
+ content: { type: "string", description: "Short imperative phrase, e.g. 'wire endpoint into UI'." },
185
+ status: {
186
+ type: "string",
187
+ enum: ["pending", "in_progress", "completed"],
188
+ description: "Task status.",
189
+ },
190
+ },
191
+ required: ["content", "status"],
192
+ },
193
+ },
194
+ },
195
+ required: ["todos"],
196
+ },
197
+ },
198
+ },
199
+ {
200
+ type: "function",
201
+ function: {
202
+ name: "ask_user",
203
+ description:
204
+ "Ask the user a single multiple-choice question and wait for their answer. Use this ONLY when the request is genuinely ambiguous AND the answer materially changes what you build — e.g. which platform/framework/language, the scope, or an irreversible decision. Ask BEFORE building when a wrong assumption would waste real work. Give 2–5 concrete options, each with a short description; put the best default first. Do NOT use it for trivial choices, things you can infer, or to confirm permission to act — just build when the task is clear.",
205
+ parameters: {
206
+ type: "object",
207
+ properties: {
208
+ question: { type: "string", description: "The single, focused question to ask." },
209
+ options: {
210
+ type: "array",
211
+ description: "2–5 options. Each is {label, description}. Best default first.",
212
+ items: {
213
+ type: "object",
214
+ properties: {
215
+ label: { type: "string", description: "Short option label." },
216
+ description: { type: "string", description: "One-line explanation of this option." },
217
+ },
218
+ required: ["label"],
219
+ },
220
+ },
221
+ },
222
+ required: ["question", "options"],
223
+ },
224
+ },
225
+ },
226
+ /* ─────────────────────── Git tools ─────────────────────── */
227
+ {
228
+ type: "function",
229
+ function: {
230
+ name: "git_status",
231
+ description:
232
+ "Show the working tree status — staged, unstaged, and untracked files. Auto-executes (read-only). Use this before committing to see what's changed.",
233
+ parameters: {
234
+ type: "object",
235
+ properties: {},
236
+ },
237
+ },
238
+ },
239
+ {
240
+ type: "function",
241
+ function: {
242
+ name: "git_diff",
243
+ description:
244
+ "Show a unified diff of changes. By default shows unstaged changes. Set staged=true for staged changes, or pass base/head for branch comparisons (e.g. base='main' head='HEAD'). Optionally limit to specific file(s).",
245
+ parameters: {
246
+ type: "object",
247
+ properties: {
248
+ staged: { type: "boolean", description: "Show staged (--cached) diff instead of unstaged. Default: false." },
249
+ base: { type: "string", description: "Base ref for comparison (e.g. 'main', 'HEAD~3'). When set, shows diff between base and head." },
250
+ head: { type: "string", description: "Head ref for comparison. Default: 'HEAD' when base is set." },
251
+ files: {
252
+ type: "array",
253
+ description: "Limit diff to these file paths.",
254
+ items: { type: "string" },
255
+ },
256
+ },
257
+ },
258
+ },
259
+ },
260
+ {
261
+ type: "function",
262
+ function: {
263
+ name: "git_log",
264
+ description:
265
+ "Show recent commit history with hash, author, date, and message. Defaults to 15 commits. Use this to understand what's been done recently, find commit hashes, or check the commit style before committing.",
266
+ parameters: {
267
+ type: "object",
268
+ properties: {
269
+ count: { type: "number", description: "Number of commits to show (1-50). Default: 15." },
270
+ oneline: { type: "boolean", description: "Compact one-line format. Default: false." },
271
+ file: { type: "string", description: "Show only commits that touched this file." },
272
+ branch: { type: "string", description: "Show commits on this branch instead of HEAD." },
273
+ },
274
+ },
275
+ },
276
+ },
277
+ {
278
+ type: "function",
279
+ function: {
280
+ name: "git_commit",
281
+ description:
282
+ "Stage files and create a commit. If no files are specified, stages all modified/new files (git add -A). The user will see what's being committed and confirm. Use git_status first to see what's changed, and git_log to match the commit message style.",
283
+ parameters: {
284
+ type: "object",
285
+ properties: {
286
+ message: { type: "string", description: "Commit message. Be concise — focus on WHY, not WHAT." },
287
+ files: {
288
+ type: "array",
289
+ description: "Specific files to stage. If omitted, stages everything (git add -A).",
290
+ items: { type: "string" },
291
+ },
292
+ },
293
+ required: ["message"],
294
+ },
295
+ },
296
+ },
297
+ {
298
+ type: "function",
299
+ function: {
300
+ name: "git_branch",
301
+ description:
302
+ "Create and/or switch to a branch. If the branch exists, switches to it. If it doesn't, creates it from the current HEAD (or from 'base' if specified) and switches to it.",
303
+ parameters: {
304
+ type: "object",
305
+ properties: {
306
+ name: { type: "string", description: "Branch name to create or switch to." },
307
+ base: { type: "string", description: "Create the branch from this ref instead of HEAD." },
308
+ },
309
+ required: ["name"],
310
+ },
311
+ },
312
+ },
313
+ {
314
+ type: "function",
315
+ function: {
316
+ name: "git_push",
317
+ description:
318
+ "Push the current branch to the remote. Sets upstream tracking (-u origin) on first push. The user will confirm before pushing.",
319
+ parameters: {
320
+ type: "object",
321
+ properties: {
322
+ force: { type: "boolean", description: "Force push (--force-with-lease). Use only when explicitly asked. Default: false." },
323
+ },
324
+ },
325
+ },
326
+ },
327
+ {
328
+ type: "function",
329
+ function: {
330
+ name: "git_create_pr",
331
+ description:
332
+ "Create a GitHub pull request using the `gh` CLI. Requires `gh` to be installed and authenticated. Pushes the branch first if needed. The user will confirm before creating.",
333
+ parameters: {
334
+ type: "object",
335
+ properties: {
336
+ title: { type: "string", description: "PR title. Keep under 72 chars." },
337
+ body: { type: "string", description: "PR body/description. Markdown supported." },
338
+ base: { type: "string", description: "Target branch to merge into. Default: repo default (usually main)." },
339
+ draft: { type: "boolean", description: "Create as a draft PR. Default: false." },
340
+ },
341
+ required: ["title", "body"],
342
+ },
343
+ },
344
+ },
345
+ ];
346
+
347
+ /* ─────────────────────── Argument validation ─────────────────────── */
348
+
349
+ const TOOL_SCHEMAS = Object.fromEntries(
350
+ TOOL_DEFINITIONS.map((t) => [t.function.name, t.function.parameters]),
351
+ );
352
+
353
+ function typeMatches(val, t) {
354
+ switch (t) {
355
+ case "string": return typeof val === "string";
356
+ case "number": return typeof val === "number";
357
+ case "integer": return typeof val === "number" && Number.isInteger(val);
358
+ case "boolean": return typeof val === "boolean";
359
+ case "object": return typeof val === "object" && val !== null && !Array.isArray(val);
360
+ case "array": return Array.isArray(val);
361
+ default: return true;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Validate parsed tool arguments against the tool's JSON schema BEFORE the
367
+ * handler runs. Returns an error string the model can act on, or null if valid.
368
+ * Catches malformed/partial args from the model that would otherwise surface as
369
+ * cryptic downstream errors.
370
+ */
371
+ export function validateToolArgs(name, args) {
372
+ const schema = TOOL_SCHEMAS[name];
373
+ if (!schema) return `Unknown tool: ${name}`;
374
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
375
+ return `Arguments for ${name} must be a JSON object.`;
376
+ }
377
+ for (const req of schema.required ?? []) {
378
+ if (args[req] === undefined || args[req] === null) {
379
+ return `Missing required argument "${req}" for ${name}.`;
380
+ }
381
+ }
382
+ for (const [key, val] of Object.entries(args)) {
383
+ const prop = schema.properties?.[key];
384
+ if (!prop || val === undefined || val === null) continue;
385
+ if (prop.type && !typeMatches(val, prop.type)) {
386
+ return `Argument "${key}" for ${name} must be of type ${prop.type}.`;
387
+ }
388
+ }
389
+ return null;
390
+ }
391
+
392
+ /* ─────────────────────── Glob + ignore helpers ─────────────────────── */
393
+
394
+ const ALWAYS_SKIP = new Set([".git", "node_modules", "dist"]);
395
+ const MAX_SEARCH_MATCHES = 200;
396
+ const MAX_GLOB_RESULTS = 300;
397
+
398
+ // Convert a glob ('**', '*', '?') to an anchored regex over a POSIX-style
399
+ // relative path. ** spans directory separators; * does not.
400
+ export function globToRegExp(glob) {
401
+ let re = "";
402
+ let braceDepth = 0;
403
+ for (let i = 0; i < glob.length; i++) {
404
+ const ch = glob[i];
405
+ if (ch === "*") {
406
+ if (glob[i + 1] === "*") {
407
+ re += ".*";
408
+ i++;
409
+ if (glob[i + 1] === "/") i++; // collapse '**/' so it can also match zero dirs
410
+ } else {
411
+ re += "[^/]*";
412
+ }
413
+ } else if (ch === "?") {
414
+ re += "[^/]";
415
+ } else if (ch === "{") {
416
+ // Brace expansion: {js,ts} -> (js|ts). Common in .gitignore (*.{js,ts});
417
+ // treating it literally silently failed to ignore those files.
418
+ re += "(";
419
+ braceDepth++;
420
+ } else if (ch === "}" && braceDepth > 0) {
421
+ re += ")";
422
+ braceDepth--;
423
+ } else if (ch === "," && braceDepth > 0) {
424
+ re += "|";
425
+ } else if ("\\^$+.()|{}[]".includes(ch)) {
426
+ re += "\\" + ch;
427
+ } else {
428
+ re += ch;
429
+ }
430
+ }
431
+ return new RegExp("^" + re + "$");
432
+ }
433
+
434
+ // Parse a .gitignore at `root` into a matcher. Pragmatic subset of gitignore
435
+ // semantics: blank/comment lines ignored; trailing '/' = directory-only;
436
+ // leading '/' = root-anchored; '*'/'?' globs supported.
437
+ function loadIgnore(root) {
438
+ let lines = [];
439
+ try { lines = fs.readFileSync(path.join(root, ".gitignore"), "utf8").split("\n"); } catch { /* none */ }
440
+ const rules = [];
441
+ for (const raw of lines) {
442
+ const line = raw.trim();
443
+ if (!line || line.startsWith("#")) continue;
444
+ let pat = line;
445
+ const dirOnly = pat.endsWith("/");
446
+ if (dirOnly) pat = pat.slice(0, -1);
447
+ const anchored = pat.startsWith("/");
448
+ if (anchored) pat = pat.slice(1);
449
+ rules.push({ re: globToRegExp(pat), anchored, dirOnly, base: !pat.includes("/") });
450
+ }
451
+ return (relPath, isDir) => {
452
+ const norm = relPath.split(path.sep).join("/");
453
+ const baseName = norm.split("/").pop();
454
+ for (const r of rules) {
455
+ if (r.dirOnly && !isDir) continue;
456
+ if (r.base) { if (r.re.test(baseName)) return true; }
457
+ else if (r.anchored) { if (r.re.test(norm)) return true; }
458
+ else if (r.re.test(norm) || r.re.test(baseName)) return true;
459
+ }
460
+ return false;
461
+ };
462
+ }
463
+
464
+ /* ─────────────────────── Helpers ─────────────────────── */
465
+
466
+ function resolveSafe(rel, opts) {
467
+ const abs = path.isAbsolute(rel) ? path.normalize(rel) : path.resolve(opts.cwd, rel);
468
+ if (!opts.unsafePaths) {
469
+ const cwd = path.resolve(opts.cwd);
470
+ // Use path.relative (not startsWith) so Windows drive-relative inputs like
471
+ // "C:evil.txt" — which path.isAbsolute() reports as NON-absolute and
472
+ // path.resolve() may anchor to a drive's own cwd outside the project — are
473
+ // caught. A path is inside cwd only if its relative form doesn't climb out
474
+ // (no leading "..") and isn't itself absolute (different drive/root).
475
+ const relToCwd = path.relative(cwd, abs);
476
+ const escapes = relToCwd === ".." || relToCwd.startsWith(".." + path.sep) || path.isAbsolute(relToCwd);
477
+ if (abs !== cwd && escapes) {
478
+ throw new Error(
479
+ `Refusing to touch path outside cwd: ${abs}\n Run with --unsafe-paths if you really mean this.`,
480
+ );
481
+ }
482
+ }
483
+ return abs;
484
+ }
485
+
486
+ function ask(question) {
487
+ if (!process.stdin.isTTY) {
488
+ return Promise.resolve("n"); // can't prompt in non-TTY; default no
489
+ }
490
+ return new Promise((resolve) => {
491
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
492
+ rl.question(question, (answer) => {
493
+ rl.close();
494
+ resolve(answer.trim().toLowerCase());
495
+ });
496
+ });
497
+ }
498
+
499
+ async function confirm(question, autoYes) {
500
+ if (autoYes) return true;
501
+ const ans = await ask(`${question} ${c.dim("[y/N]: ")}`);
502
+ return ans === "y" || ans === "yes";
503
+ }
504
+
505
+ /* ─────────────────────── Implementations ─────────────────────── */
506
+
507
+ export async function executeTool(call, opts) {
508
+ let args;
509
+ try {
510
+ args = JSON.parse(call.function.arguments || "{}");
511
+ } catch (e) {
512
+ return { ok: false, output: `Invalid JSON arguments: ${e.message}` };
513
+ }
514
+
515
+ const name = call.function.name;
516
+ const validationError = validateToolArgs(name, args);
517
+ if (validationError) {
518
+ return { ok: false, output: validationError };
519
+ }
520
+ const handlers = {
521
+ read_file: () => readFile(args, opts),
522
+ list_dir: () => listDir(args, opts),
523
+ search_files: () => searchFiles(args, opts),
524
+ glob_files: () => globFiles(args, opts),
525
+ write_file: () => writeFile(args, opts),
526
+ edit_file: () => editFile(args, opts),
527
+ run_shell: () => runShell(args, opts),
528
+ web_search: () => webSearch(args, opts),
529
+ web_fetch: () => webFetch(args, opts),
530
+ todo_write: () => todoWrite(args, opts),
531
+ ask_user: () => askUser(args, opts),
532
+ git_status: () => gitStatus(args, opts),
533
+ git_diff: () => gitDiff(args, opts),
534
+ git_log: () => gitLog(args, opts),
535
+ git_commit: () => gitCommit(args, opts),
536
+ git_branch: () => gitBranch(args, opts),
537
+ git_push: () => gitPush(args, opts),
538
+ git_create_pr: () => gitCreatePr(args, opts),
539
+ };
540
+ const fn = handlers[name];
541
+ if (!fn) {
542
+ return { ok: false, output: `Unknown tool: ${name}` };
543
+ }
544
+ try {
545
+ return await fn();
546
+ } catch (e) {
547
+ return { ok: false, output: `${name} failed: ${e.message}` };
548
+ }
549
+ }
550
+
551
+ // Ask the user a multiple-choice question and block on their answer. Not a
552
+ // permission gate — it runs even in skip-permissions mode, because the model is
553
+ // explicitly requesting input. In a non-TTY the menu auto-picks the first
554
+ // option so scripted runs don't hang.
555
+ async function askUser(args, _opts) {
556
+ const options = Array.isArray(args.options) ? args.options : [];
557
+ if (options.length === 0) {
558
+ return { ok: false, output: "ask_user requires a non-empty options array" };
559
+ }
560
+ const choice = await promptChoice({ question: String(args.question || "Which option?"), options });
561
+ if (!choice) {
562
+ return { ok: true, output: "The user cancelled the question. Proceed with your best judgment." };
563
+ }
564
+ const extra = choice.description ? ` (${choice.description})` : "";
565
+ return { ok: true, output: `The user chose: ${choice.label}${extra}` };
566
+ }
567
+
568
+ function readFile(args, opts) {
569
+ if (typeof args.path !== "string") return { ok: false, output: "path is required" };
570
+ const abs = resolveSafe(args.path, opts);
571
+ let stat;
572
+ try { stat = fs.statSync(abs); } catch (e) {
573
+ return { ok: false, output: `Cannot access ${args.path}: ${e.code || e.message}` };
574
+ }
575
+ if (stat.isDirectory()) return { ok: false, output: `${args.path} is a directory, not a file` };
576
+ // Block non-regular files (named pipes, sockets, devices) — reading them can
577
+ // hang forever (no timeout on readFileSync).
578
+ if (!stat.isFile()) return { ok: false, output: `${args.path} is not a regular file.` };
579
+ if (stat.size > 1_000_000) {
580
+ return { ok: false, output: `File too large (${stat.size} bytes). Aether refuses to read >1MB at once.` };
581
+ }
582
+ try {
583
+ return { ok: true, output: fs.readFileSync(abs, "utf8") };
584
+ } catch (e) {
585
+ return { ok: false, output: `Cannot read ${args.path}: ${e.code || e.message}` };
586
+ }
587
+ }
588
+
589
+ function listDir(args, opts) {
590
+ if (typeof args.path !== "string") return { ok: false, output: "path is required" };
591
+ const abs = resolveSafe(args.path, opts);
592
+ const entries = fs.readdirSync(abs, { withFileTypes: true });
593
+ const results = [];
594
+ for (const e of entries) {
595
+ if (!args.include_hidden && e.name.startsWith(".")) continue;
596
+ if (e.name === "node_modules" || e.name === ".git" || e.name === "dist") continue;
597
+ let size = undefined;
598
+ if (e.isFile()) {
599
+ try { size = fs.statSync(path.join(abs, e.name)).size; } catch { /* skip */ }
600
+ }
601
+ results.push({
602
+ name: e.name,
603
+ type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other",
604
+ size,
605
+ });
606
+ }
607
+ results.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
608
+ return { ok: true, output: JSON.stringify(results, null, 2) };
609
+ }
610
+
611
+ function searchFiles(args, opts) {
612
+ if (typeof args.path !== "string" || typeof args.pattern !== "string") {
613
+ return { ok: false, output: "path and pattern are required" };
614
+ }
615
+ let regex;
616
+ try { regex = new RegExp(args.pattern); } catch (e) {
617
+ return { ok: false, output: `Invalid regex: ${e.message}` };
618
+ }
619
+ const root = resolveSafe(args.path, opts);
620
+ const isIgnored = loadIgnore(path.resolve(opts.cwd));
621
+ const matches = [];
622
+ const globRe = args.glob ? globToRegExp(args.glob) : null;
623
+ let truncated = false;
624
+
625
+ function walk(dir) {
626
+ if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
627
+ let entries;
628
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
629
+ for (const e of entries) {
630
+ if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
631
+ if (e.name.startsWith(".") || ALWAYS_SKIP.has(e.name)) continue;
632
+ const full = path.join(dir, e.name);
633
+ const rel = path.relative(opts.cwd, full);
634
+ if (isIgnored(rel, e.isDirectory())) continue;
635
+ if (e.isDirectory()) {
636
+ walk(full);
637
+ } else if (e.isFile()) {
638
+ if (globRe && !globRe.test(e.name)) continue;
639
+ let content;
640
+ try {
641
+ const stat = fs.statSync(full);
642
+ if (stat.size > 500_000) continue;
643
+ content = fs.readFileSync(full, "utf8");
644
+ } catch { continue; }
645
+ const lines = content.split("\n");
646
+ for (let i = 0; i < lines.length; i++) {
647
+ // Skip very long lines: the pattern is model-supplied and a
648
+ // pathological regex on a long line can backtrack catastrophically
649
+ // (ReDoS) and freeze the single-threaded process.
650
+ if (lines[i].length > 2000) continue;
651
+ if (regex.test(lines[i])) {
652
+ matches.push({ file: rel, line: i + 1, text: lines[i].slice(0, 300) });
653
+ if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
654
+ }
655
+ }
656
+ }
657
+ }
658
+ }
659
+ walk(root);
660
+ const payload = { matches };
661
+ if (truncated) {
662
+ payload.truncated = true;
663
+ payload.note = `Showing the first ${MAX_SEARCH_MATCHES} matches — refine the pattern or pass a 'glob' filter to narrow.`;
664
+ }
665
+ return { ok: true, output: JSON.stringify(payload, null, 2) };
666
+ }
667
+
668
+ function globFiles(args, opts) {
669
+ if (typeof args.pattern !== "string") return { ok: false, output: "pattern is required" };
670
+ const root = resolveSafe(typeof args.path === "string" ? args.path : ".", opts);
671
+ const re = globToRegExp(args.pattern);
672
+ const isIgnored = loadIgnore(path.resolve(opts.cwd));
673
+ const found = [];
674
+ let truncated = false;
675
+
676
+ function walk(dir) {
677
+ if (found.length >= MAX_GLOB_RESULTS) { truncated = true; return; }
678
+ let entries;
679
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
680
+ for (const e of entries) {
681
+ if (found.length >= MAX_GLOB_RESULTS) { truncated = true; return; }
682
+ if (e.name.startsWith(".") || ALWAYS_SKIP.has(e.name)) continue;
683
+ const full = path.join(dir, e.name);
684
+ const rel = path.relative(opts.cwd, full);
685
+ if (isIgnored(rel, e.isDirectory())) continue;
686
+ if (e.isDirectory()) {
687
+ walk(full);
688
+ } else if (e.isFile()) {
689
+ const relToRoot = path.relative(root, full).split(path.sep).join("/");
690
+ if (re.test(relToRoot)) {
691
+ let mtime = 0;
692
+ try { mtime = fs.statSync(full).mtimeMs; } catch { /* ignore */ }
693
+ found.push({ path: rel, mtime });
694
+ }
695
+ }
696
+ }
697
+ }
698
+ walk(root);
699
+ found.sort((a, b) => b.mtime - a.mtime); // most-recently-modified first
700
+ const payload = { files: found.map((f) => f.path) };
701
+ if (truncated) {
702
+ payload.truncated = true;
703
+ payload.note = `Showing the first ${MAX_GLOB_RESULTS} files — narrow the pattern.`;
704
+ }
705
+ return { ok: true, output: JSON.stringify(payload, null, 2) };
706
+ }
707
+
708
+ async function writeFile(args, opts) {
709
+ if (typeof args.path !== "string" || typeof args.content !== "string") {
710
+ return { ok: false, output: "path and content are required" };
711
+ }
712
+ const abs = resolveSafe(args.path, opts);
713
+ const exists = fs.existsSync(abs);
714
+ const oldContent = exists ? fs.readFileSync(abs, "utf8") : null;
715
+ if (exists && oldContent === args.content) {
716
+ return { ok: true, output: `(no change — file already matches)` };
717
+ }
718
+ // Show diff + confirm (the "● write <file>" label already spaced this block)
719
+ console.log(summarizeWrite(oldContent, args.content, path.relative(opts.cwd, abs)));
720
+ console.log(unifiedDiff(oldContent ?? "", args.content, path.relative(opts.cwd, abs)));
721
+ const approved = await confirm(c.yellow("Apply this write?"), opts.autoYes);
722
+ if (!approved) {
723
+ return { ok: false, output: "User declined the write." };
724
+ }
725
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
726
+ fs.writeFileSync(abs, args.content, "utf8");
727
+ return { ok: true, output: `Wrote ${args.content.length} bytes to ${path.relative(opts.cwd, abs)}` };
728
+ }
729
+
730
+ async function editFile(args, opts) {
731
+ if (typeof args.path !== "string" || typeof args.find !== "string" || typeof args.replace !== "string") {
732
+ return { ok: false, output: "path, find, replace are required" };
733
+ }
734
+ const abs = resolveSafe(args.path, opts);
735
+ if (!fs.existsSync(abs)) {
736
+ return {
737
+ ok: false,
738
+ output: `File not found: ${args.path}. It doesn't exist yet — use write_file to CREATE it (edit_file only modifies existing files). Do not retry edit_file on this path.`,
739
+ };
740
+ }
741
+ const oldContent = fs.readFileSync(abs, "utf8");
742
+ const occurrences = oldContent.split(args.find).length - 1;
743
+ if (occurrences === 0) {
744
+ return { ok: false, output: `\`find\` text not found in ${args.path}. Tip: read the file first to copy exact characters.` };
745
+ }
746
+ if (!args.replace_all && occurrences > 1) {
747
+ return {
748
+ ok: false,
749
+ output: `\`find\` text appears ${occurrences} times — must be unique. Add more context to disambiguate, or set replace_all: true to replace all ${occurrences}.`,
750
+ };
751
+ }
752
+ // split/join for BOTH paths: String.replace(str, str) would interpret special
753
+ // patterns in the replacement ($&, $`, $', $$) and silently corrupt code that
754
+ // contains them (regex, jQuery $, template strings). split/join is literal.
755
+ // On the single-edit path occurrences === 1, so it's equivalent + safe.
756
+ const newContent = oldContent.split(args.find).join(args.replace);
757
+ const rel = path.relative(opts.cwd, abs);
758
+ console.log(c.dim(`edit ${rel}${args.replace_all ? ` (${occurrences} occurrences)` : ""}`));
759
+ console.log(unifiedDiff(oldContent, newContent, rel));
760
+ const approved = await confirm(c.yellow("Apply this edit?"), opts.autoYes);
761
+ if (!approved) return { ok: false, output: "User declined the edit." };
762
+ fs.writeFileSync(abs, newContent, "utf8");
763
+ return { ok: true, output: `Edited ${rel}${args.replace_all && occurrences > 1 ? ` (${occurrences} replacements)` : ""}` };
764
+ }
765
+
766
+ /* ─────────────────────── Web tools ─────────────────────── */
767
+
768
+ async function webSearch(args, opts) {
769
+ void opts;
770
+ if (typeof args.query !== "string") return { ok: false, output: "query is required" };
771
+ const { apiKey, baseUrl } = getConfig();
772
+ if (!apiKey) {
773
+ return { ok: false, output: "Web search requires AETHER_API_KEY. Set it and try again." };
774
+ }
775
+ const max = Number.isInteger(args.max_results) ? Math.min(10, Math.max(1, args.max_results)) : 5;
776
+ const maxRetries = 2;
777
+ const baseDelay = 500;
778
+ let res;
779
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
780
+ try {
781
+ res = await fetch(`${baseUrl}/api/v1/web-search`, {
782
+ method: "POST",
783
+ headers: {
784
+ "Content-Type": "application/json",
785
+ Authorization: `Bearer ${apiKey}`,
786
+ "User-Agent": `aether-code/${getVersion()}`,
787
+ },
788
+ body: JSON.stringify({ query: args.query, max_results: max }),
789
+ });
790
+ if (res.status >= 500 && attempt < maxRetries) {
791
+ res.body?.cancel().catch(() => {});
792
+ await new Promise((r) => setTimeout(r, baseDelay * 2 ** attempt));
793
+ continue;
794
+ }
795
+ break;
796
+ } catch (e) {
797
+ if (attempt < maxRetries) {
798
+ await new Promise((r) => setTimeout(r, baseDelay * 2 ** attempt));
799
+ continue;
800
+ }
801
+ return { ok: false, output: `web_search network error: ${e.message}` };
802
+ }
803
+ }
804
+ let data = null;
805
+ try { data = await res.json(); } catch { /* non-JSON */ }
806
+ if (!res.ok) {
807
+ return { ok: false, output: data?.error || `web_search HTTP ${res.status}` };
808
+ }
809
+ return { ok: true, output: JSON.stringify(data.results ?? [], null, 2) };
810
+ }
811
+
812
+ // Bounded fetch with a fixed timeout + size cap. Strips scripts/styles, removes
813
+ // tags, decodes common HTML entities. Not a full HTML parser; good enough for
814
+ // reading docs pages, GitHub READMEs, MDN, Stack Overflow answers, etc.
815
+ // SSRF guard: refuse loopback / link-local / private hosts so web_fetch can't
816
+ // be steered (directly or via a redirect) at localhost or cloud-metadata
817
+ // (169.254.169.254) or an internal service.
818
+ function isBlockedHost(hostname) {
819
+ const h = (hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
820
+ if (!h || h === "localhost" || h === "0.0.0.0" || h === "::1" || h === "::") return true;
821
+ if (h.endsWith(".localhost") || h.endsWith(".internal") || h.endsWith(".local")) return true;
822
+ if (/^127\./.test(h)) return true; // loopback
823
+ if (/^10\./.test(h)) return true; // private A
824
+ if (/^192\.168\./.test(h)) return true; // private C
825
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; // private B
826
+ if (/^169\.254\./.test(h)) return true; // link-local incl. cloud metadata
827
+ if (/^(fe80:|fc|fd|::ffff:)/.test(h)) return true; // IPv6 link-local / ULA / mapped
828
+ return false;
829
+ }
830
+
831
+ async function webFetch(args, opts) {
832
+ void opts;
833
+ if (typeof args.url !== "string") return { ok: false, output: "url is required" };
834
+ if (!/^https?:\/\//i.test(args.url)) {
835
+ return { ok: false, output: "Only http:// and https:// URLs are allowed." };
836
+ }
837
+ let initialHost;
838
+ try { initialHost = new URL(args.url).hostname; } catch { return { ok: false, output: "Invalid URL." }; }
839
+ if (isBlockedHost(initialHost)) {
840
+ return { ok: false, output: "web_fetch blocked: refusing to fetch a private/internal/loopback address." };
841
+ }
842
+ const controller = new AbortController();
843
+ const timeout = setTimeout(() => controller.abort(), 15_000);
844
+ let res;
845
+ try {
846
+ res = await fetch(args.url, {
847
+ signal: controller.signal,
848
+ redirect: "follow",
849
+ headers: {
850
+ // Looking like a normal browser dodges many anti-bot pages.
851
+ "User-Agent":
852
+ `Mozilla/5.0 (compatible; aether-code/${getVersion()}) Gecko/20100101 Firefox/130.0`,
853
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
854
+ "Accept-Language": "en-US,en;q=0.9",
855
+ },
856
+ });
857
+ } catch (e) {
858
+ clearTimeout(timeout);
859
+ return { ok: false, output: `web_fetch error: ${e.name === "AbortError" ? "timed out after 15s" : e.message}` };
860
+ }
861
+ clearTimeout(timeout);
862
+ // Re-check the FINAL url after any redirects — a public URL can 302 to an
863
+ // internal target, which the initial-URL check wouldn't catch.
864
+ try {
865
+ if (isBlockedHost(new URL(res.url).hostname)) {
866
+ res.body?.cancel().catch(() => {});
867
+ return { ok: false, output: "web_fetch blocked: redirect to a private/internal address." };
868
+ }
869
+ } catch { /* res.url unparseable — fall through */ }
870
+ if (!res.ok) {
871
+ return { ok: false, output: `web_fetch HTTP ${res.status} ${res.statusText}` };
872
+ }
873
+ // Cap at 2 MB of raw HTML before stripping — page might be huge.
874
+ const reader = res.body?.getReader();
875
+ if (!reader) return { ok: false, output: "Response had no body." };
876
+ const chunks = [];
877
+ let total = 0;
878
+ while (true) {
879
+ const { value, done } = await reader.read();
880
+ if (done) break;
881
+ total += value.length;
882
+ if (total > 2_000_000) {
883
+ reader.cancel();
884
+ break;
885
+ }
886
+ chunks.push(value);
887
+ }
888
+ const html = new TextDecoder("utf-8", { fatal: false }).decode(
889
+ Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))),
890
+ );
891
+ const text = htmlToText(html);
892
+ // Final cap on what we hand to the model so a single fetch doesn't blow the context.
893
+ const capped = text.length > 50_000 ? text.slice(0, 50_000) + "\n…(truncated; page was longer)" : text;
894
+ return { ok: true, output: capped };
895
+ }
896
+
897
+ export function htmlToText(html) {
898
+ let s = html;
899
+ // Drop script/style blocks entirely.
900
+ s = s.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, " ");
901
+ s = s.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, " ");
902
+ s = s.replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, " ");
903
+ // Preserve paragraph + heading breaks.
904
+ s = s.replace(/<\/(p|div|h[1-6]|li|tr|br)\s*>/gi, "\n");
905
+ s = s.replace(/<br\s*\/?>/gi, "\n");
906
+ // Strip remaining tags.
907
+ s = s.replace(/<[^>]+>/g, "");
908
+ // Decode common HTML entities (covers ~95% of real-world cases).
909
+ const entities = {
910
+ "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'",
911
+ "&apos;": "'", "&nbsp;": " ", "&mdash;": "—", "&ndash;": "–",
912
+ "&hellip;": "…", "&copy;": "©", "&reg;": "®", "&trade;": "™",
913
+ };
914
+ for (const [ent, ch] of Object.entries(entities)) {
915
+ s = s.split(ent).join(ch);
916
+ }
917
+ s = s.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)));
918
+ s = s.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)));
919
+ // Collapse whitespace.
920
+ s = s.replace(/[ \t]+/g, " ");
921
+ s = s.replace(/\n\s*\n\s*\n+/g, "\n\n");
922
+ return s.trim();
923
+ }
924
+
925
+ const CATASTROPHIC_CMD_RE = new RegExp(
926
+ [
927
+ String.raw`rm\s+-(r|f|rf|fr)\w*\s+[/~](?!\S*node_modules)(?!\S*dist)(?!\S*\.next)(?!\S*build)`,
928
+ String.raw`del\s+/[sfq]+\s+[cC]:\\`,
929
+ String.raw`format\s+[a-zA-Z]:`,
930
+ String.raw`mkfs\.`,
931
+ String.raw`dd\s+.*of=/dev/[sh]d`,
932
+ String.raw`:\(\)\s*\{\s*:\|:`,
933
+ String.raw`>\s*/dev/[sh]d`,
934
+ String.raw`rd\s+/[sq]+\s+[cC]:\\`,
935
+ ].join("|"),
936
+ );
937
+
938
+ async function runShell(args, opts) {
939
+ if (typeof args.command !== "string") return { ok: false, output: "command is required" };
940
+ const cwd = args.cwd ? resolveSafe(args.cwd, opts) : opts.cwd;
941
+ console.log(c.yellow("$ ") + c.bold(args.command) + (args.cwd ? c.dim(` (cwd: ${args.cwd})`) : ""));
942
+ const forceReview = CATASTROPHIC_CMD_RE.test(args.command);
943
+ if (forceReview) {
944
+ console.log(c.red(c.bold(" ⚠ DESTRUCTIVE COMMAND DETECTED")) + c.gray(" — requires manual confirmation"));
945
+ }
946
+ const approved = await confirm(c.yellow("Run this command?"), forceReview ? false : opts.autoYes);
947
+ if (!approved) return { ok: false, output: "User declined the command." };
948
+
949
+ return new Promise((resolve) => {
950
+ const child = spawn(args.command, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
951
+ let stdout = "";
952
+ let stderr = "";
953
+ let killed = false;
954
+ const timeout = setTimeout(() => {
955
+ killed = true;
956
+ child.kill("SIGTERM");
957
+ }, 120_000); // 2-minute hard cap per command
958
+
959
+ child.stdout.on("data", (d) => {
960
+ const s = d.toString();
961
+ stdout += s;
962
+ if (stdout.length < 80_000) process.stdout.write(c.dim(s));
963
+ });
964
+ child.stderr.on("data", (d) => {
965
+ const s = d.toString();
966
+ stderr += s;
967
+ if (stderr.length < 80_000) process.stderr.write(c.dim(s));
968
+ });
969
+ child.on("close", (code) => {
970
+ clearTimeout(timeout);
971
+ // Truncate huge outputs before sending back to the model
972
+ const truncate = (t) => (t.length > 20_000 ? t.slice(0, 20_000) + "\n…(truncated)" : t);
973
+ const out = JSON.stringify(
974
+ {
975
+ exit_code: code,
976
+ killed,
977
+ stdout: truncate(stdout),
978
+ stderr: truncate(stderr),
979
+ },
980
+ null,
981
+ 2,
982
+ );
983
+ resolve({ ok: code === 0 && !killed, output: out });
984
+ });
985
+ });
986
+ }
987
+
988
+ /* ─────────────────────── Git tools ─────────────────────── */
989
+
990
+ function runGit(gitArgs, cwd, timeoutMs = 30_000) {
991
+ return new Promise((resolve) => {
992
+ const child = spawn("git", gitArgs, { cwd, stdio: ["ignore", "pipe", "pipe"] });
993
+ let stdout = "";
994
+ let stderr = "";
995
+ let killed = false;
996
+ const timer = setTimeout(() => { killed = true; child.kill("SIGTERM"); }, timeoutMs);
997
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
998
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
999
+ child.on("close", (code) => {
1000
+ clearTimeout(timer);
1001
+ const truncate = (t) => (t.length > 30_000 ? t.slice(0, 30_000) + "\n…(truncated)" : t);
1002
+ resolve({ code, killed, stdout: truncate(stdout), stderr: truncate(stderr) });
1003
+ });
1004
+ child.on("error", (err) => {
1005
+ clearTimeout(timer);
1006
+ resolve({ code: 1, killed: false, stdout: "", stderr: `git spawn error: ${err.message}` });
1007
+ });
1008
+ });
1009
+ }
1010
+
1011
+ async function gitStatus(_args, opts) {
1012
+ const r = await runGit(["status", "--short", "--branch", "-u"], opts.cwd);
1013
+ if (r.code !== 0) return { ok: false, output: r.stderr || "git status failed" };
1014
+ const lines = r.stdout.trim().split("\n");
1015
+ const branchLine = lines[0] || "";
1016
+ const files = lines.slice(1).filter((l) => l.trim());
1017
+ const staged = [];
1018
+ const unstaged = [];
1019
+ const untracked = [];
1020
+ for (const line of files) {
1021
+ const x = line[0];
1022
+ const y = line[1];
1023
+ const name = line.slice(3);
1024
+ if (x === "?") { untracked.push(name); continue; }
1025
+ if (x !== " " && x !== "?") staged.push({ status: x, file: name });
1026
+ if (y !== " " && y !== "?") unstaged.push({ status: y, file: name });
1027
+ }
1028
+ const result = {
1029
+ branch: branchLine.replace(/^## /, ""),
1030
+ staged,
1031
+ unstaged,
1032
+ untracked,
1033
+ clean: staged.length === 0 && unstaged.length === 0 && untracked.length === 0,
1034
+ };
1035
+ return { ok: true, output: JSON.stringify(result, null, 2) };
1036
+ }
1037
+
1038
+ async function gitDiff(args, opts) {
1039
+ const gitArgs = ["diff", "--stat", "--patch"];
1040
+ if (args.staged) gitArgs.push("--cached");
1041
+ if (args.base) {
1042
+ const head = args.head || "HEAD";
1043
+ gitArgs.length = 0;
1044
+ gitArgs.push("diff", args.base + "..." + head, "--stat", "--patch");
1045
+ }
1046
+ if (Array.isArray(args.files) && args.files.length > 0) {
1047
+ gitArgs.push("--");
1048
+ for (const f of args.files) gitArgs.push(f);
1049
+ }
1050
+ const r = await runGit(gitArgs, opts.cwd, 60_000);
1051
+ if (r.code !== 0) return { ok: false, output: r.stderr || "git diff failed" };
1052
+ if (!r.stdout.trim()) return { ok: true, output: "(no changes)" };
1053
+ return { ok: true, output: r.stdout };
1054
+ }
1055
+
1056
+ async function gitLog(args, opts) {
1057
+ const count = Math.min(50, Math.max(1, args.count ?? 15));
1058
+ const gitArgs = ["log", `-${count}`];
1059
+ if (args.oneline) {
1060
+ gitArgs.push("--oneline");
1061
+ } else {
1062
+ gitArgs.push("--format=%h %an (%ar)%n %s%n");
1063
+ }
1064
+ if (args.branch) gitArgs.push(args.branch);
1065
+ if (args.file) { gitArgs.push("--"); gitArgs.push(args.file); }
1066
+ const r = await runGit(gitArgs, opts.cwd);
1067
+ if (r.code !== 0) return { ok: false, output: r.stderr || "git log failed" };
1068
+ if (!r.stdout.trim()) return { ok: true, output: "(no commits)" };
1069
+ return { ok: true, output: r.stdout };
1070
+ }
1071
+
1072
+ async function gitCommit(args, opts) {
1073
+ if (typeof args.message !== "string" || !args.message.trim()) {
1074
+ return { ok: false, output: "commit message is required" };
1075
+ }
1076
+ // Stage files
1077
+ const filesToStage = Array.isArray(args.files) && args.files.length > 0 ? args.files : null;
1078
+ if (filesToStage) {
1079
+ const addR = await runGit(["add", "--", ...filesToStage], opts.cwd);
1080
+ if (addR.code !== 0) return { ok: false, output: `git add failed: ${addR.stderr}` };
1081
+ } else {
1082
+ const addR = await runGit(["add", "-A"], opts.cwd);
1083
+ if (addR.code !== 0) return { ok: false, output: `git add -A failed: ${addR.stderr}` };
1084
+ }
1085
+ // Show what's staged
1086
+ const staged = await runGit(["diff", "--cached", "--stat"], opts.cwd);
1087
+ if (!staged.stdout.trim()) {
1088
+ return { ok: false, output: "Nothing to commit — no staged changes after git add." };
1089
+ }
1090
+ console.log(c.dim(staged.stdout));
1091
+ console.log(c.cyan("commit message: ") + c.bold(args.message));
1092
+ const approved = await confirm(c.yellow("Create this commit?"), opts.autoYes);
1093
+ if (!approved) {
1094
+ await runGit(["reset", "HEAD"], opts.cwd);
1095
+ return { ok: false, output: "User declined the commit. Staged changes have been unstaged." };
1096
+ }
1097
+ const commitR = await runGit(["commit", "-m", args.message], opts.cwd);
1098
+ if (commitR.code !== 0) return { ok: false, output: `git commit failed: ${commitR.stderr}` };
1099
+ // Get the commit hash for confirmation
1100
+ const hashR = await runGit(["rev-parse", "--short", "HEAD"], opts.cwd);
1101
+ const hash = hashR.stdout.trim();
1102
+ return { ok: true, output: `Committed ${hash}: ${args.message}\n${commitR.stdout}` };
1103
+ }
1104
+
1105
+ async function gitBranch(args, opts) {
1106
+ if (typeof args.name !== "string" || !args.name.trim()) {
1107
+ return { ok: false, output: "branch name is required" };
1108
+ }
1109
+ // Check if branch exists
1110
+ const checkR = await runGit(["rev-parse", "--verify", args.name], opts.cwd);
1111
+ if (checkR.code === 0) {
1112
+ // Branch exists, switch to it
1113
+ const switchR = await runGit(["checkout", args.name], opts.cwd);
1114
+ if (switchR.code !== 0) return { ok: false, output: `git checkout failed: ${switchR.stderr}` };
1115
+ return { ok: true, output: `Switched to existing branch '${args.name}'` };
1116
+ }
1117
+ // Create new branch
1118
+ const createArgs = ["checkout", "-b", args.name];
1119
+ if (args.base) createArgs.push(args.base);
1120
+ const createR = await runGit(createArgs, opts.cwd);
1121
+ if (createR.code !== 0) return { ok: false, output: `git checkout -b failed: ${createR.stderr}` };
1122
+ return { ok: true, output: `Created and switched to new branch '${args.name}'${args.base ? ` from ${args.base}` : ""}` };
1123
+ }
1124
+
1125
+ async function gitPush(args, opts) {
1126
+ // Get current branch name
1127
+ const branchR = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], opts.cwd);
1128
+ if (branchR.code !== 0) return { ok: false, output: `Cannot determine current branch: ${branchR.stderr}` };
1129
+ const branch = branchR.stdout.trim();
1130
+ if (!branch || branch === "HEAD") {
1131
+ return { ok: false, output: "Cannot push — HEAD is detached. Checkout a branch first." };
1132
+ }
1133
+ const pushArgs = ["push", "-u", "origin", branch];
1134
+ if (args.force) pushArgs.splice(1, 0, "--force-with-lease");
1135
+ console.log(c.yellow("$ ") + c.bold(`git ${pushArgs.join(" ")}`));
1136
+ const approved = await confirm(c.yellow(`Push '${branch}' to origin?`), opts.autoYes);
1137
+ if (!approved) return { ok: false, output: "User declined the push." };
1138
+ const r = await runGit(pushArgs, opts.cwd, 60_000);
1139
+ if (r.code !== 0) return { ok: false, output: `git push failed: ${r.stderr}` };
1140
+ return { ok: true, output: `Pushed '${branch}' to origin.\n${r.stderr}${r.stdout}`.trim() };
1141
+ }
1142
+
1143
+ async function gitCreatePr(args, opts) {
1144
+ if (typeof args.title !== "string" || !args.title.trim()) {
1145
+ return { ok: false, output: "PR title is required" };
1146
+ }
1147
+ if (typeof args.body !== "string") {
1148
+ return { ok: false, output: "PR body is required" };
1149
+ }
1150
+ // Check if gh CLI is available
1151
+ const ghCheck = await runGit(["--version"], opts.cwd);
1152
+ void ghCheck;
1153
+ const ghVerify = new Promise((resolve) => {
1154
+ const child = spawn("gh", ["--version"], { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
1155
+ let out = "";
1156
+ child.stdout.on("data", (d) => { out += d.toString(); });
1157
+ child.on("close", (code) => resolve({ code, stdout: out }));
1158
+ child.on("error", () => resolve({ code: 1, stdout: "" }));
1159
+ });
1160
+ const ghResult = await ghVerify;
1161
+ if (ghResult.code !== 0) {
1162
+ return { ok: false, output: "GitHub CLI (gh) is not installed or not in PATH. Install it: https://cli.github.com" };
1163
+ }
1164
+ // Push branch first if needed
1165
+ const branchR = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], opts.cwd);
1166
+ const branch = branchR.stdout.trim();
1167
+ const trackR = await runGit(["config", "--get", `branch.${branch}.remote`], opts.cwd);
1168
+ if (trackR.code !== 0 || !trackR.stdout.trim()) {
1169
+ console.log(c.gray(`Branch '${branch}' has no remote — pushing first...`));
1170
+ const pushR = await runGit(["push", "-u", "origin", branch], opts.cwd, 60_000);
1171
+ if (pushR.code !== 0) return { ok: false, output: `Failed to push branch: ${pushR.stderr}` };
1172
+ console.log(c.gray("Pushed."));
1173
+ }
1174
+ // Build gh pr create command
1175
+ console.log(c.cyan("PR title: ") + c.bold(args.title));
1176
+ const bodyPreview = args.body.length > 200 ? args.body.slice(0, 197) + "..." : args.body;
1177
+ console.log(c.gray(bodyPreview));
1178
+ if (args.base) console.log(c.gray(`base: ${args.base}`));
1179
+ if (args.draft) console.log(c.gray("(draft PR)"));
1180
+ const approved = await confirm(c.yellow("Create this pull request?"), opts.autoYes);
1181
+ if (!approved) return { ok: false, output: "User declined the PR." };
1182
+ const ghArgs = ["pr", "create", "--title", args.title, "--body", args.body];
1183
+ if (args.base) { ghArgs.push("--base"); ghArgs.push(args.base); }
1184
+ if (args.draft) ghArgs.push("--draft");
1185
+ return new Promise((resolve) => {
1186
+ const child = spawn("gh", ghArgs, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
1187
+ let stdout = "";
1188
+ let stderr = "";
1189
+ const timer = setTimeout(() => child.kill("SIGTERM"), 30_000);
1190
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
1191
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
1192
+ child.on("close", (code) => {
1193
+ clearTimeout(timer);
1194
+ if (code !== 0) {
1195
+ resolve({ ok: false, output: `gh pr create failed (exit ${code}): ${stderr || stdout}` });
1196
+ } else {
1197
+ const prUrl = stdout.trim();
1198
+ resolve({ ok: true, output: `Pull request created: ${prUrl}\n${stderr}`.trim() });
1199
+ }
1200
+ });
1201
+ child.on("error", (err) => {
1202
+ clearTimeout(timer);
1203
+ resolve({ ok: false, output: `gh spawn error: ${err.message}` });
1204
+ });
1205
+ });
1206
+ }
1207
+
1208
+ /* ─────────────────────── todo_write ─────────────────────── */
1209
+
1210
+ // Module-level singleton holding the current todo list for this CLI session.
1211
+ // Latest-wins: each todo_write call replaces the entire list. The model
1212
+ // passes the full new state every time, mirroring what Claude Code's
1213
+ // TodoWrite does. Simpler than incremental ops; gives the model full
1214
+ // control over ordering and renames.
1215
+ const VALID_TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
1216
+ const MAX_TODOS = 30;
1217
+ let todoState = [];
1218
+
1219
+ // Test-only escape hatches — exported so the test suite can reset state
1220
+ // between cases. Production code never touches these.
1221
+ export function __resetTodoState() {
1222
+ todoState = [];
1223
+ }
1224
+ export function __getTodoState() {
1225
+ return todoState.map((t) => ({ ...t }));
1226
+ }
1227
+
1228
+ function todoWrite(args, opts) {
1229
+ void opts;
1230
+ if (!Array.isArray(args.todos)) {
1231
+ return { ok: false, output: "todos must be an array" };
1232
+ }
1233
+ if (args.todos.length > MAX_TODOS) {
1234
+ return {
1235
+ ok: false,
1236
+ output: `too many todos (${args.todos.length}) — max ${MAX_TODOS}. Keep the plan focused.`,
1237
+ };
1238
+ }
1239
+ // Validate every item BEFORE mutating state — we don't want a partial write.
1240
+ for (let i = 0; i < args.todos.length; i++) {
1241
+ const t = args.todos[i];
1242
+ if (!t || typeof t !== "object") {
1243
+ return { ok: false, output: `todo[${i}] must be an object` };
1244
+ }
1245
+ if (typeof t.content !== "string" || t.content.trim().length === 0) {
1246
+ return { ok: false, output: `todo[${i}] needs a non-empty content string` };
1247
+ }
1248
+ if (!VALID_TODO_STATUSES.has(t.status)) {
1249
+ return {
1250
+ ok: false,
1251
+ output: `todo[${i}] invalid status: "${t.status}" — must be pending, in_progress, or completed`,
1252
+ };
1253
+ }
1254
+ }
1255
+ todoState = args.todos.map((t) => ({
1256
+ content: t.content.trim(),
1257
+ status: t.status,
1258
+ }));
1259
+ renderTodos(todoState);
1260
+ const counts = { pending: 0, in_progress: 0, completed: 0 };
1261
+ for (const t of todoState) counts[t.status]++;
1262
+ return {
1263
+ ok: true,
1264
+ output: `Todos updated: ${counts.pending} pending, ${counts.in_progress} in_progress, ${counts.completed} completed.`,
1265
+ };
1266
+ }
1267
+
1268
+ function renderTodos(todos) {
1269
+ if (!process.stdout.isTTY) return; // skip render in non-TTY (CI, piped, tests)
1270
+ console.log("");
1271
+ console.log(c.cyan("●") + " " + c.bold("Plan"));
1272
+ for (const t of todos) {
1273
+ const icon =
1274
+ t.status === "completed"
1275
+ ? c.green("●")
1276
+ : t.status === "in_progress"
1277
+ ? c.yellow("→")
1278
+ : c.dim("·");
1279
+ const text =
1280
+ t.status === "completed"
1281
+ ? c.dim(t.content)
1282
+ : t.status === "in_progress"
1283
+ ? c.bold(t.content)
1284
+ : c.gray(t.content);
1285
+ console.log(` ${icon} ${text}`);
1286
+ }
1287
+ console.log("");
1288
+ }