aether-code 0.43.0 → 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.js CHANGED
@@ -107,21 +107,17 @@ export async function runSetup() {
107
107
  continue;
108
108
  }
109
109
 
110
+ // Tentative save so the API client picks it up
111
+ writeConfigFile({ apiKey: key });
110
112
  process.stdout.write(c.gray("Verifying..."));
111
113
  try {
112
- const me = await fetchBalance(key);
113
- writeConfigFile({ apiKey: key });
114
- if (process.env.AETHER_API_KEY && process.env.AETHER_API_KEY !== key) {
115
- process.env.AETHER_API_KEY = key;
116
- console.log(c.yellow(" ⚠ AETHER_API_KEY env var was set to a different key — overriding for this session."));
117
- console.log(c.yellow(" Update your shell profile to avoid this next time."));
118
- }
114
+ const me = await fetchBalance();
119
115
  console.log(c.green(" ●"));
120
116
  console.log("");
121
117
  console.log(c.green(c.bold("Setup complete.")));
122
118
  console.log(
123
119
  c.gray(`Saved to ${CONFIG_PATH} (mode 0600).`) +
124
- c.gray(`\nPlan: ${me.plan} · Balance: ${me.balance.toLocaleString()} credits`),
120
+ c.gray(`\nPlan: ${me.plan || "FREE"} · Balance: ${me.balance.toLocaleString()} credits`),
125
121
  );
126
122
  console.log("");
127
123
  saved = true;
@@ -133,6 +129,8 @@ export async function runSetup() {
133
129
  } else {
134
130
  console.log(errorLine(err.message || String(err)));
135
131
  }
132
+ // Roll back the bad save so we don't leave a broken key on disk
133
+ writeConfigFile({ apiKey: "" });
136
134
  }
137
135
  }
138
136
 
package/src/tools.js CHANGED
@@ -16,7 +16,6 @@ import { c } from "./render.js";
16
16
  import { promptChoice } from "./menu.js";
17
17
  import { unifiedDiff, summarizeWrite } from "./diff.js";
18
18
  import { getConfig } from "./config.js";
19
- import { getVersion } from "./version.js";
20
19
 
21
20
  /* ─────────────────────── Tool definitions (sent to model) ─────────────────────── */
22
21
 
@@ -201,7 +200,7 @@ export const TOOL_DEFINITIONS = [
201
200
  function: {
202
201
  name: "ask_user",
203
202
  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.",
203
+ "Ask the user a single multiple-choice question ONLY when you literally cannot proceed without their input — e.g. the task names two incompatible platforms and you don't know which one. NEVER use this tool to answer a question the user asked YOU — if they ask 'what color is this?', 'what does this code do?', 'explain X', etc., respond with TEXT, not a quiz. NEVER use it to present analysis, findings, or answers as multiple-choice options. NEVER use it for trivial choices, things you can infer, or to confirm permission — just act. The ONLY valid use is: a genuinely ambiguous BUILD request where picking wrong wastes real work (which framework, which language, which scope). Give 2–5 concrete options with short descriptions; put the best default first.",
205
204
  parameters: {
206
205
  type: "object",
207
206
  properties: {
@@ -223,125 +222,6 @@ export const TOOL_DEFINITIONS = [
223
222
  },
224
223
  },
225
224
  },
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
225
  ];
346
226
 
347
227
  /* ─────────────────────── Argument validation ─────────────────────── */
@@ -529,13 +409,6 @@ export async function executeTool(call, opts) {
529
409
  web_fetch: () => webFetch(args, opts),
530
410
  todo_write: () => todoWrite(args, opts),
531
411
  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
412
  };
540
413
  const fn = handlers[name];
541
414
  if (!fn) {
@@ -773,39 +646,26 @@ async function webSearch(args, opts) {
773
646
  return { ok: false, output: "Web search requires AETHER_API_KEY. Set it and try again." };
774
647
  }
775
648
  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
649
  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
- }
650
+ try {
651
+ res = await fetch(`${baseUrl}/api/v1/web-search`, {
652
+ method: "POST",
653
+ headers: {
654
+ "Content-Type": "application/json",
655
+ Authorization: `Bearer ${apiKey}`,
656
+ "User-Agent": "aether-code/web-search",
657
+ },
658
+ body: JSON.stringify({ query: args.query, max_results: max }),
659
+ });
660
+ } catch (e) {
661
+ return { ok: false, output: `web_search network error: ${e.message}` };
803
662
  }
804
663
  let data = null;
805
664
  try { data = await res.json(); } catch { /* non-JSON */ }
806
665
  if (!res.ok) {
807
666
  return { ok: false, output: data?.error || `web_search HTTP ${res.status}` };
808
667
  }
668
+ // Hand the model just the array — that's all it needs to decide which URL to fetch.
809
669
  return { ok: true, output: JSON.stringify(data.results ?? [], null, 2) };
810
670
  }
811
671
 
@@ -849,7 +709,7 @@ async function webFetch(args, opts) {
849
709
  headers: {
850
710
  // Looking like a normal browser dodges many anti-bot pages.
851
711
  "User-Agent":
852
- `Mozilla/5.0 (compatible; aether-code/${getVersion()}) Gecko/20100101 Firefox/130.0`,
712
+ "Mozilla/5.0 (compatible; aether-code/0.7) Gecko/20100101 Firefox/130.0",
853
713
  Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
854
714
  "Accept-Language": "en-US,en;q=0.9",
855
715
  },
@@ -922,28 +782,11 @@ export function htmlToText(html) {
922
782
  return s.trim();
923
783
  }
924
784
 
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
785
  async function runShell(args, opts) {
939
786
  if (typeof args.command !== "string") return { ok: false, output: "command is required" };
940
787
  const cwd = args.cwd ? resolveSafe(args.cwd, opts) : opts.cwd;
941
788
  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);
789
+ const approved = await confirm(c.yellow("Run this command?"), opts.autoYes);
947
790
  if (!approved) return { ok: false, output: "User declined the command." };
948
791
 
949
792
  return new Promise((resolve) => {
@@ -985,226 +828,6 @@ async function runShell(args, opts) {
985
828
  });
986
829
  }
987
830
 
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
831
  /* ─────────────────────── todo_write ─────────────────────── */
1209
832
 
1210
833
  // Module-level singleton holding the current todo list for this CLI session.
@@ -1,10 +1,12 @@
1
- // Startup update check — like Claude Code / npm itself nudging you when a newer
2
- // version is published. Never throws, bounded by a short timeout so it can't
3
- // slow or block startup; silent when offline or already current.
1
+ // Startup update check + auto-update. On every REPL launch, checks the npm
2
+ // registry and silently installs the latest version in the background if one
3
+ // exists. Never throws, bounded by a short timeout so it can't slow startup.
4
4
 
5
- import { readFileSync } from "node:fs";
5
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { dirname, join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import { spawn } from "node:child_process";
8
10
  import { c } from "./render.js";
9
11
 
10
12
  export function currentVersion() {
@@ -27,10 +29,65 @@ export function cmpVersion(a, b) {
27
29
  return 0;
28
30
  }
29
31
 
32
+ const COOLDOWN_FILE = join(tmpdir(), "aether-update-ts");
33
+ const COOLDOWN_MS = 3600_000; // 1 hour
34
+
35
+ /**
36
+ * Check for updates. If a newer version exists on npm, spawn a background
37
+ * `npm install -g` to auto-install it. Returns a one-line status message
38
+ * or null. Cooldown: only checks once per hour.
39
+ */
40
+ export async function autoUpdate({ fetchImpl = fetch, timeoutMs = 2000 } = {}) {
41
+ const current = currentVersion();
42
+ if (!current) return null;
43
+
44
+ // Skip if we checked less than 1 hour ago
45
+ try {
46
+ const ts = parseInt(readFileSync(COOLDOWN_FILE, "utf8"), 10);
47
+ if (Date.now() - ts < COOLDOWN_MS) return null;
48
+ } catch { /* no file or parse error — check now */ }
49
+
50
+ try {
51
+ const ac = new AbortController();
52
+ const t = setTimeout(() => ac.abort(), timeoutMs);
53
+ let res;
54
+ try {
55
+ res = await fetchImpl("https://registry.npmjs.org/aether-code/latest", { signal: ac.signal });
56
+ } finally {
57
+ clearTimeout(t);
58
+ }
59
+ if (!res || !res.ok) return null;
60
+ const latest = (await res.json())?.version;
61
+
62
+ // Write cooldown regardless of whether an update is available
63
+ try { writeFileSync(COOLDOWN_FILE, String(Date.now())); } catch { /* ignore */ }
64
+
65
+ if (!latest || cmpVersion(latest, current) <= 0) return null;
66
+
67
+ // Spawn background npm install — detached so it doesn't block the CLI.
68
+ // On Windows, `npm` is a cmd script, so shell:true is required.
69
+ const child = spawn("npm", ["install", "-g", `aether-code@${latest}`], {
70
+ detached: true,
71
+ stdio: "ignore",
72
+ shell: true,
73
+ windowsHide: true,
74
+ });
75
+ child.unref();
76
+
77
+ return (
78
+ c.cyan("↑ auto-updating ") +
79
+ c.gray(current) + c.gray(" → ") + c.bold(c.green(latest)) +
80
+ c.gray(" (background — applies next launch)")
81
+ );
82
+ } catch {
83
+ /* offline / aborted / parse error — stay silent */
84
+ }
85
+ return null;
86
+ }
87
+
30
88
  /**
31
- * Resolve a one-line "update available" nudge if the npm registry has a newer
32
- * version than the installed one — otherwise null. Bounded to ~2s; any failure
33
- * (offline, timeout, registry hiccup) resolves to null silently.
89
+ * Legacy nudge-only check (kept for the test suite). Same as before: returns
90
+ * a one-line "update available" string or null.
34
91
  */
35
92
  export async function checkForUpdate({ fetchImpl = fetch, timeoutMs = 2000 } = {}) {
36
93
  const current = currentVersion();