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/api.js
CHANGED
|
@@ -1,37 +1,43 @@
|
|
|
1
1
|
// API client.
|
|
2
2
|
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
3
|
import { getConfig } from "./config.js";
|
|
4
|
+
import { getVersion } from "./version.js";
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
// package.json so it can't drift (the file previously sent three different
|
|
10
|
-
// hardcoded versions across three calls).
|
|
11
|
-
function readVersion() {
|
|
12
|
-
try {
|
|
13
|
-
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
14
|
-
return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || "0";
|
|
15
|
-
} catch {
|
|
16
|
-
return "0";
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
const USER_AGENT = `aether-code/${readVersion()}`;
|
|
6
|
+
const USER_AGENT = `aether-code/${getVersion()}`;
|
|
20
7
|
|
|
21
8
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
9
|
|
|
10
|
+
// Per-request connect timeout. The server retries upstream internally (up to
|
|
11
|
+
// 5 × 75s = 375s worst case), and during that time the CLI's fetch() just
|
|
12
|
+
// hangs with zero feedback. This cap ensures we abort, show a retry message,
|
|
13
|
+
// and try again rather than sitting dead for minutes.
|
|
14
|
+
const CONNECT_TIMEOUT_MS = 90_000;
|
|
15
|
+
|
|
23
16
|
/**
|
|
24
17
|
* fetch() with bounded exponential-backoff retries. Retries on thrown network
|
|
25
18
|
* errors (DNS/reset/offline blip) and transient 5xx; never retries 4xx
|
|
26
19
|
* (auth/credit/validation — a retry won't help). Body is always a small JSON
|
|
27
20
|
* string, so resending is safe. For the streaming endpoint only the initial
|
|
28
21
|
* connection is retried.
|
|
22
|
+
*
|
|
23
|
+
* Each request has a hard 90s connect timeout via AbortController — if the
|
|
24
|
+
* server hasn't responded with headers by then, we abort and retry. This
|
|
25
|
+
* prevents the CLI from hanging indefinitely while the server retries
|
|
26
|
+
* upstream internally.
|
|
29
27
|
*/
|
|
30
|
-
export async function fetchWithRetry(url, options, { retries =
|
|
28
|
+
export async function fetchWithRetry(url, options, { retries = 4, baseDelay = 1000, timeoutMs = CONNECT_TIMEOUT_MS, onRetry } = {}) {
|
|
31
29
|
let attempt = 0;
|
|
32
30
|
while (true) {
|
|
31
|
+
const ac = new AbortController();
|
|
32
|
+
const timer = setTimeout(() => ac.abort(new Error(`Server did not respond within ${Math.round(timeoutMs / 1000)}s`)), timeoutMs);
|
|
33
|
+
// If the caller already set a signal, chain it so both can abort.
|
|
34
|
+
const callerSignal = options?.signal;
|
|
35
|
+
if (callerSignal) {
|
|
36
|
+
callerSignal.addEventListener("abort", () => ac.abort(callerSignal.reason), { once: true });
|
|
37
|
+
}
|
|
33
38
|
try {
|
|
34
|
-
const res = await fetch(url, options);
|
|
39
|
+
const res = await fetch(url, { ...options, signal: ac.signal });
|
|
40
|
+
clearTimeout(timer);
|
|
35
41
|
if (res.status >= 500 && attempt < retries) {
|
|
36
42
|
// Release the connection back to the pool before retrying — an
|
|
37
43
|
// unconsumed body keeps the socket open (undici won't reuse it).
|
|
@@ -43,6 +49,7 @@ export async function fetchWithRetry(url, options, { retries = 2, baseDelay = 50
|
|
|
43
49
|
}
|
|
44
50
|
return res;
|
|
45
51
|
} catch (e) {
|
|
52
|
+
clearTimeout(timer);
|
|
46
53
|
if (attempt < retries) {
|
|
47
54
|
attempt++;
|
|
48
55
|
if (onRetry) onRetry(attempt, e.message);
|
|
@@ -60,9 +67,12 @@ function defaultOnRetry(attempt, why) {
|
|
|
60
67
|
|
|
61
68
|
/**
|
|
62
69
|
* Free balance + plan check via /api/v1/me. Doesn't charge credits.
|
|
70
|
+
* Pass an explicit key to bypass the config chain (used during setup to
|
|
71
|
+
* verify the exact key the user entered, not whatever env var is set).
|
|
63
72
|
*/
|
|
64
|
-
export async function fetchBalance() {
|
|
65
|
-
const { apiKey, baseUrl } = getConfig();
|
|
73
|
+
export async function fetchBalance(apiKeyOverride) {
|
|
74
|
+
const { apiKey: configKey, baseUrl } = getConfig();
|
|
75
|
+
const apiKey = apiKeyOverride || configKey;
|
|
66
76
|
if (!apiKey) {
|
|
67
77
|
throw new AetherError(
|
|
68
78
|
"No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
|
|
@@ -90,7 +100,7 @@ export async function fetchBalance() {
|
|
|
90
100
|
const msg = data?.error || `${res.status} ${res.statusText}`;
|
|
91
101
|
throw new AetherError(msg, code, res.status, data);
|
|
92
102
|
}
|
|
93
|
-
return data; // { plan,
|
|
103
|
+
return data; // { plan, planCredits, topupCredits, balance, isSuspended }
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
export class AetherError extends Error {
|
|
@@ -212,12 +222,12 @@ export async function agentTurnStream({
|
|
|
212
222
|
|
|
213
223
|
// Watchdog: if the open stream stalls (no bytes) for too long, abort instead
|
|
214
224
|
// of hanging the CLI forever.
|
|
215
|
-
const READ_TIMEOUT_MS =
|
|
225
|
+
const READ_TIMEOUT_MS = 180_000;
|
|
216
226
|
const readOnce = () => {
|
|
217
227
|
let timer;
|
|
218
228
|
const timeout = new Promise((_, reject) => {
|
|
219
229
|
timer = setTimeout(
|
|
220
|
-
() => reject(new AetherError(
|
|
230
|
+
() => reject(new AetherError(`Stream stalled — no data for ${Math.round(READ_TIMEOUT_MS / 1000)}s`, "STREAM_TIMEOUT", 0)),
|
|
221
231
|
READ_TIMEOUT_MS,
|
|
222
232
|
);
|
|
223
233
|
});
|
package/src/box-input.js
CHANGED
|
@@ -1,20 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Bare "> " prompt with bottom status bar, matching Claude Code's layout.
|
|
1
|
+
// Bordered single-line text input — raw-mode, cmd.exe-safe.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Like the ask_user menu, this is NOT Ink (which ghosts in the legacy Windows
|
|
4
|
+
// console). It draws a 3-line box + hint, and on every keystroke moves the
|
|
5
|
+
// cursor back up (ESC[nA), clears (ESC[J), and reprints. The caret is a FAKE
|
|
6
|
+
// cursor (inverse-video cell) and the real terminal cursor is hidden, so there
|
|
7
|
+
// is no cursor-column math to get wrong. Long input scrolls horizontally under
|
|
8
|
+
// a fixed right border.
|
|
9
9
|
//
|
|
10
|
-
// Returns the typed string
|
|
11
|
-
// or EXIT_SIGNAL on Ctrl+C / Ctrl+D.
|
|
10
|
+
// Returns the typed string, or EXIT_SIGNAL on Ctrl+C / Ctrl+D.
|
|
12
11
|
|
|
13
12
|
import readline from "node:readline";
|
|
14
|
-
import { execFileSync } from "node:child_process";
|
|
15
|
-
import fs from "node:fs";
|
|
16
|
-
import os from "node:os";
|
|
17
|
-
import path from "node:path";
|
|
18
13
|
import { c } from "./render.js";
|
|
19
14
|
|
|
20
15
|
export const EXIT_SIGNAL = "<<aether-exit>>";
|
|
@@ -23,146 +18,68 @@ const HIDE = "\x1b[?25l";
|
|
|
23
18
|
const SHOW = "\x1b[?25h";
|
|
24
19
|
const INV = (s) => `\x1b[7m${s}\x1b[27m`;
|
|
25
20
|
|
|
26
|
-
function
|
|
27
|
-
const tmpFile = path.join(os.tmpdir(), `aether-paste-${Date.now()}.png`);
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
if (process.platform === "win32") {
|
|
31
|
-
const script =
|
|
32
|
-
"Add-Type -AssemblyName System.Windows.Forms;" +
|
|
33
|
-
"$img=[System.Windows.Forms.Clipboard]::GetImage();" +
|
|
34
|
-
"if($img){$img.Save('" + tmpFile.replace(/'/g, "''") + "'," +
|
|
35
|
-
"[System.Drawing.Imaging.ImageFormat]::Png);'OK'}else{'NO_IMAGE'}";
|
|
36
|
-
const out = execFileSync("powershell.exe", [
|
|
37
|
-
"-NoProfile", "-NonInteractive", "-Command", script,
|
|
38
|
-
], { timeout: 5000, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
39
|
-
if (out.endsWith("OK") && fs.existsSync(tmpFile)) return tmpFile;
|
|
40
|
-
} else if (process.platform === "darwin") {
|
|
41
|
-
try {
|
|
42
|
-
execFileSync("pngpaste", [tmpFile], { timeout: 3000, stdio: "pipe" });
|
|
43
|
-
if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
|
|
44
|
-
} catch { /* pngpaste not installed */ }
|
|
45
|
-
} else {
|
|
46
|
-
try {
|
|
47
|
-
const fd = fs.openSync(tmpFile, "w");
|
|
48
|
-
try {
|
|
49
|
-
execFileSync("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"], {
|
|
50
|
-
timeout: 3000, stdio: ["pipe", fd, "pipe"],
|
|
51
|
-
});
|
|
52
|
-
} finally {
|
|
53
|
-
fs.closeSync(fd);
|
|
54
|
-
}
|
|
55
|
-
if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
|
|
56
|
-
} catch { /* xclip not installed */ }
|
|
57
|
-
}
|
|
58
|
-
} catch { /* any failure — fall through */ }
|
|
59
|
-
|
|
60
|
-
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch { /* ignore */ }
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function promptBox({ history = [], placeholder = "", mode = "review", suppressEcho = false, onResize = null } = {}) {
|
|
21
|
+
export function promptBox({ history = [], placeholder = "type a message… /help for commands" } = {}) {
|
|
65
22
|
return new Promise((resolve) => {
|
|
23
|
+
// Non-interactive (piped / tests): plain readline, no box.
|
|
66
24
|
if (!process.stdin.isTTY) {
|
|
67
25
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
68
|
-
rl.question(c.
|
|
26
|
+
rl.question(c.magenta("› "), (a) => { rl.close(); resolve(a); });
|
|
69
27
|
return;
|
|
70
28
|
}
|
|
71
29
|
|
|
72
30
|
let buf = "";
|
|
73
|
-
let pos = 0;
|
|
31
|
+
let pos = 0; // caret index within buf
|
|
74
32
|
const hist = history.slice();
|
|
75
|
-
let histIdx = hist.length;
|
|
33
|
+
let histIdx = hist.length; // points one past the end (current draft)
|
|
76
34
|
let draft = "";
|
|
77
35
|
let drawn = 0;
|
|
78
36
|
let lastSigint = 0;
|
|
79
|
-
const clipImages = [];
|
|
80
|
-
let resizeHandler = null;
|
|
81
|
-
let resizeTimer = null;
|
|
82
37
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
86
|
-
const rowsFor = (line, cols) => Math.max(1, Math.ceil((stripAnsi(line).length || 1) / cols));
|
|
38
|
+
const innerW = () => Math.max(24, Math.min(process.stdout.columns || 80, 100) - 6);
|
|
87
39
|
|
|
88
40
|
const draw = (first) => {
|
|
89
|
-
const W =
|
|
90
|
-
const termCols = process.stdout.columns || 80;
|
|
41
|
+
const W = innerW();
|
|
91
42
|
if (!first) process.stdout.write(`\x1b[${drawn}A`);
|
|
92
43
|
process.stdout.write("\x1b[J");
|
|
93
44
|
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
const imgSuffix = clipImages.length > 0 ? ` [+${clipImages.length} img]` : "";
|
|
97
|
-
const maxVisible = Math.max(8, W - prefixLen - imgSuffix.length);
|
|
45
|
+
// Horizontal scroll: keep the caret within the visible window.
|
|
46
|
+
const maxVisible = W - 1;
|
|
98
47
|
const start = pos > maxVisible ? pos - maxVisible : 0;
|
|
99
|
-
const view = buf.slice(start, start +
|
|
48
|
+
const view = buf.slice(start, start + W);
|
|
100
49
|
const rel = pos - start;
|
|
101
50
|
|
|
102
51
|
let content;
|
|
103
|
-
if (buf.length === 0
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const tag = `[${clipImages.length} image${clipImages.length > 1 ? "s" : ""} pasted]`;
|
|
107
|
-
content = INV(" ") + c.cyan(tag);
|
|
52
|
+
if (buf.length === 0) {
|
|
53
|
+
const ph = placeholder.slice(0, W - 1);
|
|
54
|
+
content = INV(" ") + c.dim(ph) + " ".repeat(Math.max(0, W - 1 - ph.length));
|
|
108
55
|
} else {
|
|
109
56
|
let s = "";
|
|
110
|
-
for (let i = 0; i <
|
|
57
|
+
for (let i = 0; i < W; i++) {
|
|
111
58
|
const ch = view[i] ?? " ";
|
|
112
59
|
s += i === rel ? INV(ch) : ch;
|
|
113
60
|
}
|
|
114
|
-
if (imgSuffix) s += c.cyan(imgSuffix);
|
|
115
61
|
content = s;
|
|
116
62
|
}
|
|
117
63
|
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const barPrefixLen = 2;
|
|
126
|
-
const imgNoteLen = clipImages.length > 0 ? 17 : 0;
|
|
127
|
-
const maxStatusLen = Math.max(10, termCols - barPrefixLen - imgNoteLen - 1);
|
|
128
|
-
const truncStatus = baseStatus.length > maxStatusLen ? baseStatus.slice(0, maxStatusLen) : baseStatus;
|
|
129
|
-
const statusText = c.dim(truncStatus) + imgNote;
|
|
130
|
-
|
|
131
|
-
const rule = c.cyan("─".repeat(Math.max(0, termCols)));
|
|
132
|
-
const lines = [rule, promptLine, rule, barPrefix + statusText];
|
|
133
|
-
drawn = lines.reduce((sum, l) => sum + rowsFor(l, termCols), 0);
|
|
134
|
-
process.stdout.write(lines.join("\n") + "\n");
|
|
64
|
+
const top = c.magenta("╭" + "─".repeat(W + 4) + "╮");
|
|
65
|
+
const mid = c.magenta("│") + " " + c.magenta("›") + " " + content + " " + c.magenta("│");
|
|
66
|
+
const bot = c.magenta("╰" + "─".repeat(W + 4) + "╯");
|
|
67
|
+
const hint = c.dim(" Enter send · ↑↓ history · /help · /exit");
|
|
68
|
+
|
|
69
|
+
process.stdout.write(top + "\n" + mid + "\n" + bot + "\n" + hint + "\n");
|
|
70
|
+
drawn = 4;
|
|
135
71
|
};
|
|
136
72
|
|
|
137
73
|
const finish = (result) => {
|
|
138
|
-
if (resizeTimer) { clearTimeout(resizeTimer); resizeTimer = null; }
|
|
139
|
-
if (resizeHandler) process.stdout.removeListener("resize", resizeHandler);
|
|
140
74
|
process.stdin.removeListener("keypress", onKey);
|
|
141
75
|
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
142
76
|
process.stdin.pause();
|
|
143
77
|
process.stdout.write(`\x1b[${drawn}A\x1b[J` + SHOW);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const userText = (result || "").trim();
|
|
148
|
-
const imgRefs = clipImages.map((p) => `@"${p}"`).join(" ");
|
|
149
|
-
finalResult = userText ? `${userText} ${imgRefs}` : `What is in this image? ${imgRefs}`;
|
|
78
|
+
// Echo the submitted line so it persists in scrollback.
|
|
79
|
+
if (result && result !== EXIT_SIGNAL && result.trim()) {
|
|
80
|
+
process.stdout.write(c.magenta("› ") + result + "\n");
|
|
150
81
|
}
|
|
151
|
-
|
|
152
|
-
if (!suppressEcho && finalResult && finalResult !== EXIT_SIGNAL) {
|
|
153
|
-
const echoText = (result || "").trim();
|
|
154
|
-
const imgTag = clipImages.length > 0
|
|
155
|
-
? ` [+${clipImages.length} image${clipImages.length > 1 ? "s" : ""}]`
|
|
156
|
-
: "";
|
|
157
|
-
if (echoText || clipImages.length > 0) {
|
|
158
|
-
const displayText = echoText || "(image)";
|
|
159
|
-
const cols = process.stdout.columns || 80;
|
|
160
|
-
const textLen = displayText.length + imgTag.length + 2;
|
|
161
|
-
const pad = Math.max(0, cols - textLen);
|
|
162
|
-
process.stdout.write(`\x1b[48;5;236m ${displayText}${imgTag}${" ".repeat(pad)}\x1b[0m\n`);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
resolve(finalResult);
|
|
82
|
+
resolve(result);
|
|
166
83
|
};
|
|
167
84
|
|
|
168
85
|
const onKey = (str, key) => {
|
|
@@ -172,22 +89,13 @@ export function promptBox({ history = [], placeholder = "", mode = "review", sup
|
|
|
172
89
|
if (k === "return" || k === "enter") return finish(buf);
|
|
173
90
|
if (key.ctrl && k === "c") {
|
|
174
91
|
const now = Date.now();
|
|
175
|
-
if (now - lastSigint < 1500 ||
|
|
92
|
+
if (now - lastSigint < 1500 || buf.length === 0) return finish(EXIT_SIGNAL);
|
|
176
93
|
lastSigint = now;
|
|
177
|
-
buf = ""; pos = 0;
|
|
94
|
+
buf = ""; pos = 0; draw(false);
|
|
178
95
|
return;
|
|
179
96
|
}
|
|
180
97
|
if (key.ctrl && k === "d") return finish(buf.length === 0 ? EXIT_SIGNAL : buf);
|
|
181
98
|
|
|
182
|
-
if ((key.meta && k === "v") || (key.ctrl && k === "v")) {
|
|
183
|
-
const imgPath = grabClipboardImage();
|
|
184
|
-
if (imgPath) {
|
|
185
|
-
clipImages.push(imgPath);
|
|
186
|
-
}
|
|
187
|
-
draw(false);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
99
|
if (k === "backspace") { if (pos > 0) { buf = buf.slice(0, pos - 1) + buf.slice(pos); pos--; } }
|
|
192
100
|
else if (k === "delete") { buf = buf.slice(0, pos) + buf.slice(pos + 1); }
|
|
193
101
|
else if (k === "left") { if (pos > 0) pos--; }
|
|
@@ -210,7 +118,7 @@ export function promptBox({ history = [], placeholder = "", mode = "review", sup
|
|
|
210
118
|
buf = buf.slice(0, pos) + str + buf.slice(pos);
|
|
211
119
|
pos += str.length;
|
|
212
120
|
}
|
|
213
|
-
else return;
|
|
121
|
+
else return; // ignore unhandled keys without a redraw
|
|
214
122
|
draw(false);
|
|
215
123
|
};
|
|
216
124
|
|
|
@@ -220,18 +128,5 @@ export function promptBox({ history = [], placeholder = "", mode = "review", sup
|
|
|
220
128
|
process.stdin.resume();
|
|
221
129
|
process.stdin.on("keypress", onKey);
|
|
222
130
|
draw(true);
|
|
223
|
-
resizeHandler = () => {
|
|
224
|
-
if (resizeTimer) clearTimeout(resizeTimer);
|
|
225
|
-
resizeTimer = setTimeout(() => {
|
|
226
|
-
resizeTimer = null;
|
|
227
|
-
if (onResize) {
|
|
228
|
-
onResize();
|
|
229
|
-
} else {
|
|
230
|
-
process.stdout.write(`\x1b[${drawn}A\x1b[J`);
|
|
231
|
-
}
|
|
232
|
-
draw(true);
|
|
233
|
-
}, 120);
|
|
234
|
-
};
|
|
235
|
-
process.stdout.on("resize", resizeHandler);
|
|
236
131
|
});
|
|
237
132
|
}
|
package/src/config.js
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
// Config — reads ~/.aetherrc (shared format with aether-cli) and env vars.
|
|
2
|
-
// Same precedence: env wins over file.
|
|
3
|
-
|
|
4
|
-
import fs from "node:fs";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import os from "node:os";
|
|
7
|
-
|
|
8
|
-
const FILE = path.join(os.homedir(), ".aetherrc");
|
|
9
|
-
const DEFAULT_BASE = "https://trynoguard.com";
|
|
10
|
-
|
|
11
|
-
export function readConfigFile() {
|
|
12
|
-
try {
|
|
13
|
-
const raw = fs.readFileSync(FILE, "utf8");
|
|
14
|
-
return JSON.parse(raw);
|
|
15
|
-
} catch {
|
|
16
|
-
return {};
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function writeConfigFile(patch) {
|
|
21
|
-
const current = readConfigFile();
|
|
22
|
-
const next = { ...current, ...patch };
|
|
23
|
-
// 0600 — readable only by the user. Mirrors what ssh, gnupg, etc. enforce
|
|
24
|
-
// on credential files. Treat the API key like an SSH key.
|
|
25
|
-
fs.writeFileSync(FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
26
|
-
try { fs.chmodSync(FILE, 0o600); } catch { /* windows ignores */ }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function getConfig() {
|
|
30
|
-
const file = readConfigFile();
|
|
31
|
-
return {
|
|
32
|
-
apiKey: process.env.AETHER_API_KEY || file.apiKey || "",
|
|
33
|
-
baseUrl: (process.env.AETHER_BASE_URL || file.baseUrl || DEFAULT_BASE).replace(/\/+$/, ""),
|
|
34
|
-
configPath: FILE,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const CONFIG_PATH = FILE;
|
|
1
|
+
// Config — reads ~/.aetherrc (shared format with aether-cli) and env vars.
|
|
2
|
+
// Same precedence: env wins over file.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
const FILE = path.join(os.homedir(), ".aetherrc");
|
|
9
|
+
const DEFAULT_BASE = "https://trynoguard.com";
|
|
10
|
+
|
|
11
|
+
export function readConfigFile() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = fs.readFileSync(FILE, "utf8");
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function writeConfigFile(patch) {
|
|
21
|
+
const current = readConfigFile();
|
|
22
|
+
const next = { ...current, ...patch };
|
|
23
|
+
// 0600 — readable only by the user. Mirrors what ssh, gnupg, etc. enforce
|
|
24
|
+
// on credential files. Treat the API key like an SSH key.
|
|
25
|
+
fs.writeFileSync(FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
26
|
+
try { fs.chmodSync(FILE, 0o600); } catch { /* windows ignores */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getConfig() {
|
|
30
|
+
const file = readConfigFile();
|
|
31
|
+
return {
|
|
32
|
+
apiKey: process.env.AETHER_API_KEY || file.apiKey || "",
|
|
33
|
+
baseUrl: (process.env.AETHER_BASE_URL || file.baseUrl || DEFAULT_BASE).replace(/\/+$/, ""),
|
|
34
|
+
configPath: FILE,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const CONFIG_PATH = FILE;
|
package/src/diff.js
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
// Tiny line-by-line diff for write_file confirmation prompts.
|
|
2
|
-
// Not a "real" diff — just a side-by-side highlight of what's changing.
|
|
3
|
-
// Good enough for confirmation prompts, deliberately not pretending to be `git diff`.
|
|
4
|
-
|
|
5
|
-
import { c } from "./render.js";
|
|
6
|
-
|
|
7
|
-
export function unifiedDiff(oldText, newText, filename) {
|
|
8
|
-
const oldLines = (oldText || "").split("\n");
|
|
9
|
-
const newLines = (newText || "").split("\n");
|
|
10
|
-
const max = Math.max(oldLines.length, newLines.length);
|
|
11
|
-
|
|
12
|
-
// Find common prefix and suffix to keep the diff focused
|
|
13
|
-
let prefix = 0;
|
|
14
|
-
while (prefix < max && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
15
|
-
let suffix = 0;
|
|
16
|
-
while (
|
|
17
|
-
suffix < max - prefix &&
|
|
18
|
-
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
19
|
-
) {
|
|
20
|
-
suffix++;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const changedOld = oldLines.slice(prefix, oldLines.length - suffix);
|
|
24
|
-
const changedNew = newLines.slice(prefix, newLines.length - suffix);
|
|
25
|
-
|
|
26
|
-
const lines = [];
|
|
27
|
-
// No "@@ file @@" header — the "● write <file>" label already names the file.
|
|
28
|
-
if (prefix > 0) lines.push(c.gray(` …${prefix} unchanged line${prefix === 1 ? "" : "s"} above…`));
|
|
29
|
-
for (const l of changedOld) lines.push(c.red(`- ${l}`));
|
|
30
|
-
for (const l of changedNew) lines.push(c.green(`+ ${l}`));
|
|
31
|
-
if (suffix > 0) lines.push(c.gray(` …${suffix} unchanged line${suffix === 1 ? "" : "s"} below…`));
|
|
32
|
-
|
|
33
|
-
// Cap output so writes don't flood the terminal — a short preview is enough
|
|
34
|
-
// (skip-permissions auto-applies; the full file is on disk to inspect).
|
|
35
|
-
const MAX = 14;
|
|
36
|
-
if (lines.length > MAX) {
|
|
37
|
-
const shown = lines.slice(0, MAX - 1);
|
|
38
|
-
return [...shown, c.gray(` …${lines.length - shown.length} more lines (see the file)…`)].join("\n");
|
|
39
|
-
}
|
|
40
|
-
return lines.join("\n");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function summarizeWrite(oldText, newText, filename) {
|
|
44
|
-
const oldLines = (oldText || "").split("\n").length;
|
|
45
|
-
const newLines = (newText || "").split("\n").length;
|
|
46
|
-
const isCreate = oldText === null || oldText === undefined;
|
|
47
|
-
const verb = isCreate ? "create" : "rewrite";
|
|
48
|
-
return c.dim(`${verb} ${filename} (${oldLines} → ${newLines} lines, ${(newText || "").length} bytes)`);
|
|
49
|
-
}
|
|
1
|
+
// Tiny line-by-line diff for write_file confirmation prompts.
|
|
2
|
+
// Not a "real" diff — just a side-by-side highlight of what's changing.
|
|
3
|
+
// Good enough for confirmation prompts, deliberately not pretending to be `git diff`.
|
|
4
|
+
|
|
5
|
+
import { c } from "./render.js";
|
|
6
|
+
|
|
7
|
+
export function unifiedDiff(oldText, newText, filename) {
|
|
8
|
+
const oldLines = (oldText || "").split("\n");
|
|
9
|
+
const newLines = (newText || "").split("\n");
|
|
10
|
+
const max = Math.max(oldLines.length, newLines.length);
|
|
11
|
+
|
|
12
|
+
// Find common prefix and suffix to keep the diff focused
|
|
13
|
+
let prefix = 0;
|
|
14
|
+
while (prefix < max && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
15
|
+
let suffix = 0;
|
|
16
|
+
while (
|
|
17
|
+
suffix < max - prefix &&
|
|
18
|
+
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
19
|
+
) {
|
|
20
|
+
suffix++;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const changedOld = oldLines.slice(prefix, oldLines.length - suffix);
|
|
24
|
+
const changedNew = newLines.slice(prefix, newLines.length - suffix);
|
|
25
|
+
|
|
26
|
+
const lines = [];
|
|
27
|
+
// No "@@ file @@" header — the "● write <file>" label already names the file.
|
|
28
|
+
if (prefix > 0) lines.push(c.gray(` …${prefix} unchanged line${prefix === 1 ? "" : "s"} above…`));
|
|
29
|
+
for (const l of changedOld) lines.push(c.red(`- ${l}`));
|
|
30
|
+
for (const l of changedNew) lines.push(c.green(`+ ${l}`));
|
|
31
|
+
if (suffix > 0) lines.push(c.gray(` …${suffix} unchanged line${suffix === 1 ? "" : "s"} below…`));
|
|
32
|
+
|
|
33
|
+
// Cap output so writes don't flood the terminal — a short preview is enough
|
|
34
|
+
// (skip-permissions auto-applies; the full file is on disk to inspect).
|
|
35
|
+
const MAX = 14;
|
|
36
|
+
if (lines.length > MAX) {
|
|
37
|
+
const shown = lines.slice(0, MAX - 1);
|
|
38
|
+
return [...shown, c.gray(` …${lines.length - shown.length} more lines (see the file)…`)].join("\n");
|
|
39
|
+
}
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function summarizeWrite(oldText, newText, filename) {
|
|
44
|
+
const oldLines = (oldText || "").split("\n").length;
|
|
45
|
+
const newLines = (newText || "").split("\n").length;
|
|
46
|
+
const isCreate = oldText === null || oldText === undefined;
|
|
47
|
+
const verb = isCreate ? "create" : "rewrite";
|
|
48
|
+
return c.dim(`${verb} ${filename} (${oldLines} → ${newLines} lines, ${(newText || "").length} bytes)`);
|
|
49
|
+
}
|