aether-code 0.11.1 → 0.12.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/README.md CHANGED
@@ -78,12 +78,27 @@ aether balance # check credits
78
78
  Configure servers with one command — no JSON editing:
79
79
 
80
80
  ```bash
81
- aether mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /data
81
+ # Browse the curated registry
82
+ aether mcp search browser
83
+ aether mcp search # everything available
84
+
85
+ # One-command install for well-known servers (interactive prompts for any required values)
86
+ aether mcp install filesystem # prompts for the path
87
+ aether mcp install playwright # no inputs needed — just installs
88
+ aether mcp install github # prompts for your GitHub token
89
+ aether mcp install postgres # prompts for connection string
90
+
91
+ # Manual install for anything else (e.g. local Python servers like IDA Pro)
82
92
  aether mcp add ida --env IDA_PATH=/opt/ida -- python -m ida_pro_mcp
83
- aether mcp add playwright -- npx -y @playwright/mcp
93
+ aether mcp add roblox -- npx -y robloxstudio-mcp
94
+
95
+ # Inspect + manage
84
96
  aether mcp list
97
+ aether mcp remove ida
85
98
  ```
86
99
 
100
+ Built-in registry covers the official MCP servers (filesystem, github, gitlab, postgres, sqlite, puppeteer, playwright, slack, google-drive, fetch, memory, everart). For Python-based or community servers, use `aether mcp add` with the full command.
101
+
87
102
  On the next `aether` launch you'll see:
88
103
 
89
104
  ```
@@ -16,9 +16,17 @@ import { fetchBalance, AetherError } from "../src/api.js";
16
16
  import { writeConfigFile, getConfig, CONFIG_PATH } from "../src/config.js";
17
17
  import { loadMcpConfig, MCPManager } from "../src/mcp.js";
18
18
  import { addServer, removeServer, listServers } from "../src/mcp-cli.js";
19
+ import {
20
+ MCP_REGISTRY,
21
+ findRegistryEntry,
22
+ resolveEntry,
23
+ searchRegistry,
24
+ suggestSimilar,
25
+ } from "../src/mcp-registry.js";
26
+ import readline from "node:readline";
19
27
  import { c, errorLine, divider } from "../src/render.js";
20
28
 
21
- const VERSION = "0.11.1";
29
+ const VERSION = "0.12.0";
22
30
 
23
31
  /**
24
32
  * Try to start MCP servers from ~/.aether/mcp.json. Returns a started
@@ -65,7 +73,7 @@ ${c.bold("SUBCOMMANDS")}
65
73
  ${c.cyan("logout")} Clear saved API key
66
74
  ${c.cyan("balance")} Show plan + credit balance
67
75
  ${c.cyan("config")} show|set|set-base|path Manage config file
68
- ${c.cyan("mcp")} list|add|remove Manage MCP server connections
76
+ ${c.cyan("mcp")} list|search|install|add|remove Manage MCP server connections
69
77
 
70
78
  ${c.bold("EXAMPLES")}
71
79
  aether # interactive REPL
@@ -348,12 +356,100 @@ async function handleMcp(rest) {
348
356
  return;
349
357
  }
350
358
 
359
+ if (sub === "search" || sub === "find") {
360
+ const query = rest.slice(1).join(" ").trim();
361
+ const results = searchRegistry(query);
362
+ if (results.length === 0) {
363
+ console.log(c.gray(`No MCP servers in the registry match "${query}".`));
364
+ console.log(c.gray("Browse the full list: ") + c.cyan("aether mcp search"));
365
+ return;
366
+ }
367
+ console.log(
368
+ c.bold(query ? `MCP servers matching "${query}":` : `Available MCP servers (${results.length}):`),
369
+ );
370
+ for (const e of results) {
371
+ const sourceTag = e.source === "official"
372
+ ? c.gray("(official)")
373
+ : c.yellow("(community)");
374
+ console.log(` ${c.cyan(e.id.padEnd(16))} ${sourceTag} ${e.description}`);
375
+ }
376
+ console.log("");
377
+ console.log(c.gray("Install one with: ") + c.cyan("aether mcp install <name>"));
378
+ return;
379
+ }
380
+
381
+ if (sub === "install" || sub === "get") {
382
+ const name = rest[1];
383
+ if (!name) {
384
+ die(
385
+ "aether mcp install: missing <name>.\n" +
386
+ "Try `aether mcp search` to see what's available.",
387
+ );
388
+ }
389
+ const entry = findRegistryEntry(name);
390
+ if (!entry) {
391
+ const suggestions = suggestSimilar(name);
392
+ let msg = `aether mcp install: unknown server "${name}".`;
393
+ if (suggestions.length > 0) {
394
+ msg += `\nDid you mean: ${suggestions.map((s) => c.cyan(s)).join(", ")}?`;
395
+ } else {
396
+ msg += `\nTry \`aether mcp search\` to browse the registry.`;
397
+ }
398
+ die(msg);
399
+ }
400
+ // Prompt for any required values (placeholders in args + env)
401
+ const allRequired = [...(entry.requires ?? []), ...(entry.requiresEnv ?? [])];
402
+ const values = {};
403
+ if (allRequired.length > 0) {
404
+ console.log(c.gray(`Installing ${c.cyan(entry.id)} — needs ${allRequired.length} input${allRequired.length === 1 ? "" : "s"}:`));
405
+ for (const key of allRequired) {
406
+ const promptText = entry.prompts?.[key] ?? key;
407
+ // eslint-disable-next-line no-await-in-loop -- sequential prompts are intentional
408
+ values[key] = await promptUser(` ${promptText}: `);
409
+ if (!values[key]) die(`Cancelled — "${key}" is required.`);
410
+ }
411
+ }
412
+ let resolved;
413
+ try {
414
+ resolved = resolveEntry(entry, values);
415
+ } catch (e) {
416
+ die(e.message);
417
+ }
418
+ try {
419
+ const added = addServer({
420
+ name: entry.id,
421
+ command: resolved.command,
422
+ args: resolved.args,
423
+ env: resolved.env,
424
+ });
425
+ console.log(`${c.green("✓")} Installed MCP server "${c.cyan(entry.id)}".`);
426
+ console.log(c.gray(` ${added.command}${added.args ? " " + added.args.join(" ") : ""}`));
427
+ console.log(c.gray("Restart aether (or run `aether`) to attach it."));
428
+ } catch (e) {
429
+ die(e.message || String(e));
430
+ }
431
+ return;
432
+ }
433
+
351
434
  die(
352
435
  `aether mcp: unknown subcommand "${sub}".\n` +
353
- "Try one of: list, add, remove.",
436
+ "Try one of: list, add, install, search, remove.",
354
437
  );
355
438
  }
356
439
 
440
+ function promptUser(question) {
441
+ if (!process.stdin.isTTY) {
442
+ return Promise.reject(new Error("Interactive prompt unavailable (non-TTY)"));
443
+ }
444
+ return new Promise((resolve) => {
445
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
446
+ rl.question(question, (answer) => {
447
+ rl.close();
448
+ resolve(answer.trim());
449
+ });
450
+ });
451
+ }
452
+
357
453
  main().catch((err) => {
358
454
  console.error(errorLine(err.message || String(err)));
359
455
  if (process.env.DEBUG) console.error(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-code",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Uncensored AI coding agent for your terminal — Claude Code alternative with MCP support. Reads code, writes files, runs commands. Drives IDA Pro, Roblox Studio, Wireshark, Blender, and any MCP server. No refusal layer.",
5
5
  "homepage": "https://trynoguard.com",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  "node": ">=18"
24
24
  },
25
25
  "scripts": {
26
- "lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js src/skills.js",
26
+ "lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js src/mcp-registry.js src/skills.js",
27
27
  "test": "node --test \"test/**/*.test.js\""
28
28
  },
29
29
  "keywords": [
@@ -0,0 +1,266 @@
1
+ // Curated registry of well-known MCP servers users can install with
2
+ // `aether mcp install <name>` instead of typing the full npx command.
3
+ //
4
+ // Conservative inclusion criteria:
5
+ // 1. Published to npm under a known scope (@modelcontextprotocol/*,
6
+ // @playwright/*, etc.) OR widely-cited community packages
7
+ // 2. stdio transport (which is all MCPManager supports today)
8
+ // 3. Installable via npx -y with no separate global install step
9
+ //
10
+ // We DON'T include servers that need a local binary (IDA Pro, Ghidra,
11
+ // Wireshark) because the install path is environment-specific — those
12
+ // users need to read each project's README anyway, so `mcp install` won't
13
+ // save them work. The README shows their `mcp add` commands instead.
14
+ //
15
+ // Adding a new entry: confirm the package exists on npm, confirm the
16
+ // stdio entrypoint runs cleanly with `npx -y <pkg>`, then drop in here.
17
+
18
+ /**
19
+ * Each entry has:
20
+ * id — kebab-case name used as the MCP server name in config
21
+ * (must satisfy /^[a-z0-9_-]{1,40}$/i per mcp.js validator)
22
+ * command — base spawn command (typically "npx")
23
+ * args — array of args; entries wrapped in `{placeholder}` get
24
+ * substituted from user-provided inputs
25
+ * requires — array of placeholder names the user must supply
26
+ * prompts — { placeholder: "interactive prompt text shown to user" }
27
+ * description — one-line summary shown by `mcp list`/`mcp search`
28
+ * tags — keywords for `mcp search` matching
29
+ * source — "official" (anthropic / first-party orgs), "community"
30
+ */
31
+ export const MCP_REGISTRY = [
32
+ {
33
+ id: "filesystem",
34
+ command: "npx",
35
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "{path}"],
36
+ requires: ["path"],
37
+ prompts: { path: "Allowed filesystem path (the server will be sandboxed here)" },
38
+ description: "Read/write/list files under a whitelisted directory",
39
+ tags: ["files", "fs", "disk", "filesystem", "local"],
40
+ source: "official",
41
+ },
42
+ {
43
+ id: "github",
44
+ command: "npx",
45
+ args: ["-y", "@modelcontextprotocol/server-github"],
46
+ requires: [],
47
+ env: { GITHUB_PERSONAL_ACCESS_TOKEN: "{token}" },
48
+ requiresEnv: ["token"],
49
+ prompts: { token: "GitHub personal access token (https://github.com/settings/tokens — repo scope)" },
50
+ description: "Browse + edit GitHub repos, issues, PRs",
51
+ tags: ["github", "git", "repo", "issues", "pull request", "pr"],
52
+ source: "official",
53
+ },
54
+ {
55
+ id: "gitlab",
56
+ command: "npx",
57
+ args: ["-y", "@modelcontextprotocol/server-gitlab"],
58
+ requires: [],
59
+ env: { GITLAB_PERSONAL_ACCESS_TOKEN: "{token}", GITLAB_API_URL: "{url}" },
60
+ requiresEnv: ["token", "url"],
61
+ prompts: {
62
+ token: "GitLab personal access token (api scope)",
63
+ url: "GitLab API URL (default: https://gitlab.com/api/v4)",
64
+ },
65
+ description: "Browse + edit GitLab projects, issues, MRs",
66
+ tags: ["gitlab", "git", "repo", "issues", "merge request", "mr"],
67
+ source: "official",
68
+ },
69
+ {
70
+ id: "postgres",
71
+ command: "npx",
72
+ args: ["-y", "@modelcontextprotocol/server-postgres", "{connection}"],
73
+ requires: ["connection"],
74
+ prompts: { connection: "Postgres connection string (postgresql://user:pass@host:port/db)" },
75
+ description: "Query Postgres databases (read-only by default)",
76
+ tags: ["postgres", "postgresql", "database", "db", "sql"],
77
+ source: "official",
78
+ },
79
+ {
80
+ id: "sqlite",
81
+ command: "npx",
82
+ args: ["-y", "@modelcontextprotocol/server-sqlite", "{db_path}"],
83
+ requires: ["db_path"],
84
+ prompts: { db_path: "Path to the SQLite database file" },
85
+ description: "Query SQLite databases",
86
+ tags: ["sqlite", "database", "db", "sql"],
87
+ source: "official",
88
+ },
89
+ {
90
+ id: "puppeteer",
91
+ command: "npx",
92
+ args: ["-y", "@modelcontextprotocol/server-puppeteer"],
93
+ requires: [],
94
+ description: "Drive a headless browser via Puppeteer (navigate, screenshot, fill forms, scrape)",
95
+ tags: ["browser", "headless", "scrape", "puppeteer", "automation", "chrome"],
96
+ source: "official",
97
+ },
98
+ {
99
+ id: "playwright",
100
+ command: "npx",
101
+ args: ["-y", "@playwright/mcp"],
102
+ requires: [],
103
+ description: "Drive Chrome/Firefox/WebKit via Playwright (more capable than puppeteer)",
104
+ tags: ["browser", "playwright", "scrape", "automation", "chromium", "firefox", "webkit"],
105
+ source: "official",
106
+ },
107
+ {
108
+ id: "slack",
109
+ command: "npx",
110
+ args: ["-y", "@modelcontextprotocol/server-slack"],
111
+ requires: [],
112
+ env: { SLACK_BOT_TOKEN: "{bot_token}", SLACK_TEAM_ID: "{team_id}" },
113
+ requiresEnv: ["bot_token", "team_id"],
114
+ prompts: {
115
+ bot_token: "Slack bot token (xoxb-…)",
116
+ team_id: "Slack team/workspace ID (T…)",
117
+ },
118
+ description: "Read + post in Slack channels, list users, fetch threads",
119
+ tags: ["slack", "chat", "messaging", "workspace"],
120
+ source: "official",
121
+ },
122
+ {
123
+ id: "google-drive",
124
+ command: "npx",
125
+ args: ["-y", "@modelcontextprotocol/server-gdrive"],
126
+ requires: [],
127
+ description: "List + read Google Drive files (requires OAuth setup, see docs)",
128
+ tags: ["google", "drive", "gdrive", "files", "cloud"],
129
+ source: "official",
130
+ },
131
+ {
132
+ id: "memory",
133
+ command: "npx",
134
+ args: ["-y", "@modelcontextprotocol/server-memory"],
135
+ requires: [],
136
+ description: "Persistent knowledge-graph memory across agent sessions",
137
+ tags: ["memory", "kg", "knowledge graph", "persistence", "notes"],
138
+ source: "official",
139
+ },
140
+ {
141
+ id: "fetch",
142
+ command: "npx",
143
+ args: ["-y", "@modelcontextprotocol/server-fetch"],
144
+ requires: [],
145
+ description: "Fetch web pages + convert to markdown (alternative to built-in web_fetch)",
146
+ tags: ["http", "fetch", "web", "url", "markdown"],
147
+ source: "official",
148
+ },
149
+ {
150
+ id: "everart",
151
+ command: "npx",
152
+ args: ["-y", "@modelcontextprotocol/server-everart"],
153
+ requires: [],
154
+ env: { EVERART_API_KEY: "{key}" },
155
+ requiresEnv: ["key"],
156
+ prompts: { key: "EverArt API key (https://everart.ai)" },
157
+ description: "Generate images via EverArt",
158
+ tags: ["image", "generate", "art", "everart"],
159
+ source: "official",
160
+ },
161
+ ];
162
+
163
+ const PLACEHOLDER_RE = /\{([a-zA-Z0-9_]+)\}/g;
164
+
165
+ /**
166
+ * Look up a registry entry by id. Returns null for unknown names.
167
+ */
168
+ export function findRegistryEntry(id) {
169
+ return MCP_REGISTRY.find((e) => e.id === id) ?? null;
170
+ }
171
+
172
+ /**
173
+ * Apply user-provided placeholder values to an entry's args + env.
174
+ * Returns { command, args, env } ready to pass to addServer(), or
175
+ * throws if a required placeholder wasn't supplied.
176
+ *
177
+ * resolveEntry(filesystemEntry, { path: "/tmp" })
178
+ * → { command: "npx", args: ["-y", "...", "/tmp"], env: {} }
179
+ */
180
+ export function resolveEntry(entry, values) {
181
+ if (!entry) throw new Error("resolveEntry: entry is required");
182
+ const seen = new Set();
183
+ const substitute = (s) =>
184
+ s.replace(PLACEHOLDER_RE, (_, key) => {
185
+ seen.add(key);
186
+ const v = values?.[key];
187
+ if (v === undefined || v === null || v === "") {
188
+ throw new Error(`Missing required value for {${key}}`);
189
+ }
190
+ return String(v);
191
+ });
192
+ const args = (entry.args ?? []).map(substitute);
193
+ const env = {};
194
+ for (const [k, raw] of Object.entries(entry.env ?? {})) {
195
+ env[k] = substitute(raw);
196
+ }
197
+ // Sanity-check that we got every required placeholder.
198
+ for (const k of entry.requires ?? []) {
199
+ if (!seen.has(k)) {
200
+ // Required but never referenced in args — flag in case the registry
201
+ // entry is malformed. Defense in depth.
202
+ throw new Error(`Registry entry "${entry.id}": "${k}" listed in requires but never used`);
203
+ }
204
+ }
205
+ for (const k of entry.requiresEnv ?? []) {
206
+ if (!seen.has(k)) {
207
+ throw new Error(`Registry entry "${entry.id}": env placeholder "${k}" listed but never used`);
208
+ }
209
+ }
210
+ return { command: entry.command, args, env };
211
+ }
212
+
213
+ /**
214
+ * Search registry by free-text query. Matches id, description, and tags.
215
+ * Case-insensitive substring match. Returns entries ordered by relevance
216
+ * (id matches first, then tag matches, then description matches).
217
+ */
218
+ export function searchRegistry(query) {
219
+ const q = (query || "").toLowerCase().trim();
220
+ if (!q) return [...MCP_REGISTRY];
221
+ const idHits = [];
222
+ const tagHits = [];
223
+ const descHits = [];
224
+ for (const e of MCP_REGISTRY) {
225
+ if (e.id.includes(q)) {
226
+ idHits.push(e);
227
+ } else if ((e.tags ?? []).some((t) => t.toLowerCase().includes(q))) {
228
+ tagHits.push(e);
229
+ } else if ((e.description ?? "").toLowerCase().includes(q)) {
230
+ descHits.push(e);
231
+ }
232
+ }
233
+ return [...idHits, ...tagHits, ...descHits];
234
+ }
235
+
236
+ /**
237
+ * For "unknown id" errors: return the closest registry ids to a typo,
238
+ * up to 3 suggestions. Simple edit-distance-style proximity using the
239
+ * length-diff + character-overlap heuristic — good enough to suggest
240
+ * "playwright" when the user types "playright".
241
+ */
242
+ export function suggestSimilar(id) {
243
+ const target = id.toLowerCase();
244
+ const scored = MCP_REGISTRY.map((e) => ({
245
+ id: e.id,
246
+ score: similarity(target, e.id.toLowerCase()),
247
+ }));
248
+ scored.sort((a, b) => b.score - a.score);
249
+ return scored.filter((s) => s.score > 0.4).slice(0, 3).map((s) => s.id);
250
+ }
251
+
252
+ function similarity(a, b) {
253
+ if (a === b) return 1;
254
+ if (a.length === 0 || b.length === 0) return 0;
255
+ // Crude bigram-overlap. Good enough to catch one-character typos.
256
+ const bigrams = (s) => {
257
+ const out = new Set();
258
+ for (let i = 0; i < s.length - 1; i++) out.add(s.slice(i, i + 2));
259
+ return out;
260
+ };
261
+ const A = bigrams(a);
262
+ const B = bigrams(b);
263
+ let shared = 0;
264
+ for (const x of A) if (B.has(x)) shared++;
265
+ return (2 * shared) / (A.size + B.size);
266
+ }