aether-code 0.37.0 → 0.39.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-code",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "description": "Uncensored AI coding agent for your terminal — Claude Code alternative with MCP support. Reads code, writes files, runs commands. Drives IDA Pro, Roblox Studio, Wireshark, Blender, and any MCP server. No refusal layer.",
5
5
  "homepage": "https://trynoguard.com",
6
6
  "repository": {
package/src/agent.js CHANGED
@@ -4,11 +4,12 @@
4
4
 
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { spawn } from "node:child_process";
7
8
  import { agentTurnStream, AetherError } from "./api.js";
8
9
  import { TOOL_DEFINITIONS, executeTool } from "./tools.js";
9
10
  import { unnamespaceToolName } from "./mcp.js";
10
11
  import { loadAllSkills, selectSkills, renderSkillsBlock } from "./skills.js";
11
- import { scanProject } from "./project-context.js";
12
+ import { scanProject, detectVerifyCommand } from "./project-context.js";
12
13
  import { c, divider, turn, toolLabel, toolSummary, makeTokenStripper, errorLine, startSpinner } from "./render.js";
13
14
 
14
15
  const DEFAULT_MAX_TURNS = 25;
@@ -109,6 +110,7 @@ export async function runAgent({
109
110
  // finishes having read files it never edited, we nudge it once to finish.
110
111
  const looksLikeModification = MOD_RE.test(initialPrompt);
111
112
  let appliedNothingNudges = 0;
113
+ let verifyRounds = 0;
112
114
 
113
115
  for (let i = 0; i < maxTurns; i++) {
114
116
  // No turn header and no leading blank here — each step (assistant text and
@@ -218,6 +220,30 @@ export async function runAgent({
218
220
  messages.push({ role: "user", content: msg });
219
221
  continue; // give the model another turn to finish
220
222
  }
223
+ // Auto-verify: if we touched files, run the project's typecheck/lint
224
+ // and feed errors back so the model can self-correct without the user
225
+ // having to say "fix that." At most 2 verify rounds to avoid infinite loops.
226
+ if (filesTouched.size > 0 && verifyRounds < 2) {
227
+ const verifyResult = await runVerify(cwd);
228
+ if (verifyResult && !verifyResult.ok) {
229
+ verifyRounds++;
230
+ console.log("\n" + c.yellow("●") + " " + c.bold("auto-verify") + c.gray(` (${verifyResult.label})`));
231
+ console.log(c.red(" └─") + " " + c.gray("errors found — feeding back to fix"));
232
+ const errPreview = verifyResult.output.length > 4000
233
+ ? verifyResult.output.slice(0, 4000) + "\n…(truncated)"
234
+ : verifyResult.output;
235
+ messages.push({
236
+ role: "user",
237
+ content:
238
+ `AUTO-VERIFY FAILED (${verifyResult.label}):\n\`\`\`\n${errPreview}\n\`\`\`\n` +
239
+ `Fix these errors NOW using edit_file. Do not explain — just fix and move on.`,
240
+ });
241
+ continue;
242
+ }
243
+ if (verifyResult && verifyResult.ok) {
244
+ console.log("\n" + c.green("●") + " " + c.bold("auto-verify") + c.gray(` (${verifyResult.label}) — passed`));
245
+ }
246
+ }
221
247
  if (filesTouched.size) {
222
248
  console.log("\n" + c.bold("Files") + c.gray(` in ${cwd}`));
223
249
  for (const [p, action] of filesTouched) {
@@ -298,6 +324,32 @@ export async function runAgent({
298
324
  return { ok: false, error: new Error("Max turns reached"), totalCredits, totalIn, totalOut, balance: lastBalance, messages };
299
325
  }
300
326
 
327
+ // Auto-verify: run the project's typecheck/lint command silently and return
328
+ // { ok, output, label }. Returns null if no verify command is detected.
329
+ let cachedVerify = undefined;
330
+ async function runVerify(cwd) {
331
+ if (cachedVerify === undefined) cachedVerify = detectVerifyCommand(cwd);
332
+ if (!cachedVerify) return null;
333
+ const { cmd, label } = cachedVerify;
334
+ return new Promise((resolve) => {
335
+ const child = spawn(cmd, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
336
+ let stdout = "";
337
+ let stderr = "";
338
+ const timer = setTimeout(() => { child.kill("SIGTERM"); }, 60_000);
339
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
340
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
341
+ child.on("close", (code) => {
342
+ clearTimeout(timer);
343
+ const output = (stdout + "\n" + stderr).trim();
344
+ resolve({ ok: code === 0, output, label });
345
+ });
346
+ child.on("error", () => {
347
+ clearTimeout(timer);
348
+ resolve(null);
349
+ });
350
+ });
351
+ }
352
+
301
353
  /**
302
354
  * Per-turn skill injection. Selects skills against the latest user message
303
355
  * + paths the model has touched, then prepends matching bodies onto the
@@ -263,6 +263,278 @@ export function scanProject(cwd) {
263
263
  return lines.join("\n") + "\n";
264
264
  }
265
265
 
266
+ // ─────────── /init — deep project indexing ───────────
267
+
268
+ const KEY_FILES = [
269
+ "README.md", "CLAUDE.md", ".env.example", "Dockerfile",
270
+ "docker-compose.yml", "docker-compose.yaml",
271
+ ];
272
+
273
+ const ENTRY_PATTERNS = [
274
+ /^(src\/)?index\.[jt]sx?$/,
275
+ /^(src\/)?main\.[jt]sx?$/,
276
+ /^(src\/)?app\.[jt]sx?$/,
277
+ /^(src\/)?server\.[jt]sx?$/,
278
+ /^(bin|cli|cmd)\/[^/]+\.[jt]sx?$/,
279
+ /^(pages|app)\/_app\.[jt]sx?$/,
280
+ /^(pages|app)\/layout\.[jt]sx?$/,
281
+ ];
282
+
283
+ const CONFIG_FILES = [
284
+ "next.config.js", "next.config.mjs", "next.config.ts",
285
+ "vite.config.ts", "vite.config.js", "webpack.config.js",
286
+ "tsconfig.json", "tailwind.config.js", "tailwind.config.ts",
287
+ "eslint.config.js", ".eslintrc.js", ".eslintrc.json",
288
+ "jest.config.js", "jest.config.ts", "vitest.config.ts",
289
+ "prisma/schema.prisma", "drizzle.config.ts",
290
+ ];
291
+
292
+ function collectSourceFiles(dir, base, maxFiles = 500) {
293
+ const results = [];
294
+ function walk(current, rel) {
295
+ if (results.length >= maxFiles) return;
296
+ let entries;
297
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
298
+ for (const e of entries) {
299
+ if (results.length >= maxFiles) return;
300
+ if (e.name.startsWith(".") && e.name !== ".env.example") continue;
301
+ if (SKIP_DIRS.has(e.name)) continue;
302
+ const fullPath = path.join(current, e.name);
303
+ const relPath = rel ? `${rel}/${e.name}` : e.name;
304
+ if (e.isDirectory()) {
305
+ walk(fullPath, relPath);
306
+ } else if (e.isFile()) {
307
+ let size = 0;
308
+ try { size = fs.statSync(fullPath).size; } catch { /* skip */ }
309
+ results.push({ path: relPath, size });
310
+ }
311
+ }
312
+ }
313
+ walk(dir, "");
314
+ return results;
315
+ }
316
+
317
+ function identifyKeyFiles(allFiles, cwd) {
318
+ const key = [];
319
+ for (const f of allFiles) {
320
+ const name = f.path.split("/").pop();
321
+ if (KEY_FILES.includes(name)) { key.push(f.path); continue; }
322
+ if (CONFIG_FILES.includes(f.path)) { key.push(f.path); continue; }
323
+ for (const pat of ENTRY_PATTERNS) {
324
+ if (pat.test(f.path)) { key.push(f.path); break; }
325
+ }
326
+ }
327
+ return key;
328
+ }
329
+
330
+ function summarizeFile(filePath, maxLines = 30) {
331
+ try {
332
+ const stat = fs.statSync(filePath);
333
+ if (!stat.isFile() || stat.size > 200_000) return null;
334
+ const content = fs.readFileSync(filePath, "utf8");
335
+ const lines = content.split("\n");
336
+ const exports = [];
337
+ const imports = [];
338
+ for (const line of lines.slice(0, 100)) {
339
+ if (/^export\s+(default\s+)?(function|class|const|let|async)\s+(\w+)/m.test(line)) {
340
+ const match = line.match(/^export\s+(?:default\s+)?(?:function|class|const|let|async\s+function)\s+(\w+)/);
341
+ if (match) exports.push(match[1]);
342
+ }
343
+ if (/^import\s/.test(line)) {
344
+ const match = line.match(/from\s+["']([^"']+)["']/);
345
+ if (match && !match[1].startsWith(".")) imports.push(match[1]);
346
+ }
347
+ }
348
+ const summary = { lines: lines.length };
349
+ if (exports.length > 0) summary.exports = exports.slice(0, 15);
350
+ if (imports.length > 0) summary.externalImports = [...new Set(imports)].slice(0, 10);
351
+ const firstComment = content.match(/^\/\/\s*(.+)/m);
352
+ if (firstComment) summary.description = firstComment[1].trim().slice(0, 120);
353
+ return summary;
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+
359
+ function detectConventions(allFiles, cwd) {
360
+ const conventions = [];
361
+ const extensions = new Set();
362
+ for (const f of allFiles) {
363
+ const ext = path.extname(f.path);
364
+ if (ext) extensions.add(ext);
365
+ }
366
+ if (extensions.has(".ts") || extensions.has(".tsx")) conventions.push("TypeScript");
367
+ if (extensions.has(".jsx") || extensions.has(".tsx")) conventions.push("JSX/TSX (React-style)");
368
+ const hasTest = allFiles.some((f) =>
369
+ /\.(test|spec)\.[jt]sx?$/.test(f.path) || f.path.startsWith("test/") || f.path.startsWith("__tests__/"),
370
+ );
371
+ if (hasTest) {
372
+ const testDirs = [];
373
+ if (allFiles.some((f) => f.path.startsWith("test/"))) testDirs.push("test/");
374
+ if (allFiles.some((f) => f.path.startsWith("__tests__/"))) testDirs.push("__tests__/");
375
+ const colocated = allFiles.some((f) => /src\/.*\.(test|spec)\.[jt]sx?$/.test(f.path));
376
+ if (colocated) testDirs.push("colocated in src/");
377
+ conventions.push(`Tests: ${testDirs.join(", ") || "detected"}`);
378
+ }
379
+ if (fs.existsSync(path.join(cwd, ".prettierrc")) || fs.existsSync(path.join(cwd, ".prettierrc.json"))) {
380
+ conventions.push("Prettier formatting");
381
+ }
382
+ if (fs.existsSync(path.join(cwd, ".eslintrc.js")) || fs.existsSync(path.join(cwd, "eslint.config.js"))) {
383
+ conventions.push("ESLint");
384
+ }
385
+ return conventions;
386
+ }
387
+
388
+ function buildDirectoryTree(allFiles, maxDepth = 3) {
389
+ const tree = {};
390
+ for (const f of allFiles) {
391
+ const parts = f.path.split("/");
392
+ let node = tree;
393
+ for (let i = 0; i < Math.min(parts.length - 1, maxDepth); i++) {
394
+ if (!node[parts[i]]) node[parts[i]] = {};
395
+ node = node[parts[i]];
396
+ }
397
+ }
398
+ function render(node, indent = "") {
399
+ const lines = [];
400
+ const entries = Object.keys(node).sort();
401
+ for (const key of entries) {
402
+ const children = Object.keys(node[key]);
403
+ lines.push(`${indent}${key}/`);
404
+ if (children.length > 0 && indent.length < maxDepth * 2) {
405
+ lines.push(...render(node[key], indent + " "));
406
+ }
407
+ }
408
+ return lines;
409
+ }
410
+ return render(tree);
411
+ }
412
+
413
+ export function generateProjectIndex(cwd) {
414
+ const lines = [];
415
+ lines.push("# Project Context");
416
+ lines.push(`# Auto-generated by \`aether /init\` — edit freely, this file is yours.`);
417
+ lines.push(`# Loaded on every agent turn to give the model deep project understanding.`);
418
+ lines.push("");
419
+
420
+ const { dirs, files: topFiles } = listTopLevel(cwd);
421
+ const frameworks = detectFrameworks(cwd, topFiles);
422
+ const pkg = extractPackageInfo(cwd);
423
+ const cargo = extractCargoInfo(cwd);
424
+ const goInfo = extractGoInfo(cwd);
425
+ const pyInfo = extractPythonInfo(cwd);
426
+
427
+ lines.push("## Overview");
428
+ if (pkg?.name) lines.push(`- **Package:** ${pkg.name}${pkg.version ? ` v${pkg.version}` : ""}`);
429
+ if (cargo?.name) lines.push(`- **Crate:** ${cargo.name}${cargo.version ? ` v${cargo.version}` : ""}`);
430
+ if (goInfo?.module) lines.push(`- **Module:** ${goInfo.module}${goInfo.goVersion ? ` (go ${goInfo.goVersion})` : ""}`);
431
+ if (pyInfo?.name) lines.push(`- **Python:** ${pyInfo.name || pyInfo.type}`);
432
+ if (frameworks.length > 0) lines.push(`- **Stack:** ${frameworks.join(", ")}`);
433
+ if (pkg?.frameworks?.length) lines.push(`- **Libraries:** ${pkg.frameworks.join(", ")}`);
434
+ if (pkg?.depCounts) lines.push(`- **Dependencies:** ${pkg.depCounts}`);
435
+ lines.push("");
436
+
437
+ const allFiles = collectSourceFiles(cwd);
438
+ const conventions = detectConventions(allFiles, cwd);
439
+ if (conventions.length > 0) {
440
+ lines.push("## Conventions");
441
+ for (const c of conventions) lines.push(`- ${c}`);
442
+ lines.push("");
443
+ }
444
+
445
+ if (pkg?.scripts?.length) {
446
+ lines.push("## Scripts");
447
+ const pkgFull = readJsonSafe(path.join(cwd, "package.json"));
448
+ if (pkgFull?.scripts) {
449
+ for (const [name, cmd] of Object.entries(pkgFull.scripts)) {
450
+ lines.push(`- \`npm run ${name}\` → \`${cmd}\``);
451
+ }
452
+ }
453
+ lines.push("");
454
+ }
455
+
456
+ lines.push("## Structure");
457
+ const tree = buildDirectoryTree(allFiles);
458
+ for (const t of tree.slice(0, 60)) lines.push(t);
459
+ if (tree.length > 60) lines.push(`... (${tree.length - 60} more directories)`);
460
+ lines.push("");
461
+
462
+ const keyFiles = identifyKeyFiles(allFiles, cwd);
463
+ if (keyFiles.length > 0) {
464
+ lines.push("## Key Files");
465
+ for (const kf of keyFiles.slice(0, 25)) {
466
+ const abs = path.join(cwd, kf.replace(/\//g, path.sep));
467
+ const summary = summarizeFile(abs);
468
+ if (summary) {
469
+ const desc = summary.description ? ` — ${summary.description}` : "";
470
+ const exports = summary.exports?.length ? ` (exports: ${summary.exports.join(", ")})` : "";
471
+ lines.push(`- \`${kf}\` [${summary.lines} lines]${desc}${exports}`);
472
+ } else {
473
+ lines.push(`- \`${kf}\``);
474
+ }
475
+ }
476
+ lines.push("");
477
+ }
478
+
479
+ const sourceByDir = {};
480
+ for (const f of allFiles) {
481
+ const dir = f.path.includes("/") ? f.path.split("/").slice(0, -1).join("/") : ".";
482
+ if (!sourceByDir[dir]) sourceByDir[dir] = [];
483
+ sourceByDir[dir].push(f);
484
+ }
485
+ const topDirs = Object.entries(sourceByDir)
486
+ .sort((a, b) => b[1].length - a[1].length)
487
+ .slice(0, 10);
488
+ if (topDirs.length > 0) {
489
+ lines.push("## Module Map");
490
+ for (const [dir, dirFiles] of topDirs) {
491
+ const exts = {};
492
+ for (const f of dirFiles) {
493
+ const ext = path.extname(f.path) || "(no ext)";
494
+ exts[ext] = (exts[ext] || 0) + 1;
495
+ }
496
+ const extStr = Object.entries(exts).map(([e, c]) => `${c}${e}`).join(", ");
497
+ lines.push(`- \`${dir}/\` — ${dirFiles.length} files (${extStr})`);
498
+ }
499
+ lines.push("");
500
+ }
501
+
502
+ lines.push("## Notes");
503
+ lines.push("<!-- Add project-specific instructions, patterns, or gotchas below. -->");
504
+ lines.push("<!-- The agent reads this file on every turn. -->");
505
+ lines.push("");
506
+
507
+ return lines.join("\n");
508
+ }
509
+
510
+ // ─────────── Auto-verify: detect project check commands ───────────
511
+
512
+ export function detectVerifyCommand(cwd) {
513
+ const pkg = readJsonSafe(path.join(cwd, "package.json"));
514
+ if (pkg?.scripts) {
515
+ if (pkg.scripts.typecheck) return { cmd: "npm run typecheck", label: "typecheck" };
516
+ if (pkg.scripts["type-check"]) return { cmd: "npm run type-check", label: "typecheck" };
517
+ }
518
+ if (fs.existsSync(path.join(cwd, "tsconfig.json"))) {
519
+ const hasTsc = pkg?.devDependencies?.typescript || pkg?.dependencies?.typescript;
520
+ if (hasTsc) return { cmd: "npx tsc --noEmit", label: "typecheck" };
521
+ }
522
+ if (pkg?.scripts) {
523
+ if (pkg.scripts.lint) return { cmd: "npm run lint", label: "lint" };
524
+ if (pkg.scripts.check) return { cmd: "npm run check", label: "check" };
525
+ }
526
+ const cargo = readTextSafe(path.join(cwd, "Cargo.toml"), 500);
527
+ if (cargo) return { cmd: "cargo check", label: "cargo check" };
528
+ const goMod = readTextSafe(path.join(cwd, "go.mod"), 500);
529
+ if (goMod) return { cmd: "go vet ./...", label: "go vet" };
530
+ if (fs.existsSync(path.join(cwd, "pyproject.toml"))) {
531
+ if (pkg?.scripts?.mypy || fs.existsSync(path.join(cwd, "mypy.ini"))) {
532
+ return { cmd: "mypy .", label: "mypy" };
533
+ }
534
+ }
535
+ return null;
536
+ }
537
+
266
538
  export function scanProjectQuick(cwd) {
267
539
  const { dirs, files } = listTopLevel(cwd);
268
540
  const total = dirs.length + files.length;
package/src/render.js CHANGED
@@ -88,6 +88,13 @@ export function toolLabel(name, args) {
88
88
  case "web_fetch": return `${verb("fetch")} ${arg(a.url)}`;
89
89
  case "todo_write": return ""; // its Plan box is the label
90
90
  case "ask_user": return ""; // the interactive menu is its display
91
+ case "git_status": return `${verb("git")} ${arg("status")}`;
92
+ case "git_diff": return `${verb("git")} ${arg("diff" + (a.staged ? " --staged" : "") + (a.base ? ` ${a.base}...${a.head || "HEAD"}` : ""))}`;
93
+ case "git_log": return `${verb("git")} ${arg("log" + (a.file ? ` -- ${a.file}` : "") + (a.branch ? ` ${a.branch}` : ""))}`;
94
+ case "git_commit": return `${verb("git")} ${arg(`commit "${ellip(a.message || "", 50)}"`)}`
95
+ case "git_branch": return `${verb("git")} ${arg(`branch ${a.name}`)}`;
96
+ case "git_push": return `${verb("git")} ${arg("push" + (a.force ? " --force" : ""))}`;
97
+ case "git_create_pr": return `${verb("git")} ${arg(`pr "${ellip(a.title || "", 50)}"`)}`
91
98
  default: return `${verb(name)} ${arg(JSON.stringify(a))}`;
92
99
  }
93
100
  }
@@ -142,6 +149,32 @@ export function toolSummary(name, result) {
142
149
  }
143
150
  if (name === "todo_write") return ""; // the Plan is its own feedback
144
151
  if (name === "ask_user") return ""; // the menu already showed the answer
152
+ if (name === "git_commit" || name === "git_push" || name === "git_create_pr") {
153
+ return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
154
+ }
155
+ if (name === "git_status") {
156
+ try {
157
+ const s = JSON.parse(out);
158
+ if (s.clean) return ` ${branch} ${c.gray("clean")}`;
159
+ const parts = [];
160
+ if (s.staged?.length) parts.push(`${s.staged.length} staged`);
161
+ if (s.unstaged?.length) parts.push(`${s.unstaged.length} modified`);
162
+ if (s.untracked?.length) parts.push(`${s.untracked.length} untracked`);
163
+ return ` ${branch} ${c.gray(parts.join(", "))}`;
164
+ } catch { /* fall through */ }
165
+ }
166
+ if (name === "git_diff") {
167
+ if (out === "(no changes)") return ` ${branch} ${c.gray("no changes")}`;
168
+ const diffLines = out.split("\n").length;
169
+ return ` ${branch} ${c.gray(`${diffLines} lines of diff`)}`;
170
+ }
171
+ if (name === "git_log") {
172
+ const logLines = out.trim().split("\n").filter((l) => l.trim()).length;
173
+ return ` ${branch} ${c.gray(`${logLines} lines`)}`;
174
+ }
175
+ if (name === "git_branch") {
176
+ return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
177
+ }
145
178
  if (name === "write_file" || name === "edit_file") {
146
179
  // Handler already printed the diff; echo its short status line.
147
180
  return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
package/src/repl.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // - Bottom status line is rendered after each turn
9
9
  // - Ctrl+C: first press cancels in-progress turn, second press exits
10
10
 
11
+ import fs from "node:fs";
11
12
  import path from "node:path";
12
13
  import { runAgent } from "./agent.js";
13
14
  import { fetchBalance, AetherError } from "./api.js";
@@ -20,7 +21,7 @@ import {
20
21
  generateSessionId, saveSession, loadSession, listSessions,
21
22
  findRecentSession, deleteSession, extractSummary, formatAge,
22
23
  } from "./sessions.js";
23
- import { scanProjectQuick } from "./project-context.js";
24
+ import { scanProjectQuick, generateProjectIndex } from "./project-context.js";
24
25
 
25
26
  const VERSION = getVersion();
26
27
  const MODEL_NAME = "Aether Core";
@@ -44,6 +45,7 @@ const SHORTCUTS = `
44
45
  ${c.cyan("/yes")} Toggle auto-approve mode (skip y/N prompts)
45
46
  ${c.cyan("/turns")} ${c.gray("<n>")} Set max turns per prompt (default 25)
46
47
  ${c.cyan("/model")} ${c.gray("[core|ultra]")} Show or switch model (ultra=Opus — premium)
48
+ ${c.cyan("/init")} Deep-scan project → generate .aether/context.md
47
49
  ${c.cyan("/sessions")} List saved sessions for this directory
48
50
  ${c.cyan("/resume")} ${c.gray("[id]")} Resume a saved session (default: most recent)
49
51
  ${c.cyan("/new")} Start a fresh session (save current first)
@@ -286,6 +288,31 @@ async function handleSlash(line, state) {
286
288
  }
287
289
  break;
288
290
  }
291
+ case "init": {
292
+ console.log(c.gray("Scanning project..."));
293
+ try {
294
+ const content = generateProjectIndex(state.cwd);
295
+ const dir = path.join(state.cwd, ".aether");
296
+ fs.mkdirSync(dir, { recursive: true });
297
+ const filePath = path.join(dir, "context.md");
298
+ fs.writeFileSync(filePath, content, "utf8");
299
+ const lineCount = content.split("\n").length;
300
+ console.log(c.green("●") + " " + c.bold(`.aether/context.md`) + c.gray(` generated (${lineCount} lines)`));
301
+ console.log(c.gray(" The agent now reads this on every turn. Edit it freely to add project-specific notes."));
302
+ // Add to .gitignore if not already there
303
+ const gitignorePath = path.join(state.cwd, ".gitignore");
304
+ try {
305
+ const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
306
+ if (!existing.includes(".aether/")) {
307
+ fs.appendFileSync(gitignorePath, `\n# Aether agent context\n.aether/\n`, "utf8");
308
+ console.log(c.gray(" Added .aether/ to .gitignore"));
309
+ }
310
+ } catch { /* non-fatal */ }
311
+ } catch (err) {
312
+ console.log(errorLine(`/init failed: ${err.message}`));
313
+ }
314
+ break;
315
+ }
289
316
  case "sessions": {
290
317
  const sessions = listSessions(state.cwd);
291
318
  if (sessions.length === 0) {
package/src/tools.js CHANGED
@@ -223,6 +223,125 @@ export const TOOL_DEFINITIONS = [
223
223
  },
224
224
  },
225
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
+ },
226
345
  ];
227
346
 
228
347
  /* ─────────────────────── Argument validation ─────────────────────── */
@@ -410,6 +529,13 @@ export async function executeTool(call, opts) {
410
529
  web_fetch: () => webFetch(args, opts),
411
530
  todo_write: () => todoWrite(args, opts),
412
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),
413
539
  };
414
540
  const fn = handlers[name];
415
541
  if (!fn) {
@@ -859,6 +985,226 @@ async function runShell(args, opts) {
859
985
  });
860
986
  }
861
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
+
862
1208
  /* ─────────────────────── todo_write ─────────────────────── */
863
1209
 
864
1210
  // Module-level singleton holding the current todo list for this CLI session.