autorouter-mcp 0.2.0 → 0.2.2

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.
Files changed (4) hide show
  1. package/README.md +96 -28
  2. package/dist/cli.js +1123 -772
  3. package/package.json +2 -2
  4. package/server.json +3 -3
package/dist/cli.js CHANGED
@@ -7496,6 +7496,10 @@ var DEFAULT_CONFIG = {
7496
7496
  exclude: [],
7497
7497
  alwaysExpose: [],
7498
7498
  confirm: [],
7499
+ promptMode: "commands",
7500
+ activation: "lazy",
7501
+ autoAdopt: true,
7502
+ allowAddServer: true,
7499
7503
  selector: {
7500
7504
  mode: "auto",
7501
7505
  candidates: 30,
@@ -8910,6 +8914,23 @@ function substitutePluginRoot(value, root) {
8910
8914
  }
8911
8915
 
8912
8916
  // src/config/resolve.ts
8917
+ function harnessSources(name, cwd) {
8918
+ const home = homeDir();
8919
+ switch (name) {
8920
+ case "claude":
8921
+ return [join7(home, ".claude.json"), join7(cwd, ".mcp.json")];
8922
+ case "cursor":
8923
+ return [join7(home, ".cursor", "mcp.json"), join7(cwd, ".cursor", "mcp.json")];
8924
+ case "vscode":
8925
+ return [join7(home, ".vscode", "mcp.json"), join7(cwd, ".vscode", "mcp.json")];
8926
+ case "codex":
8927
+ return [join7(home, ".codex", "config.toml"), join7(cwd, ".codex", "config.toml")];
8928
+ case "plugins":
8929
+ return [join7(home, ".claude", "plugins", "installed_plugins.json")];
8930
+ default:
8931
+ return [];
8932
+ }
8933
+ }
8913
8934
  function configCandidates(cwd) {
8914
8935
  const explicit = process.env.AUTOROUTER_CONFIG;
8915
8936
  return [
@@ -8945,6 +8966,7 @@ async function resolveConfig(cwd = process.cwd()) {
8945
8966
  };
8946
8967
  applyEnvOverrides(config);
8947
8968
  const imported = [];
8969
+ const sources = [];
8948
8970
  const loaders = {
8949
8971
  claude: () => loadClaude(cwd),
8950
8972
  cursor: () => loadCursor(cwd),
@@ -8956,6 +8978,7 @@ async function resolveConfig(cwd = process.cwd()) {
8956
8978
  const load = loaders[name];
8957
8979
  if (!load)
8958
8980
  continue;
8981
+ sources.push(...harnessSources(name, cwd));
8959
8982
  try {
8960
8983
  imported.push(...await load());
8961
8984
  } catch {}
@@ -8965,7 +8988,7 @@ async function resolveConfig(cwd = process.cwd()) {
8965
8988
  if (entry)
8966
8989
  imported.push(entry);
8967
8990
  }
8968
- return { config, servers: dedupeServers(imported), configPath, cwd };
8991
+ return { config, servers: dedupeServers(imported), configPath, cwd, sources };
8969
8992
  }
8970
8993
  function applyEnvOverrides(config) {
8971
8994
  const env = process.env;
@@ -8982,6 +9005,16 @@ function applyEnvOverrides(config) {
8982
9005
  }
8983
9006
  if (env.AUTOROUTER_SELECTOR_BASE_URL)
8984
9007
  config.selector.baseUrl = env.AUTOROUTER_SELECTOR_BASE_URL;
9008
+ if (env.AUTOROUTER_PROMPT_MODE) {
9009
+ config.promptMode = env.AUTOROUTER_PROMPT_MODE;
9010
+ }
9011
+ if (env.AUTOROUTER_ACTIVATION) {
9012
+ config.activation = env.AUTOROUTER_ACTIVATION;
9013
+ }
9014
+ if (env.AUTOROUTER_AUTO_ADOPT)
9015
+ config.autoAdopt = env.AUTOROUTER_AUTO_ADOPT !== "0";
9016
+ if (env.AUTOROUTER_ALLOW_ADD_SERVER)
9017
+ config.allowAddServer = env.AUTOROUTER_ALLOW_ADD_SERVER !== "0";
8985
9018
  if (env.AUTOROUTER_EMBEDDINGS_PROVIDER) {
8986
9019
  config.embeddings.provider = env.AUTOROUTER_EMBEDDINGS_PROVIDER;
8987
9020
  }
@@ -19211,7 +19244,7 @@ async function buildCatalog(resolved) {
19211
19244
  const { config: config2, servers, cwd, configPath } = resolved;
19212
19245
  const capabilities = [];
19213
19246
  const errors3 = {};
19214
- const sourceFiles = configPath ? [configPath] : [];
19247
+ const sourceFiles = [...configPath ? [configPath] : [], ...resolved.sources];
19215
19248
  sourceFiles.push(...servers.filter((s) => s.transport === "http").map((s) => `${AUTH}${authPath(s.name)}`));
19216
19249
  const skills = await collectSkills(config2.skillPaths, cwd, {
19217
19250
  includePlugins: config2.import.includes("plugins")
@@ -20201,6 +20234,10 @@ class ConnectionPool {
20201
20234
  names() {
20202
20235
  return [...this.byName.keys()];
20203
20236
  }
20237
+ setEntries(entries) {
20238
+ this.reset();
20239
+ this.byName = new Map(entries.map((e) => [e.name, e]));
20240
+ }
20204
20241
  async get(serverName) {
20205
20242
  const existing = this.clients.get(serverName);
20206
20243
  if (existing) {
@@ -20302,15 +20339,15 @@ class CapabilityError extends Error {
20302
20339
  }
20303
20340
 
20304
20341
  class Router {
20305
- resolved;
20306
20342
  pool;
20307
- guard;
20308
20343
  catalog;
20309
20344
  index;
20345
+ resolved;
20346
+ guard;
20310
20347
  refreshing = null;
20311
20348
  constructor(resolved, catalog, index, pool, guard) {
20312
- this.resolved = resolved;
20313
20349
  this.pool = pool;
20350
+ this.resolved = resolved;
20314
20351
  this.guard = guard;
20315
20352
  this.catalog = catalog;
20316
20353
  this.index = index;
@@ -20329,13 +20366,16 @@ class Router {
20329
20366
  return;
20330
20367
  this.refreshing = (async () => {
20331
20368
  try {
20332
- const catalog = await buildCatalog(this.resolved);
20369
+ const resolved = await resolveConfig(this.resolved.cwd);
20370
+ const catalog = await buildCatalog(resolved);
20333
20371
  await saveCatalog(catalog);
20334
- const index = new HybridIndex(catalog.capabilities, this.resolved.config);
20372
+ const index = new HybridIndex(catalog.capabilities, resolved.config);
20335
20373
  await index.warmEmbeddings();
20374
+ this.resolved = resolved;
20375
+ this.guard = new Guard(resolved.config);
20336
20376
  this.catalog = catalog;
20337
20377
  this.index = index;
20338
- this.pool.reset();
20378
+ this.pool.setEntries(resolved.servers);
20339
20379
  } catch {} finally {
20340
20380
  this.refreshing = null;
20341
20381
  }
@@ -21152,9 +21192,27 @@ function oneLine(text, max) {
21152
21192
 
21153
21193
  // src/server/prompts.ts
21154
21194
  var INSTRUCTION_KINDS = new Set(["skill", "command", "agent"]);
21195
+ var KINDS_BY_MODE = {
21196
+ all: INSTRUCTION_KINDS,
21197
+ commands: new Set(["command", "agent"]),
21198
+ none: new Set
21199
+ };
21155
21200
  var FIND_PROMPT = "find";
21156
- var DESCRIPTION_CHARS = 180;
21157
- function promptList(capabilities) {
21201
+ var DESCRIPTION_CHARS = 120;
21202
+ function* published(capabilities, mode) {
21203
+ const kinds = KINDS_BY_MODE[mode] ?? KINDS_BY_MODE.commands;
21204
+ if (!kinds.size)
21205
+ return;
21206
+ const seen = new Set([FIND_PROMPT]);
21207
+ for (const cap of capabilities) {
21208
+ if (!kinds.has(cap.kind))
21209
+ continue;
21210
+ const name = uniquePromptName(cap, seen);
21211
+ seen.add(name);
21212
+ yield [name, cap];
21213
+ }
21214
+ }
21215
+ function promptList(capabilities, mode = "commands") {
21158
21216
  const prompts = [
21159
21217
  {
21160
21218
  name: FIND_PROMPT,
@@ -21162,12 +21220,7 @@ function promptList(capabilities) {
21162
21220
  arguments: [{ name: "query", description: "What you are trying to accomplish.", required: true }]
21163
21221
  }
21164
21222
  ];
21165
- const seen = new Set([FIND_PROMPT]);
21166
- for (const cap of capabilities) {
21167
- if (!INSTRUCTION_KINDS.has(cap.kind))
21168
- continue;
21169
- const name = uniquePromptName(cap, seen);
21170
- seen.add(name);
21223
+ for (const [name, cap] of published(capabilities, mode)) {
21171
21224
  prompts.push({
21172
21225
  name,
21173
21226
  description: clamp(cap.description || `${cap.kind} ${cap.name}`, DESCRIPTION_CHARS),
@@ -21197,13 +21250,8 @@ function uniquePromptName(cap, taken) {
21197
21250
  }
21198
21251
  return base;
21199
21252
  }
21200
- function capabilityForPrompt(capabilities, name) {
21201
- const seen = new Set([FIND_PROMPT]);
21202
- for (const cap of capabilities) {
21203
- if (!INSTRUCTION_KINDS.has(cap.kind))
21204
- continue;
21205
- const promptName2 = uniquePromptName(cap, seen);
21206
- seen.add(promptName2);
21253
+ function capabilityForPrompt(capabilities, name, mode = "commands") {
21254
+ for (const [promptName2, cap] of published(capabilities, mode)) {
21207
21255
  if (promptName2 === name)
21208
21256
  return cap;
21209
21257
  }
@@ -21230,187 +21278,725 @@ function mentionsArguments(body) {
21230
21278
  return /\$ARGUMENTS\b|\$\d\b/.test(body);
21231
21279
  }
21232
21280
 
21233
- // src/server/index.ts
21234
- var NAME = "autorouter";
21235
- var VERSION = "0.1.0";
21236
- var INSTRUCTIONS = `This server is a capability router. Instead of loading every
21237
- available tool into your context, it exposes a search interface over all of them.
21281
+ // src/cli/adopt.ts
21282
+ import { join as join16 } from "node:path";
21238
21283
 
21239
- Workflow:
21240
- 1. find_capabilities({ query }) describe what you are trying to do in plain
21241
- language. Matching tools come back with their full input schema, and on
21242
- hosts that support it are added to your tool list immediately, so you can
21243
- call them directly like any other tool.
21244
- 2. call_capability({ id, arguments }) — the fallback path, and the only one on
21245
- hosts that do not refresh their tool list. Use it whenever a tool named in a
21246
- search result is not yet callable directly; the result is identical.
21247
- 3. describe_capability({ id }) — full detail on demand: the schema for a tool
21248
- that was not inlined, or the complete instruction text for a skill. Reading a
21249
- skill's instructions is how a skill runs.
21250
-
21251
- Search before concluding that something is impossible: the catalog covers many
21252
- servers, skills and plugin commands that are not visible in your tool list.`;
21253
- async function serve(opts = {}) {
21254
- const router = await Router.create({ cwd: opts.cwd });
21255
- const server = new Server({ name: NAME, version: VERSION }, {
21256
- capabilities: {
21257
- tools: { listChanged: true },
21258
- prompts: { listChanged: true }
21259
- },
21260
- instructions: INSTRUCTIONS
21261
- });
21262
- let profile = null;
21263
- const activated = new Map;
21264
- server.oninitialized = () => {
21265
- profile = profileClient(server.getClientVersion(), server.getClientCapabilities());
21266
- log(`client: ${profile.name} ${profile.version} — ${profile.rationale}`);
21267
- };
21268
- server.setRequestHandler(ListToolsRequestSchema, async () => {
21269
- refreshAndAnnounce();
21270
- const tools = routerTools(router, profile);
21271
- for (const [name, cap] of activated)
21272
- tools.push(promotedTool(name, cap));
21273
- return { tools };
21274
- });
21275
- server.setRequestHandler(ListPromptsRequestSchema, async () => {
21276
- refreshAndAnnounce();
21277
- return { prompts: promptList(router.catalog.capabilities) };
21278
- });
21279
- server.setRequestHandler(GetPromptRequestSchema, async (request) => {
21280
- const { name, arguments: args = {} } = request.params;
21281
- if (await router.isStale())
21282
- await router.refresh();
21283
- return await handleGetPrompt(router, name, args);
21284
- });
21285
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
21286
- const { name, arguments: args = {} } = request.params;
21287
- try {
21288
- switch (name) {
21289
- case "find_capabilities":
21290
- if (await router.isStale())
21291
- await router.refresh();
21292
- return await handleFind(router, server, profile, activated, args);
21293
- case "describe_capability":
21294
- return await handleDescribe(router, args);
21295
- case "call_capability":
21296
- return await handleCall(router, args);
21297
- case "activate_capabilities":
21298
- return await handleActivate(router, server, activated, args);
21299
- case "deactivate_capabilities":
21300
- return await handleDeactivate(server, activated, args);
21301
- default: {
21302
- const cap = activated.get(name);
21303
- if (cap)
21304
- return await invoke(router, cap, args, true);
21305
- return errorResult(`Unknown tool: ${name}. Use find_capabilities to search.`);
21306
- }
21307
- }
21308
- } catch (err) {
21309
- return errorResult(err instanceof Error ? err.message : String(err));
21310
- }
21311
- });
21312
- async function refreshAndAnnounce() {
21313
- const before = router.summary();
21314
- await router.refresh();
21315
- if (router.summary() === before)
21316
- return;
21317
- log(`catalog changed — ${router.summary()}`);
21318
- server.sendToolListChanged?.();
21319
- server.sendPromptListChanged?.();
21284
+ // src/config/write.ts
21285
+ import { join as join14 } from "node:path";
21286
+ function routerConfigPath(existingPath) {
21287
+ return existingPath ?? join14(homeDir(), ".config", "autorouter", "config.json");
21288
+ }
21289
+ async function read(path) {
21290
+ if (!await exists(path))
21291
+ return {};
21292
+ try {
21293
+ return JSON.parse(stripJsonComments(await readText(path) ?? "{}"));
21294
+ } catch {
21295
+ throw new Error(`${path} is not valid JSON — fix it before writing to it.`);
21320
21296
  }
21321
- const transport = new StdioServerTransport;
21322
- await server.connect(transport);
21323
- log(`ready — ${router.summary()}`);
21324
- await new Promise((resolve2) => {
21325
- let done = false;
21326
- const finish = () => {
21327
- if (done)
21328
- return;
21329
- done = true;
21330
- router.close().catch(() => {});
21331
- resolve2();
21332
- };
21333
- server.onclose = finish;
21334
- transport.onclose = finish;
21335
- process.on("SIGINT", finish);
21336
- process.on("SIGTERM", finish);
21337
- process.stdin.on("end", finish);
21338
- });
21339
21297
  }
21340
- function routerTools(router, profile) {
21341
- const tools = [
21342
- {
21343
- name: "find_capabilities",
21344
- description: `Search every available tool, skill, prompt and command by what you are trying to do, and get back only the ones that fit. Catalog: ${router.summary()}. Use this whenever a task might need a capability you cannot already see — searching is cheap and the catalog is much larger than your tool list.`,
21345
- inputSchema: {
21346
- type: "object",
21347
- properties: {
21348
- query: {
21349
- type: "string",
21350
- description: "What you are trying to accomplish, in plain language. Describe the goal, not a guessed tool name."
21351
- },
21352
- kind: {
21353
- type: "string",
21354
- enum: ["tool", "skill", "prompt", "resource", "command", "agent"],
21355
- description: "Optional: restrict results to one kind of capability."
21356
- },
21357
- server: { type: "string", description: "Optional: restrict to one provider." },
21358
- limit: { type: "number", description: "Max results (default 8)." }
21359
- },
21360
- required: ["query"]
21361
- }
21362
- },
21363
- {
21364
- name: "describe_capability",
21365
- description: "Get full detail for one capability id from find_capabilities: the complete input schema for a tool, or the full instruction text for a skill or command.",
21366
- inputSchema: {
21367
- type: "object",
21368
- properties: { id: { type: "string", description: "Capability id, e.g. mcp:supabase/execute_sql" } },
21369
- required: ["id"]
21370
- }
21371
- },
21372
- {
21373
- name: "call_capability",
21374
- description: "Run a capability by id and return its result. Works for tools, prompts and resources; skills and commands are executed by following the text from describe_capability instead.",
21375
- inputSchema: {
21376
- type: "object",
21377
- properties: {
21378
- id: { type: "string", description: "Capability id to invoke." },
21379
- arguments: { type: "object", description: "Arguments matching the capability's input schema." },
21380
- confirm: {
21381
- type: "boolean",
21382
- description: "Set true to proceed with a capability that requires explicit confirmation."
21383
- }
21384
- },
21385
- required: ["id"]
21386
- }
21298
+ async function upsertServers(servers, opts) {
21299
+ const path = routerConfigPath(opts.configPath);
21300
+ await ensureDir(join14(path, ".."));
21301
+ const config2 = await read(path);
21302
+ config2.servers = opts.overwrite ? { ...config2.servers, ...servers } : { ...servers, ...config2.servers };
21303
+ await writeText(path, `${JSON.stringify(config2, null, 2)}
21304
+ `);
21305
+ return path;
21306
+ }
21307
+ async function removeServer(name, configPath) {
21308
+ const path = routerConfigPath(configPath);
21309
+ const config2 = await read(path);
21310
+ if (!config2.servers || !(name in config2.servers))
21311
+ return { removed: false, path };
21312
+ delete config2.servers[name];
21313
+ await writeText(path, `${JSON.stringify(config2, null, 2)}
21314
+ `);
21315
+ return { removed: true, path };
21316
+ }
21317
+
21318
+ // src/cli/adoptExtras.ts
21319
+ import { join as join15 } from "node:path";
21320
+ function settingsPath() {
21321
+ return join15(homeDir(), ".claude", "settings.json");
21322
+ }
21323
+ async function planExtras(opts) {
21324
+ const file = settingsPath();
21325
+ const settings = await readSettings(file) ?? {};
21326
+ const plan = { file, skills: [], plugins: [], skipped: [] };
21327
+ const keepSkills = new Set(opts.keepSkills);
21328
+ const keepPlugins = new Set(opts.keepPlugins);
21329
+ const plugins = await listInstalledPlugins();
21330
+ const pluginByName = new Map(plugins.map((p) => [p.name, p]));
21331
+ const disabledPluginNames = new Set;
21332
+ const untouched = new Set;
21333
+ for (const plugin of plugins) {
21334
+ if (keepPlugins.has(plugin.name) || keepPlugins.has(plugin.key)) {
21335
+ untouched.add(plugin.name);
21336
+ continue;
21387
21337
  }
21388
- ];
21389
- if (profile?.supportsListChanged) {
21390
- tools.push({
21391
- name: "activate_capabilities",
21392
- description: "Promote capabilities to real, first-class tools in your tool list so you can call them directly with full schema validation. Use after find_capabilities when you expect to call something several times.",
21393
- inputSchema: {
21394
- type: "object",
21395
- properties: {
21396
- ids: { type: "array", items: { type: "string" }, description: "Capability ids to promote." }
21397
- },
21398
- required: ["ids"]
21399
- }
21400
- }, {
21401
- name: "deactivate_capabilities",
21402
- description: "Remove previously activated capabilities from your tool list to reclaim context.",
21403
- inputSchema: {
21404
- type: "object",
21405
- properties: {
21406
- ids: { type: "array", items: { type: "string" }, description: "Ids to remove; omit to remove all." }
21407
- }
21408
- }
21338
+ if (settings.enabledPlugins?.[plugin.key] === false) {
21339
+ plan.skipped.push(`plugin ${plugin.name} (already disabled)`);
21340
+ disabledPluginNames.add(plugin.name);
21341
+ continue;
21342
+ }
21343
+ const servers = await pluginServerNames(plugin.root);
21344
+ const broken = servers.map((s) => opts.unreachable?.has(`${plugin.name}:${s}`) ? `${plugin.name}:${s}` : opts.unreachable?.has(s) ? s : null).filter((s) => s !== null);
21345
+ if (broken.length) {
21346
+ plan.skipped.push(`plugin ${plugin.name} (the router cannot reach ${broken.join(", ")} — run: ${broken.map((s) => `autorouter login ${s}`).join("; ")})`);
21347
+ untouched.add(plugin.name);
21348
+ continue;
21349
+ }
21350
+ const unrouted = servers.filter((s) => !opts.routedServers.has(s) && !opts.routedServers.has(`${plugin.name}:${s}`));
21351
+ if (unrouted.length) {
21352
+ plan.skipped.push(`plugin ${plugin.name} (${unrouted.length > 1 ? `its servers ${unrouted.join(", ")} are` : `its server ${unrouted[0]} is`} not routed yet — adopt ${unrouted.length > 1 ? "them" : "it"} first)`);
21353
+ continue;
21354
+ }
21355
+ plan.plugins.push({
21356
+ id: plugin.key,
21357
+ name: plugin.name,
21358
+ reason: servers.length ? `${servers.length} server(s) already routed` : "skills only"
21409
21359
  });
21360
+ disabledPluginNames.add(plugin.name);
21410
21361
  }
21411
- return tools;
21362
+ const { capabilities } = await collectSkills(opts.skillPaths, opts.cwd, { includePlugins: true });
21363
+ for (const cap of capabilities) {
21364
+ if (cap.server && (disabledPluginNames.has(cap.server) || untouched.has(cap.server)))
21365
+ continue;
21366
+ if (keepSkills.has(cap.name))
21367
+ continue;
21368
+ const current = settings.skillOverrides?.[cap.name];
21369
+ if (current === "off" || current === "user-invocable-only") {
21370
+ plan.skipped.push(`skill ${cap.name} (already ${current})`);
21371
+ continue;
21372
+ }
21373
+ plan.skills.push({ name: cap.name, mode: opts.mode, from: cap.server ?? null });
21374
+ }
21375
+ return plan;
21412
21376
  }
21413
- function promotedTool(name, cap) {
21377
+ async function applyExtras(plan) {
21378
+ const settings = await readSettings(plan.file) ?? {};
21379
+ settings.skillOverrides = { ...settings.skillOverrides };
21380
+ settings.enabledPlugins = { ...settings.enabledPlugins };
21381
+ for (const s of plan.skills)
21382
+ settings.skillOverrides[s.name] = s.mode;
21383
+ for (const p of plan.plugins)
21384
+ settings.enabledPlugins[p.id] = false;
21385
+ await writeText(plan.file, `${JSON.stringify(settings, null, 2)}
21386
+ `);
21387
+ }
21388
+ async function readSettings(file) {
21389
+ if (!await exists(file))
21390
+ return null;
21391
+ const text = await readText(file);
21392
+ if (!text?.trim())
21393
+ return null;
21394
+ try {
21395
+ return JSON.parse(stripJsonComments(text));
21396
+ } catch {
21397
+ return null;
21398
+ }
21399
+ }
21400
+ async function pluginServerNames(root) {
21401
+ const manifestText = await readText(join15(root, ".claude-plugin", "plugin.json"));
21402
+ if (!manifestText)
21403
+ return [];
21404
+ let manifest;
21405
+ try {
21406
+ manifest = JSON.parse(stripJsonComments(manifestText));
21407
+ } catch {
21408
+ return [];
21409
+ }
21410
+ if (!manifest.mcpServers)
21411
+ return [];
21412
+ if (typeof manifest.mcpServers !== "string")
21413
+ return Object.keys(manifest.mcpServers);
21414
+ const rel = manifest.mcpServers.replace(/^\.\//, "");
21415
+ const text = await readText(join15(root, rel));
21416
+ if (!text)
21417
+ return [];
21418
+ try {
21419
+ const loaded = JSON.parse(stripJsonComments(text));
21420
+ return Object.keys(loaded.mcpServers ?? loaded ?? {});
21421
+ } catch {
21422
+ return [];
21423
+ }
21424
+ }
21425
+
21426
+ // src/cli/adopt.ts
21427
+ async function runAdopt(opts) {
21428
+ const resolved = await resolveConfig(opts.cwd);
21429
+ const keep = new Set([
21430
+ "autorouter",
21431
+ ...opts.keep,
21432
+ ...serversBehind(resolved.config.alwaysExpose)
21433
+ ]);
21434
+ const plans = [];
21435
+ const adopted = {};
21436
+ const backup = {};
21437
+ const docs = new Map;
21438
+ const apply = (site, entries) => {
21439
+ for (const [name, spec] of Object.entries(entries)) {
21440
+ if (keep.has(name)) {
21441
+ site.kept.push(name);
21442
+ continue;
21443
+ }
21444
+ site.moved.push(name);
21445
+ adopted[name] ??= spec;
21446
+ delete entries[name];
21447
+ }
21448
+ };
21449
+ if (opts.harness === "codex") {
21450
+ const file = join16(homeDir(), ".codex", "config.toml");
21451
+ const text = await readText(file);
21452
+ if (text) {
21453
+ let config2;
21454
+ try {
21455
+ config2 = parse(text);
21456
+ } catch {
21457
+ config2 = null;
21458
+ }
21459
+ if (config2) {
21460
+ const plan = { harness: "codex", file, section: "mcp_servers", moved: [], kept: [] };
21461
+ backup[file] = text;
21462
+ docs.set(file, config2);
21463
+ apply(plan, config2.mcp_servers ?? {});
21464
+ plans.push(plan);
21465
+ }
21466
+ }
21467
+ } else {
21468
+ for (const site of jsonSites(opts.harness, opts.cwd)) {
21469
+ if (!docs.has(site.file)) {
21470
+ const text = await readText(site.file);
21471
+ if (!text)
21472
+ continue;
21473
+ try {
21474
+ docs.set(site.file, JSON.parse(stripJsonComments(text)));
21475
+ } catch {
21476
+ continue;
21477
+ }
21478
+ backup[site.file] = text;
21479
+ }
21480
+ const config2 = docs.get(site.file);
21481
+ const container = site.path.reduce((acc, k) => acc?.[k], config2);
21482
+ if (!container || typeof container !== "object")
21483
+ continue;
21484
+ const plan = {
21485
+ harness: opts.harness,
21486
+ file: site.file,
21487
+ section: site.section,
21488
+ moved: [],
21489
+ kept: []
21490
+ };
21491
+ apply(plan, container);
21492
+ plans.push(plan);
21493
+ }
21494
+ }
21495
+ const routedServers = new Set([
21496
+ ...resolved.servers.map((s) => s.name),
21497
+ ...resolved.servers.flatMap((s) => s.name.includes(":") ? [s.name.split(":").pop()] : []),
21498
+ ...Object.keys(adopted)
21499
+ ]);
21500
+ const extras = opts.extras && opts.harness === "claude" ? await planExtras({
21501
+ cwd: opts.cwd,
21502
+ skillPaths: resolved.config.skillPaths,
21503
+ keepSkills: opts.keepSkills ?? [],
21504
+ keepPlugins: opts.keepPlugins ?? [],
21505
+ mode: opts.skillMode ?? "user-invocable-only",
21506
+ routedServers,
21507
+ unreachable: opts.unreachable
21508
+ }) : null;
21509
+ const extrasCount = (extras?.skills.length ?? 0) + (extras?.plugins.length ?? 0);
21510
+ const movedCount = plans.reduce((n, p) => n + p.moved.length, 0);
21511
+ if (!movedCount && !extrasCount) {
21512
+ return {
21513
+ plans,
21514
+ extras,
21515
+ backup: null,
21516
+ notes: ["Nothing to adopt — no downstream servers, skills or plugins are loaded directly by this harness."]
21517
+ };
21518
+ }
21519
+ if (opts.dryRun)
21520
+ return { plans, extras, backup: null, notes: ["Dry run — no files were changed."] };
21521
+ if (extrasCount) {
21522
+ const text = await readText(settingsPath());
21523
+ if (text !== null)
21524
+ backup[settingsPath()] = text;
21525
+ }
21526
+ const backupPath = join16(backupDir(), `${opts.harness}-${stamp()}.json`);
21527
+ await ensureDir(join16(backupPath, ".."));
21528
+ await writeText(backupPath, `${JSON.stringify({ harness: opts.harness, files: backup }, null, 2)}
21529
+ `);
21530
+ const touched = new Set(plans.filter((p) => p.moved.length).map((p) => p.file));
21531
+ for (const file of touched) {
21532
+ const config2 = docs.get(file);
21533
+ await writeText(file, file.endsWith(".toml") ? stringify(config2) : `${JSON.stringify(config2, null, 2)}
21534
+ `);
21535
+ }
21536
+ if (movedCount)
21537
+ await upsertServers(adopted, { configPath: resolved.configPath });
21538
+ if (extras && extrasCount)
21539
+ await applyExtras(extras);
21540
+ const notes = [];
21541
+ if (movedCount)
21542
+ notes.push(`Moved ${movedCount} server(s) into the router's config.`);
21543
+ if (extras?.skills.length) {
21544
+ notes.push(`Hid ${extras.skills.length} skill(s) from the model` + (extras.skills[0]?.mode === "user-invocable-only" ? " — /name still works for you." : "."));
21545
+ }
21546
+ if (extras?.plugins.length)
21547
+ notes.push(`Disabled ${extras.plugins.length} plugin(s).`);
21548
+ notes.push(`Backup at ${backupPath}`);
21549
+ notes.push("Restart the harness — its tool list should now show only the router.");
21550
+ return { plans, extras, backup: backupPath, notes };
21551
+ }
21552
+ var HARNESSES = ["claude", "codex", "cursor", "vscode"];
21553
+ async function runAutoAdopt(cwd) {
21554
+ const resolved = await resolveConfig(cwd);
21555
+ if (resolved.config.autoAdopt === false)
21556
+ return [];
21557
+ const notes = [];
21558
+ for (const harness of HARNESSES) {
21559
+ if (!resolved.config.import.includes(harness))
21560
+ continue;
21561
+ try {
21562
+ const result = await runAdopt({ harness, cwd, keep: [], dryRun: false, extras: false });
21563
+ const moved = result.plans.flatMap((p) => p.moved);
21564
+ if (moved.length)
21565
+ notes.push(`adopted ${moved.join(", ")} from ${harness}`);
21566
+ } catch {}
21567
+ }
21568
+ return notes;
21569
+ }
21570
+ function backupDir() {
21571
+ return join16(homeDir(), ".autorouter", "adopted");
21572
+ }
21573
+ function stamp() {
21574
+ return new Date().toISOString().replace(/[:.]/g, "-");
21575
+ }
21576
+ async function runRestore(harness) {
21577
+ const dir = backupDir();
21578
+ const { readdir: readdir2 } = await import("node:fs/promises");
21579
+ let files;
21580
+ try {
21581
+ files = (await readdir2(dir)).filter((f) => f.startsWith(`${harness}-`)).sort();
21582
+ } catch {
21583
+ return [`No adoption backups found for ${harness}.`];
21584
+ }
21585
+ const latest = files.at(-1);
21586
+ if (!latest)
21587
+ return [`No adoption backups found for ${harness}.`];
21588
+ const payload = JSON.parse(await readText(join16(dir, latest)));
21589
+ const notes = [];
21590
+ for (const [file, text] of Object.entries(payload.files)) {
21591
+ await writeText(file, text);
21592
+ notes.push(`Restored ${file}`);
21593
+ }
21594
+ notes.push(`From ${join16(dir, latest)}. The router's own config still lists these servers; that is harmless (duplicates are deduped) but you can remove them.`);
21595
+ return notes;
21596
+ }
21597
+ function jsonSites(harness, cwd) {
21598
+ switch (harness) {
21599
+ case "claude":
21600
+ return [
21601
+ { file: join16(homeDir(), ".claude.json"), section: "mcpServers", path: ["mcpServers"] },
21602
+ {
21603
+ file: join16(homeDir(), ".claude.json"),
21604
+ section: `projects[${cwd}].mcpServers`,
21605
+ path: ["projects", cwd, "mcpServers"]
21606
+ },
21607
+ { file: join16(cwd, ".mcp.json"), section: "mcpServers", path: ["mcpServers"] }
21608
+ ];
21609
+ case "cursor":
21610
+ return [
21611
+ { file: join16(homeDir(), ".cursor", "mcp.json"), section: "mcpServers", path: ["mcpServers"] },
21612
+ { file: join16(cwd, ".cursor", "mcp.json"), section: "mcpServers", path: ["mcpServers"] }
21613
+ ];
21614
+ case "vscode":
21615
+ return [
21616
+ { file: join16(homeDir(), ".vscode", "mcp.json"), section: "servers", path: ["servers"] },
21617
+ { file: join16(cwd, ".vscode", "mcp.json"), section: "servers", path: ["servers"] }
21618
+ ];
21619
+ default:
21620
+ return [];
21621
+ }
21622
+ }
21623
+ function serversBehind(patterns) {
21624
+ const out = [];
21625
+ for (const p of patterns) {
21626
+ const m = /^(?:mcp:)?([^:/.*]+)[/.]/.exec(p);
21627
+ if (m?.[1])
21628
+ out.push(m[1]);
21629
+ }
21630
+ return out;
21631
+ }
21632
+
21633
+ // src/cli/add.ts
21634
+ function parseAddSpec(spec) {
21635
+ if (spec.json) {
21636
+ let parsed;
21637
+ try {
21638
+ parsed = JSON.parse(spec.json);
21639
+ } catch (err) {
21640
+ throw new Error(`--json is not valid JSON: ${err instanceof Error ? err.message : err}`);
21641
+ }
21642
+ const servers = parsed?.mcpServers ?? parsed?.servers;
21643
+ if (servers && typeof servers === "object") {
21644
+ const entries = Object.entries(servers);
21645
+ if (entries.length !== 1 && !spec.name) {
21646
+ throw new Error(`--json holds ${entries.length} servers; name the one you want: autorouter add <name> --json '…'`);
21647
+ }
21648
+ const picked = spec.name ? entries.find(([n]) => n === spec.name) : entries[0];
21649
+ if (!picked)
21650
+ throw new Error(`--json has no server named "${spec.name}".`);
21651
+ return { name: spec.name ?? picked[0], raw: picked[1] };
21652
+ }
21653
+ if (!spec.name)
21654
+ throw new Error("A name is required: autorouter add <name> --json '…'");
21655
+ return { name: spec.name, raw: parsed };
21656
+ }
21657
+ if (!spec.name)
21658
+ throw new Error("A name is required: autorouter add <name> --url … | --command …");
21659
+ if (!spec.url && !spec.command) {
21660
+ throw new Error(`Nothing to register: pass --url, --command, or -- <command> <args…>`);
21661
+ }
21662
+ return {
21663
+ name: spec.name,
21664
+ raw: {
21665
+ ...spec.url ? { url: spec.url, headers: spec.headers } : {},
21666
+ ...spec.command ? { command: spec.command, args: spec.args ?? [] } : {},
21667
+ ...spec.env ? { env: spec.env } : {}
21668
+ }
21669
+ };
21670
+ }
21671
+ function describeSpec(raw) {
21672
+ return raw.url ? raw.url : `${raw.command} ${(raw.args ?? []).join(" ")}`.trim();
21673
+ }
21674
+ async function runAdd(spec, cwd = process.cwd()) {
21675
+ const { name, raw } = parseAddSpec(spec);
21676
+ const target = describeSpec(raw);
21677
+ const entry = normalizeServer(name, raw, "config");
21678
+ if (!entry) {
21679
+ return { ok: false, name, target, message: `Could not read a server out of that: ${target || "(empty)"}` };
21680
+ }
21681
+ if (isSelfReference(entry)) {
21682
+ return {
21683
+ ok: false,
21684
+ name,
21685
+ target,
21686
+ message: `"${target}" launches autorouter itself. Registering the router with the router would recurse forever.`
21687
+ };
21688
+ }
21689
+ const resolved = await resolveConfig(cwd);
21690
+ const path = await upsertServers({ [name]: raw }, {
21691
+ configPath: resolved.configPath,
21692
+ overwrite: true
21693
+ });
21694
+ let client;
21695
+ let capabilities;
21696
+ let failure;
21697
+ try {
21698
+ client = await connect(entry);
21699
+ capabilities = (await enumerateServer(entry, client)).length;
21700
+ } catch (err) {
21701
+ failure = authHint(name, err);
21702
+ } finally {
21703
+ try {
21704
+ await client?.close();
21705
+ } catch {}
21706
+ }
21707
+ if (failure) {
21708
+ return {
21709
+ ok: false,
21710
+ name,
21711
+ target,
21712
+ path,
21713
+ message: `Registered ${name} in ${path}, but it did not answer: ${failure}`
21714
+ };
21715
+ }
21716
+ const router = await Router.create({ cwd, force: true });
21717
+ await router.close();
21718
+ return {
21719
+ ok: true,
21720
+ name,
21721
+ target,
21722
+ path,
21723
+ capabilities,
21724
+ message: `Added ${name} → ${target}
21725
+ ${capabilities} capabilities, registered in ${path}`
21726
+ };
21727
+ }
21728
+ async function runRemove(name, cwd = process.cwd()) {
21729
+ const resolved = await resolveConfig(cwd);
21730
+ const { removed, path } = await removeServer(name, resolved.configPath);
21731
+ if (!removed)
21732
+ return `No server named "${name}" in ${path}.`;
21733
+ const stillImported = (await resolveConfig(cwd)).servers.some((s) => s.name === name);
21734
+ return stillImported ? `Removed ${name} from ${path}, but a harness still registers it — it will keep appearing. ` + `Add "${name}/*" to "exclude" to hide it entirely.` : `Removed ${name} from ${path}.`;
21735
+ }
21736
+
21737
+ // src/server/index.ts
21738
+ var NAME = "autorouter";
21739
+ var VERSION = "0.1.0";
21740
+ var INSTRUCTIONS = `This server is a capability router. Instead of loading every
21741
+ available tool into your context, it exposes a search interface over all of them.
21742
+
21743
+ 1. find_capabilities({ query }) — say what you are trying to do, in plain
21744
+ language. Top tool hits come back with their input schema attached.
21745
+ 2. call_capability({ id, arguments }) — run any hit by its id. A tool you call
21746
+ more than once is promoted into your tool list automatically.
21747
+ 3. describe_capability({ id }) — the full schema for a hit whose schema was
21748
+ abbreviated, or a skill's instruction text. Reading those instructions is how
21749
+ a skill runs.
21750
+
21751
+ Search before concluding something is impossible: the catalog is much larger
21752
+ than your tool list.`;
21753
+ async function serve(opts = {}) {
21754
+ const router = await Router.create({ cwd: opts.cwd });
21755
+ const server = new Server({ name: NAME, version: VERSION }, {
21756
+ capabilities: {
21757
+ tools: { listChanged: true },
21758
+ prompts: { listChanged: true }
21759
+ },
21760
+ instructions: INSTRUCTIONS
21761
+ });
21762
+ let profile = null;
21763
+ const activated = new Map;
21764
+ const { promptMode, activation, allowAddServer } = router.resolved.config;
21765
+ let pendingNotes = [];
21766
+ const canPromote = () => Boolean(profile?.supportsListChanged) && activation !== "off";
21767
+ const promoteOnUse = async (cap) => {
21768
+ if (activation !== "lazy" || !canPromote())
21769
+ return null;
21770
+ if (cap.kind !== "tool" || !cap.inputSchema)
21771
+ return null;
21772
+ const [name] = await promote(server, activated, [cap]);
21773
+ return name ?? null;
21774
+ };
21775
+ server.oninitialized = () => {
21776
+ profile = profileClient(server.getClientVersion(), server.getClientCapabilities());
21777
+ log(`client: ${profile.name} ${profile.version} — ${profile.rationale}`);
21778
+ };
21779
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
21780
+ refreshAndAnnounce();
21781
+ const tools = routerTools(router, profile, activation, allowAddServer);
21782
+ for (const [name, cap] of activated)
21783
+ tools.push(promotedTool(name, cap));
21784
+ return { tools };
21785
+ });
21786
+ server.setRequestHandler(ListPromptsRequestSchema, async () => {
21787
+ refreshAndAnnounce();
21788
+ return { prompts: promptList(router.catalog.capabilities, promptMode) };
21789
+ });
21790
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
21791
+ const { name, arguments: args = {} } = request.params;
21792
+ if (await router.isStale())
21793
+ await router.refresh();
21794
+ return await handleGetPrompt(router, name, args, promptMode);
21795
+ });
21796
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
21797
+ const { name, arguments: args = {} } = request.params;
21798
+ try {
21799
+ switch (name) {
21800
+ case "find_capabilities":
21801
+ if (await router.isStale())
21802
+ await refreshAndAnnounce();
21803
+ return await handleFind(router, server, profile, activated, activation, args, drainNotes());
21804
+ case "describe_capability":
21805
+ return await handleDescribe(router, args);
21806
+ case "call_capability":
21807
+ return await handleCall(router, promoteOnUse, args);
21808
+ case "activate_capabilities":
21809
+ return await handleActivate(router, server, activated, args);
21810
+ case "deactivate_capabilities":
21811
+ return await handleDeactivate(server, activated, args);
21812
+ case "add_server":
21813
+ return await handleAddServer(router, server, args);
21814
+ default: {
21815
+ const cap = activated.get(name);
21816
+ if (cap)
21817
+ return await invoke(router, cap, args, true);
21818
+ return errorResult(`Unknown tool: ${name}. Use find_capabilities to search.`);
21819
+ }
21820
+ }
21821
+ } catch (err) {
21822
+ return errorResult(err instanceof Error ? err.message : String(err));
21823
+ }
21824
+ });
21825
+ let adopting = false;
21826
+ async function maybeAutoAdopt() {
21827
+ if (adopting)
21828
+ return;
21829
+ adopting = true;
21830
+ try {
21831
+ const adopted = await runAutoAdopt(router.resolved.cwd);
21832
+ for (const note of adopted) {
21833
+ log(note);
21834
+ pendingNotes.push(`${note} — restart the harness to reclaim its context.`);
21835
+ }
21836
+ if (adopted.length)
21837
+ await router.refresh({ force: true });
21838
+ } catch {} finally {
21839
+ adopting = false;
21840
+ }
21841
+ }
21842
+ async function refreshAndAnnounce() {
21843
+ const before = router.summary();
21844
+ const wasStale = await router.isStale();
21845
+ await router.refresh();
21846
+ if (wasStale)
21847
+ await maybeAutoAdopt();
21848
+ if (router.summary() === before)
21849
+ return;
21850
+ log(`catalog changed — ${router.summary()}`);
21851
+ server.sendToolListChanged?.();
21852
+ server.sendPromptListChanged?.();
21853
+ }
21854
+ function drainNotes() {
21855
+ const notes = pendingNotes;
21856
+ pendingNotes = [];
21857
+ return notes;
21858
+ }
21859
+ const transport = new StdioServerTransport;
21860
+ await server.connect(transport);
21861
+ log(`ready — ${router.summary()}`);
21862
+ await new Promise((resolve2) => {
21863
+ let done = false;
21864
+ const finish = () => {
21865
+ if (done)
21866
+ return;
21867
+ done = true;
21868
+ router.close().catch(() => {});
21869
+ resolve2();
21870
+ };
21871
+ server.onclose = finish;
21872
+ transport.onclose = finish;
21873
+ process.on("SIGINT", finish);
21874
+ process.on("SIGTERM", finish);
21875
+ process.stdin.on("end", finish);
21876
+ });
21877
+ }
21878
+ function routerTools(router, profile, activation, allowAddServer) {
21879
+ const tools = [
21880
+ {
21881
+ name: "find_capabilities",
21882
+ description: `Search every available tool, skill, prompt and command by what you are trying to do, and get back only the ones that fit. Catalog: ${router.summary()}. Use this whenever a task might need a capability you cannot already see — searching is cheap and the catalog is much larger than your tool list.`,
21883
+ inputSchema: {
21884
+ type: "object",
21885
+ properties: {
21886
+ query: {
21887
+ type: "string",
21888
+ description: "What you are trying to accomplish, in plain language. Describe the goal, not a guessed tool name."
21889
+ },
21890
+ kind: {
21891
+ type: "string",
21892
+ enum: ["tool", "skill", "prompt", "resource", "command", "agent"],
21893
+ description: "Optional: restrict results to one kind of capability."
21894
+ },
21895
+ server: { type: "string", description: "Optional: restrict to one provider." },
21896
+ limit: { type: "number", description: "Max results (default 8)." }
21897
+ },
21898
+ required: ["query"]
21899
+ }
21900
+ },
21901
+ {
21902
+ name: "describe_capability",
21903
+ description: "Get full detail for one capability id from find_capabilities: the complete input schema for a tool, or the full instruction text for a skill or command.",
21904
+ inputSchema: {
21905
+ type: "object",
21906
+ properties: { id: { type: "string", description: "Capability id, e.g. mcp:supabase/execute_sql" } },
21907
+ required: ["id"]
21908
+ }
21909
+ },
21910
+ {
21911
+ name: "call_capability",
21912
+ description: "Run a capability by id and return its result. Works for tools, prompts and resources; skills and commands are executed by following the text from describe_capability instead.",
21913
+ inputSchema: {
21914
+ type: "object",
21915
+ properties: {
21916
+ id: { type: "string", description: "Capability id to invoke." },
21917
+ arguments: { type: "object", description: "Arguments matching the capability's input schema." },
21918
+ confirm: {
21919
+ type: "boolean",
21920
+ description: "Set true to proceed with a capability that requires explicit confirmation."
21921
+ }
21922
+ },
21923
+ required: ["id"]
21924
+ }
21925
+ }
21926
+ ];
21927
+ if (profile?.supportsListChanged && activation !== "off") {
21928
+ tools.push({
21929
+ name: "activate_capabilities",
21930
+ description: "Promote capabilities to real, first-class tools in your tool list so you can call them directly with full schema validation. Use after find_capabilities when you expect to call something several times.",
21931
+ inputSchema: {
21932
+ type: "object",
21933
+ properties: {
21934
+ ids: { type: "array", items: { type: "string" }, description: "Capability ids to promote." }
21935
+ },
21936
+ required: ["ids"]
21937
+ }
21938
+ }, {
21939
+ name: "deactivate_capabilities",
21940
+ description: "Remove previously activated capabilities from your tool list to reclaim context.",
21941
+ inputSchema: {
21942
+ type: "object",
21943
+ properties: {
21944
+ ids: { type: "array", items: { type: "string" }, description: "Ids to remove; omit to remove all." }
21945
+ }
21946
+ }
21947
+ });
21948
+ }
21949
+ if (allowAddServer) {
21950
+ tools.push({
21951
+ name: "add_server",
21952
+ description: 'Register a new MCP server behind the router, instead of running `claude mcp add`. Pass url or command+args, or json for a pasted {"mcpServers":{…}} snippet. Rejected without confirm: true.',
21953
+ inputSchema: {
21954
+ type: "object",
21955
+ properties: {
21956
+ name: { type: "string" },
21957
+ url: { type: "string" },
21958
+ command: { type: "string" },
21959
+ args: { type: "array", items: { type: "string" } },
21960
+ env: { type: "object" },
21961
+ headers: { type: "object" },
21962
+ json: { type: "string" },
21963
+ confirm: { type: "boolean" }
21964
+ },
21965
+ required: ["name"]
21966
+ }
21967
+ });
21968
+ }
21969
+ return tools;
21970
+ }
21971
+ async function handleAddServer(router, server, args) {
21972
+ let target;
21973
+ let name;
21974
+ try {
21975
+ const parsed = parseAddSpec(args);
21976
+ name = parsed.name;
21977
+ target = describeSpec(parsed.raw);
21978
+ } catch (err) {
21979
+ return errorResult(err instanceof Error ? err.message : String(err));
21980
+ }
21981
+ if (args.confirm !== true) {
21982
+ return textResult([
21983
+ `Confirmation required before registering "${name}".`,
21984
+ `This machine will ${args.url || target.startsWith("http") ? "connect to" : "execute"}: ${target}`,
21985
+ "",
21986
+ "Show the user that line. If it is what they asked for, call add_server again with confirm: true."
21987
+ ].join(`
21988
+ `));
21989
+ }
21990
+ const result = await runAdd(args, router.resolved.cwd);
21991
+ if (!result.ok)
21992
+ return errorResult(result.message);
21993
+ await router.refresh({ force: true });
21994
+ server.sendToolListChanged?.();
21995
+ server.sendPromptListChanged?.();
21996
+ return textResult(`${result.message}
21997
+ Search for them with find_capabilities.`);
21998
+ }
21999
+ function promotedTool(name, cap) {
21414
22000
  return {
21415
22001
  name,
21416
22002
  description: clamp(cap.description || `${cap.kind} ${cap.name}`, PROMOTED_DESC_CHARS),
@@ -21421,7 +22007,7 @@ var PROMOTED_DESC_CHARS = 320;
21421
22007
  function promotedCost(name, cap) {
21422
22008
  return tokensOf(promotedTool(name, cap));
21423
22009
  }
21424
- async function handleGetPrompt(router, name, args) {
22010
+ async function handleGetPrompt(router, name, args, mode) {
21425
22011
  if (name === FIND_PROMPT) {
21426
22012
  const query = (args.query ?? "").trim();
21427
22013
  if (!query)
@@ -21440,7 +22026,7 @@ async function handleGetPrompt(router, name, args) {
21440
22026
  ]
21441
22027
  };
21442
22028
  }
21443
- const cap = capabilityForPrompt(router.catalog.capabilities, name);
22029
+ const cap = capabilityForPrompt(router.catalog.capabilities, name, mode);
21444
22030
  if (!cap)
21445
22031
  throw new Error(`Unknown prompt: ${name}`);
21446
22032
  const found = await router.describe(cap.id);
@@ -21451,7 +22037,7 @@ async function handleGetPrompt(router, name, args) {
21451
22037
  }
21452
22038
  var AUTO_ACTIVATE_LIMIT = 5;
21453
22039
  var ACTIVE_TOKEN_BUDGET = 3000;
21454
- async function handleFind(router, server, profile, activated, args) {
22040
+ async function handleFind(router, server, profile, activated, activation, args, notes = []) {
21455
22041
  const query = (args.query ?? "").trim();
21456
22042
  if (!query)
21457
22043
  return errorResult("query is required");
@@ -21462,587 +22048,301 @@ async function handleFind(router, server, profile, activated, args) {
21462
22048
  harness: profile?.name,
21463
22049
  server_handle: server
21464
22050
  });
21465
- const promoted = profile?.supportsListChanged ? await promote(server, activated, result.hits.map((h) => h.capability)) : [];
21466
- const text = renderRouteResult(query, result, { inlineSchemas: promoted.length === 0 });
22051
+ const promoted = activation === "eager" && profile?.supportsListChanged ? await promote(server, activated, result.hits.map((h) => h.capability)) : [];
22052
+ const body = renderRouteResult(query, result, { inlineSchemas: promoted.length === 0 });
22053
+ const text = notes.length ? `${notes.map((n) => `[autorouter] ${n}`).join(`
22054
+ `)}
22055
+
22056
+ ${body}` : body;
21467
22057
  if (!promoted.length)
21468
22058
  return textResult(text);
21469
- return textResult(`${text}
21470
-
21471
- Now callable directly as: ${promoted.join(", ")}.
21472
- ` + "If they are not yet in your tool list, use call_capability with the ids above — the result is identical.");
21473
- }
21474
- async function promote(server, activated, caps) {
21475
- const names = [];
21476
- let changed = false;
21477
- for (const cap of caps) {
21478
- if (cap.kind !== "tool" || !cap.inputSchema)
21479
- continue;
21480
- if (names.length >= AUTO_ACTIVATE_LIMIT)
21481
- break;
21482
- const name = exposedName(cap);
21483
- names.push(name);
21484
- if (activated.get(name)?.id !== cap.id)
21485
- changed = true;
21486
- activated.delete(name);
21487
- activated.set(name, cap);
21488
- }
21489
- if (evictToBudget(activated, names))
21490
- changed = true;
21491
- if (changed)
21492
- await server.sendToolListChanged();
21493
- return names;
21494
- }
21495
- function evictToBudget(activated, keep) {
21496
- let total = 0;
21497
- for (const [name, cap] of activated)
21498
- total += promotedCost(name, cap);
21499
- if (total <= ACTIVE_TOKEN_BUDGET)
21500
- return false;
21501
- const protectedNames = new Set(keep);
21502
- let changed = false;
21503
- for (const [name, cap] of activated) {
21504
- if (total <= ACTIVE_TOKEN_BUDGET)
21505
- break;
21506
- if (protectedNames.has(name))
21507
- continue;
21508
- total -= promotedCost(name, cap);
21509
- activated.delete(name);
21510
- changed = true;
21511
- }
21512
- return changed;
21513
- }
21514
- async function handleDescribe(router, args) {
21515
- if (!args.id)
21516
- return errorResult("id is required");
21517
- const found = await router.describe(args.id);
21518
- if (!found) {
21519
- return errorResult(`No capability with id "${args.id}". Use find_capabilities to search.`);
21520
- }
21521
- return textResult(renderCapability(found.capability, found.body));
21522
- }
21523
- async function handleCall(router, args) {
21524
- if (!args.id)
21525
- return errorResult("id is required");
21526
- const cap = router.get(args.id);
21527
- if (!cap)
21528
- return errorResult(`No capability with id "${args.id}".`);
21529
- return invoke(router, cap, args.arguments ?? {}, args.confirm === true);
21530
- }
21531
- async function invoke(router, cap, args, confirmed) {
21532
- try {
21533
- const out = await router.call(cap.id, args, { confirmed });
21534
- if (out.capability.kind === "skill" || out.capability.kind === "command" || out.capability.kind === "agent") {
21535
- return { content: out.content, isError: false };
21536
- }
21537
- return {
21538
- content: [{ type: "text", text: `[${out.source}]` }, ...out.content],
21539
- isError: out.isError,
21540
- ...out.structuredContent ? { structuredContent: out.structuredContent } : {}
21541
- };
21542
- } catch (err) {
21543
- return errorResult(err instanceof Error ? err.message : String(err));
21544
- }
21545
- }
21546
- async function handleActivate(router, server, activated, args) {
21547
- const ids = args.ids ?? [];
21548
- if (!ids.length)
21549
- return errorResult("ids is required");
21550
- const added = [];
21551
- const missing = [];
21552
- for (const id of ids) {
21553
- const cap = router.get(id);
21554
- if (!cap || cap.kind !== "tool") {
21555
- missing.push(id);
21556
- continue;
21557
- }
21558
- const toolName = exposedName(cap);
21559
- activated.set(toolName, cap);
21560
- added.push(toolName);
21561
- }
21562
- if (added.length)
21563
- await server.sendToolListChanged();
21564
- const lines = [
21565
- added.length ? `Activated as tools: ${added.join(", ")}` : "Nothing activated.",
21566
- missing.length ? `Not activatable (unknown, or not a tool): ${missing.join(", ")}` : "",
21567
- added.length ? "If these do not appear in your tool list yet, call them through call_capability instead — the result is identical." : ""
21568
- ].filter(Boolean);
21569
- return textResult(lines.join(`
21570
- `));
21571
- }
21572
- async function handleDeactivate(server, activated, args) {
21573
- const before = activated.size;
21574
- if (!args.ids?.length)
21575
- activated.clear();
21576
- else {
21577
- for (const id of args.ids) {
21578
- for (const [name, cap] of activated) {
21579
- if (cap.id === id || name === id)
21580
- activated.delete(name);
21581
- }
21582
- }
21583
- }
21584
- if (activated.size !== before)
21585
- await server.sendToolListChanged();
21586
- return textResult(`Active tools: ${activated.size} (was ${before}).`);
21587
- }
21588
- function exposedName(cap) {
21589
- const parsed = parseCapabilityId(cap.id);
21590
- const raw = parsed.server ? `${parsed.server}__${cap.name}` : cap.name;
21591
- return raw.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
21592
- }
21593
- function textResult(text) {
21594
- return { content: [{ type: "text", text }] };
21595
- }
21596
- function errorResult(text) {
21597
- return { content: [{ type: "text", text }], isError: true };
21598
- }
21599
- function log(message) {
21600
- process.stderr.write(`[autorouter] ${message}
21601
- `);
21602
- }
21603
-
21604
- // src/cli/init.ts
21605
- import { join as join14 } from "node:path";
21606
- import { createInterface } from "node:readline/promises";
21607
- var HARNESS_LABEL = {
21608
- claude: "Claude Code",
21609
- codex: "Codex CLI",
21610
- cursor: "Cursor",
21611
- vscode: "VS Code / Copilot"
21612
- };
21613
- async function runInit(opts) {
21614
- const notes = [];
21615
- const env = {};
21616
- const suggested = await suggestModel(opts.harness);
21617
- const model = opts.yes ? suggested : await promptModel(opts.harness, suggested);
21618
- if (model) {
21619
- env.AUTOROUTER_SELECTOR_MODEL = model;
21620
- notes.push(`Selector model: ${model}`);
21621
- } else {
21622
- notes.push("No selector model set — the router will use MCP sampling if the host offers it, otherwise raw index ranking.");
21623
- }
21624
- switch (opts.harness) {
21625
- case "claude":
21626
- notes.push(await writeJsonServer(join14(homeDir(), ".claude.json"), "mcpServers", opts, env));
21627
- break;
21628
- case "cursor":
21629
- notes.push(await writeJsonServer(join14(homeDir(), ".cursor", "mcp.json"), "mcpServers", opts, env));
21630
- break;
21631
- case "vscode":
21632
- notes.push(await writeJsonServer(join14(homeDir(), ".vscode", "mcp.json"), "servers", opts, env));
21633
- break;
21634
- case "codex":
21635
- notes.push(await writeCodexServer(opts, env));
21636
- break;
21637
- }
21638
- notes.push(await writePrimer(opts.harness));
21639
- return notes;
21640
- }
21641
- async function writeJsonServer(path, key, opts, env) {
21642
- await ensureDir(join14(path, ".."));
21643
- const existing = await readText(path) ?? "{}";
21644
- let config2;
21645
- try {
21646
- config2 = JSON.parse(existing);
21647
- } catch {
21648
- return `Could not parse ${path}; add the server manually.`;
21649
- }
21650
- config2[key] ??= {};
21651
- config2[key].autorouter = {
21652
- command: opts.command,
21653
- args: opts.args,
21654
- ...Object.keys(env).length ? { env } : {}
21655
- };
21656
- await writeText(path, `${JSON.stringify(config2, null, 2)}
21657
- `);
21658
- return `Registered autorouter in ${path}`;
21659
- }
21660
- async function writeCodexServer(opts, env) {
21661
- const path = join14(homeDir(), ".codex", "config.toml");
21662
- await ensureDir(join14(path, ".."));
21663
- const text = await readText(path) ?? "";
21664
- let config2;
21665
- try {
21666
- config2 = text ? parse(text) : {};
21667
- } catch {
21668
- return `Could not parse ${path}; add [mcp_servers.autorouter] manually.`;
21669
- }
21670
- config2.mcp_servers ??= {};
21671
- config2.mcp_servers.autorouter = {
21672
- command: opts.command,
21673
- args: opts.args,
21674
- startup_timeout_sec: 60,
21675
- ...Object.keys(env).length ? { env } : {}
21676
- };
21677
- await writeText(path, stringify(config2));
21678
- return `Registered autorouter in ${path}`;
21679
- }
21680
- async function writePrimer(harness) {
21681
- const targets = {
21682
- claude: join14(homeDir(), ".claude", "CLAUDE.md"),
21683
- codex: join14(homeDir(), ".codex", "AGENTS.md"),
21684
- cursor: join14(process.cwd(), ".cursor", "rules", "autorouter.mdc"),
21685
- vscode: join14(process.cwd(), ".github", "copilot-instructions.md")
21686
- };
21687
- const path = targets[harness];
21688
- await ensureDir(join14(path, ".."));
21689
- const current = await readText(path) ?? "";
21690
- if (current.includes("<!-- autorouter -->"))
21691
- return `Primer already present in ${path}`;
21692
- const section = harness === "cursor" ? `---
21693
- description: Capability router
21694
- alwaysApply: true
21695
- ---
21696
-
21697
- ${PRIMER}` : PRIMER;
21698
- await writeText(path, current ? `${current.trimEnd()}
21699
-
21700
- ${section}
21701
- ` : `${section}
21702
- `);
21703
- return `Added router primer to ${path}`;
21704
- }
21705
- var PRIMER = `<!-- autorouter -->
21706
- ## Capability router
21707
-
21708
- Most tools, skills and commands on this machine are not in your tool list. They
21709
- are behind \`find_capabilities\`.
21710
-
21711
- Before deciding a task cannot be done, or reaching for a manual workaround, call
21712
- \`find_capabilities({ query: "<what you are trying to do>" })\`. It searches every
21713
- configured MCP server, skill and plugin command and returns only what fits.
21714
- Then \`describe_capability\` for the schema, and \`call_capability\` to run it.
21715
- <!-- /autorouter -->`;
21716
- async function promptModel(harness, suggested) {
21717
- if (!process.stdin.isTTY)
21718
- return suggested;
21719
- const rl = createInterface({ input: process.stdin, output: process.stdout });
21720
- try {
21721
- console.log(`
21722
- The router uses a small model to pick which capabilities fit a request.
21723
- ` + `It should be the cheapest model available in ${HARNESS_LABEL[harness]}.`);
21724
- const answer = (await rl.question(suggested ? `Selector model [${suggested}] (enter to accept, "none" to skip): ` : "Selector model (blank to skip): ")).trim();
21725
- if (answer.toLowerCase() === "none")
21726
- return null;
21727
- return answer || suggested;
21728
- } finally {
21729
- rl.close();
21730
- }
21731
- }
21732
-
21733
- // src/cli/adopt.ts
21734
- import { join as join16 } from "node:path";
22059
+ return textResult(`${text}
21735
22060
 
21736
- // src/cli/adoptExtras.ts
21737
- import { join as join15 } from "node:path";
21738
- function settingsPath() {
21739
- return join15(homeDir(), ".claude", "settings.json");
22061
+ Now callable directly as: ${promoted.join(", ")}.
22062
+ ` + "If they are not yet in your tool list, use call_capability with the ids above — the result is identical.");
21740
22063
  }
21741
- async function planExtras(opts) {
21742
- const file = settingsPath();
21743
- const settings = await readSettings(file) ?? {};
21744
- const plan = { file, skills: [], plugins: [], skipped: [] };
21745
- const keepSkills = new Set(opts.keepSkills);
21746
- const keepPlugins = new Set(opts.keepPlugins);
21747
- const plugins = await listInstalledPlugins();
21748
- const pluginByName = new Map(plugins.map((p) => [p.name, p]));
21749
- const disabledPluginNames = new Set;
21750
- const untouched = new Set;
21751
- for (const plugin of plugins) {
21752
- if (keepPlugins.has(plugin.name) || keepPlugins.has(plugin.key)) {
21753
- untouched.add(plugin.name);
21754
- continue;
21755
- }
21756
- if (settings.enabledPlugins?.[plugin.key] === false) {
21757
- plan.skipped.push(`plugin ${plugin.name} (already disabled)`);
21758
- disabledPluginNames.add(plugin.name);
21759
- continue;
21760
- }
21761
- const servers = await pluginServerNames(plugin.root);
21762
- const broken = servers.map((s) => opts.unreachable?.has(`${plugin.name}:${s}`) ? `${plugin.name}:${s}` : opts.unreachable?.has(s) ? s : null).filter((s) => s !== null);
21763
- if (broken.length) {
21764
- plan.skipped.push(`plugin ${plugin.name} (the router cannot reach ${broken.join(", ")} — run: ${broken.map((s) => `autorouter login ${s}`).join("; ")})`);
21765
- untouched.add(plugin.name);
21766
- continue;
21767
- }
21768
- const unrouted = servers.filter((s) => !opts.routedServers.has(s) && !opts.routedServers.has(`${plugin.name}:${s}`));
21769
- if (unrouted.length) {
21770
- plan.skipped.push(`plugin ${plugin.name} (${unrouted.length > 1 ? `its servers ${unrouted.join(", ")} are` : `its server ${unrouted[0]} is`} not routed yet — adopt ${unrouted.length > 1 ? "them" : "it"} first)`);
21771
- continue;
21772
- }
21773
- plan.plugins.push({
21774
- id: plugin.key,
21775
- name: plugin.name,
21776
- reason: servers.length ? `${servers.length} server(s) already routed` : "skills only"
21777
- });
21778
- disabledPluginNames.add(plugin.name);
21779
- }
21780
- const { capabilities } = await collectSkills(opts.skillPaths, opts.cwd, { includePlugins: true });
21781
- for (const cap of capabilities) {
21782
- if (cap.server && (disabledPluginNames.has(cap.server) || untouched.has(cap.server)))
21783
- continue;
21784
- if (keepSkills.has(cap.name))
21785
- continue;
21786
- const current = settings.skillOverrides?.[cap.name];
21787
- if (current === "off" || current === "user-invocable-only") {
21788
- plan.skipped.push(`skill ${cap.name} (already ${current})`);
22064
+ async function promote(server, activated, caps) {
22065
+ const names = [];
22066
+ let changed = false;
22067
+ for (const cap of caps) {
22068
+ if (cap.kind !== "tool" || !cap.inputSchema)
21789
22069
  continue;
21790
- }
21791
- plan.skills.push({ name: cap.name, mode: opts.mode, from: cap.server ?? null });
22070
+ if (names.length >= AUTO_ACTIVATE_LIMIT)
22071
+ break;
22072
+ const name = exposedName(cap);
22073
+ names.push(name);
22074
+ if (activated.get(name)?.id !== cap.id)
22075
+ changed = true;
22076
+ activated.delete(name);
22077
+ activated.set(name, cap);
21792
22078
  }
21793
- return plan;
21794
- }
21795
- async function applyExtras(plan) {
21796
- const settings = await readSettings(plan.file) ?? {};
21797
- settings.skillOverrides = { ...settings.skillOverrides };
21798
- settings.enabledPlugins = { ...settings.enabledPlugins };
21799
- for (const s of plan.skills)
21800
- settings.skillOverrides[s.name] = s.mode;
21801
- for (const p of plan.plugins)
21802
- settings.enabledPlugins[p.id] = false;
21803
- await writeText(plan.file, `${JSON.stringify(settings, null, 2)}
21804
- `);
22079
+ if (evictToBudget(activated, names))
22080
+ changed = true;
22081
+ if (changed)
22082
+ await server.sendToolListChanged();
22083
+ return names;
21805
22084
  }
21806
- async function readSettings(file) {
21807
- if (!await exists(file))
21808
- return null;
21809
- const text = await readText(file);
21810
- if (!text?.trim())
21811
- return null;
21812
- try {
21813
- return JSON.parse(stripJsonComments(text));
21814
- } catch {
21815
- return null;
22085
+ function evictToBudget(activated, keep) {
22086
+ let total = 0;
22087
+ for (const [name, cap] of activated)
22088
+ total += promotedCost(name, cap);
22089
+ if (total <= ACTIVE_TOKEN_BUDGET)
22090
+ return false;
22091
+ const protectedNames = new Set(keep);
22092
+ let changed = false;
22093
+ for (const [name, cap] of activated) {
22094
+ if (total <= ACTIVE_TOKEN_BUDGET)
22095
+ break;
22096
+ if (protectedNames.has(name))
22097
+ continue;
22098
+ total -= promotedCost(name, cap);
22099
+ activated.delete(name);
22100
+ changed = true;
21816
22101
  }
22102
+ return changed;
21817
22103
  }
21818
- async function pluginServerNames(root) {
21819
- const manifestText = await readText(join15(root, ".claude-plugin", "plugin.json"));
21820
- if (!manifestText)
21821
- return [];
21822
- let manifest;
21823
- try {
21824
- manifest = JSON.parse(stripJsonComments(manifestText));
21825
- } catch {
21826
- return [];
21827
- }
21828
- if (!manifest.mcpServers)
21829
- return [];
21830
- if (typeof manifest.mcpServers !== "string")
21831
- return Object.keys(manifest.mcpServers);
21832
- const rel = manifest.mcpServers.replace(/^\.\//, "");
21833
- const text = await readText(join15(root, rel));
21834
- if (!text)
21835
- return [];
21836
- try {
21837
- const loaded = JSON.parse(stripJsonComments(text));
21838
- return Object.keys(loaded.mcpServers ?? loaded ?? {});
21839
- } catch {
21840
- return [];
22104
+ async function handleDescribe(router, args) {
22105
+ if (!args.id)
22106
+ return errorResult("id is required");
22107
+ const found = await router.describe(args.id);
22108
+ if (!found) {
22109
+ return errorResult(`No capability with id "${args.id}". Use find_capabilities to search.`);
21841
22110
  }
22111
+ return textResult(renderCapability(found.capability, found.body));
21842
22112
  }
21843
-
21844
- // src/cli/adopt.ts
21845
- async function runAdopt(opts) {
21846
- const resolved = await resolveConfig(opts.cwd);
21847
- const keep = new Set([
21848
- "autorouter",
21849
- ...opts.keep,
21850
- ...serversBehind(resolved.config.alwaysExpose)
21851
- ]);
21852
- const plans = [];
21853
- const adopted = {};
21854
- const backup = {};
21855
- const docs = new Map;
21856
- const apply = (site, entries) => {
21857
- for (const [name, spec] of Object.entries(entries)) {
21858
- if (keep.has(name)) {
21859
- site.kept.push(name);
21860
- continue;
21861
- }
21862
- site.moved.push(name);
21863
- adopted[name] ??= spec;
21864
- delete entries[name];
21865
- }
22113
+ async function handleCall(router, promoteOnUse, args) {
22114
+ if (!args.id)
22115
+ return errorResult("id is required");
22116
+ const cap = router.get(args.id);
22117
+ if (!cap)
22118
+ return errorResult(`No capability with id "${args.id}".`);
22119
+ const out = await invoke(router, cap, args.arguments ?? {}, args.confirm === true);
22120
+ if (out.isError)
22121
+ return out;
22122
+ const name = await promoteOnUse(cap);
22123
+ if (!name)
22124
+ return out;
22125
+ return {
22126
+ ...out,
22127
+ content: [
22128
+ ...out.content,
22129
+ {
22130
+ type: "text",
22131
+ text: `(now in your tool list as ${name} — call it directly next time)`
22132
+ }
22133
+ ]
21866
22134
  };
21867
- if (opts.harness === "codex") {
21868
- const file = join16(homeDir(), ".codex", "config.toml");
21869
- const text = await readText(file);
21870
- if (text) {
21871
- let config2;
21872
- try {
21873
- config2 = parse(text);
21874
- } catch {
21875
- config2 = null;
21876
- }
21877
- if (config2) {
21878
- const plan = { harness: "codex", file, section: "mcp_servers", moved: [], kept: [] };
21879
- backup[file] = text;
21880
- docs.set(file, config2);
21881
- apply(plan, config2.mcp_servers ?? {});
21882
- plans.push(plan);
21883
- }
21884
- }
21885
- } else {
21886
- for (const site of jsonSites(opts.harness, opts.cwd)) {
21887
- if (!docs.has(site.file)) {
21888
- const text = await readText(site.file);
21889
- if (!text)
21890
- continue;
21891
- try {
21892
- docs.set(site.file, JSON.parse(stripJsonComments(text)));
21893
- } catch {
21894
- continue;
21895
- }
21896
- backup[site.file] = text;
21897
- }
21898
- const config2 = docs.get(site.file);
21899
- const container = site.path.reduce((acc, k) => acc?.[k], config2);
21900
- if (!container || typeof container !== "object")
21901
- continue;
21902
- const plan = {
21903
- harness: opts.harness,
21904
- file: site.file,
21905
- section: site.section,
21906
- moved: [],
21907
- kept: []
21908
- };
21909
- apply(plan, container);
21910
- plans.push(plan);
22135
+ }
22136
+ async function invoke(router, cap, args, confirmed) {
22137
+ try {
22138
+ const out = await router.call(cap.id, args, { confirmed });
22139
+ if (out.capability.kind === "skill" || out.capability.kind === "command" || out.capability.kind === "agent") {
22140
+ return { content: out.content, isError: false };
21911
22141
  }
21912
- }
21913
- const routedServers = new Set([
21914
- ...resolved.servers.map((s) => s.name),
21915
- ...resolved.servers.flatMap((s) => s.name.includes(":") ? [s.name.split(":").pop()] : []),
21916
- ...Object.keys(adopted)
21917
- ]);
21918
- const extras = opts.extras && opts.harness === "claude" ? await planExtras({
21919
- cwd: opts.cwd,
21920
- skillPaths: resolved.config.skillPaths,
21921
- keepSkills: opts.keepSkills ?? [],
21922
- keepPlugins: opts.keepPlugins ?? [],
21923
- mode: opts.skillMode ?? "user-invocable-only",
21924
- routedServers,
21925
- unreachable: opts.unreachable
21926
- }) : null;
21927
- const extrasCount = (extras?.skills.length ?? 0) + (extras?.plugins.length ?? 0);
21928
- const movedCount = plans.reduce((n, p) => n + p.moved.length, 0);
21929
- if (!movedCount && !extrasCount) {
21930
22142
  return {
21931
- plans,
21932
- extras,
21933
- backup: null,
21934
- notes: ["Nothing to adopt — no downstream servers, skills or plugins are loaded directly by this harness."]
22143
+ content: [{ type: "text", text: `[${out.source}]` }, ...out.content],
22144
+ isError: out.isError,
22145
+ ...out.structuredContent ? { structuredContent: out.structuredContent } : {}
21935
22146
  };
22147
+ } catch (err) {
22148
+ return errorResult(err instanceof Error ? err.message : String(err));
21936
22149
  }
21937
- if (opts.dryRun)
21938
- return { plans, extras, backup: null, notes: ["Dry run — no files were changed."] };
21939
- if (extrasCount) {
21940
- const text = await readText(settingsPath());
21941
- if (text !== null)
21942
- backup[settingsPath()] = text;
21943
- }
21944
- const backupPath = join16(backupDir(), `${opts.harness}-${stamp()}.json`);
21945
- await ensureDir(join16(backupPath, ".."));
21946
- await writeText(backupPath, `${JSON.stringify({ harness: opts.harness, files: backup }, null, 2)}
21947
- `);
21948
- const touched = new Set(plans.filter((p) => p.moved.length).map((p) => p.file));
21949
- for (const file of touched) {
21950
- const config2 = docs.get(file);
21951
- await writeText(file, file.endsWith(".toml") ? stringify(config2) : `${JSON.stringify(config2, null, 2)}
21952
- `);
22150
+ }
22151
+ async function handleActivate(router, server, activated, args) {
22152
+ const ids = args.ids ?? [];
22153
+ if (!ids.length)
22154
+ return errorResult("ids is required");
22155
+ const added = [];
22156
+ const missing = [];
22157
+ for (const id of ids) {
22158
+ const cap = router.get(id);
22159
+ if (!cap || cap.kind !== "tool") {
22160
+ missing.push(id);
22161
+ continue;
22162
+ }
22163
+ const toolName = exposedName(cap);
22164
+ activated.set(toolName, cap);
22165
+ added.push(toolName);
21953
22166
  }
21954
- if (movedCount)
21955
- await mergeIntoRouterConfig(adopted, resolved.configPath);
21956
- if (extras && extrasCount)
21957
- await applyExtras(extras);
21958
- const notes = [];
21959
- if (movedCount)
21960
- notes.push(`Moved ${movedCount} server(s) into the router's config.`);
21961
- if (extras?.skills.length) {
21962
- notes.push(`Hid ${extras.skills.length} skill(s) from the model` + (extras.skills[0]?.mode === "user-invocable-only" ? " — /name still works for you." : "."));
22167
+ if (added.length)
22168
+ await server.sendToolListChanged();
22169
+ const lines = [
22170
+ added.length ? `Activated as tools: ${added.join(", ")}` : "Nothing activated.",
22171
+ missing.length ? `Not activatable (unknown, or not a tool): ${missing.join(", ")}` : "",
22172
+ added.length ? "If these do not appear in your tool list yet, call them through call_capability instead — the result is identical." : ""
22173
+ ].filter(Boolean);
22174
+ return textResult(lines.join(`
22175
+ `));
22176
+ }
22177
+ async function handleDeactivate(server, activated, args) {
22178
+ const before = activated.size;
22179
+ if (!args.ids?.length)
22180
+ activated.clear();
22181
+ else {
22182
+ for (const id of args.ids) {
22183
+ for (const [name, cap] of activated) {
22184
+ if (cap.id === id || name === id)
22185
+ activated.delete(name);
22186
+ }
22187
+ }
21963
22188
  }
21964
- if (extras?.plugins.length)
21965
- notes.push(`Disabled ${extras.plugins.length} plugin(s).`);
21966
- notes.push(`Backup at ${backupPath}`);
21967
- notes.push("Restart the harness — its tool list should now show only the router.");
21968
- return { plans, extras, backup: backupPath, notes };
22189
+ if (activated.size !== before)
22190
+ await server.sendToolListChanged();
22191
+ return textResult(`Active tools: ${activated.size} (was ${before}).`);
21969
22192
  }
21970
- function backupDir() {
21971
- return join16(homeDir(), ".autorouter", "adopted");
22193
+ function exposedName(cap) {
22194
+ const parsed = parseCapabilityId(cap.id);
22195
+ const raw = parsed.server ? `${parsed.server}__${cap.name}` : cap.name;
22196
+ return raw.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
21972
22197
  }
21973
- function stamp() {
21974
- return new Date().toISOString().replace(/[:.]/g, "-");
22198
+ function textResult(text) {
22199
+ return { content: [{ type: "text", text }] };
21975
22200
  }
21976
- async function runRestore(harness) {
21977
- const dir = backupDir();
21978
- const { readdir: readdir2 } = await import("node:fs/promises");
21979
- let files;
21980
- try {
21981
- files = (await readdir2(dir)).filter((f) => f.startsWith(`${harness}-`)).sort();
21982
- } catch {
21983
- return [`No adoption backups found for ${harness}.`];
21984
- }
21985
- const latest = files.at(-1);
21986
- if (!latest)
21987
- return [`No adoption backups found for ${harness}.`];
21988
- const payload = JSON.parse(await readText(join16(dir, latest)));
22201
+ function errorResult(text) {
22202
+ return { content: [{ type: "text", text }], isError: true };
22203
+ }
22204
+ function log(message) {
22205
+ process.stderr.write(`[autorouter] ${message}
22206
+ `);
22207
+ }
22208
+
22209
+ // src/cli/init.ts
22210
+ import { join as join17 } from "node:path";
22211
+ import { createInterface } from "node:readline/promises";
22212
+ var HARNESS_LABEL = {
22213
+ claude: "Claude Code",
22214
+ codex: "Codex CLI",
22215
+ cursor: "Cursor",
22216
+ vscode: "VS Code / Copilot"
22217
+ };
22218
+ async function runInit(opts) {
21989
22219
  const notes = [];
21990
- for (const [file, text] of Object.entries(payload.files)) {
21991
- await writeText(file, text);
21992
- notes.push(`Restored ${file}`);
22220
+ const env = {};
22221
+ const suggested = await suggestModel(opts.harness);
22222
+ const model = opts.yes ? suggested : await promptModel(opts.harness, suggested);
22223
+ if (model) {
22224
+ env.AUTOROUTER_SELECTOR_MODEL = model;
22225
+ notes.push(`Selector model: ${model}`);
22226
+ } else {
22227
+ notes.push("No selector model set — the router will use MCP sampling if the host offers it, otherwise raw index ranking.");
21993
22228
  }
21994
- notes.push(`From ${join16(dir, latest)}. The router's own config still lists these servers; that is harmless (duplicates are deduped) but you can remove them.`);
21995
- return notes;
21996
- }
21997
- function jsonSites(harness, cwd) {
21998
- switch (harness) {
22229
+ switch (opts.harness) {
21999
22230
  case "claude":
22000
- return [
22001
- { file: join16(homeDir(), ".claude.json"), section: "mcpServers", path: ["mcpServers"] },
22002
- {
22003
- file: join16(homeDir(), ".claude.json"),
22004
- section: `projects[${cwd}].mcpServers`,
22005
- path: ["projects", cwd, "mcpServers"]
22006
- },
22007
- { file: join16(cwd, ".mcp.json"), section: "mcpServers", path: ["mcpServers"] }
22008
- ];
22231
+ notes.push(await writeJsonServer(join17(homeDir(), ".claude.json"), "mcpServers", opts, env));
22232
+ break;
22009
22233
  case "cursor":
22010
- return [
22011
- { file: join16(homeDir(), ".cursor", "mcp.json"), section: "mcpServers", path: ["mcpServers"] },
22012
- { file: join16(cwd, ".cursor", "mcp.json"), section: "mcpServers", path: ["mcpServers"] }
22013
- ];
22234
+ notes.push(await writeJsonServer(join17(homeDir(), ".cursor", "mcp.json"), "mcpServers", opts, env));
22235
+ break;
22014
22236
  case "vscode":
22015
- return [
22016
- { file: join16(homeDir(), ".vscode", "mcp.json"), section: "servers", path: ["servers"] },
22017
- { file: join16(cwd, ".vscode", "mcp.json"), section: "servers", path: ["servers"] }
22018
- ];
22019
- default:
22020
- return [];
22237
+ notes.push(await writeJsonServer(join17(homeDir(), ".vscode", "mcp.json"), "servers", opts, env));
22238
+ break;
22239
+ case "codex":
22240
+ notes.push(await writeCodexServer(opts, env));
22241
+ break;
22021
22242
  }
22243
+ notes.push(await writePrimer(opts.harness));
22244
+ return notes;
22022
22245
  }
22023
- function serversBehind(patterns) {
22024
- const out = [];
22025
- for (const p of patterns) {
22026
- const m = /^(?:mcp:)?([^:/.*]+)[/.]/.exec(p);
22027
- if (m?.[1])
22028
- out.push(m[1]);
22246
+ async function writeJsonServer(path, key, opts, env) {
22247
+ await ensureDir(join17(path, ".."));
22248
+ const existing = await readText(path) ?? "{}";
22249
+ let config2;
22250
+ try {
22251
+ config2 = JSON.parse(existing);
22252
+ } catch {
22253
+ return `Could not parse ${path}; add the server manually.`;
22029
22254
  }
22030
- return out;
22255
+ config2[key] ??= {};
22256
+ config2[key].autorouter = {
22257
+ command: opts.command,
22258
+ args: opts.args,
22259
+ ...Object.keys(env).length ? { env } : {}
22260
+ };
22261
+ await writeText(path, `${JSON.stringify(config2, null, 2)}
22262
+ `);
22263
+ return `Registered autorouter in ${path}`;
22031
22264
  }
22032
- async function mergeIntoRouterConfig(servers, existingPath) {
22033
- const path = existingPath ?? join16(homeDir(), ".config", "autorouter", "config.json");
22034
- await ensureDir(join16(path, ".."));
22035
- let config2 = {};
22036
- if (await exists(path)) {
22037
- try {
22038
- config2 = JSON.parse(stripJsonComments(await readText(path) ?? "{}"));
22039
- } catch {
22040
- config2 = {};
22041
- }
22265
+ async function writeCodexServer(opts, env) {
22266
+ const path = join17(homeDir(), ".codex", "config.toml");
22267
+ await ensureDir(join17(path, ".."));
22268
+ const text = await readText(path) ?? "";
22269
+ let config2;
22270
+ try {
22271
+ config2 = text ? parse(text) : {};
22272
+ } catch {
22273
+ return `Could not parse ${path}; add [mcp_servers.autorouter] manually.`;
22042
22274
  }
22043
- config2.servers = { ...servers, ...config2.servers };
22044
- await writeText(path, `${JSON.stringify(config2, null, 2)}
22275
+ config2.mcp_servers ??= {};
22276
+ config2.mcp_servers.autorouter = {
22277
+ command: opts.command,
22278
+ args: opts.args,
22279
+ startup_timeout_sec: 60,
22280
+ ...Object.keys(env).length ? { env } : {}
22281
+ };
22282
+ await writeText(path, stringify(config2));
22283
+ return `Registered autorouter in ${path}`;
22284
+ }
22285
+ async function writePrimer(harness) {
22286
+ const targets = {
22287
+ claude: join17(homeDir(), ".claude", "CLAUDE.md"),
22288
+ codex: join17(homeDir(), ".codex", "AGENTS.md"),
22289
+ cursor: join17(process.cwd(), ".cursor", "rules", "autorouter.mdc"),
22290
+ vscode: join17(process.cwd(), ".github", "copilot-instructions.md")
22291
+ };
22292
+ const path = targets[harness];
22293
+ await ensureDir(join17(path, ".."));
22294
+ const current = await readText(path) ?? "";
22295
+ const section = harness === "cursor" ? `---
22296
+ description: Capability router
22297
+ alwaysApply: true
22298
+ ---
22299
+
22300
+ ${PRIMER}` : PRIMER;
22301
+ const existing = /<!-- autorouter -->[\s\S]*?<!-- \/autorouter -->/.exec(current);
22302
+ if (existing) {
22303
+ if (existing[0] === PRIMER)
22304
+ return `Primer already current in ${path}`;
22305
+ await writeText(path, current.replace(existing[0], PRIMER));
22306
+ return `Updated router primer in ${path}`;
22307
+ }
22308
+ await writeText(path, current ? `${current.trimEnd()}
22309
+
22310
+ ${section}
22311
+ ` : `${section}
22045
22312
  `);
22313
+ return `Added router primer to ${path}`;
22314
+ }
22315
+ var PRIMER = `<!-- autorouter -->
22316
+ ## Capability router
22317
+
22318
+ Most tools, skills and commands on this machine are not in your tool list. They
22319
+ are behind \`find_capabilities\`.
22320
+
22321
+ Before deciding a task cannot be done, or reaching for a manual workaround, call
22322
+ \`find_capabilities({ query: "<what you are trying to do>" })\`. It searches every
22323
+ configured MCP server, skill and plugin command and returns only what fits.
22324
+ Then \`describe_capability\` for the schema, and \`call_capability\` to run it.
22325
+
22326
+ To install a new MCP server, use the router's \`add_server\` tool, or run
22327
+ \`autorouter add <name> --url <url>\` / \`autorouter add <name> -- <command>\`.
22328
+ Do not run \`claude mcp add\` — that loads the server's whole tool list into
22329
+ context, which is what the router is here to avoid.
22330
+ <!-- /autorouter -->`;
22331
+ async function promptModel(harness, suggested) {
22332
+ if (!process.stdin.isTTY)
22333
+ return suggested;
22334
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
22335
+ try {
22336
+ console.log(`
22337
+ The router uses a small model to pick which capabilities fit a request.
22338
+ ` + `It should be the cheapest model available in ${HARNESS_LABEL[harness]}.`);
22339
+ const answer = (await rl.question(suggested ? `Selector model [${suggested}] (enter to accept, "none" to skip): ` : "Selector model (blank to skip): ")).trim();
22340
+ if (answer.toLowerCase() === "none")
22341
+ return null;
22342
+ return answer || suggested;
22343
+ } finally {
22344
+ rl.close();
22345
+ }
22046
22346
  }
22047
22347
 
22048
22348
  // src/cli/doctor.ts
@@ -22099,7 +22399,7 @@ async function runDoctor(cwd) {
22099
22399
  lines.push("");
22100
22400
  const stillDirect = new Map;
22101
22401
  const stillLoaded = new Map;
22102
- for (const harness of ["claude", "codex", "cursor", "vscode"]) {
22402
+ for (const harness of HARNESSES2) {
22103
22403
  try {
22104
22404
  const { plans, extras } = await runAdopt({ harness, cwd, keep: [], dryRun: true, extras: true });
22105
22405
  const names = plans.flatMap((p) => p.moved);
@@ -22113,18 +22413,29 @@ async function runDoctor(cwd) {
22113
22413
  }
22114
22414
  } catch {}
22115
22415
  }
22116
- const directNames = new Set([...stillDirect.values()].flat());
22117
- const directTokens = catalog.capabilities.filter((c) => c.server && directNames.has(c.server)).reduce((sum, c) => sum + c.approxTokens, 0);
22118
- const loadedSkills = new Set([...stillLoaded.values()].flatMap((x) => x.skills));
22119
- const skillTokens = catalog.capabilities.filter((c) => c.kind === "skill" && loadedSkills.has(c.name)).reduce((sum, c) => sum + c.approxTokens, 0);
22416
+ const unrealized = (harness) => {
22417
+ const servers = new Set(stillDirect.get(harness) ?? []);
22418
+ const skills = new Set(stillLoaded.get(harness)?.skills ?? []);
22419
+ return catalog.capabilities.filter((c) => c.server && servers.has(c.server) || c.kind === "skill" && skills.has(c.name)).reduce((sum, c) => sum + c.approxTokens, 0);
22420
+ };
22120
22421
  const full = catalog.capabilities.reduce((sum, c) => sum + c.approxTokens, 0);
22121
- const routerSurface = 700 + tokensOf(promptList(catalog.capabilities));
22422
+ const routerSurface = 700 + tokensOf(promptList(catalog.capabilities, cfg.promptMode));
22122
22423
  lines.push("## Context cost");
22123
22424
  lines.push(` exposing everything: ~${full.toLocaleString()} tokens`);
22124
- lines.push(` router surface: ~${routerSurface} tokens`);
22125
- lines.push(` still loaded direct: ~${(directTokens + skillTokens).toLocaleString()} tokens` + (skillTokens ? ` (${directTokens.toLocaleString()} servers + ${skillTokens.toLocaleString()} skills)` : ""));
22126
- const saved = full - routerSurface - directTokens - skillTokens;
22127
- lines.push(saved > 0 ? ` actually saved: ~${saved.toLocaleString()} tokens per request (${Math.round(saved / full * 100)}%)` : " actually saved: nothing yet");
22425
+ lines.push(` router surface: ~${routerSurface.toLocaleString()} tokens`);
22426
+ lines.push(` best case: ~${(full - routerSurface).toLocaleString()} tokens saved (${Math.round((full - routerSurface) / full * 100)}%)`);
22427
+ lines.push("", " per harness (a session only ever runs in one):");
22428
+ for (const harness of HARNESSES2) {
22429
+ if (!stillDirect.has(harness) && !stillLoaded.has(harness))
22430
+ continue;
22431
+ const direct = unrealized(harness);
22432
+ const saved = full - routerSurface - direct;
22433
+ lines.push(` ${harness.padEnd(7)} still loaded ~${direct.toLocaleString()}` + (saved > 0 ? ` → saves ~${saved.toLocaleString()} (${Math.round(saved / full * 100)}%)` : " → saves nothing yet"));
22434
+ }
22435
+ const adopted = HARNESSES2.filter((h) => !stillDirect.has(h) && !stillLoaded.has(h));
22436
+ if (adopted.length) {
22437
+ lines.push(` ${adopted.join(", ")}: fully adopted → saves ~${(full - routerSurface).toLocaleString()} (${Math.round((full - routerSurface) / full * 100)}%)`);
22438
+ }
22128
22439
  if (stillDirect.size || stillLoaded.size) {
22129
22440
  lines.push("", "## Not yet adopted");
22130
22441
  lines.push(" These are loaded by a harness directly, so they are injected on every");
@@ -22148,6 +22459,7 @@ async function runDoctor(cwd) {
22148
22459
  return lines.join(`
22149
22460
  `);
22150
22461
  }
22462
+ var HARNESSES2 = ["claude", "codex", "cursor", "vscode"];
22151
22463
  function truncate2(s, max) {
22152
22464
  return s.length > max ? `${s.slice(0, max - 1)}…` : s;
22153
22465
  }
@@ -22400,7 +22712,7 @@ function summarizeScopes(scope, limit = 6) {
22400
22712
  }
22401
22713
 
22402
22714
  // src/cli.ts
22403
- var VERSION2 = "0.2.0";
22715
+ var VERSION2 = "0.2.2";
22404
22716
  var USAGE = `autorouter — one search tool instead of every tool
22405
22717
 
22406
22718
  autorouter serve Run as an MCP server over stdio (default)
@@ -22413,6 +22725,10 @@ var USAGE = `autorouter — one search tool instead of every tool
22413
22725
  autorouter login [server] Authorize an OAuth server (opens a browser);
22414
22726
  with no argument, lists what needs one
22415
22727
  autorouter logout <server> Forget a stored grant
22728
+ autorouter add <name> --url URL Register a server with the router directly
22729
+ autorouter add <name> -- <cmd> ... Same, for a stdio server
22730
+ autorouter add --json '<snippet>' Same, from a pasted mcpServers block
22731
+ autorouter remove <name> Unregister one
22416
22732
  autorouter init --target <harness> Register with claude|codex|cursor|vscode
22417
22733
  autorouter adopt --target <harness> Move that harness's other MCP servers —
22418
22734
  and, on Claude Code, its skills and
@@ -22423,6 +22739,9 @@ var USAGE = `autorouter — one search tool instead of every tool
22423
22739
 
22424
22740
  Options
22425
22741
  --raw search: skip the selector model, show index ranking
22742
+ --url URL add: an http server
22743
+ --command C add: a stdio server (or put the command after a bare --)
22744
+ --json SNIPPET add: a pasted {"mcpServers": {...}} block or a bare entry
22426
22745
  --kind K restrict to tool|skill|prompt|resource|command|agent
22427
22746
  --server S restrict to one provider
22428
22747
  --limit N max results
@@ -22442,13 +22761,17 @@ Options
22442
22761
  --scopes S login: request exactly these scopes (comma or space separated)
22443
22762
  --list-scopes login: show what the server offers, authorize nothing
22444
22763
 
22764
+ \`add\` registers behind the router, so a new server never enters your context.
22765
+ Servers added to a harness the normal way (\`claude mcp add\`) are moved behind
22766
+ it automatically; set "autoAdopt": false to keep that manual.
22767
+
22445
22768
  Servers like Datadog and Supabase hold no credentials in their config — the
22446
22769
  working token is an OAuth grant the harness keeps privately. The router obtains
22447
22770
  its own grant instead of borrowing one, so \`autorouter login <server>\` is a
22448
22771
  one-time step before those can be adopted.
22449
22772
  `;
22450
22773
  async function main(argv) {
22451
- const { command, positionals, flags } = parseArgs(argv);
22774
+ const { command, positionals, flags, rest } = parseArgs(argv);
22452
22775
  switch (command) {
22453
22776
  case undefined:
22454
22777
  case "serve":
@@ -22480,6 +22803,11 @@ async function main(argv) {
22480
22803
  return await cmdLogin(positionals[0], flags);
22481
22804
  case "logout":
22482
22805
  return await cmdLogout(positionals[0]);
22806
+ case "add":
22807
+ return await cmdAdd(positionals[0], flags, rest);
22808
+ case "remove":
22809
+ case "rm":
22810
+ return await cmdRemove(positionals[0]);
22483
22811
  case "adopt":
22484
22812
  return await cmdAdopt(flags);
22485
22813
  case "restore": {
@@ -22509,6 +22837,33 @@ ${USAGE}`);
22509
22837
  return 1;
22510
22838
  }
22511
22839
  }
22840
+ async function cmdAdd(name, flags, rest) {
22841
+ try {
22842
+ const result = await runAdd({
22843
+ name,
22844
+ url: flags.url,
22845
+ command: flags.command ?? rest[0],
22846
+ args: flags.command ? undefined : rest.slice(1),
22847
+ json: flags.json
22848
+ });
22849
+ console.log(result.message);
22850
+ if (!result.ok && result.path) {
22851
+ console.log("Fix it and re-run, or `autorouter remove` it.");
22852
+ }
22853
+ return result.ok ? 0 : 1;
22854
+ } catch (err) {
22855
+ console.error(err instanceof Error ? err.message : String(err));
22856
+ return 1;
22857
+ }
22858
+ }
22859
+ async function cmdRemove(name) {
22860
+ if (!name) {
22861
+ console.error("Usage: autorouter remove <name>");
22862
+ return 1;
22863
+ }
22864
+ console.log(await runRemove(name));
22865
+ return 0;
22866
+ }
22512
22867
  async function cmdLogin(server, flags) {
22513
22868
  if (!server) {
22514
22869
  const resolved = await resolveConfig(process.cwd());
@@ -22828,24 +23183,20 @@ function parseArgs(argv) {
22828
23183
  const positionals = [];
22829
23184
  const flags = {};
22830
23185
  let command;
23186
+ let rest = [];
22831
23187
  for (let i = 0;i < argv.length; i++) {
22832
23188
  const arg = argv[i];
23189
+ if (arg === "--") {
23190
+ rest = argv.slice(i + 1);
23191
+ break;
23192
+ }
22833
23193
  if (arg.startsWith("--")) {
22834
23194
  const [name, inline] = splitOnce(arg.slice(2), "=");
22835
23195
  if (!command && (name === "help" || name === "version")) {
22836
23196
  command = `--${name}`;
22837
23197
  continue;
22838
23198
  }
22839
- const boolean5 = [
22840
- "raw",
22841
- "json",
22842
- "yes",
22843
- "dry-run",
22844
- "force",
22845
- "servers-only",
22846
- "read-only",
22847
- "list-scopes"
22848
- ].includes(name);
23199
+ const boolean5 = ["raw", "json", "yes", "dry-run", "force", "servers-only", "read-only", "list-scopes"].includes(name) && !(name === "json" && command === "add");
22849
23200
  if (boolean5) {
22850
23201
  flags[name] = true;
22851
23202
  } else if (inline !== undefined) {
@@ -22860,7 +23211,7 @@ function parseArgs(argv) {
22860
23211
  else
22861
23212
  positionals.push(arg);
22862
23213
  }
22863
- return { command, positionals, flags };
23214
+ return { command, positionals, flags, rest };
22864
23215
  }
22865
23216
  function splitOnce(s, sep) {
22866
23217
  const i = s.indexOf(sep);