aether-code 0.6.2 → 0.9.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/src/mcp.js ADDED
@@ -0,0 +1,259 @@
1
+ // MCP (Model Context Protocol) client manager.
2
+ //
3
+ // Lets aether-code consume any MCP server as agent tools. Users configure
4
+ // servers in ~/.aether/mcp.json (mirror of Claude Code's pattern):
5
+ //
6
+ // {
7
+ // "mcpServers": {
8
+ // "filesystem": {
9
+ // "command": "npx",
10
+ // "args": ["-y", "@modelcontextprotocol/server-filesystem", "/some/path"]
11
+ // },
12
+ // "ida": {
13
+ // "command": "python",
14
+ // "args": ["-m", "ida_pro_mcp"],
15
+ // "env": { "IDA_PATH": "/opt/ida" }
16
+ // }
17
+ // }
18
+ // }
19
+ //
20
+ // Each server's tools are exposed to the model namespaced as
21
+ // `mcp__<serverName>__<toolName>` so a `filesystem` server's `read_file`
22
+ // doesn't collide with our built-in `read_file`. The manager handles the
23
+ // JSON-RPC handshake, tool discovery, call routing, and cleanup on exit.
24
+ //
25
+ // Failure model is fail-soft per server: if `ida` fails to start (binary
26
+ // missing, init handshake errors out), we log it and continue with the
27
+ // servers that DID start. One bad server should not break the whole CLI.
28
+
29
+ import fs from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
34
+
35
+ const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".aether", "mcp.json");
36
+ const NAMESPACE_SEP = "__";
37
+ const NAMESPACE_PREFIX = "mcp" + NAMESPACE_SEP;
38
+ const INIT_TIMEOUT_MS = 10_000;
39
+ const CALL_TIMEOUT_MS = 60_000;
40
+
41
+ /**
42
+ * Read + validate the MCP config file. Returns the parsed config or null if
43
+ * the file is missing (which is the normal case for users who don't use MCP).
44
+ * Throws on malformed JSON or schema violations so the user sees the error
45
+ * immediately instead of silently running without their servers.
46
+ */
47
+ export function loadMcpConfig(configPath) {
48
+ const p = configPath || DEFAULT_CONFIG_PATH;
49
+ if (!fs.existsSync(p)) return null;
50
+ let raw;
51
+ try {
52
+ raw = fs.readFileSync(p, "utf8");
53
+ } catch (e) {
54
+ throw new Error(`MCP config exists at ${p} but isn't readable: ${e.message}`);
55
+ }
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(raw);
59
+ } catch (e) {
60
+ throw new Error(`MCP config at ${p} is not valid JSON: ${e.message}`);
61
+ }
62
+ return validateMcpConfig(parsed, p);
63
+ }
64
+
65
+ export function validateMcpConfig(parsed, sourcePath = "(inline)") {
66
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
67
+ throw new Error(`MCP config at ${sourcePath} must be a JSON object`);
68
+ }
69
+ const servers = parsed.mcpServers;
70
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
71
+ throw new Error(`MCP config at ${sourcePath} needs an "mcpServers" object`);
72
+ }
73
+ for (const [name, cfg] of Object.entries(servers)) {
74
+ if (!/^[a-z0-9_-]{1,40}$/i.test(name)) {
75
+ throw new Error(
76
+ `MCP server name "${name}" invalid — must be 1-40 chars of [A-Za-z0-9_-]. Used in tool namespacing.`,
77
+ );
78
+ }
79
+ if (!cfg || typeof cfg !== "object") {
80
+ throw new Error(`MCP server "${name}" entry must be an object`);
81
+ }
82
+ if (typeof cfg.command !== "string" || cfg.command.length === 0) {
83
+ throw new Error(`MCP server "${name}" needs a non-empty "command" string`);
84
+ }
85
+ if (cfg.args !== undefined && !Array.isArray(cfg.args)) {
86
+ throw new Error(`MCP server "${name}".args must be an array of strings`);
87
+ }
88
+ if (Array.isArray(cfg.args) && cfg.args.some((a) => typeof a !== "string")) {
89
+ throw new Error(`MCP server "${name}".args must be an array of STRINGS`);
90
+ }
91
+ if (cfg.env !== undefined && (typeof cfg.env !== "object" || Array.isArray(cfg.env))) {
92
+ throw new Error(`MCP server "${name}".env must be an object {KEY: value}`);
93
+ }
94
+ }
95
+ return parsed;
96
+ }
97
+
98
+ /**
99
+ * Build the `mcp__<server>__<tool>` namespaced tool name. Defense against
100
+ * underscores in the user-defined server name accidentally collapsing the
101
+ * boundary (config validation already rejects those, but we double-check
102
+ * here since this string is what the model sees).
103
+ */
104
+ export function namespaceToolName(serverName, toolName) {
105
+ if (serverName.includes(NAMESPACE_SEP) || toolName.includes(NAMESPACE_SEP)) {
106
+ // Replace with a single underscore so the boundary stays unambiguous.
107
+ return (
108
+ NAMESPACE_PREFIX +
109
+ serverName.replaceAll(NAMESPACE_SEP, "_") +
110
+ NAMESPACE_SEP +
111
+ toolName.replaceAll(NAMESPACE_SEP, "_")
112
+ );
113
+ }
114
+ return NAMESPACE_PREFIX + serverName + NAMESPACE_SEP + toolName;
115
+ }
116
+
117
+ /**
118
+ * Inverse of namespaceToolName: split a `mcp__server__tool` name back into
119
+ * its parts. Returns null if the name doesn't follow the convention (i.e.
120
+ * it's a built-in tool, not an MCP one).
121
+ */
122
+ export function unnamespaceToolName(namespaced) {
123
+ if (!namespaced.startsWith(NAMESPACE_PREFIX)) return null;
124
+ const rest = namespaced.slice(NAMESPACE_PREFIX.length);
125
+ const sepIdx = rest.indexOf(NAMESPACE_SEP);
126
+ if (sepIdx <= 0 || sepIdx >= rest.length - NAMESPACE_SEP.length) return null;
127
+ return {
128
+ serverName: rest.slice(0, sepIdx),
129
+ toolName: rest.slice(sepIdx + NAMESPACE_SEP.length),
130
+ };
131
+ }
132
+
133
+ export class MCPManager {
134
+ constructor() {
135
+ this.servers = new Map(); // name -> { client, transport, tools: [...] }
136
+ this.startErrors = []; // [{ serverName, error }]
137
+ }
138
+
139
+ /**
140
+ * Start every server in the config. Failures are collected (in
141
+ * `startErrors`) rather than thrown so one bad server doesn't kill the
142
+ * CLI. Returns the count of successfully-started servers.
143
+ */
144
+ async start(config) {
145
+ if (!config || !config.mcpServers) return 0;
146
+ const entries = Object.entries(config.mcpServers);
147
+ await Promise.allSettled(
148
+ entries.map(async ([name, cfg]) => {
149
+ try {
150
+ await this.#startOne(name, cfg);
151
+ } catch (e) {
152
+ this.startErrors.push({ serverName: name, error: e.message || String(e) });
153
+ }
154
+ }),
155
+ );
156
+ return this.servers.size;
157
+ }
158
+
159
+ async #startOne(name, cfg) {
160
+ const transport = new StdioClientTransport({
161
+ command: cfg.command,
162
+ args: cfg.args ?? [],
163
+ env: { ...process.env, ...(cfg.env ?? {}) },
164
+ });
165
+ const client = new Client(
166
+ { name: "aether-code", version: "0.9.0" },
167
+ { capabilities: {} },
168
+ );
169
+ // Bound the init handshake so a hung server doesn't stall startup.
170
+ await withTimeout(client.connect(transport), INIT_TIMEOUT_MS, `MCP server "${name}" init`);
171
+ const toolList = await withTimeout(client.listTools(), INIT_TIMEOUT_MS, `MCP server "${name}" listTools`);
172
+ const tools = (toolList?.tools ?? []).map((t) => ({
173
+ originalName: t.name,
174
+ namespacedName: namespaceToolName(name, t.name),
175
+ description: t.description ?? "",
176
+ inputSchema: t.inputSchema ?? { type: "object", properties: {} },
177
+ }));
178
+ this.servers.set(name, { client, transport, tools });
179
+ }
180
+
181
+ /**
182
+ * Returns OpenAI-format tool definitions for every connected MCP tool,
183
+ * ready to merge into TOOL_DEFINITIONS for the agent loop.
184
+ */
185
+ getToolDefinitions() {
186
+ const out = [];
187
+ for (const { tools } of this.servers.values()) {
188
+ for (const t of tools) {
189
+ out.push({
190
+ type: "function",
191
+ function: {
192
+ name: t.namespacedName,
193
+ description: t.description || `(MCP tool ${t.originalName})`,
194
+ parameters: t.inputSchema,
195
+ },
196
+ });
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * Resolve a namespaced tool name to the right server + original name.
204
+ * Returns null if the name isn't an MCP tool or no server matches.
205
+ */
206
+ resolve(namespacedName) {
207
+ const split = unnamespaceToolName(namespacedName);
208
+ if (!split) return null;
209
+ const server = this.servers.get(split.serverName);
210
+ if (!server) return null;
211
+ return { server, serverName: split.serverName, toolName: split.toolName };
212
+ }
213
+
214
+ /**
215
+ * Invoke a namespaced MCP tool. Returns { ok, output } matching the
216
+ * shape executeTool() uses for built-in tools.
217
+ */
218
+ async callTool(namespacedName, args) {
219
+ const resolved = this.resolve(namespacedName);
220
+ if (!resolved) {
221
+ return { ok: false, output: `Unknown MCP tool: ${namespacedName}` };
222
+ }
223
+ try {
224
+ const result = await withTimeout(
225
+ resolved.server.client.callTool({
226
+ name: resolved.toolName,
227
+ arguments: args,
228
+ }),
229
+ CALL_TIMEOUT_MS,
230
+ `MCP tool ${namespacedName}`,
231
+ );
232
+ // MCP tool results are { content: [{type, text|data, ...}], isError? }
233
+ const textParts = (result?.content ?? [])
234
+ .filter((c) => c.type === "text")
235
+ .map((c) => c.text);
236
+ const text = textParts.join("\n") || JSON.stringify(result, null, 2);
237
+ return { ok: !result?.isError, output: text };
238
+ } catch (e) {
239
+ return { ok: false, output: `MCP call ${namespacedName} failed: ${e.message}` };
240
+ }
241
+ }
242
+
243
+ async shutdown() {
244
+ const tasks = [];
245
+ for (const { client } of this.servers.values()) {
246
+ tasks.push(client.close().catch(() => {}));
247
+ }
248
+ await Promise.allSettled(tasks);
249
+ this.servers.clear();
250
+ }
251
+ }
252
+
253
+ function withTimeout(promise, ms, label) {
254
+ let timer;
255
+ const timeout = new Promise((_, reject) => {
256
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
257
+ });
258
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
259
+ }
package/src/render.js CHANGED
@@ -1,198 +1,58 @@
1
- // ANSI helpers — no chalk dependency. Designed to feel like Claude Code:
2
- // bordered banner, indented tool calls, structured tool-result rendering.
3
-
4
- const isTty = process.stdout.isTTY;
5
- const noColor = !!process.env.NO_COLOR || !isTty;
6
-
7
- function wrap(open, close) {
8
- return (s) => (noColor ? String(s) : `\x1b[${open}m${s}\x1b[${close}m`);
9
- }
10
-
11
- export const c = {
12
- bold: wrap(1, 22),
13
- dim: wrap(2, 22),
14
- italic: wrap(3, 23),
15
- red: wrap(31, 39),
16
- green: wrap(32, 39),
17
- yellow: wrap(33, 39),
18
- blue: wrap(34, 39),
19
- magenta: wrap(35, 39),
20
- cyan: wrap(36, 39),
21
- gray: wrap(90, 39),
22
- white: wrap(37, 39),
23
- };
24
-
25
- const W = () => Math.min(process.stdout.columns || 80, 90);
26
-
27
- export function hr() {
28
- return c.gray("─".repeat(W()));
29
- }
30
-
31
- export function divider() {
32
- return hr();
33
- }
34
-
35
- /* ─── Banner — Claude-Code-style box ─── */
36
-
37
- export function banner({ version, model, mode, balance, cwd }) {
38
- const w = Math.min(W(), 78);
39
- const top = c.gray("" + "─".repeat(w - 2) + "╮");
40
- const bot = c.gray("╰" + "─".repeat(w - 2) + "╯");
41
- const pad = (line) => {
42
- // Strip ANSI for length calc
43
- const visible = line.replace(/\x1b\[[0-9;]*m/g, "");
44
- const space = Math.max(0, w - 2 - visible.length - 2);
45
- return c.gray("│ ") + line + " ".repeat(space) + c.gray(" │");
46
- };
47
- const blank = pad("");
48
- const title = pad(`${c.magenta(c.bold("✻ aether-code"))} ${c.gray("v" + version)}`);
49
- const sub = pad(`${c.gray(model + " · uncensored · " + mode + (balance != null ? " · " + balance.toLocaleString() + " credits" : ""))}`);
50
- const cwdLine = pad(c.gray("📁 " + truncatePath(cwd, w - 6)));
51
- const tip = pad(c.gray("Type ") + c.cyan("/help") + c.gray(" for shortcuts. ") + c.cyan("/exit") + c.gray(" or Ctrl+C twice to quit."));
52
- return [top, blank, title, sub, cwdLine, blank, tip, bot].join("\n");
53
- }
54
-
55
- function truncatePath(p, max) {
56
- if (!p) return "";
57
- if (p.length <= max) return p;
58
- return "…" + p.slice(-(max - 1));
59
- }
60
-
61
- /* ─── Turn / cost line ─── */
62
-
63
- export function turnLine(n) {
64
- return c.dim("─── turn " + n + " " + "─".repeat(Math.max(0, W() - 12 - String(n).length)));
65
- }
66
-
67
- export function costLine({ creditsCharged, inputTokens, outputTokens, finishReason }) {
68
- const parts = [
69
- `${creditsCharged} cr`,
70
- `${inputTokens}→${outputTokens} tok`,
71
- finishReason ? `finish: ${finishReason}` : null,
72
- ].filter(Boolean);
73
- return " " + c.dim(parts.join(" · "));
74
- }
75
-
76
- /* ─── Assistant text ─── */
77
-
78
- export function assistantPrefix() {
79
- return c.magenta("● ") + c.bold(c.magenta("Aether")) + c.gray(":");
80
- }
81
-
82
- /* ─── Tool calls — friendly labels instead of raw JSON ─── */
83
-
84
- export function toolAnnounce(name, args) {
85
- const label = TOOL_LABELS[name] ? TOOL_LABELS[name](args) : `${name}(${jsonPreview(args)})`;
86
- return c.cyan(" ⚡ ") + c.bold(c.cyan(name)) + c.gray(" ") + c.gray(label);
87
- }
88
-
89
- const TOOL_LABELS = {
90
- read_file: (a) => `→ ${a.path || "?"}`,
91
- list_dir: (a) => `→ ${a.path || "."}`,
92
- search_files: (a) => `→ ${a.pattern || "?"} in ${a.path || "."}`,
93
- write_file: (a) => `→ ${a.path || "?"} ${c.gray("(" + (a.content?.length || 0) + " bytes)")}`,
94
- edit_file: (a) => `→ ${a.path || "?"}`,
95
- run_shell: (a) => `${c.yellow("$")} ${a.command || "?"}` + (a.cwd && a.cwd !== "." ? c.gray(` (cwd: ${a.cwd})`) : ""),
96
- };
97
-
98
- function jsonPreview(obj) {
99
- const s = JSON.stringify(obj);
100
- return s.length > 80 ? s.slice(0, 77) + "..." : s;
101
- }
102
-
103
- /* ─── Tool results — structured render per tool ─── */
104
-
105
- export function toolResultLine(name, result) {
106
- const icon = result.ok ? c.green(" ✓") : c.red(" ✗");
107
- const summary = summarizeResult(name, result);
108
- return `${icon} ${c.dim(summary)}`;
109
- }
110
-
111
- function summarizeResult(name, result) {
112
- if (!result.ok) {
113
- // Try to extract a useful one-liner from error JSON
114
- let txt = result.output || "";
115
- try {
116
- const j = JSON.parse(txt);
117
- if (j.exit_code !== undefined) {
118
- // Shell error
119
- const stderr = (j.stderr || "").trim().split("\n")[0] || "(no stderr)";
120
- return `exit ${j.exit_code} — ${stderr}`;
121
- }
122
- } catch { /* not JSON */ }
123
- const firstLine = txt.split("\n")[0];
124
- return firstLine.length > 100 ? firstLine.slice(0, 97) + "..." : firstLine;
125
- }
126
- // Success — short summary by tool type
127
- const out = result.output || "";
128
- if (name === "read_file") {
129
- const lines = out.split("\n").length;
130
- return `read ${out.length} bytes (${lines} line${lines === 1 ? "" : "s"})`;
131
- }
132
- if (name === "list_dir") {
133
- try {
134
- const items = JSON.parse(out);
135
- return `${items.length} entr${items.length === 1 ? "y" : "ies"}`;
136
- } catch {
137
- return "ok";
138
- }
139
- }
140
- if (name === "search_files") {
141
- try {
142
- const matches = JSON.parse(out);
143
- return `${matches.length} match${matches.length === 1 ? "" : "es"}`;
144
- } catch {
145
- return "ok";
146
- }
147
- }
148
- if (name === "write_file" || name === "edit_file") {
149
- return out.split("\n")[0];
150
- }
151
- if (name === "run_shell") {
152
- try {
153
- const j = JSON.parse(out);
154
- const stdoutLines = (j.stdout || "").split("\n").filter(Boolean).length;
155
- return `exit ${j.exit_code} · ${stdoutLines} stdout line${stdoutLines === 1 ? "" : "s"}`;
156
- } catch {
157
- return "ok";
158
- }
159
- }
160
- return out.split("\n")[0].slice(0, 80);
161
- }
162
-
163
- /* ─── Status line at end of turn ─── */
164
-
165
- export function statusLine({ sessionCredits, sessionIn, sessionOut, balance, history, mode }) {
166
- const parts = [];
167
- parts.push(`session: ${sessionCredits} cr · ${sessionIn}→${sessionOut} tok`);
168
- if (balance != null) parts.push(`balance: ${balance.toLocaleString()}`);
169
- if (history > 0) parts.push(`history: ${history} msg${history === 1 ? "" : "s"}`);
170
- parts.push(c.cyan(mode));
171
- return c.dim(parts.join(" · "));
172
- }
173
-
174
- /* ─── Errors ─── */
175
-
176
- export function errorLine(msg) {
177
- return `${c.red(c.bold("✗ Error:"))} ${msg}`;
178
- }
179
-
180
- export function warnLine(msg) {
181
- return `${c.yellow("⚠ ")} ${msg}`;
182
- }
183
-
184
- export function note(msg) {
185
- return c.dim(msg);
186
- }
187
-
188
- /* ─── Confirmation prompt rendering ─── */
189
-
190
- export function confirmPrompt(label) {
191
- return c.yellow(" ? ") + c.bold(label) + c.gray(" [y/N] ");
192
- }
193
-
194
- /* ─── Spinner / pending indicator (no animation, just text) ─── */
195
-
196
- export function pending(msg) {
197
- return c.gray(" ⋯ " + msg);
198
- }
1
+ // ANSI helpers — no chalk dependency.
2
+
3
+ const isTty = process.stdout.isTTY;
4
+ const noColor = !!process.env.NO_COLOR || !isTty;
5
+
6
+ function wrap(open, close) {
7
+ return (s) => (noColor ? String(s) : `\x1b[${open}m${s}\x1b[${close}m`);
8
+ }
9
+
10
+ export const c = {
11
+ bold: wrap(1, 22),
12
+ dim: wrap(2, 22),
13
+ red: wrap(31, 39),
14
+ green: wrap(32, 39),
15
+ yellow: wrap(33, 39),
16
+ blue: wrap(34, 39),
17
+ magenta: wrap(35, 39),
18
+ cyan: wrap(36, 39),
19
+ gray: wrap(90, 39),
20
+ };
21
+
22
+ export function divider() {
23
+ const w = process.stdout.columns || 60;
24
+ return c.gray("─".repeat(Math.min(60, w)));
25
+ }
26
+
27
+ export function turn(n) {
28
+ return c.gray(`turn ${n}`);
29
+ }
30
+
31
+ export function toolHeader(name, args) {
32
+ // Format args compactly. If any value is huge, truncate it.
33
+ const compact = JSON.stringify(args);
34
+ const trimmed = compact.length > 120 ? compact.slice(0, 117) + "..." : compact;
35
+ return `${c.cyan(c.bold(name))}${c.gray("(")}${c.gray(trimmed)}${c.gray(")")}`;
36
+ }
37
+
38
+ export function toolResult(text, ok = true) {
39
+ const prefix = ok ? c.green("") : c.red(" ✗ ");
40
+ // First line bold-ish, then dim continuation
41
+ const lines = text.split("\n");
42
+ const head = lines[0].slice(0, 200);
43
+ const rest = lines.slice(1, 6).join("\n").slice(0, 600);
44
+ return `${prefix}${head}${rest ? "\n" + c.dim(rest) : ""}`;
45
+ }
46
+
47
+ export function assistant(text) {
48
+ // Indent each line for visual separation from tool calls
49
+ return text.split("\n").map((l) => ` ${l}`).join("\n");
50
+ }
51
+
52
+ export function errorLine(msg) {
53
+ return `${c.red(c.bold("Error:"))} ${msg}`;
54
+ }
55
+
56
+ export function note(msg) {
57
+ return c.dim(msg);
58
+ }