aether-code 0.3.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/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/aether-code.js +228 -0
- package/package.json +38 -0
- package/src/agent.js +115 -0
- package/src/api.js +234 -0
- package/src/config.js +38 -0
- package/src/diff.js +48 -0
- package/src/render.js +58 -0
- package/src/repl.js +246 -0
- package/src/setup.js +139 -0
- package/src/tools.js +357 -0
package/src/setup.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// First-run / no-key setup flow.
|
|
2
|
+
//
|
|
3
|
+
// UX mirrors `gh auth login` / `railway login` / `npm login`:
|
|
4
|
+
// 1. Print a friendly explanation
|
|
5
|
+
// 2. Open the browser to https://trynoguard.com/account
|
|
6
|
+
// 3. Prompt the user to paste the ak_live_ key
|
|
7
|
+
// 4. Validate format + verify against /api/v1/me
|
|
8
|
+
// 5. Save to ~/.aetherrc with mode 0600
|
|
9
|
+
//
|
|
10
|
+
// If the user is in a non-TTY environment (CI, piped stdin), we skip the
|
|
11
|
+
// auto-open and just print clear instructions for AETHER_API_KEY env var.
|
|
12
|
+
|
|
13
|
+
import readline from "node:readline";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { writeConfigFile, CONFIG_PATH } from "./config.js";
|
|
16
|
+
import { fetchBalance, AetherError } from "./api.js";
|
|
17
|
+
import { c, errorLine } from "./render.js";
|
|
18
|
+
|
|
19
|
+
const ACCOUNT_URL = "https://trynoguard.com/account";
|
|
20
|
+
const SIGNUP_URL = "https://trynoguard.com/signup";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cross-platform "open this URL in the user's default browser".
|
|
24
|
+
* Best-effort — if it fails (no GUI, blocked, etc.) we just continue.
|
|
25
|
+
*/
|
|
26
|
+
function openInBrowser(url) {
|
|
27
|
+
try {
|
|
28
|
+
const platform = process.platform;
|
|
29
|
+
if (platform === "win32") {
|
|
30
|
+
// The empty "" is the window title — required because cmd parses the
|
|
31
|
+
// first quoted arg as the title otherwise.
|
|
32
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
33
|
+
} else if (platform === "darwin") {
|
|
34
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
35
|
+
} else {
|
|
36
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ask(rl, question) {
|
|
45
|
+
return new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim())));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns true if setup completed successfully (key saved + verified).
|
|
50
|
+
* Returns false if the user gave up.
|
|
51
|
+
*/
|
|
52
|
+
export async function runSetup() {
|
|
53
|
+
console.log("");
|
|
54
|
+
console.log(c.bold(c.magenta("aether")) + c.gray(" — first-time setup"));
|
|
55
|
+
console.log(c.gray("─".repeat(60)));
|
|
56
|
+
console.log("");
|
|
57
|
+
console.log("To use Aether, you need an API key tied to your account.");
|
|
58
|
+
console.log("Keys start with " + c.cyan("ak_live_") + ".");
|
|
59
|
+
console.log("");
|
|
60
|
+
|
|
61
|
+
// Non-TTY — bail with instructions instead of prompting
|
|
62
|
+
if (!process.stdin.isTTY) {
|
|
63
|
+
console.log(errorLine("Can't run interactive setup (stdin isn't a TTY)."));
|
|
64
|
+
console.log("");
|
|
65
|
+
console.log("Options:");
|
|
66
|
+
console.log(` · Set ${c.cyan("AETHER_API_KEY")} env var to your ak_live_ key.`);
|
|
67
|
+
console.log(` · Or run ${c.cyan("aether config set <key>")} from a real terminal.`);
|
|
68
|
+
console.log(` · Get a key at ${c.blue(ACCOUNT_URL)}.`);
|
|
69
|
+
console.log("");
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log(c.bold("Step 1: ") + "Open " + c.blue(ACCOUNT_URL) + " in your browser.");
|
|
74
|
+
console.log(c.gray(" (no account yet? sign up free at " + SIGNUP_URL + ")"));
|
|
75
|
+
|
|
76
|
+
const opened = openInBrowser(ACCOUNT_URL);
|
|
77
|
+
if (opened) {
|
|
78
|
+
console.log(c.gray(" ↪ opened it for you."));
|
|
79
|
+
}
|
|
80
|
+
console.log("");
|
|
81
|
+
console.log(c.bold("Step 2: ") + "Click " + c.cyan("Generate API key") + " and copy the key shown.");
|
|
82
|
+
console.log(c.gray(" (the key is shown ONCE — copy it before navigating away)"));
|
|
83
|
+
console.log("");
|
|
84
|
+
console.log(c.bold("Step 3: ") + "Paste the key below.");
|
|
85
|
+
console.log("");
|
|
86
|
+
|
|
87
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
88
|
+
|
|
89
|
+
// Up to 3 attempts at a valid key
|
|
90
|
+
let saved = false;
|
|
91
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
92
|
+
const key = await ask(rl, c.magenta("API key: "));
|
|
93
|
+
if (!key) {
|
|
94
|
+
console.log(c.gray("(empty — skipping)"));
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (key.toLowerCase() === "q" || key.toLowerCase() === "quit") {
|
|
98
|
+
console.log(c.gray("Cancelled."));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (!key.startsWith("ak_live_")) {
|
|
102
|
+
console.log(errorLine(`Keys start with ${c.cyan("ak_live_")} — that doesn't look right. Try again or type 'q' to cancel.`));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (key.length < 30) {
|
|
106
|
+
console.log(errorLine("That key looks too short. Try copying again."));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Tentative save so the API client picks it up
|
|
111
|
+
writeConfigFile({ apiKey: key });
|
|
112
|
+
process.stdout.write(c.gray("Verifying..."));
|
|
113
|
+
try {
|
|
114
|
+
const me = await fetchBalance();
|
|
115
|
+
console.log(c.green(" ✓"));
|
|
116
|
+
console.log("");
|
|
117
|
+
console.log(c.green(c.bold("Setup complete.")));
|
|
118
|
+
console.log(
|
|
119
|
+
c.gray(`Saved to ${CONFIG_PATH} (mode 0600).`) +
|
|
120
|
+
c.gray(`\nPlan: ${me.plan} · Balance: ${me.balance.toLocaleString()} credits`),
|
|
121
|
+
);
|
|
122
|
+
console.log("");
|
|
123
|
+
saved = true;
|
|
124
|
+
break;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.log(c.red(" ✗"));
|
|
127
|
+
if (err instanceof AetherError && err.status === 401) {
|
|
128
|
+
console.log(errorLine("Server rejected that key (401). Double-check you copied it correctly."));
|
|
129
|
+
} else {
|
|
130
|
+
console.log(errorLine(err.message || String(err)));
|
|
131
|
+
}
|
|
132
|
+
// Roll back the bad save so we don't leave a broken key on disk
|
|
133
|
+
writeConfigFile({ apiKey: "" });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
rl.close();
|
|
138
|
+
return saved;
|
|
139
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Tool implementations + JSON-schema definitions.
|
|
2
|
+
//
|
|
3
|
+
// Safety model:
|
|
4
|
+
// - read_file, list_dir, search_files: auto-execute (read-only)
|
|
5
|
+
// - write_file, edit_file: show diff, require y/n confirmation (or --yes flag)
|
|
6
|
+
// - run_shell: show command, require y/n confirmation (or --yes flag)
|
|
7
|
+
//
|
|
8
|
+
// Path safety: every path is resolved against `cwd` and rejected if it
|
|
9
|
+
// escapes `cwd` — unless the user explicitly passes --unsafe-paths.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import readline from "node:readline";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { c } from "./render.js";
|
|
16
|
+
import { unifiedDiff, summarizeWrite } from "./diff.js";
|
|
17
|
+
|
|
18
|
+
/* ─────────────────────── Tool definitions (sent to model) ─────────────────────── */
|
|
19
|
+
|
|
20
|
+
export const TOOL_DEFINITIONS = [
|
|
21
|
+
{
|
|
22
|
+
type: "function",
|
|
23
|
+
function: {
|
|
24
|
+
name: "read_file",
|
|
25
|
+
description:
|
|
26
|
+
"Read the contents of a file as UTF-8 text. Returns the file contents or an error if the file doesn't exist.",
|
|
27
|
+
parameters: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
path: { type: "string", description: "Path relative to the working directory, or absolute." },
|
|
31
|
+
},
|
|
32
|
+
required: ["path"],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: "function",
|
|
38
|
+
function: {
|
|
39
|
+
name: "list_dir",
|
|
40
|
+
description:
|
|
41
|
+
"List the entries in a directory. Returns an array of {name, type: 'file'|'dir', size?: number}. Hidden files (starting with .) are excluded by default.",
|
|
42
|
+
parameters: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
path: { type: "string", description: "Directory path." },
|
|
46
|
+
include_hidden: { type: "boolean", description: "Include dotfiles. Default: false." },
|
|
47
|
+
},
|
|
48
|
+
required: ["path"],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: "function",
|
|
54
|
+
function: {
|
|
55
|
+
name: "search_files",
|
|
56
|
+
description:
|
|
57
|
+
"Recursively search for a regex pattern across files in a directory. Returns matching file paths and the matching line. Limited to 50 results.",
|
|
58
|
+
parameters: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
path: { type: "string", description: "Directory to search." },
|
|
62
|
+
pattern: { type: "string", description: "JavaScript-style regex (without slashes)." },
|
|
63
|
+
glob: { type: "string", description: "Optional file-name glob filter, e.g. '*.ts'." },
|
|
64
|
+
},
|
|
65
|
+
required: ["path", "pattern"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: "function",
|
|
71
|
+
function: {
|
|
72
|
+
name: "write_file",
|
|
73
|
+
description:
|
|
74
|
+
"Create or completely overwrite a file with the given content. The user will be shown a diff and may decline. If the parent directory doesn't exist, it will be created.",
|
|
75
|
+
parameters: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
path: { type: "string", description: "File path." },
|
|
79
|
+
content: { type: "string", description: "Full file content to write." },
|
|
80
|
+
},
|
|
81
|
+
required: ["path", "content"],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
type: "function",
|
|
87
|
+
function: {
|
|
88
|
+
name: "edit_file",
|
|
89
|
+
description:
|
|
90
|
+
"Replace exactly one occurrence of `find` with `replace` in an existing file. Use this for targeted edits instead of rewriting whole files. Fails if `find` is not found or appears more than once.",
|
|
91
|
+
parameters: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
path: { type: "string", description: "File path." },
|
|
95
|
+
find: { type: "string", description: "Exact text to replace (must appear exactly once)." },
|
|
96
|
+
replace: { type: "string", description: "Text to substitute in." },
|
|
97
|
+
},
|
|
98
|
+
required: ["path", "find", "replace"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: "function",
|
|
104
|
+
function: {
|
|
105
|
+
name: "run_shell",
|
|
106
|
+
description:
|
|
107
|
+
"Run a shell command and return its stdout, stderr, and exit code. The user will be shown the command and may decline. Used for builds, tests, package installs, git operations, etc.",
|
|
108
|
+
parameters: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
command: { type: "string", description: "The shell command to run." },
|
|
112
|
+
cwd: { type: "string", description: "Optional working directory (relative or absolute)." },
|
|
113
|
+
},
|
|
114
|
+
required: ["command"],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
/* ─────────────────────── Helpers ─────────────────────── */
|
|
121
|
+
|
|
122
|
+
function resolveSafe(rel, opts) {
|
|
123
|
+
const abs = path.isAbsolute(rel) ? path.normalize(rel) : path.resolve(opts.cwd, rel);
|
|
124
|
+
if (!opts.unsafePaths) {
|
|
125
|
+
const cwd = path.resolve(opts.cwd);
|
|
126
|
+
if (!abs.startsWith(cwd + path.sep) && abs !== cwd) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Refusing to touch path outside cwd: ${abs}\n Run with --unsafe-paths if you really mean this.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return abs;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ask(question) {
|
|
136
|
+
if (!process.stdin.isTTY) {
|
|
137
|
+
return Promise.resolve("n"); // can't prompt in non-TTY; default no
|
|
138
|
+
}
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
141
|
+
rl.question(question, (answer) => {
|
|
142
|
+
rl.close();
|
|
143
|
+
resolve(answer.trim().toLowerCase());
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function confirm(question, autoYes) {
|
|
149
|
+
if (autoYes) return true;
|
|
150
|
+
const ans = await ask(`${question} ${c.dim("[y/N]: ")}`);
|
|
151
|
+
return ans === "y" || ans === "yes";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ─────────────────────── Implementations ─────────────────────── */
|
|
155
|
+
|
|
156
|
+
export async function executeTool(call, opts) {
|
|
157
|
+
let args;
|
|
158
|
+
try {
|
|
159
|
+
args = JSON.parse(call.function.arguments || "{}");
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { ok: false, output: `Invalid JSON arguments: ${e.message}` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const name = call.function.name;
|
|
165
|
+
const handlers = {
|
|
166
|
+
read_file: () => readFile(args, opts),
|
|
167
|
+
list_dir: () => listDir(args, opts),
|
|
168
|
+
search_files: () => searchFiles(args, opts),
|
|
169
|
+
write_file: () => writeFile(args, opts),
|
|
170
|
+
edit_file: () => editFile(args, opts),
|
|
171
|
+
run_shell: () => runShell(args, opts),
|
|
172
|
+
};
|
|
173
|
+
const fn = handlers[name];
|
|
174
|
+
if (!fn) {
|
|
175
|
+
return { ok: false, output: `Unknown tool: ${name}` };
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
return await fn();
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return { ok: false, output: `${name} failed: ${e.message}` };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readFile(args, opts) {
|
|
185
|
+
if (typeof args.path !== "string") return { ok: false, output: "path is required" };
|
|
186
|
+
const abs = resolveSafe(args.path, opts);
|
|
187
|
+
const stat = fs.statSync(abs);
|
|
188
|
+
if (stat.isDirectory()) return { ok: false, output: `${args.path} is a directory, not a file` };
|
|
189
|
+
if (stat.size > 1_000_000) {
|
|
190
|
+
return { ok: false, output: `File too large (${stat.size} bytes). Aether refuses to read >1MB at once.` };
|
|
191
|
+
}
|
|
192
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
193
|
+
return { ok: true, output: text };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function listDir(args, opts) {
|
|
197
|
+
if (typeof args.path !== "string") return { ok: false, output: "path is required" };
|
|
198
|
+
const abs = resolveSafe(args.path, opts);
|
|
199
|
+
const entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
200
|
+
const results = [];
|
|
201
|
+
for (const e of entries) {
|
|
202
|
+
if (!args.include_hidden && e.name.startsWith(".")) continue;
|
|
203
|
+
if (e.name === "node_modules" || e.name === ".git" || e.name === "dist") continue;
|
|
204
|
+
let size = undefined;
|
|
205
|
+
if (e.isFile()) {
|
|
206
|
+
try { size = fs.statSync(path.join(abs, e.name)).size; } catch { /* skip */ }
|
|
207
|
+
}
|
|
208
|
+
results.push({
|
|
209
|
+
name: e.name,
|
|
210
|
+
type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other",
|
|
211
|
+
size,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
results.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
|
|
215
|
+
return { ok: true, output: JSON.stringify(results, null, 2) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function searchFiles(args, opts) {
|
|
219
|
+
if (typeof args.path !== "string" || typeof args.pattern !== "string") {
|
|
220
|
+
return { ok: false, output: "path and pattern are required" };
|
|
221
|
+
}
|
|
222
|
+
let regex;
|
|
223
|
+
try { regex = new RegExp(args.pattern); } catch (e) {
|
|
224
|
+
return { ok: false, output: `Invalid regex: ${e.message}` };
|
|
225
|
+
}
|
|
226
|
+
const root = resolveSafe(args.path, opts);
|
|
227
|
+
const matches = [];
|
|
228
|
+
const globRe = args.glob ? new RegExp("^" + args.glob.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$") : null;
|
|
229
|
+
|
|
230
|
+
function walk(dir) {
|
|
231
|
+
if (matches.length >= 50) return;
|
|
232
|
+
let entries;
|
|
233
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
234
|
+
for (const e of entries) {
|
|
235
|
+
if (matches.length >= 50) return;
|
|
236
|
+
if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue;
|
|
237
|
+
const full = path.join(dir, e.name);
|
|
238
|
+
if (e.isDirectory()) {
|
|
239
|
+
walk(full);
|
|
240
|
+
} else if (e.isFile()) {
|
|
241
|
+
if (globRe && !globRe.test(e.name)) continue;
|
|
242
|
+
let content;
|
|
243
|
+
try {
|
|
244
|
+
const stat = fs.statSync(full);
|
|
245
|
+
if (stat.size > 500_000) continue;
|
|
246
|
+
content = fs.readFileSync(full, "utf8");
|
|
247
|
+
} catch { continue; }
|
|
248
|
+
const lines = content.split("\n");
|
|
249
|
+
for (let i = 0; i < lines.length; i++) {
|
|
250
|
+
if (regex.test(lines[i])) {
|
|
251
|
+
matches.push({ file: path.relative(opts.cwd, full), line: i + 1, text: lines[i].slice(0, 300) });
|
|
252
|
+
if (matches.length >= 50) return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
walk(root);
|
|
259
|
+
return { ok: true, output: JSON.stringify(matches, null, 2) };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function writeFile(args, opts) {
|
|
263
|
+
if (typeof args.path !== "string" || typeof args.content !== "string") {
|
|
264
|
+
return { ok: false, output: "path and content are required" };
|
|
265
|
+
}
|
|
266
|
+
const abs = resolveSafe(args.path, opts);
|
|
267
|
+
const exists = fs.existsSync(abs);
|
|
268
|
+
const oldContent = exists ? fs.readFileSync(abs, "utf8") : null;
|
|
269
|
+
if (exists && oldContent === args.content) {
|
|
270
|
+
return { ok: true, output: `(no change — file already matches)` };
|
|
271
|
+
}
|
|
272
|
+
// Show diff + confirm
|
|
273
|
+
console.log("");
|
|
274
|
+
console.log(summarizeWrite(oldContent, args.content, path.relative(opts.cwd, abs)));
|
|
275
|
+
console.log(unifiedDiff(oldContent ?? "", args.content, path.relative(opts.cwd, abs)));
|
|
276
|
+
const approved = await confirm(c.yellow("Apply this write?"), opts.autoYes);
|
|
277
|
+
if (!approved) {
|
|
278
|
+
return { ok: false, output: "User declined the write." };
|
|
279
|
+
}
|
|
280
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
281
|
+
fs.writeFileSync(abs, args.content, "utf8");
|
|
282
|
+
return { ok: true, output: `Wrote ${args.content.length} bytes to ${path.relative(opts.cwd, abs)}` };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function editFile(args, opts) {
|
|
286
|
+
if (typeof args.path !== "string" || typeof args.find !== "string" || typeof args.replace !== "string") {
|
|
287
|
+
return { ok: false, output: "path, find, replace are required" };
|
|
288
|
+
}
|
|
289
|
+
const abs = resolveSafe(args.path, opts);
|
|
290
|
+
if (!fs.existsSync(abs)) return { ok: false, output: `File not found: ${args.path}` };
|
|
291
|
+
const oldContent = fs.readFileSync(abs, "utf8");
|
|
292
|
+
const occurrences = oldContent.split(args.find).length - 1;
|
|
293
|
+
if (occurrences === 0) {
|
|
294
|
+
return { ok: false, output: `\`find\` text not found in ${args.path}. Tip: read the file first to copy exact characters.` };
|
|
295
|
+
}
|
|
296
|
+
if (occurrences > 1) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
output: `\`find\` text appears ${occurrences} times — must be unique. Add more context to disambiguate.`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const newContent = oldContent.replace(args.find, args.replace);
|
|
303
|
+
console.log("");
|
|
304
|
+
console.log(c.dim(`edit ${path.relative(opts.cwd, abs)}`));
|
|
305
|
+
console.log(unifiedDiff(oldContent, newContent, path.relative(opts.cwd, abs)));
|
|
306
|
+
const approved = await confirm(c.yellow("Apply this edit?"), opts.autoYes);
|
|
307
|
+
if (!approved) return { ok: false, output: "User declined the edit." };
|
|
308
|
+
fs.writeFileSync(abs, newContent, "utf8");
|
|
309
|
+
return { ok: true, output: `Edited ${path.relative(opts.cwd, abs)}` };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function runShell(args, opts) {
|
|
313
|
+
if (typeof args.command !== "string") return { ok: false, output: "command is required" };
|
|
314
|
+
const cwd = args.cwd ? resolveSafe(args.cwd, opts) : opts.cwd;
|
|
315
|
+
console.log("");
|
|
316
|
+
console.log(c.yellow("$ ") + c.bold(args.command) + (args.cwd ? c.dim(` (cwd: ${args.cwd})`) : ""));
|
|
317
|
+
const approved = await confirm(c.yellow("Run this command?"), opts.autoYes);
|
|
318
|
+
if (!approved) return { ok: false, output: "User declined the command." };
|
|
319
|
+
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
const child = spawn(args.command, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
322
|
+
let stdout = "";
|
|
323
|
+
let stderr = "";
|
|
324
|
+
let killed = false;
|
|
325
|
+
const timeout = setTimeout(() => {
|
|
326
|
+
killed = true;
|
|
327
|
+
child.kill("SIGTERM");
|
|
328
|
+
}, 120_000); // 2-minute hard cap per command
|
|
329
|
+
|
|
330
|
+
child.stdout.on("data", (d) => {
|
|
331
|
+
const s = d.toString();
|
|
332
|
+
stdout += s;
|
|
333
|
+
if (stdout.length < 80_000) process.stdout.write(c.dim(s));
|
|
334
|
+
});
|
|
335
|
+
child.stderr.on("data", (d) => {
|
|
336
|
+
const s = d.toString();
|
|
337
|
+
stderr += s;
|
|
338
|
+
if (stderr.length < 80_000) process.stderr.write(c.dim(s));
|
|
339
|
+
});
|
|
340
|
+
child.on("close", (code) => {
|
|
341
|
+
clearTimeout(timeout);
|
|
342
|
+
// Truncate huge outputs before sending back to the model
|
|
343
|
+
const truncate = (t) => (t.length > 20_000 ? t.slice(0, 20_000) + "\n…(truncated)" : t);
|
|
344
|
+
const out = JSON.stringify(
|
|
345
|
+
{
|
|
346
|
+
exit_code: code,
|
|
347
|
+
killed,
|
|
348
|
+
stdout: truncate(stdout),
|
|
349
|
+
stderr: truncate(stderr),
|
|
350
|
+
},
|
|
351
|
+
null,
|
|
352
|
+
2,
|
|
353
|
+
);
|
|
354
|
+
resolve({ ok: code === 0 && !killed, output: out });
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
}
|