aether-code 0.43.1 → 0.43.3
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/LICENSE +21 -21
- package/bin/aether-code.js +23 -18
- package/package.json +2 -4
- package/skills/adult-creative-writing.md +60 -60
- package/skills/debugging.md +51 -51
- package/skills/game-modding.md +73 -73
- package/skills/reverse-engineering.md +41 -41
- package/skills/scraping-automation.md +77 -77
- package/skills/security-research.md +67 -67
- package/src/agent.js +311 -175
- package/src/api.js +32 -22
- package/src/box-input.js +36 -141
- package/src/config.js +38 -38
- package/src/diff.js +49 -49
- package/src/ink-input.js +91 -91
- package/src/mcp-cli.js +94 -94
- package/src/mcp-registry.js +266 -266
- package/src/mcp.js +260 -259
- package/src/menu.js +83 -83
- package/src/project-context.js +712 -0
- package/src/render.js +254 -288
- package/src/repl.js +441 -408
- package/src/sessions.js +167 -0
- package/src/setup.js +8 -6
- package/src/tools.js +393 -16
- package/src/update-check.js +7 -64
- package/src/version.js +15 -0
- package/scripts/postinstall.js +0 -182
package/src/sessions.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Session persistence. Saves conversation state to ~/.aether/sessions/ so
|
|
2
|
+
// users can close the terminal, come back tomorrow, and resume exactly
|
|
3
|
+
// where they left off. Auto-saves after every agent turn.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
|
|
10
|
+
const SESSIONS_DIR = path.join(os.homedir(), ".aether", "sessions");
|
|
11
|
+
const MAX_SESSIONS = 50;
|
|
12
|
+
const STALE_DAYS = 14;
|
|
13
|
+
|
|
14
|
+
function ensureDir() {
|
|
15
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cwdHash(cwd) {
|
|
19
|
+
return crypto.createHash("sha256").update(cwd.toLowerCase()).digest("hex").slice(0, 12);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sessionPath(id) {
|
|
23
|
+
return path.join(SESSIONS_DIR, `${id}.json`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function generateSessionId(cwd) {
|
|
27
|
+
const hash = cwdHash(cwd);
|
|
28
|
+
const ts = Date.now().toString(36);
|
|
29
|
+
return `${hash}-${ts}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveSession(session) {
|
|
33
|
+
ensureDir();
|
|
34
|
+
const data = {
|
|
35
|
+
id: session.id,
|
|
36
|
+
cwd: session.cwd,
|
|
37
|
+
model: session.model ?? null,
|
|
38
|
+
modelName: session.modelName ?? "Aether Core",
|
|
39
|
+
messages: session.messages ?? [],
|
|
40
|
+
credits: session.credits ?? 0,
|
|
41
|
+
tokensIn: session.tokensIn ?? 0,
|
|
42
|
+
tokensOut: session.tokensOut ?? 0,
|
|
43
|
+
createdAt: session.createdAt ?? new Date().toISOString(),
|
|
44
|
+
updatedAt: new Date().toISOString(),
|
|
45
|
+
summary: session.summary ?? "",
|
|
46
|
+
turnCount: session.turnCount ?? 0,
|
|
47
|
+
};
|
|
48
|
+
const filePath = sessionPath(data.id);
|
|
49
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
50
|
+
try { fs.chmodSync(filePath, 0o600); } catch { /* windows */ }
|
|
51
|
+
pruneOldSessions();
|
|
52
|
+
return data;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadSession(id) {
|
|
56
|
+
const filePath = sessionPath(id);
|
|
57
|
+
try {
|
|
58
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
59
|
+
return JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function deleteSession(id) {
|
|
66
|
+
try {
|
|
67
|
+
fs.unlinkSync(sessionPath(id));
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function listSessions(filterCwd) {
|
|
75
|
+
ensureDir();
|
|
76
|
+
let files;
|
|
77
|
+
try {
|
|
78
|
+
files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const sessions = [];
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
try {
|
|
85
|
+
const raw = fs.readFileSync(path.join(SESSIONS_DIR, f), "utf8");
|
|
86
|
+
const data = JSON.parse(raw);
|
|
87
|
+
if (filterCwd && data.cwd?.toLowerCase() !== filterCwd.toLowerCase()) continue;
|
|
88
|
+
sessions.push({
|
|
89
|
+
id: data.id,
|
|
90
|
+
cwd: data.cwd,
|
|
91
|
+
model: data.model,
|
|
92
|
+
modelName: data.modelName,
|
|
93
|
+
summary: data.summary,
|
|
94
|
+
turnCount: data.turnCount ?? 0,
|
|
95
|
+
messageCount: data.messages?.length ?? 0,
|
|
96
|
+
credits: data.credits ?? 0,
|
|
97
|
+
createdAt: data.createdAt,
|
|
98
|
+
updatedAt: data.updatedAt,
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
sessions.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
|
|
105
|
+
return sessions;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function findRecentSession(cwd, maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
109
|
+
const sessions = listSessions(cwd);
|
|
110
|
+
if (sessions.length === 0) return null;
|
|
111
|
+
const newest = sessions[0];
|
|
112
|
+
if (!newest.updatedAt) return null;
|
|
113
|
+
const age = Date.now() - new Date(newest.updatedAt).getTime();
|
|
114
|
+
if (age > maxAgeMs) return null;
|
|
115
|
+
if (newest.messageCount < 2) return null;
|
|
116
|
+
return newest;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pruneOldSessions() {
|
|
120
|
+
let files;
|
|
121
|
+
try {
|
|
122
|
+
files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (files.length <= MAX_SESSIONS) return;
|
|
127
|
+
const entries = [];
|
|
128
|
+
for (const f of files) {
|
|
129
|
+
try {
|
|
130
|
+
const stat = fs.statSync(path.join(SESSIONS_DIR, f));
|
|
131
|
+
entries.push({ file: f, mtime: stat.mtimeMs });
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
entries.sort((a, b) => b.mtime - a.mtime);
|
|
137
|
+
const cutoff = Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000;
|
|
138
|
+
for (const e of entries.slice(MAX_SESSIONS)) {
|
|
139
|
+
if (e.mtime < cutoff) {
|
|
140
|
+
try { fs.unlinkSync(path.join(SESSIONS_DIR, e.file)); } catch { /* ignore */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function extractSummary(messages) {
|
|
146
|
+
if (!messages || messages.length === 0) return "";
|
|
147
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
148
|
+
if (!firstUser || typeof firstUser.content !== "string") return "";
|
|
149
|
+
const text = firstUser.content
|
|
150
|
+
.replace(/\[environment\][\s\S]*?\[\/environment\]\s*/g, "")
|
|
151
|
+
.replace(/\[project\][\s\S]*?\[\/project\]\s*/g, "")
|
|
152
|
+
.replace(/=== LOADED SKILLS[\s\S]*?---\s*/g, "")
|
|
153
|
+
.trim();
|
|
154
|
+
return text.length > 120 ? text.slice(0, 117) + "..." : text;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function formatAge(isoDate) {
|
|
158
|
+
if (!isoDate) return "unknown";
|
|
159
|
+
const ms = Date.now() - new Date(isoDate).getTime();
|
|
160
|
+
const mins = Math.floor(ms / 60_000);
|
|
161
|
+
if (mins < 1) return "just now";
|
|
162
|
+
if (mins < 60) return `${mins}m ago`;
|
|
163
|
+
const hours = Math.floor(mins / 60);
|
|
164
|
+
if (hours < 24) return `${hours}h ago`;
|
|
165
|
+
const days = Math.floor(hours / 24);
|
|
166
|
+
return `${days}d ago`;
|
|
167
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -107,17 +107,21 @@ 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 });
|
|
112
110
|
process.stdout.write(c.gray("Verifying..."));
|
|
113
111
|
try {
|
|
114
|
-
const me = await fetchBalance();
|
|
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
|
+
}
|
|
115
119
|
console.log(c.green(" ●"));
|
|
116
120
|
console.log("");
|
|
117
121
|
console.log(c.green(c.bold("Setup complete.")));
|
|
118
122
|
console.log(
|
|
119
123
|
c.gray(`Saved to ${CONFIG_PATH} (mode 0600).`) +
|
|
120
|
-
c.gray(`\nPlan: ${me.plan
|
|
124
|
+
c.gray(`\nPlan: ${me.plan} · Balance: ${me.balance.toLocaleString()} credits`),
|
|
121
125
|
);
|
|
122
126
|
console.log("");
|
|
123
127
|
saved = true;
|
|
@@ -129,8 +133,6 @@ export async function runSetup() {
|
|
|
129
133
|
} else {
|
|
130
134
|
console.log(errorLine(err.message || String(err)));
|
|
131
135
|
}
|
|
132
|
-
// Roll back the bad save so we don't leave a broken key on disk
|
|
133
|
-
writeConfigFile({ apiKey: "" });
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
|
package/src/tools.js
CHANGED
|
@@ -16,6 +16,7 @@ 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";
|
|
19
20
|
|
|
20
21
|
/* ─────────────────────── Tool definitions (sent to model) ─────────────────────── */
|
|
21
22
|
|
|
@@ -200,7 +201,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
200
201
|
function: {
|
|
201
202
|
name: "ask_user",
|
|
202
203
|
description:
|
|
203
|
-
"Ask the user a single multiple-choice question
|
|
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.",
|
|
204
205
|
parameters: {
|
|
205
206
|
type: "object",
|
|
206
207
|
properties: {
|
|
@@ -222,6 +223,125 @@ export const TOOL_DEFINITIONS = [
|
|
|
222
223
|
},
|
|
223
224
|
},
|
|
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
|
+
},
|
|
225
345
|
];
|
|
226
346
|
|
|
227
347
|
/* ─────────────────────── Argument validation ─────────────────────── */
|
|
@@ -409,6 +529,13 @@ export async function executeTool(call, opts) {
|
|
|
409
529
|
web_fetch: () => webFetch(args, opts),
|
|
410
530
|
todo_write: () => todoWrite(args, opts),
|
|
411
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),
|
|
412
539
|
};
|
|
413
540
|
const fn = handlers[name];
|
|
414
541
|
if (!fn) {
|
|
@@ -646,26 +773,39 @@ async function webSearch(args, opts) {
|
|
|
646
773
|
return { ok: false, output: "Web search requires AETHER_API_KEY. Set it and try again." };
|
|
647
774
|
}
|
|
648
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;
|
|
649
778
|
let res;
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
+
}
|
|
662
803
|
}
|
|
663
804
|
let data = null;
|
|
664
805
|
try { data = await res.json(); } catch { /* non-JSON */ }
|
|
665
806
|
if (!res.ok) {
|
|
666
807
|
return { ok: false, output: data?.error || `web_search HTTP ${res.status}` };
|
|
667
808
|
}
|
|
668
|
-
// Hand the model just the array — that's all it needs to decide which URL to fetch.
|
|
669
809
|
return { ok: true, output: JSON.stringify(data.results ?? [], null, 2) };
|
|
670
810
|
}
|
|
671
811
|
|
|
@@ -709,7 +849,7 @@ async function webFetch(args, opts) {
|
|
|
709
849
|
headers: {
|
|
710
850
|
// Looking like a normal browser dodges many anti-bot pages.
|
|
711
851
|
"User-Agent":
|
|
712
|
-
|
|
852
|
+
`Mozilla/5.0 (compatible; aether-code/${getVersion()}) Gecko/20100101 Firefox/130.0`,
|
|
713
853
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
714
854
|
"Accept-Language": "en-US,en;q=0.9",
|
|
715
855
|
},
|
|
@@ -782,11 +922,28 @@ export function htmlToText(html) {
|
|
|
782
922
|
return s.trim();
|
|
783
923
|
}
|
|
784
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
|
+
|
|
785
938
|
async function runShell(args, opts) {
|
|
786
939
|
if (typeof args.command !== "string") return { ok: false, output: "command is required" };
|
|
787
940
|
const cwd = args.cwd ? resolveSafe(args.cwd, opts) : opts.cwd;
|
|
788
941
|
console.log(c.yellow("$ ") + c.bold(args.command) + (args.cwd ? c.dim(` (cwd: ${args.cwd})`) : ""));
|
|
789
|
-
const
|
|
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);
|
|
790
947
|
if (!approved) return { ok: false, output: "User declined the command." };
|
|
791
948
|
|
|
792
949
|
return new Promise((resolve) => {
|
|
@@ -828,6 +985,226 @@ async function runShell(args, opts) {
|
|
|
828
985
|
});
|
|
829
986
|
}
|
|
830
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
|
+
|
|
831
1208
|
/* ─────────────────────── todo_write ─────────────────────── */
|
|
832
1209
|
|
|
833
1210
|
// Module-level singleton holding the current todo list for this CLI session.
|