captain-tool 0.0.39 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -89,6 +89,18 @@ claude mcp add captain -- cmd /c captain-tool
89
89
 
90
90
  > Registers under the short name `captain` (tool prefix `mcp__captain__`). If you previously added it as `captain-tool`, run `claude mcp remove captain-tool` to avoid a duplicate registration.
91
91
 
92
+ **Auto-approve captain tools (recommended).** Registration alone does not grant permissions — Claude Code will prompt "Allow captain to …?" on every tool call. The setup wizard (`npx captain-tool setup`) offers to fix this for you; to do it by hand, add one allow rule to `~/.claude/settings.json`:
93
+
94
+ ```json
95
+ {
96
+ "permissions": {
97
+ "allow": ["mcp__captain"]
98
+ }
99
+ }
100
+ ```
101
+
102
+ `mcp__captain` (no tool suffix) approves every tool from this server. Your own `deny` and `ask` rules always take precedence over this allow rule. Note the rule prefix is the *registration key*: if you registered under a different name, the rule must match it. Old `mcp__captain-tool__*` rules from before the rename match nothing — the wizard sweeps them out of `~/.claude/settings.json`; per-project `.claude/settings.local.json` files can hold more, so clean those by hand if Claude Code still prompts. For scripted installs, `npx captain-tool setup --yes` configures all clients and enables auto-approval without prompting.
103
+
92
104
  ### VS Code + Copilot
93
105
 
94
106
  [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-0098FF?logo=visualstudiocode)](vscode:mcp/install?%7B%22name%22%3A%22captain%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22captain-tool%22%5D%2C%22env%22%3A%7B%22CAPTAIN_API_URL%22%3A%22https%3A%2F%2Fapp.getcaptain.dev%2F%22%7D%7D)
package/bin/captain-tool CHANGED
@@ -63,30 +63,37 @@ function findBinary() {
63
63
 
64
64
  // ── Entry point ──────────────────────────────────────────────────────────────
65
65
 
66
- // If the first real argument is 'setup', hand off to the interactive setup CLI.
67
- // We use require() so it runs in the same process (no extra spawn needed).
66
+ // If the first real argument is 'setup', hand off to the interactive setup CLI
67
+ // in this same process. We must CALL the exported main() the setup script
68
+ // gates its wizard on `require.main === module` (so tests can require its
69
+ // helpers without launching prompts), which means a bare require() runs
70
+ // nothing. An earlier version did exactly that and then exit(0)'d, making
71
+ // `captain-tool setup` a silent no-op. main() is async and owns its own exit
72
+ // codes; on success the process exits naturally when the wizard finishes, so
73
+ // there must be no process.exit() after this call — it would kill main() at
74
+ // its first await.
68
75
  if (process.argv[2] === 'setup') {
69
- require('./captain-tool-setup');
70
- // captain-tool-setup manages its own process.exit(), but we add an explicit
71
- // one here as a safety net in case it returns normally.
72
- process.exit(0);
73
- }
74
-
75
- const binary = findBinary();
76
+ require('./captain-tool-setup').main().catch((err) => {
77
+ console.error('Unexpected error:', err);
78
+ process.exit(1);
79
+ });
80
+ } else {
81
+ const binary = findBinary();
76
82
 
77
- if (!binary) {
78
- process.stderr.write(
79
- 'captain-tool: no pre-built binary found for this platform.\n' +
80
- 'If you are on an unsupported platform, please open an issue at:\n' +
81
- ' https://github.com/your-org/captain-tool/issues\n'
82
- );
83
- process.exit(1);
84
- }
83
+ if (!binary) {
84
+ process.stderr.write(
85
+ 'captain-tool: no pre-built binary found for this platform.\n' +
86
+ 'If you are on an unsupported platform, please open an issue at:\n' +
87
+ ' https://github.com/your-org/captain-tool/issues\n'
88
+ );
89
+ process.exit(1);
90
+ }
85
91
 
86
- // Spawn the Rust binary, forwarding all arguments after the node script name.
87
- // stdio: 'inherit' is non-negotiable — see the module-level comment above.
88
- const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
92
+ // Spawn the Rust binary, forwarding all arguments after the node script name.
93
+ // stdio: 'inherit' is non-negotiable — see the module-level comment above.
94
+ const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
89
95
 
90
- // spawnSync returns null for status if the process was killed by a signal.
91
- // We default to exit code 1 in that case so the MCP host sees a failure.
92
- process.exit(result.status ?? 1);
96
+ // spawnSync returns null for status if the process was killed by a signal.
97
+ // We default to exit code 1 in that case so the MCP host sees a failure.
98
+ process.exit(result.status ?? 1);
99
+ }
@@ -39,6 +39,21 @@ const SERVER_NAME = 'captain';
39
39
  // is left alone.
40
40
  const LEGACY_NAMES = ['captain-tool'];
41
41
 
42
+ // Claude Code permission rule that auto-approves every captain tool call.
43
+ // Registering an MCP server does NOT grant it permissions — without an allow
44
+ // rule Claude Code prompts on every single tool call, which makes captain
45
+ // unusable as a background workflow tool. `mcp__<server>` (no tool suffix) is
46
+ // the documented "all tools from this server" form. Derived from SERVER_NAME
47
+ // because the rule prefix IS the registration key — a future rename must not
48
+ // be able to update one without the other.
49
+ const PERMISSION_RULE = `mcp__${SERVER_NAME}`;
50
+
51
+ // Permission-rule prefixes for every name this server was registered under
52
+ // previously (same derivation, one per LEGACY_NAMES entry). After a rename
53
+ // the old rules match nothing — they silently re-enable per-call prompting
54
+ // while still looking like a working allowlist — so the wizard sweeps them.
55
+ const LEGACY_PERMISSION_PREFIXES = LEGACY_NAMES.map((name) => `mcp__${name}`);
56
+
42
57
  // ── Binary resolution (same logic as the runner) ─────────────────────────────
43
58
 
44
59
  const PLATFORMS = [
@@ -109,6 +124,10 @@ const CLIENTS = [
109
124
  // ~/.claude.json is special: it has many top-level keys (auth state, prefs, etc.)
110
125
  // We only touch the mcpServers key and leave everything else alone.
111
126
  rootKey: 'mcpServers',
127
+ // Claude Code is the only client whose permission system we can configure
128
+ // from a file (~/.claude/settings.json). Other clients use in-app
129
+ // "always allow" buttons, so they get no permissions step.
130
+ permissionsPath: () => expandPath('~/.claude/settings.json'),
112
131
  },
113
132
  {
114
133
  name: 'VS Code + Copilot',
@@ -312,6 +331,101 @@ function removeLegacyEverywhere(config) {
312
331
  }
313
332
  }
314
333
 
334
+ // ── Claude Code permission allowlist ──────────────────────────────────────────
335
+
336
+ /**
337
+ * Mutate a parsed ~/.claude/settings.json object so captain tools are
338
+ * auto-approved. Two independent changes, each gated by its own option:
339
+ *
340
+ * 1. sweepLegacy — drop allow rules for legacy server names
341
+ * (`mcp__captain-tool` and `mcp__captain-tool__<tool>`). The rule prefix
342
+ * is the registration key, so after the rename these match nothing —
343
+ * keeping them around just hides the fact that nothing is allowlisted
344
+ * anymore. Removing them never narrows what's allowed, which is why the
345
+ * caller runs the sweep even when the user declines auto-approval.
346
+ * 2. addAllowRule — add the server-wide `mcp__captain` rule unless an
347
+ * equivalent is already present. This one widens permissions, so it is
348
+ * consent-gated.
349
+ *
350
+ * Only `permissions.allow` is touched; deny/ask rules and every other settings
351
+ * key are preserved verbatim (a user deny or ask rule outranks our allow rule
352
+ * anyway, so we must not edit those). Returns { added, removed } so the caller
353
+ * can tell the user exactly what changed.
354
+ */
355
+ function applyPermissionRule(settings, { addAllowRule = true, sweepLegacy = true } = {}) {
356
+ if (!settings.permissions || typeof settings.permissions !== 'object') {
357
+ settings.permissions = {};
358
+ }
359
+ if (!Array.isArray(settings.permissions.allow)) {
360
+ settings.permissions.allow = [];
361
+ }
362
+
363
+ let removed = 0;
364
+ if (sweepLegacy) {
365
+ const isLegacy = (rule) =>
366
+ typeof rule === 'string' &&
367
+ LEGACY_PERMISSION_PREFIXES.some((p) => rule === p || rule.startsWith(`${p}__`));
368
+ // Single pass: keep the survivors, derive the count from the length delta,
369
+ // and reassign immediately so no later code can touch the stale array.
370
+ const kept = settings.permissions.allow.filter((rule) => !isLegacy(rule));
371
+ removed = settings.permissions.allow.length - kept.length;
372
+ settings.permissions.allow = kept;
373
+ }
374
+
375
+ let added = false;
376
+ if (addAllowRule) {
377
+ // `mcp__captain` and `mcp__captain__*` are equivalent server-wide rules;
378
+ // don't stack a duplicate if either form is already there.
379
+ const covered = settings.permissions.allow.some(
380
+ (rule) => rule === PERMISSION_RULE || rule === `${PERMISSION_RULE}__*`
381
+ );
382
+ if (!covered) {
383
+ settings.permissions.allow.push(PERMISSION_RULE);
384
+ added = true;
385
+ }
386
+ }
387
+ return { added, removed };
388
+ }
389
+
390
+ /**
391
+ * Read ~/.claude/settings.json (or start fresh), apply the allow rule /
392
+ * legacy sweep, and write back atomically. Same read-merge-write discipline
393
+ * as the MCP config writer: every key we don't own is left untouched — and
394
+ * when nothing changed we skip the write entirely, so a re-run never
395
+ * reformats the user's file or churns its mtime for a no-op.
396
+ */
397
+ function configurePermissions(permissionsPath, opts) {
398
+ const settings = readJsonConfig(permissionsPath);
399
+ const result = applyPermissionRule(settings, opts);
400
+ if (result.added || result.removed > 0) {
401
+ atomicWriteJson(permissionsPath, settings);
402
+ }
403
+ return result;
404
+ }
405
+
406
+ /**
407
+ * True if the agent config registers a server under a legacy name that is NOT
408
+ * our binary. entryIsOurs promises such a server "is left alone" — that must
409
+ * extend to its permission rules: `mcp__captain-tool__*` rules are only stale
410
+ * if `captain-tool` was OUR dead registration, not someone else's live server.
411
+ * Checks every scope removeLegacyEverywhere does (top-level + per-project).
412
+ */
413
+ function hasUnrelatedLegacyServer(config) {
414
+ const maps = [config.mcpServers, config.servers];
415
+ if (config.projects && typeof config.projects === 'object') {
416
+ for (const proj of Object.values(config.projects)) {
417
+ if (proj && typeof proj === 'object') {
418
+ maps.push(proj.mcpServers, proj.servers);
419
+ }
420
+ }
421
+ }
422
+ return maps.some(
423
+ (m) =>
424
+ m && typeof m === 'object' &&
425
+ LEGACY_NAMES.some((name) => m[name] && !entryIsOurs(m[name]))
426
+ );
427
+ }
428
+
315
429
  // ── Per-client config writer ──────────────────────────────────────────────────
316
430
 
317
431
  /**
@@ -351,16 +465,36 @@ function configureClient(client, binaryPath) {
351
465
  // ── Interactive prompt helpers ────────────────────────────────────────────────
352
466
 
353
467
  /**
354
- * Read a single line from stdin. Returns a Promise<string>.
468
+ * Read a single line from stdin. Returns a Promise<string|null>.
355
469
  * We keep our own readline interface and close it when done to avoid holding
356
470
  * the event loop open after the prompts finish.
471
+ *
472
+ * Resolves null on EOF. WHY: when piped stdin runs out, readline emits
473
+ * 'close' and the question callback simply never fires — the awaited promise
474
+ * would stay pending forever, the event loop would drain, and node would exit
475
+ * 0 having configured nothing. Resolving null lets callers abort loudly
476
+ * instead of vanishing with a success exit code.
357
477
  */
358
478
  function prompt(rl, question) {
359
479
  return new Promise((resolve) => {
360
- rl.question(question, (answer) => resolve(answer.trim()));
480
+ const onClose = () => resolve(null);
481
+ rl.once('close', onClose);
482
+ rl.question(question, (answer) => {
483
+ rl.removeListener('close', onClose);
484
+ resolve(answer.trim());
485
+ });
361
486
  });
362
487
  }
363
488
 
489
+ /**
490
+ * Bail out when stdin hits EOF mid-wizard. Exiting non-zero matters: a
491
+ * calling script must not believe setup succeeded when nothing was written.
492
+ */
493
+ function abortNoInput() {
494
+ console.error('\nNo input (stdin closed) — aborting without changes. Use --yes for non-interactive setup.');
495
+ process.exit(1);
496
+ }
497
+
364
498
  // ── Main ──────────────────────────────────────────────────────────────────────
365
499
 
366
500
  async function main() {
@@ -388,57 +522,93 @@ async function main() {
388
522
  }
389
523
  console.log('');
390
524
 
391
- // Present the client menu.
392
- console.log('Select which agent(s) to configure:');
393
- console.log(' 0. All of the above');
394
- CLIENTS.forEach((client, i) => {
395
- console.log(` ${i + 1}. ${client.name}`);
396
- });
397
- console.log('');
398
-
399
- const rl = readline.createInterface({
400
- input: process.stdin,
401
- output: process.stdout,
402
- });
525
+ // Non-interactive paths first. `--yes`/`-y` configures every client and
526
+ // enables auto-approval without prompting (for scripts/CI). Without the
527
+ // flag, a non-interactive stdin must be rejected up front — see prompt()
528
+ // for why EOF mid-question would otherwise end as a silent exit 0.
529
+ const assumeYes = process.argv.includes('--yes') || process.argv.includes('-y');
403
530
 
404
531
  let selected;
405
- while (true) {
406
- const answer = await prompt(
407
- rl,
408
- `Enter number(s) separated by commas (e.g. "1,3") or 0 for all: `
409
- );
532
+ let wantPermissions = false;
533
+ if (assumeYes) {
534
+ selected = CLIENTS.map((_, i) => i);
535
+ wantPermissions = true;
536
+ console.log('--yes: configuring all clients and enabling auto-approval.');
537
+ console.log('');
538
+ } else if (!process.stdin.isTTY) {
539
+ console.error('stdin is not interactive. Re-run with --yes to configure all clients without prompts.');
540
+ process.exit(1);
541
+ } else {
542
+ // Present the client menu.
543
+ console.log('Select which agent(s) to configure:');
544
+ console.log(' 0. All of the above');
545
+ CLIENTS.forEach((client, i) => {
546
+ console.log(` ${i + 1}. ${client.name}`);
547
+ });
548
+ console.log('');
549
+
550
+ const rl = readline.createInterface({
551
+ input: process.stdin,
552
+ output: process.stdout,
553
+ });
554
+
555
+ while (true) {
556
+ const answer = await prompt(
557
+ rl,
558
+ `Enter number(s) separated by commas (e.g. "1,3") or 0 for all: `
559
+ );
560
+ if (answer === null) abortNoInput();
561
+
562
+ // Parse the answer into a list of client indices (0-based into CLIENTS).
563
+ if (answer === '0') {
564
+ selected = CLIENTS.map((_, i) => i);
565
+ break;
566
+ }
567
+
568
+ const indices = answer
569
+ .split(',')
570
+ .map((s) => parseInt(s.trim(), 10) - 1) // convert 1-based menu to 0-based
571
+ .filter((n) => Number.isInteger(n) && n >= 0 && n < CLIENTS.length);
410
572
 
411
- // Parse the answer into a list of client indices (0-based into CLIENTS).
412
- if (answer === '0') {
413
- selected = CLIENTS.map((_, i) => i);
573
+ if (indices.length === 0) {
574
+ console.log(`Please enter a number between 0 and ${CLIENTS.length}.`);
575
+ continue;
576
+ }
577
+
578
+ selected = indices;
414
579
  break;
415
580
  }
416
581
 
417
- const indices = answer
418
- .split(',')
419
- .map((s) => parseInt(s.trim(), 10) - 1) // convert 1-based menu to 0-based
420
- .filter((n) => Number.isInteger(n) && n >= 0 && n < CLIENTS.length);
421
-
422
- if (indices.length === 0) {
423
- console.log(`Please enter a number between 0 and ${CLIENTS.length}.`);
424
- continue;
582
+ // If Claude Code is among the selections, offer to auto-approve captain's
583
+ // tools. Registration alone still leaves Claude Code prompting "Allow
584
+ // captain to ...?" on every call; one allow rule makes it seamless. The
585
+ // user already opted into installing this server, so default to yes — but
586
+ // do ask, because the rule widens what an agent may run without prompting
587
+ // in every project on this machine.
588
+ const permClient = selected.map((idx) => CLIENTS[idx]).find((c) => c.permissionsPath);
589
+ if (permClient) {
590
+ const answer = await prompt(
591
+ rl,
592
+ `Auto-approve captain tools in Claude Code? Adds "${PERMISSION_RULE}" to\n` +
593
+ `permissions.allow in ${permClient.permissionsPath()} so tool calls never prompt. [Y/n]: `
594
+ );
595
+ if (answer === null) abortNoInput();
596
+ wantPermissions = answer === '' || /^y(es)?$/i.test(answer);
425
597
  }
426
598
 
427
- selected = indices;
428
- break;
599
+ rl.close();
600
+ console.log('');
429
601
  }
430
602
 
431
- rl.close();
432
-
433
- console.log('');
434
-
435
603
  // Configure each selected client.
436
604
  let anyFailed = false;
605
+ const succeeded = new Set();
437
606
  for (const idx of selected) {
438
607
  const client = CLIENTS[idx];
439
608
  process.stdout.write(`Configuring ${client.name}... `);
440
609
  try {
441
610
  const writtenPath = configureClient(client, binaryPath);
611
+ succeeded.add(idx);
442
612
  console.log('done.');
443
613
  console.log(` Written to: ${writtenPath}`);
444
614
  console.log(` ${client.restartMsg}`);
@@ -450,6 +620,46 @@ async function main() {
450
620
  console.log('');
451
621
  }
452
622
 
623
+ // Permissions step — deliberately OUTSIDE the registration loop and its
624
+ // try/catch: a problem in ~/.claude/settings.json must not be reported as a
625
+ // failed registration (the MCP entry was already written above), so this
626
+ // step warns instead of setting anyFailed. The stale-rule sweep runs even
627
+ // when the user declined auto-approval: dead legacy rules match nothing, so
628
+ // removing them never narrows what is allowed.
629
+ const permIdx = selected.find((idx) => CLIENTS[idx].permissionsPath && succeeded.has(idx));
630
+ if (permIdx !== undefined) {
631
+ const client = CLIENTS[permIdx];
632
+ const permissionsPath = client.permissionsPath();
633
+ try {
634
+ // Honor the "an unrelated server named captain-tool is left alone"
635
+ // promise (see LEGACY_NAMES): if such a server is still registered, its
636
+ // legacy-prefixed rules are live, not stale — skip the sweep.
637
+ const sweepLegacy = !hasUnrelatedLegacyServer(readJsonConfig(client.configPath(binaryPath)));
638
+ const { added, removed } = configurePermissions(permissionsPath, {
639
+ addAllowRule: wantPermissions,
640
+ sweepLegacy,
641
+ });
642
+ if (added) {
643
+ console.log(`Auto-approval: added "${PERMISSION_RULE}" to ${permissionsPath}`);
644
+ } else if (wantPermissions) {
645
+ console.log(`Auto-approval: "${PERMISSION_RULE}" already allowed in ${permissionsPath}`);
646
+ }
647
+ if (removed > 0) {
648
+ console.log(`Removed ${removed} stale permission rule(s) for the old server name (${LEGACY_PERMISSION_PREFIXES.join(', ')}).`);
649
+ }
650
+ if (wantPermissions) {
651
+ console.log('Note: stale rules can also live in per-project .claude/settings.local.json files — clean those by hand if Claude Code still prompts.');
652
+ }
653
+ console.log('');
654
+ } catch (err) {
655
+ const reason = err instanceof SyntaxError
656
+ ? `${permissionsPath} is not valid JSON — fix it, or add "${PERMISSION_RULE}" to permissions.allow yourself`
657
+ : err.message;
658
+ console.warn(`Warning: auto-approval step skipped: ${reason}`);
659
+ console.log('');
660
+ }
661
+ }
662
+
453
663
  if (anyFailed) {
454
664
  process.exit(1);
455
665
  }
@@ -471,9 +681,17 @@ if (require.main === module) {
471
681
  module.exports = {
472
682
  SERVER_NAME,
473
683
  LEGACY_NAMES,
684
+ PERMISSION_RULE,
685
+ LEGACY_PERMISSION_PREFIXES,
474
686
  entryIsOurs,
475
687
  removeLegacyEntries,
476
688
  removeLegacyEverywhere,
477
689
  configureClient,
478
690
  resolveCommand,
691
+ applyPermissionRule,
692
+ configurePermissions,
693
+ hasUnrelatedLegacyServer,
694
+ // Exported so the npx runner (`captain-tool setup`) can launch the wizard:
695
+ // the require.main gate above means a bare require() runs nothing.
696
+ main,
479
697
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "captain-tool",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
4
4
  "description": "MCP server connecting Claude Desktop and VS Code Copilot to Captain Cloud",
5
5
  "bin": {
6
6
  "captain-tool": "bin/captain-tool",
@@ -11,10 +11,10 @@
11
11
  "test": "node test/setup.test.js"
12
12
  },
13
13
  "optionalDependencies": {
14
- "@captain-tool/captain-tool-win32-x64": "0.0.39",
15
- "@captain-tool/captain-tool-darwin-x64": "0.0.39",
16
- "@captain-tool/captain-tool-darwin-arm64": "0.0.39",
17
- "@captain-tool/captain-tool-linux-x64": "0.0.39"
14
+ "@captain-tool/captain-tool-win32-x64": "0.0.41",
15
+ "@captain-tool/captain-tool-darwin-x64": "0.0.41",
16
+ "@captain-tool/captain-tool-darwin-arm64": "0.0.41",
17
+ "@captain-tool/captain-tool-linux-x64": "0.0.41"
18
18
  },
19
19
  "files": [
20
20
  "bin/",