@heretyc/subagent-mcp 2.6.2 → 2.7.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.
Files changed (2) hide show
  1. package/dist/setup.js +192 -91
  2. package/package.json +2 -2
package/dist/setup.js CHANGED
@@ -23,6 +23,7 @@ import { fileURLToPath } from "node:url";
23
23
  import { execFileSync, execSync } from "node:child_process";
24
24
  const cliArgs = process.argv.slice(3); // argv[2]='setup', flags start at [3]
25
25
  const DRY_RUN = cliArgs.includes("--dry-run");
26
+ export const SERVER_NAME = "subagent-mcp";
26
27
  // Install root: dist/setup.js -> dist/ -> <install-root>
27
28
  const INSTALL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
29
  export function fwd(p) {
@@ -241,13 +242,36 @@ function backup(file) {
241
242
  function runCmd(cmd, cmdArgs) {
242
243
  return runCmdCapture(cmd, cmdArgs).ok;
243
244
  }
244
- function quoteWinShellArg(arg) {
245
- if (!/[ \t"]/.test(arg))
245
+ /**
246
+ * Parse an npm cmd-shim (.cmd) for its dp0-relative node script.
247
+ * Matches both modern `"%dp0%\node_modules\...\cli.js"` and legacy
248
+ * `"%~dp0\..."` forms. Returns the absolute JS path or null.
249
+ */
250
+ export function resolveCmdShimNodeScript(cmdPath) {
251
+ try {
252
+ const text = readFileSync(cmdPath, "utf8");
253
+ const m = text.match(/"%(?:~dp0|dp0%)\\([^"]+\.(?:js|cjs|mjs))"/i);
254
+ if (!m)
255
+ return null;
256
+ const js = join(dirname(cmdPath), m[1]);
257
+ return existsSync(js) ? js : null;
258
+ }
259
+ catch {
260
+ return null;
261
+ }
262
+ }
263
+ // Conservative safe-charset: quote on ANYTHING else, including the cmd.exe
264
+ // metachars & | < > ^ ( ) % ! plus space, tab, and ".
265
+ const WIN_SAFE_ARG = /^[A-Za-z0-9_.,:=@+\/\\-]+$/;
266
+ export function quoteWinShellArg(arg) {
267
+ if (arg !== "" && WIN_SAFE_ARG.test(arg))
246
268
  return arg;
247
- return `"${arg.replace(/"/g, '\\"')}"`;
269
+ // "" doubling: quote-state-safe at the cmd parse stage, a literal " at the
270
+ // final MSVCRT argv stage (\" would close the quote and expose metachars).
271
+ return `"${arg.replace(/"/g, '""')}"`;
248
272
  }
249
- function quoteWinShellExe(exe) {
250
- return `"${exe.replace(/"/g, '\\"')}"`;
273
+ export function quoteWinShellExe(exe) {
274
+ return `"${exe.replace(/"/g, '""')}"`;
251
275
  }
252
276
  function runCmdCapture(cmd, cmdArgs) {
253
277
  console.log(` $ ${cmd} ${cmdArgs.join(" ")}`);
@@ -258,9 +282,30 @@ function runCmdCapture(cmd, cmdArgs) {
258
282
  try {
259
283
  const exe = findOnPath(cmd) ?? cmd;
260
284
  const isWinCmdShim = process.platform === "win32" && /\.(?:cmd|bat)$/i.test(exe);
261
- const stdout = isWinCmdShim
262
- ? execSync([quoteWinShellExe(exe), ...cmdArgs.map(quoteWinShellArg)].join(" "), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
263
- : execFileSync(exe, cmdArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
285
+ let stdout;
286
+ if (!isWinCmdShim) {
287
+ stdout = execFileSync(exe, cmdArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
288
+ }
289
+ else {
290
+ // Primary: bypass cmd.exe by invoking node on the shim's JS entry —
291
+ // execFileSync without a shell does correct argv quoting, so cmd.exe
292
+ // metachar/percent expansion never applies.
293
+ const js = resolveCmdShimNodeScript(exe);
294
+ if (js) {
295
+ stdout = execFileSync(process.execPath, [js, ...cmdArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
296
+ }
297
+ else {
298
+ // Fallback for non-npm shims: hardened cmd.exe quoting. '%' is the one
299
+ // channel cmd cannot neutralize on a /c line — warn, don't reject.
300
+ if ([exe, ...cmdArgs].some((a) => a.includes("%"))) {
301
+ console.log(" note: an argument contains '%' — cmd.exe may expand it as an env var; if this command fails, check for name collisions.");
302
+ }
303
+ stdout = execSync([quoteWinShellExe(exe), ...cmdArgs.map(quoteWinShellArg)].join(" "), {
304
+ encoding: "utf8",
305
+ stdio: ["ignore", "pipe", "pipe"],
306
+ });
307
+ }
308
+ }
264
309
  return { ok: true, stdout };
265
310
  }
266
311
  catch {
@@ -279,19 +324,28 @@ export function codexAddArgs(serverPath) {
279
324
  export function codexRemoveArgs() {
280
325
  return ["mcp", "remove", "subagent-mcp"];
281
326
  }
282
- function claudeRegisteredViaCli() {
283
- const get = runCmdCapture("claude", ["mcp", "get", "subagent-mcp"]);
284
- if (get.ok && get.stdout.includes("subagent-mcp"))
285
- return true;
286
- const list = runCmdCapture("claude", ["mcp", "list"]);
287
- return list.ok && list.stdout.includes("subagent-mcp");
327
+ /**
328
+ * True iff CLI output mentions `name` as a standalone token: the chars
329
+ * immediately around it must not be server-name chars [A-Za-z0-9._-].
330
+ * Robust to `claude mcp list` / `codex mcp list` / `mcp get` formats
331
+ * ("name: cmd - ✓ Connected", table rows, "Name: x" detail views), and
332
+ * rejects sibling names like `subagent-mcp-dev` / `my-subagent-mcp`.
333
+ */
334
+ export function outputListsServer(stdout, name = SERVER_NAME) {
335
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
336
+ return new RegExp(`(^|[^A-Za-z0-9._-])${esc}($|[^A-Za-z0-9._-])`, "m").test(stdout);
288
337
  }
289
- function codexRegisteredViaCli() {
290
- const get = runCmdCapture("codex", ["mcp", "get", "subagent-mcp"]);
291
- if (get.ok && get.stdout.includes("subagent-mcp"))
338
+ const defaultExecDeps = {
339
+ run: runCmd,
340
+ capture: runCmdCapture,
341
+ dryRun: DRY_RUN,
342
+ };
343
+ function registeredViaCli(cli, deps = defaultExecDeps) {
344
+ const get = deps.capture(cli, ["mcp", "get", SERVER_NAME]);
345
+ if (get.ok && outputListsServer(get.stdout))
292
346
  return true;
293
- const list = runCmdCapture("codex", ["mcp", "list"]);
294
- return list.ok && list.stdout.includes("subagent-mcp");
347
+ const list = deps.capture(cli, ["mcp", "list"]);
348
+ return list.ok && outputListsServer(list.stdout);
295
349
  }
296
350
  const issues = [];
297
351
  function repairPromptFor(vendor, problem) {
@@ -328,45 +382,91 @@ function describe(status, what) {
328
382
  else
329
383
  console.log(` ${what}: pointed at a stale path — repaired.`);
330
384
  }
385
+ export function vendorWireSpecs(p = serverPaths(), home = homedir()) {
386
+ const claudeFile = join(home, ".claude.json");
387
+ const codexDir = join(home, ".codex");
388
+ const codexFile = join(codexDir, "config.toml");
389
+ return {
390
+ claude: {
391
+ vendor: "claude",
392
+ cli: "claude",
393
+ configFile: claudeFile,
394
+ addArgs: claudeAddArgs(),
395
+ removeArgs: claudeRemoveArgs(),
396
+ read: () => readJson(claudeFile, {}),
397
+ reconcile: (cfg) => {
398
+ const r = reconcileClaudeJson(cfg, p.server);
399
+ return { status: r.status, changed: r.changed, out: cfg };
400
+ },
401
+ serialize: (out) => JSON.stringify(out, null, 2),
402
+ cliFailMsg: "MCP server file shape is correct, but 'claude mcp add' failed to register it with the CLI",
403
+ },
404
+ codex: {
405
+ vendor: "codex",
406
+ cli: "codex",
407
+ configFile: codexFile,
408
+ addArgs: codexAddArgs(p.server),
409
+ removeArgs: codexRemoveArgs(),
410
+ read: () => (existsSync(codexFile) ? readFileSync(codexFile, "utf8") : ""),
411
+ reconcile: (cfg) => {
412
+ const r = reconcileCodexToml(cfg, p.server);
413
+ return { status: r.status, changed: r.changed, out: r.toml };
414
+ },
415
+ serialize: (out) => out,
416
+ ensureDir: () => mkdirSync(codexDir, { recursive: true }),
417
+ cliFailMsg: "MCP server file shape is correct, but 'codex mcp add' failed to register it with the CLI",
418
+ },
419
+ };
420
+ }
421
+ /**
422
+ * Single wiring driver for both vendors. Policy: CLI-first -> read-back ->
423
+ * reconcile -> unconditional canonical write on divergence -> fail only when
424
+ * neither the CLI registration nor the file fallback took.
425
+ */
426
+ export function wireMcpServer(spec, deps = defaultExecDeps) {
427
+ const initial = spec.reconcile(spec.read());
428
+ if (initial.status === "repaired") {
429
+ console.log(" MCP server registration points at a stale path — re-registering.");
430
+ deps.run(spec.cli, spec.removeArgs);
431
+ }
432
+ let registered = initial.status === "ok" && registeredViaCli(spec.cli, deps);
433
+ if (!registered) {
434
+ deps.run(spec.cli, spec.addArgs);
435
+ registered = registeredViaCli(spec.cli, deps);
436
+ }
437
+ if (deps.dryRun) {
438
+ // Never verify/fail/write in dry-run: capture() returns empty stdout, so
439
+ // any verification below would fail spuriously.
440
+ if (initial.changed)
441
+ console.log(" (dry-run: not written)");
442
+ return { status: initial.status, registered: true, wroteFile: false, failure: null };
443
+ }
444
+ const after = spec.reconcile(spec.read()); // read-back: what is ACTUALLY on disk now
445
+ let wroteFile = false;
446
+ if (after.status !== "ok") {
447
+ spec.ensureDir?.();
448
+ backup(spec.configFile);
449
+ writeFileSync(spec.configFile, spec.serialize(after.out));
450
+ wroteFile = true;
451
+ console.log(registered
452
+ ? ` NOTE: ${spec.cli} CLI registration diverged from the canonical config — rewrote ${spec.configFile} to the canonical form.`
453
+ : ` '${spec.cli} mcp add' failed — writing ${spec.configFile} directly.`);
454
+ }
455
+ const failure = registered || wroteFile ? null : spec.cliFailMsg;
456
+ return { status: initial.status, registered, wroteFile, failure };
457
+ }
331
458
  function wireClaude() {
332
459
  console.log("\n--- Claude Code CLI ---");
333
460
  const p = serverPaths();
334
- const cjFile = join(homedir(), ".claude.json");
335
- // 1) MCP server (user scope). Reconcile against ~/.claude.json; prefer the
336
- // official CLI for writes, fall back to a direct (schema-identical) edit.
461
+ const specs = vendorWireSpecs(p);
462
+ // 1) MCP server (user scope). CLI-first, read-back verified, with a direct
463
+ // (schema-identical) ~/.claude.json write whenever the file diverges.
337
464
  try {
338
- const cj = readJson(cjFile, {});
339
- const probe = JSON.parse(JSON.stringify(cj));
340
- const { status } = reconcileClaudeJson(probe, p.server);
341
- if (status === "ok") {
342
- let registered = claudeRegisteredViaCli();
343
- if (!registered) {
344
- runCmd("claude", claudeAddArgs());
345
- registered = claudeRegisteredViaCli();
346
- }
347
- if (registered)
348
- describe("ok", "MCP server (user scope)");
349
- else
350
- fail("claude", "MCP server file shape is correct, but 'claude mcp add' failed to register it with the CLI");
351
- }
352
- else {
353
- if (status === "repaired") {
354
- console.log(" MCP server registration points at a stale path — re-registering.");
355
- runCmd("claude", claudeRemoveArgs());
356
- }
357
- const cliOk = runCmd("claude", claudeAddArgs());
358
- const cliVerified = cliOk && claudeRegisteredViaCli();
359
- // Read back; if the CLI failed or didn't take, write the entry directly.
360
- const after = readJson(cjFile, {});
361
- const verify = reconcileClaudeJson(after, p.server);
362
- if (!cliVerified && verify.status !== "ok" && !DRY_RUN) {
363
- if (!cliOk)
364
- console.log(" 'claude mcp add' failed — writing ~/.claude.json directly.");
365
- backup(cjFile);
366
- writeFileSync(cjFile, JSON.stringify(after, null, 2));
367
- }
368
- describe(status, "MCP server (user scope)");
369
- }
465
+ const r = wireMcpServer(specs.claude);
466
+ if (r.failure)
467
+ fail("claude", r.failure);
468
+ else
469
+ describe(r.status, "MCP server (user scope)");
370
470
  }
371
471
  catch (e) {
372
472
  fail("claude", `could not register the MCP server: ${e.message}`);
@@ -392,25 +492,15 @@ function wireCodex() {
392
492
  console.log("\n--- Codex CLI ---");
393
493
  const p = serverPaths();
394
494
  const codexDir = join(homedir(), ".codex");
495
+ const specs = vendorWireSpecs(p);
395
496
  // 1) config.toml — MCP server block (created if the file is missing).
396
497
  try {
397
- const cfg = join(codexDir, "config.toml");
398
- const toml = existsSync(cfg) ? readFileSync(cfg, "utf8") : "";
399
- const r = reconcileCodexToml(toml, p.server);
400
- if (r.status === "repaired") {
401
- console.log(" MCP server registration points at a stale path — re-registering.");
402
- runCmd("codex", codexRemoveArgs());
403
- }
404
- const cliOk = r.status === "ok" && codexRegisteredViaCli() ? true : runCmd("codex", codexAddArgs(p.server));
405
- if (!cliOk && r.changed && !DRY_RUN) {
406
- console.log(" 'codex mcp add' failed — writing ~/.codex/config.toml directly.");
407
- mkdirSync(codexDir, { recursive: true });
408
- backup(cfg);
409
- writeFileSync(cfg, r.toml);
410
- }
411
- describe(r.status, toml === "" ? "config.toml (created) MCP server block" : "config.toml MCP server block");
412
- if (r.changed && DRY_RUN)
413
- console.log(" (dry-run: not written)");
498
+ const existed = existsSync(specs.codex.configFile);
499
+ const r = wireMcpServer(specs.codex);
500
+ if (r.failure)
501
+ fail("codex", r.failure);
502
+ else
503
+ describe(r.status, existed ? "config.toml MCP server block" : "config.toml (created) MCP server block");
414
504
  }
415
505
  catch (e) {
416
506
  fail("codex", `could not write config.toml: ${e.message}`);
@@ -438,6 +528,25 @@ function wireCodex() {
438
528
  fail("codex", `could not write hooks.json: ${e.message}`);
439
529
  }
440
530
  }
531
+ /** Detail string for a CLI registration check; "CLI repair failed" only when a
532
+ * repair was actually attempted. */
533
+ export function registrationDetail(registered, attemptedRepair) {
534
+ if (registered)
535
+ return attemptedRepair ? "repaired" : "registered";
536
+ return attemptedRepair
537
+ ? "not registered; CLI repair failed"
538
+ : "not registered — run: subagent-mcp doctor";
539
+ }
540
+ function checkCliRegistration(cli, addArgs, repair, deps = defaultExecDeps) {
541
+ let registered = registeredViaCli(cli, deps);
542
+ let attemptedRepair = false;
543
+ if (!registered && repair) {
544
+ deps.run(cli, addArgs);
545
+ attemptedRepair = true;
546
+ registered = registeredViaCli(cli, deps);
547
+ }
548
+ return { registered, attemptedRepair };
549
+ }
441
550
  export function verifyWiring(root = INSTALL_ROOT, repair = false) {
442
551
  const p = serverPaths(root);
443
552
  const results = [];
@@ -452,18 +561,12 @@ export function verifyWiring(root = INSTALL_ROOT, repair = false) {
452
561
  const hasClaudeConfig = existsSync(join(home, ".claude.json"));
453
562
  if (hasClaude) {
454
563
  const sj = readJson(join(home, ".claude", "settings.json"), {});
455
- let registered = claudeRegisteredViaCli();
456
- let repaired = false;
457
- if (!registered && repair) {
458
- runCmd("claude", claudeAddArgs());
459
- repaired = true;
460
- registered = claudeRegisteredViaCli();
461
- }
462
- const hk = reconcileClaudeSettings(JSON.parse(JSON.stringify(sj)), p.claudeHook);
564
+ const { registered, attemptedRepair } = checkCliRegistration("claude", claudeAddArgs(), repair);
565
+ const hk = reconcileClaudeSettings(sj, p.claudeHook);
463
566
  results.push({
464
567
  label: "claude: MCP server (user scope)",
465
568
  ok: registered,
466
- detail: registered ? (repaired ? "repaired" : "registered") : "not registered; CLI repair failed",
569
+ detail: registrationDetail(registered, attemptedRepair),
467
570
  });
468
571
  results.push({
469
572
  label: "claude: UserPromptSubmit hook",
@@ -474,12 +577,12 @@ export function verifyWiring(root = INSTALL_ROOT, repair = false) {
474
577
  else if (hasClaudeConfig) {
475
578
  const cj = readJson(join(home, ".claude.json"), {});
476
579
  const sj = readJson(join(home, ".claude", "settings.json"), {});
477
- const srv = reconcileClaudeJson(JSON.parse(JSON.stringify(cj)), p.server);
478
- const hk = reconcileClaudeSettings(JSON.parse(JSON.stringify(sj)), p.claudeHook);
580
+ const srv = reconcileClaudeJson(cj, p.server);
581
+ const hk = reconcileClaudeSettings(sj, p.claudeHook);
479
582
  results.push({
480
583
  label: "claude: MCP server (user scope)",
481
584
  ok: srv.status === "ok",
482
- detail: srv.status === "ok" ? "registered (file fallback)" : "not registered; claude CLI not on PATH",
585
+ detail: srv.status === "ok" ? "registered (file fallback)" : "config stale run: subagent-mcp setup",
483
586
  });
484
587
  results.push({
485
588
  label: "claude: UserPromptSubmit hook",
@@ -496,22 +599,20 @@ export function verifyWiring(root = INSTALL_ROOT, repair = false) {
496
599
  const hj = readJson(join(home, ".codex", "hooks.json"), { hooks: {} });
497
600
  const hkR = reconcileCodexHooks(hj, `node "${p.codexHook}"`);
498
601
  let registered = false;
499
- let repaired = false;
602
+ let detail;
500
603
  if (hasCodexCli) {
501
- registered = codexRegisteredViaCli();
502
- if (!registered && repair) {
503
- runCmd("codex", codexAddArgs(p.server));
504
- repaired = true;
505
- registered = codexRegisteredViaCli();
506
- }
604
+ const r = checkCliRegistration("codex", codexAddArgs(p.server), repair);
605
+ registered = r.registered;
606
+ detail = registrationDetail(r.registered, r.attemptedRepair);
507
607
  }
508
608
  else {
509
609
  registered = tomlR.status === "ok";
610
+ detail = registered ? "registered" : "config stale — run: subagent-mcp setup";
510
611
  }
511
612
  results.push({
512
613
  label: "codex: config.toml MCP server block",
513
614
  ok: registered,
514
- detail: registered ? (repaired ? "repaired" : "registered") : "not registered; CLI repair failed",
615
+ detail,
515
616
  });
516
617
  const allOk = Object.values(hkR.statuses).every((s) => s === "ok");
517
618
  results.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretyc/subagent-mcp",
3
- "version": "2.6.2",
3
+ "version": "2.7.0",
4
4
  "description": "MCP server that launches and manages local Claude Code and Codex CLI sub-agents as child processes (no direct Anthropic/OpenAI API).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "postinstall": "node scripts/postinstall.mjs",
22
22
  "prepare": "npm run build",
23
23
  "prepublishOnly": "npm test",
24
- "test": "node test/effort.test.mjs && node test/platform.test.mjs && node test/wait.test.mjs && node test/status.test.mjs && node test/output.test.mjs && node test/stream.test.mjs && node test/routing.test.mjs && node test/deadlock.test.mjs && node test/handler-validation.test.mjs && node test/index-handler.test.mjs && node test/ruleset.test.mjs && node test/ruleset-exec.test.mjs && node test/ruleset-handler.test.mjs && node test/failover.test.mjs && node test/orchestration-marker.test.mjs && node test/orchestration-hook-core.test.mjs && node test/orchestration-adapters.test.mjs && node test/orchestration-directives.test.mjs && node test/setup-repair.test.mjs && node scripts/validate_provider.mjs && node scripts/validate_seed_sites.mjs && node scripts/validate_routing_audit.mjs && node test/seed-sites.test.mjs && node test/mcp-compliance.test.mjs"
24
+ "test": "node test/effort.test.mjs && node test/platform.test.mjs && node test/wait.test.mjs && node test/status.test.mjs && node test/output.test.mjs && node test/stream.test.mjs && node test/routing.test.mjs && node test/deadlock.test.mjs && node test/handler-validation.test.mjs && node test/index-handler.test.mjs && node test/ruleset.test.mjs && node test/ruleset-exec.test.mjs && node test/ruleset-handler.test.mjs && node test/failover.test.mjs && node test/orchestration-marker.test.mjs && node test/orchestration-hook-core.test.mjs && node test/orchestration-adapters.test.mjs && node test/orchestration-directives.test.mjs && node test/setup-repair.test.mjs && node test/setup-quoting.test.mjs && node test/setup-wire.test.mjs && node test/setup-cli-integration.test.mjs && node scripts/validate_provider.mjs && node scripts/validate_seed_sites.mjs && node scripts/validate_routing_audit.mjs && node test/seed-sites.test.mjs && node test/mcp-compliance.test.mjs"
25
25
  },
26
26
  "author": "Lexi Blackburn",
27
27
  "license": "Apache-2.0",