aether-code 0.42.0 → 0.43.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.
@@ -1,514 +1,514 @@
1
- #!/usr/bin/env node
2
- // aether-code — uncensored AI coding agent.
3
- //
4
- // Examples:
5
- // aether-code "build me a TypeScript todo CLI in this folder"
6
- // aether-code --yes "add JSDoc to every exported function in src/"
7
- // aether-code --cwd ./my-project "fix the failing tests"
8
- // aether-code --max-turns 40 "refactor the auth module to use bcrypt"
9
-
10
- import process from "node:process";
11
- import path from "node:path";
12
- import os from "node:os";
13
- import fs from "node:fs";
14
- import { runAgent } from "../src/agent.js";
15
- import { runRepl } from "../src/repl.js";
16
- import { runSetup } from "../src/setup.js";
17
- import { fetchBalance, AetherError } from "../src/api.js";
18
- import { writeConfigFile, getConfig, CONFIG_PATH } from "../src/config.js";
19
- import { loadMcpConfig, MCPManager } from "../src/mcp.js";
20
- import { addServer, removeServer, listServers } from "../src/mcp-cli.js";
21
- import {
22
- MCP_REGISTRY,
23
- findRegistryEntry,
24
- resolveEntry,
25
- searchRegistry,
26
- suggestSimilar,
27
- } from "../src/mcp-registry.js";
28
- import readline from "node:readline";
29
- import { c, errorLine, divider, setTerminalTitle } from "../src/render.js";
30
- import { getVersion } from "../src/version.js";
31
-
32
- const VERSION = getVersion();
33
-
34
- /**
35
- * Try to start MCP servers from ~/.aether/mcp.json. Returns a started
36
- * MCPManager (possibly with zero servers) or null if no config exists.
37
- * Prints a one-line summary so the user can see what attached.
38
- */
39
- async function bootMcp() {
40
- let config;
41
- try {
42
- config = loadMcpConfig();
43
- } catch (e) {
44
- console.log(errorLine(`MCP config: ${e.message}`));
45
- return null;
46
- }
47
- if (!config) return null;
48
- const manager = new MCPManager();
49
- const started = await manager.start(config);
50
- const requested = Object.keys(config.mcpServers).length;
51
- const failed = manager.startErrors.length;
52
- if (started > 0 || failed > 0) {
53
- const parts = [`${c.cyan("MCP")}`, `${started}/${requested} servers attached`];
54
- const toolCount = manager.getToolDefinitions().length;
55
- if (toolCount > 0) parts.push(`${toolCount} tools`);
56
- console.log(c.gray(parts.join(" · ")));
57
- for (const { serverName, error } of manager.startErrors) {
58
- console.log(c.gray(` ${c.yellow("!")} ${serverName}: ${error}`));
59
- }
60
- }
61
- // Best-effort cleanup so child processes don't leak on normal exit.
62
- process.on("exit", () => { manager.shutdown().catch(() => {}); });
63
- process.on("SIGINT", () => { manager.shutdown().catch(() => {}); process.exit(130); });
64
- return manager;
65
- }
66
-
67
- const HELP = `${c.bold("aether")} — uncensored AI coding agent
68
-
69
- ${c.bold("USAGE")}
70
- aether Launch interactive REPL (Claude-CLI-style)
71
- aether [flags] "<task>" Run agent once on a single task
72
- aether <subcommand> [args] Run a utility subcommand
73
-
74
- ${c.bold("SUBCOMMANDS")}
75
- ${c.cyan("login")} Open browser, paste API key, save
76
- ${c.cyan("logout")} Clear saved API key
77
- ${c.cyan("balance")} Show plan + credit balance
78
- ${c.cyan("config")} show|set|set-base|path Manage config file
79
- ${c.cyan("mcp")} list|search|install|add|remove Manage MCP server connections
80
-
81
- ${c.bold("EXAMPLES")}
82
- aether # interactive REPL
83
- aether login # first-time setup
84
- aether balance # quick credit check
85
- aether "build a TypeScript todo CLI in this folder"
86
- aether --yes "add JSDoc to every exported function"
87
- aether --cwd ./my-project "fix the failing tests"
88
-
89
- ${c.bold("FLAGS")}
90
- --review Confirm (y/N) before each write + shell command.
91
- Default is auto-approve (skip-permissions).
92
- --sandbox Restrict file access to --cwd. Default is full access.
93
- --cwd <path> Working directory for the agent (default: current dir).
94
- --model <name> core (default) | ultra (Opus). Premium models need
95
- purchased credits, else they fall back to Core.
96
- --max-turns <n> Maximum turns before stopping (default: 25).
97
- --help, -h Show this help.
98
- --version, -v Print version.
99
-
100
- ${c.bold("CONFIG")}
101
- Same config as aether-cli — uses ${c.cyan("AETHER_API_KEY")} or ${c.cyan("~/.aetherrc")}.
102
- Get a key at ${c.blue("https://trynoguard.com/account")}.
103
-
104
- ${c.bold("SAFETY")}
105
- - By DEFAULT aether runs in skip-permissions mode (like Claude's
106
- --dangerously-skip-permissions): it auto-approves file writes + shell
107
- commands and can access your whole filesystem, so it just builds.
108
- - Use ${c.cyan("--review")} to approve each action, ${c.cyan("--sandbox")} to clamp paths to --cwd.
109
- - Each shell command has a 2-minute hard timeout.
110
-
111
- ${c.gray(`v${VERSION}`)}
112
- `;
113
-
114
- function parseArgs(argv) {
115
- const args = { _: [], flags: {} };
116
- for (let i = 0; i < argv.length; i++) {
117
- const a = argv[i];
118
- if (a === "--yes") { args.flags.yes = true; }
119
- else if (a === "--unsafe-paths") { args.flags.unsafePaths = true; }
120
- else if (a === "--help" || a === "-h") { args.flags.help = true; }
121
- else if (a === "--version" || a === "-v") { args.flags.version = true; }
122
- else if (a === "--cwd") { args.flags.cwd = argv[++i]; }
123
- else if (a === "--model") { args.flags.model = argv[++i]; }
124
- else if (a === "--max-turns") { args.flags.maxTurns = parseInt(argv[++i], 10); }
125
- else if (a.startsWith("--")) {
126
- const eq = a.indexOf("=");
127
- if (eq >= 0) args.flags[a.slice(2, eq)] = a.slice(eq + 1);
128
- else args.flags[a.slice(2)] = true;
129
- } else {
130
- args._.push(a);
131
- }
132
- }
133
- return args;
134
- }
135
-
136
- function die(msg, code = 1) {
137
- process.stderr.write(errorLine(msg) + "\n");
138
- process.exit(code);
139
- }
140
-
141
- // True for Windows system folders we should never write a user's project into.
142
- function isSystemDir(p) {
143
- const low = p.toLowerCase().replace(/[\\/]+$/, "").replace(/\//g, "\\");
144
- return (
145
- low.endsWith("\\windows\\system32") ||
146
- low.endsWith("\\windows\\syswow64") ||
147
- low.endsWith("\\windows") ||
148
- low.endsWith("\\system32") ||
149
- /\\program files( \(x86\))?$/.test(low)
150
- );
151
- }
152
-
153
- // Always restore the terminal on exit. The raw-mode input box, the ask_user
154
- // menu, and the spinner hide the cursor (\x1b[?25l) and set raw mode; if the
155
- // process dies mid-prompt (an uncaught error, or the MCP SIGINT handler's
156
- // process.exit(130)) their own cleanup never runs and the user's shell is left
157
- // with no cursor and no echo. This idempotent backstop fires on every exit.
158
- function restoreTerminal() {
159
- try {
160
- if (process.stdout.isTTY) process.stdout.write("\x1b[?25h"); // show cursor
161
- if (process.stdin.isTTY && process.stdin.setRawMode) process.stdin.setRawMode(false);
162
- } catch { /* terminal already gone — nothing to restore */ }
163
- }
164
- process.on("exit", restoreTerminal);
165
-
166
- async function main() {
167
- const args = parseArgs(process.argv.slice(2));
168
- setTerminalTitle("Aether");
169
-
170
- if (args.flags.help) {
171
- process.stdout.write(HELP);
172
- return;
173
- }
174
- if (args.flags.version) {
175
- process.stdout.write(`aether-code ${VERSION}\n`);
176
- return;
177
- }
178
-
179
- // Where files land. If the user didn't pass --cwd and we were launched from a
180
- // Windows system folder (admin cmd opens in C:\WINDOWS\system32), don't dump
181
- // their project there — they'd never find it. Work in the Desktop instead.
182
- let cwd = args.flags.cwd ? path.resolve(args.flags.cwd) : process.cwd();
183
- let redirectedFrom = null;
184
- if (!args.flags.cwd && isSystemDir(cwd)) {
185
- const desktop = path.join(os.homedir(), "Desktop");
186
- const target = fs.existsSync(desktop) ? desktop : os.homedir();
187
- if (target.toLowerCase() !== cwd.toLowerCase()) { redirectedFrom = cwd; cwd = target; }
188
- }
189
- // Skip-permissions by DEFAULT (like `claude --dangerously-skip-permissions`):
190
- // auto-approve writes/shell and don't sandbox file paths, so the agent can
191
- // just build. Opt back in with --review (y/N before each action) and
192
- // --sandbox (clamp file access to --cwd).
193
- const autoYes = !args.flags.review;
194
- const unsafePaths = !args.flags.sandbox;
195
- const maxTurns = Number.isInteger(args.flags.maxTurns) ? args.flags.maxTurns : 25;
196
-
197
- // --model: core (default) or ultra (Opus). Premium models are gated
198
- // server-side — without purchased credits the server uses Core regardless.
199
- const MODEL_IDS = { core: null, gemma: null, ultra: "claude-opus-4-6", opus: "claude-opus-4-6" };
200
- const modelKey = args.flags.model ? String(args.flags.model).toLowerCase() : null;
201
- const model = modelKey && modelKey in MODEL_IDS ? MODEL_IDS[modelKey] : (args.flags.model || null);
202
-
203
- // Subcommand routing — these shadow the "task as positional arg" mode
204
- const sub = args._[0]?.toLowerCase();
205
- if (sub === "login" || sub === "auth") {
206
- const ok = await runSetup();
207
- process.exit(ok ? 0 : 1);
208
- }
209
- if (sub === "logout") {
210
- writeConfigFile({ apiKey: "" });
211
- console.log(c.gray(`Cleared API key from ${CONFIG_PATH}.`));
212
- return;
213
- }
214
- if (sub === "config") {
215
- await handleConfig(args._.slice(1));
216
- return;
217
- }
218
- if (sub === "balance") {
219
- await handleBalance();
220
- return;
221
- }
222
- if (sub === "mcp") {
223
- await handleMcp(args._.slice(1));
224
- return;
225
- }
226
-
227
- const prompt = args._.join(" ").trim();
228
-
229
- // No task → drop into interactive REPL (Claude-CLI-style)
230
- if (!prompt) {
231
- if (cwd !== process.cwd()) process.chdir(cwd);
232
- const mcpManager = await bootMcp();
233
- if (redirectedFrom) {
234
- console.log(c.gray(`Note: launched from a system folder (${redirectedFrom}).`));
235
- console.log(c.gray(`Your files will be saved to ${c.cyan(cwd)} so you can find them. Use ${c.cyan("--cwd <path>")} or ${c.cyan("/cwd")} to change.`));
236
- }
237
- await runRepl({ cwd, autoYes, unsafePaths, maxTurns, model, mcpManager });
238
- return;
239
- }
240
-
241
- // One-shot mode also needs an API key. If missing, run setup before the task.
242
- const cfg = getConfig();
243
- if (!cfg.apiKey) {
244
- const ok = await runSetup();
245
- if (!ok) process.exit(1);
246
- }
247
-
248
- console.log(divider());
249
- const modeLabel = autoYes && unsafePaths ? " · skip-permissions" : `${autoYes ? " · auto-yes" : " · review"}${unsafePaths ? "" : " · sandboxed"}`;
250
- console.log(c.magenta(c.bold("aether-code")) + c.gray(` · cwd ${cwd}${modeLabel}`));
251
- if (redirectedFrom) console.log(c.gray(`(launched from ${redirectedFrom} — saving to ${cwd} instead)`));
252
- console.log(c.gray(`task: `) + prompt);
253
- console.log(divider());
254
-
255
- const mcpManager = await bootMcp();
256
- const result = await runAgent({
257
- initialPrompt: prompt,
258
- cwd,
259
- autoYes,
260
- unsafePaths,
261
- maxTurns,
262
- model,
263
- mcpManager,
264
- });
265
- if (mcpManager) await mcpManager.shutdown().catch(() => {});
266
-
267
- console.log("\n" + divider());
268
- if (result.ok) {
269
- console.log(c.green(c.bold("● Done")) + c.gray(` ${result.turns} turn${result.turns === 1 ? "" : "s"} · ${result.totalCredits} credits · ${result.totalIn}→${result.totalOut} tokens`));
270
- if (typeof result.balance === "number") {
271
- console.log(c.gray(` balance: ${result.balance.toLocaleString()} credits`));
272
- }
273
- } else {
274
- console.log(c.red(c.bold("× Stopped")) + c.gray(` ${result.totalCredits} credits used · ${result.totalIn}→${result.totalOut} tokens`));
275
- if (result.error) console.log(errorLine(result.error.message));
276
- }
277
- console.log(divider());
278
- }
279
-
280
- async function handleConfig(rest) {
281
- const sub = (rest[0] || "").toLowerCase();
282
- if (sub === "show" || !sub) {
283
- const cfg = getConfig();
284
- console.log(`Config file: ${cfg.configPath}`);
285
- console.log(`API key: ${cfg.apiKey ? cfg.apiKey.slice(0, 12) + "…" + cfg.apiKey.slice(-4) : c.gray("(none)")}`);
286
- console.log(`Base URL: ${cfg.baseUrl}`);
287
- console.log(`Source: ${process.env.AETHER_API_KEY ? "AETHER_API_KEY env" : "config file"}`);
288
- return;
289
- }
290
- if (sub === "set") {
291
- const key = rest[1];
292
- if (!key) die("config set: missing API key argument.");
293
- if (!key.startsWith("ak_live_")) {
294
- process.stderr.write(c.yellow("warning: keys normally start with ak_live_; saving anyway.\n"));
295
- }
296
- writeConfigFile({ apiKey: key });
297
- console.log(`${c.green("●")} API key saved to ${CONFIG_PATH}`);
298
- return;
299
- }
300
- if (sub === "set-base") {
301
- const url = rest[1];
302
- if (!url) die("config set-base: missing URL argument.");
303
- writeConfigFile({ baseUrl: url });
304
- console.log(`${c.green("●")} Base URL saved.`);
305
- return;
306
- }
307
- if (sub === "path") {
308
- console.log(CONFIG_PATH);
309
- return;
310
- }
311
- die(`config: unknown subcommand "${sub}". Try 'config show', 'config set <key>', 'config path'.`);
312
- }
313
-
314
- async function handleBalance() {
315
- try {
316
- const me = await fetchBalance();
317
- console.log(c.bold(c.magenta("Aether")));
318
- console.log(c.gray("─".repeat(50)));
319
- console.log(`Plan ${c.cyan(me.plan)}`);
320
- console.log(`Balance ${c.bold(me.balance.toLocaleString())} credits`);
321
- console.log(` plan ${me.planCredits.toLocaleString()}`);
322
- console.log(` topup ${me.topupCredits.toLocaleString()}`);
323
- if (me.isSuspended) console.log(c.red("\n⚠ Account is suspended."));
324
- } catch (err) {
325
- if (err instanceof AetherError && err.code === "NO_API_KEY") {
326
- console.log(errorLine("No API key. Run `aether login` first."));
327
- } else {
328
- die(err.message || String(err));
329
- }
330
- }
331
- }
332
-
333
- async function handleMcp(rest) {
334
- const sub = (rest[0] || "list").toLowerCase();
335
-
336
- if (sub === "list" || sub === "ls") {
337
- const servers = listServers();
338
- if (servers.length === 0) {
339
- console.log(c.gray("No MCP servers configured."));
340
- console.log(c.gray("Add one with:"));
341
- console.log(c.gray(" aether mcp add <name> -- <command> [args...]"));
342
- console.log(c.gray("Example:"));
343
- console.log(
344
- c.gray(' aether mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /path'),
345
- );
346
- return;
347
- }
348
- console.log(c.bold(`Configured MCP servers (${servers.length}):`));
349
- for (const [name, cfg] of servers) {
350
- const argsStr = cfg.args && cfg.args.length > 0 ? " " + cfg.args.join(" ") : "";
351
- console.log(` ${c.cyan(name)}: ${cfg.command}${argsStr}`);
352
- if (cfg.env && Object.keys(cfg.env).length > 0) {
353
- for (const [k, v] of Object.entries(cfg.env)) {
354
- console.log(c.gray(` env ${k}=${v}`));
355
- }
356
- }
357
- }
358
- return;
359
- }
360
-
361
- if (sub === "add") {
362
- // Syntax: aether mcp add <name> [--env KEY=VAL]... -- <command> [args...]
363
- const tail = rest.slice(1);
364
- const dashIdx = tail.indexOf("--");
365
- if (dashIdx === -1) {
366
- die(
367
- 'Usage: aether mcp add <name> [--env KEY=VAL]... -- <command> [args...]\n' +
368
- 'Example: aether mcp add fs -- npx -y @modelcontextprotocol/server-filesystem /tmp',
369
- );
370
- }
371
- const pre = tail.slice(0, dashIdx);
372
- const post = tail.slice(dashIdx + 1);
373
- const name = pre[0];
374
- if (!name) die("aether mcp add: missing <name>");
375
- if (post.length === 0) die("aether mcp add: missing <command> after '--'");
376
-
377
- const env = {};
378
- for (let i = 1; i < pre.length; i++) {
379
- if (pre[i] === "--env") {
380
- const kv = pre[++i];
381
- if (!kv) die("--env needs a KEY=VAL argument");
382
- const eq = kv.indexOf("=");
383
- if (eq <= 0) die(`--env value must be KEY=VAL, got: ${kv}`);
384
- env[kv.slice(0, eq)] = kv.slice(eq + 1);
385
- } else {
386
- die(`aether mcp add: unrecognized option "${pre[i]}" before the '--' separator`);
387
- }
388
- }
389
-
390
- const command = post[0];
391
- const cmdArgs = post.slice(1);
392
- try {
393
- const entry = addServer({ name, command, args: cmdArgs, env });
394
- console.log(`${c.green("●")} Added MCP server "${c.cyan(name)}".`);
395
- const argsStr = entry.args && entry.args.length > 0 ? " " + entry.args.join(" ") : "";
396
- console.log(c.gray(` ${entry.command}${argsStr}`));
397
- console.log(c.gray("Restart the agent (or run `aether`) to attach it."));
398
- } catch (e) {
399
- die(e.message || String(e));
400
- }
401
- return;
402
- }
403
-
404
- if (sub === "remove" || sub === "rm" || sub === "delete") {
405
- const name = rest[1];
406
- if (!name) die("aether mcp remove: missing <name>");
407
- try {
408
- removeServer({ name });
409
- console.log(`${c.green("●")} Removed MCP server "${c.cyan(name)}".`);
410
- } catch (e) {
411
- die(e.message || String(e));
412
- }
413
- return;
414
- }
415
-
416
- if (sub === "search" || sub === "find") {
417
- const query = rest.slice(1).join(" ").trim();
418
- const results = searchRegistry(query);
419
- if (results.length === 0) {
420
- console.log(c.gray(`No MCP servers in the registry match "${query}".`));
421
- console.log(c.gray("Browse the full list: ") + c.cyan("aether mcp search"));
422
- return;
423
- }
424
- console.log(
425
- c.bold(query ? `MCP servers matching "${query}":` : `Available MCP servers (${results.length}):`),
426
- );
427
- for (const e of results) {
428
- const sourceTag = e.source === "official"
429
- ? c.gray("(official)")
430
- : c.yellow("(community)");
431
- console.log(` ${c.cyan(e.id.padEnd(16))} ${sourceTag} ${e.description}`);
432
- }
433
- console.log("");
434
- console.log(c.gray("Install one with: ") + c.cyan("aether mcp install <name>"));
435
- return;
436
- }
437
-
438
- if (sub === "install" || sub === "get") {
439
- const name = rest[1];
440
- if (!name) {
441
- die(
442
- "aether mcp install: missing <name>.\n" +
443
- "Try `aether mcp search` to see what's available.",
444
- );
445
- }
446
- const entry = findRegistryEntry(name);
447
- if (!entry) {
448
- const suggestions = suggestSimilar(name);
449
- let msg = `aether mcp install: unknown server "${name}".`;
450
- if (suggestions.length > 0) {
451
- msg += `\nDid you mean: ${suggestions.map((s) => c.cyan(s)).join(", ")}?`;
452
- } else {
453
- msg += `\nTry \`aether mcp search\` to browse the registry.`;
454
- }
455
- die(msg);
456
- }
457
- // Prompt for any required values (placeholders in args + env)
458
- const allRequired = [...(entry.requires ?? []), ...(entry.requiresEnv ?? [])];
459
- const values = {};
460
- if (allRequired.length > 0) {
461
- console.log(c.gray(`Installing ${c.cyan(entry.id)} — needs ${allRequired.length} input${allRequired.length === 1 ? "" : "s"}:`));
462
- for (const key of allRequired) {
463
- const promptText = entry.prompts?.[key] ?? key;
464
- // eslint-disable-next-line no-await-in-loop -- sequential prompts are intentional
465
- values[key] = await promptUser(` ${promptText}: `);
466
- if (!values[key]) die(`Cancelled — "${key}" is required.`);
467
- }
468
- }
469
- let resolved;
470
- try {
471
- resolved = resolveEntry(entry, values);
472
- } catch (e) {
473
- die(e.message);
474
- }
475
- try {
476
- const added = addServer({
477
- name: entry.id,
478
- command: resolved.command,
479
- args: resolved.args,
480
- env: resolved.env,
481
- });
482
- console.log(`${c.green("●")} Installed MCP server "${c.cyan(entry.id)}".`);
483
- console.log(c.gray(` ${added.command}${added.args ? " " + added.args.join(" ") : ""}`));
484
- console.log(c.gray("Restart aether (or run `aether`) to attach it."));
485
- } catch (e) {
486
- die(e.message || String(e));
487
- }
488
- return;
489
- }
490
-
491
- die(
492
- `aether mcp: unknown subcommand "${sub}".\n` +
493
- "Try one of: list, add, install, search, remove.",
494
- );
495
- }
496
-
497
- function promptUser(question) {
498
- if (!process.stdin.isTTY) {
499
- return Promise.reject(new Error("Interactive prompt unavailable (non-TTY)"));
500
- }
501
- return new Promise((resolve) => {
502
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
503
- rl.question(question, (answer) => {
504
- rl.close();
505
- resolve(answer.trim());
506
- });
507
- });
508
- }
509
-
510
- main().catch((err) => {
511
- console.error(errorLine(err.message || String(err)));
512
- if (process.env.DEBUG) console.error(err);
513
- process.exit(1);
514
- });
1
+ #!/usr/bin/env node
2
+ // aether-code — uncensored AI coding agent.
3
+ //
4
+ // Examples:
5
+ // aether-code "build me a TypeScript todo CLI in this folder"
6
+ // aether-code --yes "add JSDoc to every exported function in src/"
7
+ // aether-code --cwd ./my-project "fix the failing tests"
8
+ // aether-code --max-turns 40 "refactor the auth module to use bcrypt"
9
+
10
+ import process from "node:process";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import fs from "node:fs";
14
+ import { runAgent } from "../src/agent.js";
15
+ import { runRepl } from "../src/repl.js";
16
+ import { runSetup } from "../src/setup.js";
17
+ import { fetchBalance, AetherError } from "../src/api.js";
18
+ import { writeConfigFile, getConfig, CONFIG_PATH } from "../src/config.js";
19
+ import { loadMcpConfig, MCPManager } from "../src/mcp.js";
20
+ import { addServer, removeServer, listServers } from "../src/mcp-cli.js";
21
+ import {
22
+ MCP_REGISTRY,
23
+ findRegistryEntry,
24
+ resolveEntry,
25
+ searchRegistry,
26
+ suggestSimilar,
27
+ } from "../src/mcp-registry.js";
28
+ import readline from "node:readline";
29
+ import { c, errorLine, divider, setTerminalTitle } from "../src/render.js";
30
+ import { getVersion } from "../src/version.js";
31
+
32
+ const VERSION = getVersion();
33
+
34
+ /**
35
+ * Try to start MCP servers from ~/.aether/mcp.json. Returns a started
36
+ * MCPManager (possibly with zero servers) or null if no config exists.
37
+ * Prints a one-line summary so the user can see what attached.
38
+ */
39
+ async function bootMcp() {
40
+ let config;
41
+ try {
42
+ config = loadMcpConfig();
43
+ } catch (e) {
44
+ console.log(errorLine(`MCP config: ${e.message}`));
45
+ return null;
46
+ }
47
+ if (!config) return null;
48
+ const manager = new MCPManager();
49
+ const started = await manager.start(config);
50
+ const requested = Object.keys(config.mcpServers).length;
51
+ const failed = manager.startErrors.length;
52
+ if (started > 0 || failed > 0) {
53
+ const parts = [`${c.cyan("MCP")}`, `${started}/${requested} servers attached`];
54
+ const toolCount = manager.getToolDefinitions().length;
55
+ if (toolCount > 0) parts.push(`${toolCount} tools`);
56
+ console.log(c.gray(parts.join(" · ")));
57
+ for (const { serverName, error } of manager.startErrors) {
58
+ console.log(c.gray(` ${c.yellow("!")} ${serverName}: ${error}`));
59
+ }
60
+ }
61
+ // Best-effort cleanup so child processes don't leak on normal exit.
62
+ process.on("exit", () => { manager.shutdown().catch(() => {}); });
63
+ process.on("SIGINT", () => { manager.shutdown().catch(() => {}); process.exit(130); });
64
+ return manager;
65
+ }
66
+
67
+ const HELP = `${c.bold("aether")} — uncensored AI coding agent
68
+
69
+ ${c.bold("USAGE")}
70
+ aether Launch interactive REPL (Claude-CLI-style)
71
+ aether [flags] "<task>" Run agent once on a single task
72
+ aether <subcommand> [args] Run a utility subcommand
73
+
74
+ ${c.bold("SUBCOMMANDS")}
75
+ ${c.cyan("login")} Open browser, paste API key, save
76
+ ${c.cyan("logout")} Clear saved API key
77
+ ${c.cyan("balance")} Show plan + credit balance
78
+ ${c.cyan("config")} show|set|set-base|path Manage config file
79
+ ${c.cyan("mcp")} list|search|install|add|remove Manage MCP server connections
80
+
81
+ ${c.bold("EXAMPLES")}
82
+ aether # interactive REPL
83
+ aether login # first-time setup
84
+ aether balance # quick credit check
85
+ aether "build a TypeScript todo CLI in this folder"
86
+ aether --yes "add JSDoc to every exported function"
87
+ aether --cwd ./my-project "fix the failing tests"
88
+
89
+ ${c.bold("FLAGS")}
90
+ --review Confirm (y/N) before each write + shell command.
91
+ Default is auto-approve (skip-permissions).
92
+ --sandbox Restrict file access to --cwd. Default is full access.
93
+ --cwd <path> Working directory for the agent (default: current dir).
94
+ --model <name> core (default) | ultra (Opus). Premium models need
95
+ purchased credits, else they fall back to Core.
96
+ --max-turns <n> Maximum turns before stopping (default: 25).
97
+ --help, -h Show this help.
98
+ --version, -v Print version.
99
+
100
+ ${c.bold("CONFIG")}
101
+ Same config as aether-cli — uses ${c.cyan("AETHER_API_KEY")} or ${c.cyan("~/.aetherrc")}.
102
+ Get a key at ${c.blue("https://trynoguard.com/account")}.
103
+
104
+ ${c.bold("SAFETY")}
105
+ - By DEFAULT aether runs in skip-permissions mode (like Claude's
106
+ --dangerously-skip-permissions): it auto-approves file writes + shell
107
+ commands and can access your whole filesystem, so it just builds.
108
+ - Use ${c.cyan("--review")} to approve each action, ${c.cyan("--sandbox")} to clamp paths to --cwd.
109
+ - Each shell command has a 2-minute hard timeout.
110
+
111
+ ${c.gray(`v${VERSION}`)}
112
+ `;
113
+
114
+ function parseArgs(argv) {
115
+ const args = { _: [], flags: {} };
116
+ for (let i = 0; i < argv.length; i++) {
117
+ const a = argv[i];
118
+ if (a === "--yes") { args.flags.yes = true; }
119
+ else if (a === "--unsafe-paths") { args.flags.unsafePaths = true; }
120
+ else if (a === "--help" || a === "-h") { args.flags.help = true; }
121
+ else if (a === "--version" || a === "-v") { args.flags.version = true; }
122
+ else if (a === "--cwd") { args.flags.cwd = argv[++i]; }
123
+ else if (a === "--model") { args.flags.model = argv[++i]; }
124
+ else if (a === "--max-turns") { args.flags.maxTurns = parseInt(argv[++i], 10); }
125
+ else if (a.startsWith("--")) {
126
+ const eq = a.indexOf("=");
127
+ if (eq >= 0) args.flags[a.slice(2, eq)] = a.slice(eq + 1);
128
+ else args.flags[a.slice(2)] = true;
129
+ } else {
130
+ args._.push(a);
131
+ }
132
+ }
133
+ return args;
134
+ }
135
+
136
+ function die(msg, code = 1) {
137
+ process.stderr.write(errorLine(msg) + "\n");
138
+ process.exit(code);
139
+ }
140
+
141
+ // True for Windows system folders we should never write a user's project into.
142
+ function isSystemDir(p) {
143
+ const low = p.toLowerCase().replace(/[\\/]+$/, "").replace(/\//g, "\\");
144
+ return (
145
+ low.endsWith("\\windows\\system32") ||
146
+ low.endsWith("\\windows\\syswow64") ||
147
+ low.endsWith("\\windows") ||
148
+ low.endsWith("\\system32") ||
149
+ /\\program files( \(x86\))?$/.test(low)
150
+ );
151
+ }
152
+
153
+ // Always restore the terminal on exit. The raw-mode input box, the ask_user
154
+ // menu, and the spinner hide the cursor (\x1b[?25l) and set raw mode; if the
155
+ // process dies mid-prompt (an uncaught error, or the MCP SIGINT handler's
156
+ // process.exit(130)) their own cleanup never runs and the user's shell is left
157
+ // with no cursor and no echo. This idempotent backstop fires on every exit.
158
+ function restoreTerminal() {
159
+ try {
160
+ if (process.stdout.isTTY) process.stdout.write("\x1b[?25h"); // show cursor
161
+ if (process.stdin.isTTY && process.stdin.setRawMode) process.stdin.setRawMode(false);
162
+ } catch { /* terminal already gone — nothing to restore */ }
163
+ }
164
+ process.on("exit", restoreTerminal);
165
+
166
+ async function main() {
167
+ const args = parseArgs(process.argv.slice(2));
168
+ setTerminalTitle("Aether");
169
+
170
+ if (args.flags.help) {
171
+ process.stdout.write(HELP);
172
+ return;
173
+ }
174
+ if (args.flags.version) {
175
+ process.stdout.write(`aether-code ${VERSION}\n`);
176
+ return;
177
+ }
178
+
179
+ // Where files land. If the user didn't pass --cwd and we were launched from a
180
+ // Windows system folder (admin cmd opens in C:\WINDOWS\system32), don't dump
181
+ // their project there — they'd never find it. Work in the Desktop instead.
182
+ let cwd = args.flags.cwd ? path.resolve(args.flags.cwd) : process.cwd();
183
+ let redirectedFrom = null;
184
+ if (!args.flags.cwd && isSystemDir(cwd)) {
185
+ const desktop = path.join(os.homedir(), "Desktop");
186
+ const target = fs.existsSync(desktop) ? desktop : os.homedir();
187
+ if (target.toLowerCase() !== cwd.toLowerCase()) { redirectedFrom = cwd; cwd = target; }
188
+ }
189
+ // Skip-permissions by DEFAULT (like `claude --dangerously-skip-permissions`):
190
+ // auto-approve writes/shell and don't sandbox file paths, so the agent can
191
+ // just build. Opt back in with --review (y/N before each action) and
192
+ // --sandbox (clamp file access to --cwd).
193
+ const autoYes = !args.flags.review;
194
+ const unsafePaths = !args.flags.sandbox;
195
+ const maxTurns = Number.isInteger(args.flags.maxTurns) ? args.flags.maxTurns : 25;
196
+
197
+ // --model: core (default) or ultra (Opus). Premium models are gated
198
+ // server-side — without purchased credits the server uses Core regardless.
199
+ const MODEL_IDS = { core: null, gemma: null, ultra: "claude-opus-4-6", opus: "claude-opus-4-6" };
200
+ const modelKey = args.flags.model ? String(args.flags.model).toLowerCase() : null;
201
+ const model = modelKey && modelKey in MODEL_IDS ? MODEL_IDS[modelKey] : (args.flags.model || null);
202
+
203
+ // Subcommand routing — these shadow the "task as positional arg" mode
204
+ const sub = args._[0]?.toLowerCase();
205
+ if (sub === "login" || sub === "auth") {
206
+ const ok = await runSetup();
207
+ process.exit(ok ? 0 : 1);
208
+ }
209
+ if (sub === "logout") {
210
+ writeConfigFile({ apiKey: "" });
211
+ console.log(c.gray(`Cleared API key from ${CONFIG_PATH}.`));
212
+ return;
213
+ }
214
+ if (sub === "config") {
215
+ await handleConfig(args._.slice(1));
216
+ return;
217
+ }
218
+ if (sub === "balance") {
219
+ await handleBalance();
220
+ return;
221
+ }
222
+ if (sub === "mcp") {
223
+ await handleMcp(args._.slice(1));
224
+ return;
225
+ }
226
+
227
+ const prompt = args._.join(" ").trim();
228
+
229
+ // No task → drop into interactive REPL (Claude-CLI-style)
230
+ if (!prompt) {
231
+ if (cwd !== process.cwd()) process.chdir(cwd);
232
+ const mcpManager = await bootMcp();
233
+ if (redirectedFrom) {
234
+ console.log(c.gray(`Note: launched from a system folder (${redirectedFrom}).`));
235
+ console.log(c.gray(`Your files will be saved to ${c.cyan(cwd)} so you can find them. Use ${c.cyan("--cwd <path>")} or ${c.cyan("/cwd")} to change.`));
236
+ }
237
+ await runRepl({ cwd, autoYes, unsafePaths, maxTurns, model, mcpManager });
238
+ return;
239
+ }
240
+
241
+ // One-shot mode also needs an API key. If missing, run setup before the task.
242
+ const cfg = getConfig();
243
+ if (!cfg.apiKey) {
244
+ const ok = await runSetup();
245
+ if (!ok) process.exit(1);
246
+ }
247
+
248
+ console.log(divider());
249
+ const modeLabel = autoYes && unsafePaths ? " · skip-permissions" : `${autoYes ? " · auto-yes" : " · review"}${unsafePaths ? "" : " · sandboxed"}`;
250
+ console.log(c.magenta(c.bold("aether-code")) + c.gray(` · cwd ${cwd}${modeLabel}`));
251
+ if (redirectedFrom) console.log(c.gray(`(launched from ${redirectedFrom} — saving to ${cwd} instead)`));
252
+ console.log(c.gray(`task: `) + prompt);
253
+ console.log(divider());
254
+
255
+ const mcpManager = await bootMcp();
256
+ const result = await runAgent({
257
+ initialPrompt: prompt,
258
+ cwd,
259
+ autoYes,
260
+ unsafePaths,
261
+ maxTurns,
262
+ model,
263
+ mcpManager,
264
+ });
265
+ if (mcpManager) await mcpManager.shutdown().catch(() => {});
266
+
267
+ console.log("\n" + divider());
268
+ if (result.ok) {
269
+ console.log(c.green(c.bold("● Done")) + c.gray(` ${result.turns} turn${result.turns === 1 ? "" : "s"} · ${result.totalCredits} credits · ${result.totalIn}→${result.totalOut} tokens`));
270
+ if (typeof result.balance === "number") {
271
+ console.log(c.gray(` balance: ${result.balance.toLocaleString()} credits`));
272
+ }
273
+ } else {
274
+ console.log(c.red(c.bold("× Stopped")) + c.gray(` ${result.totalCredits} credits used · ${result.totalIn}→${result.totalOut} tokens`));
275
+ if (result.error) console.log(errorLine(result.error.message));
276
+ }
277
+ console.log(divider());
278
+ }
279
+
280
+ async function handleConfig(rest) {
281
+ const sub = (rest[0] || "").toLowerCase();
282
+ if (sub === "show" || !sub) {
283
+ const cfg = getConfig();
284
+ console.log(`Config file: ${cfg.configPath}`);
285
+ console.log(`API key: ${cfg.apiKey ? cfg.apiKey.slice(0, 12) + "…" + cfg.apiKey.slice(-4) : c.gray("(none)")}`);
286
+ console.log(`Base URL: ${cfg.baseUrl}`);
287
+ console.log(`Source: ${process.env.AETHER_API_KEY ? "AETHER_API_KEY env" : "config file"}`);
288
+ return;
289
+ }
290
+ if (sub === "set") {
291
+ const key = rest[1];
292
+ if (!key) die("config set: missing API key argument.");
293
+ if (!key.startsWith("ak_live_")) {
294
+ process.stderr.write(c.yellow("warning: keys normally start with ak_live_; saving anyway.\n"));
295
+ }
296
+ writeConfigFile({ apiKey: key });
297
+ console.log(`${c.green("●")} API key saved to ${CONFIG_PATH}`);
298
+ return;
299
+ }
300
+ if (sub === "set-base") {
301
+ const url = rest[1];
302
+ if (!url) die("config set-base: missing URL argument.");
303
+ writeConfigFile({ baseUrl: url });
304
+ console.log(`${c.green("●")} Base URL saved.`);
305
+ return;
306
+ }
307
+ if (sub === "path") {
308
+ console.log(CONFIG_PATH);
309
+ return;
310
+ }
311
+ die(`config: unknown subcommand "${sub}". Try 'config show', 'config set <key>', 'config path'.`);
312
+ }
313
+
314
+ async function handleBalance() {
315
+ try {
316
+ const me = await fetchBalance();
317
+ console.log(c.bold(c.magenta("Aether")));
318
+ console.log(c.gray("─".repeat(50)));
319
+ console.log(`Plan ${c.cyan(me.plan)}`);
320
+ console.log(`Balance ${c.bold(me.balance.toLocaleString())} credits`);
321
+ console.log(` plan ${me.planCredits.toLocaleString()}`);
322
+ console.log(` topup ${me.topupCredits.toLocaleString()}`);
323
+ if (me.isSuspended) console.log(c.red("\n⚠ Account is suspended."));
324
+ } catch (err) {
325
+ if (err instanceof AetherError && err.code === "NO_API_KEY") {
326
+ console.log(errorLine("No API key. Run `aether login` first."));
327
+ } else {
328
+ die(err.message || String(err));
329
+ }
330
+ }
331
+ }
332
+
333
+ async function handleMcp(rest) {
334
+ const sub = (rest[0] || "list").toLowerCase();
335
+
336
+ if (sub === "list" || sub === "ls") {
337
+ const servers = listServers();
338
+ if (servers.length === 0) {
339
+ console.log(c.gray("No MCP servers configured."));
340
+ console.log(c.gray("Add one with:"));
341
+ console.log(c.gray(" aether mcp add <name> -- <command> [args...]"));
342
+ console.log(c.gray("Example:"));
343
+ console.log(
344
+ c.gray(' aether mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /path'),
345
+ );
346
+ return;
347
+ }
348
+ console.log(c.bold(`Configured MCP servers (${servers.length}):`));
349
+ for (const [name, cfg] of servers) {
350
+ const argsStr = cfg.args && cfg.args.length > 0 ? " " + cfg.args.join(" ") : "";
351
+ console.log(` ${c.cyan(name)}: ${cfg.command}${argsStr}`);
352
+ if (cfg.env && Object.keys(cfg.env).length > 0) {
353
+ for (const [k, v] of Object.entries(cfg.env)) {
354
+ console.log(c.gray(` env ${k}=${v}`));
355
+ }
356
+ }
357
+ }
358
+ return;
359
+ }
360
+
361
+ if (sub === "add") {
362
+ // Syntax: aether mcp add <name> [--env KEY=VAL]... -- <command> [args...]
363
+ const tail = rest.slice(1);
364
+ const dashIdx = tail.indexOf("--");
365
+ if (dashIdx === -1) {
366
+ die(
367
+ 'Usage: aether mcp add <name> [--env KEY=VAL]... -- <command> [args...]\n' +
368
+ 'Example: aether mcp add fs -- npx -y @modelcontextprotocol/server-filesystem /tmp',
369
+ );
370
+ }
371
+ const pre = tail.slice(0, dashIdx);
372
+ const post = tail.slice(dashIdx + 1);
373
+ const name = pre[0];
374
+ if (!name) die("aether mcp add: missing <name>");
375
+ if (post.length === 0) die("aether mcp add: missing <command> after '--'");
376
+
377
+ const env = {};
378
+ for (let i = 1; i < pre.length; i++) {
379
+ if (pre[i] === "--env") {
380
+ const kv = pre[++i];
381
+ if (!kv) die("--env needs a KEY=VAL argument");
382
+ const eq = kv.indexOf("=");
383
+ if (eq <= 0) die(`--env value must be KEY=VAL, got: ${kv}`);
384
+ env[kv.slice(0, eq)] = kv.slice(eq + 1);
385
+ } else {
386
+ die(`aether mcp add: unrecognized option "${pre[i]}" before the '--' separator`);
387
+ }
388
+ }
389
+
390
+ const command = post[0];
391
+ const cmdArgs = post.slice(1);
392
+ try {
393
+ const entry = addServer({ name, command, args: cmdArgs, env });
394
+ console.log(`${c.green("●")} Added MCP server "${c.cyan(name)}".`);
395
+ const argsStr = entry.args && entry.args.length > 0 ? " " + entry.args.join(" ") : "";
396
+ console.log(c.gray(` ${entry.command}${argsStr}`));
397
+ console.log(c.gray("Restart the agent (or run `aether`) to attach it."));
398
+ } catch (e) {
399
+ die(e.message || String(e));
400
+ }
401
+ return;
402
+ }
403
+
404
+ if (sub === "remove" || sub === "rm" || sub === "delete") {
405
+ const name = rest[1];
406
+ if (!name) die("aether mcp remove: missing <name>");
407
+ try {
408
+ removeServer({ name });
409
+ console.log(`${c.green("●")} Removed MCP server "${c.cyan(name)}".`);
410
+ } catch (e) {
411
+ die(e.message || String(e));
412
+ }
413
+ return;
414
+ }
415
+
416
+ if (sub === "search" || sub === "find") {
417
+ const query = rest.slice(1).join(" ").trim();
418
+ const results = searchRegistry(query);
419
+ if (results.length === 0) {
420
+ console.log(c.gray(`No MCP servers in the registry match "${query}".`));
421
+ console.log(c.gray("Browse the full list: ") + c.cyan("aether mcp search"));
422
+ return;
423
+ }
424
+ console.log(
425
+ c.bold(query ? `MCP servers matching "${query}":` : `Available MCP servers (${results.length}):`),
426
+ );
427
+ for (const e of results) {
428
+ const sourceTag = e.source === "official"
429
+ ? c.gray("(official)")
430
+ : c.yellow("(community)");
431
+ console.log(` ${c.cyan(e.id.padEnd(16))} ${sourceTag} ${e.description}`);
432
+ }
433
+ console.log("");
434
+ console.log(c.gray("Install one with: ") + c.cyan("aether mcp install <name>"));
435
+ return;
436
+ }
437
+
438
+ if (sub === "install" || sub === "get") {
439
+ const name = rest[1];
440
+ if (!name) {
441
+ die(
442
+ "aether mcp install: missing <name>.\n" +
443
+ "Try `aether mcp search` to see what's available.",
444
+ );
445
+ }
446
+ const entry = findRegistryEntry(name);
447
+ if (!entry) {
448
+ const suggestions = suggestSimilar(name);
449
+ let msg = `aether mcp install: unknown server "${name}".`;
450
+ if (suggestions.length > 0) {
451
+ msg += `\nDid you mean: ${suggestions.map((s) => c.cyan(s)).join(", ")}?`;
452
+ } else {
453
+ msg += `\nTry \`aether mcp search\` to browse the registry.`;
454
+ }
455
+ die(msg);
456
+ }
457
+ // Prompt for any required values (placeholders in args + env)
458
+ const allRequired = [...(entry.requires ?? []), ...(entry.requiresEnv ?? [])];
459
+ const values = {};
460
+ if (allRequired.length > 0) {
461
+ console.log(c.gray(`Installing ${c.cyan(entry.id)} — needs ${allRequired.length} input${allRequired.length === 1 ? "" : "s"}:`));
462
+ for (const key of allRequired) {
463
+ const promptText = entry.prompts?.[key] ?? key;
464
+ // eslint-disable-next-line no-await-in-loop -- sequential prompts are intentional
465
+ values[key] = await promptUser(` ${promptText}: `);
466
+ if (!values[key]) die(`Cancelled — "${key}" is required.`);
467
+ }
468
+ }
469
+ let resolved;
470
+ try {
471
+ resolved = resolveEntry(entry, values);
472
+ } catch (e) {
473
+ die(e.message);
474
+ }
475
+ try {
476
+ const added = addServer({
477
+ name: entry.id,
478
+ command: resolved.command,
479
+ args: resolved.args,
480
+ env: resolved.env,
481
+ });
482
+ console.log(`${c.green("●")} Installed MCP server "${c.cyan(entry.id)}".`);
483
+ console.log(c.gray(` ${added.command}${added.args ? " " + added.args.join(" ") : ""}`));
484
+ console.log(c.gray("Restart aether (or run `aether`) to attach it."));
485
+ } catch (e) {
486
+ die(e.message || String(e));
487
+ }
488
+ return;
489
+ }
490
+
491
+ die(
492
+ `aether mcp: unknown subcommand "${sub}".\n` +
493
+ "Try one of: list, add, install, search, remove.",
494
+ );
495
+ }
496
+
497
+ function promptUser(question) {
498
+ if (!process.stdin.isTTY) {
499
+ return Promise.reject(new Error("Interactive prompt unavailable (non-TTY)"));
500
+ }
501
+ return new Promise((resolve) => {
502
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
503
+ rl.question(question, (answer) => {
504
+ rl.close();
505
+ resolve(answer.trim());
506
+ });
507
+ });
508
+ }
509
+
510
+ main().catch((err) => {
511
+ console.error(errorLine(err.message || String(err)));
512
+ if (process.env.DEBUG) console.error(err);
513
+ process.exit(1);
514
+ });