borgmcp 2.15.0 → 2.16.1

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 (48) hide show
  1. package/README.md +35 -11
  2. package/dist/assimilate-cmd.d.ts +2 -0
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +20 -7
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts.map +1 -1
  7. package/dist/assimilate-deps.js +2 -1
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/bare-launch-menu.d.ts +8 -0
  10. package/dist/bare-launch-menu.d.ts.map +1 -1
  11. package/dist/bare-launch-menu.js +12 -0
  12. package/dist/bare-launch-menu.js.map +1 -1
  13. package/dist/claude.d.ts.map +1 -1
  14. package/dist/claude.js +26 -39
  15. package/dist/claude.js.map +1 -1
  16. package/dist/cubes.d.ts +6 -1
  17. package/dist/cubes.d.ts.map +1 -1
  18. package/dist/cubes.js +7 -2
  19. package/dist/cubes.js.map +1 -1
  20. package/dist/parse-assimilate-args.d.ts.map +1 -1
  21. package/dist/parse-assimilate-args.js +6 -1
  22. package/dist/parse-assimilate-args.js.map +1 -1
  23. package/dist/regen-format.d.ts.map +1 -1
  24. package/dist/regen-format.js +7 -1
  25. package/dist/regen-format.js.map +1 -1
  26. package/dist/resolved-cli-config.d.ts +16 -0
  27. package/dist/resolved-cli-config.d.ts.map +1 -0
  28. package/dist/resolved-cli-config.js +26 -0
  29. package/dist/resolved-cli-config.js.map +1 -0
  30. package/dist/setup-selection.d.ts +38 -0
  31. package/dist/setup-selection.d.ts.map +1 -0
  32. package/dist/setup-selection.js +51 -0
  33. package/dist/setup-selection.js.map +1 -0
  34. package/dist/setup.js +80 -21
  35. package/dist/setup.js.map +1 -1
  36. package/docs/EXTRACTION_PROVENANCE.md +23 -10
  37. package/docs/RELEASING.md +8 -282
  38. package/package.json +8 -9
  39. package/src/assimilate-cmd.ts +24 -7
  40. package/src/assimilate-deps.ts +2 -0
  41. package/src/bare-launch-menu.ts +17 -0
  42. package/src/claude.ts +29 -37
  43. package/src/cubes.ts +7 -2
  44. package/src/parse-assimilate-args.ts +7 -1
  45. package/src/regen-format.ts +7 -1
  46. package/src/resolved-cli-config.ts +40 -0
  47. package/src/setup-selection.ts +87 -0
  48. package/src/setup.ts +98 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "2.15.0",
3
+ "version": "2.16.1",
4
4
  "description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,23 +24,22 @@
24
24
  },
25
25
  "scripts": {
26
26
  "clean": "node scripts/clean.mjs",
27
- "build": "npm run clean && tsc -p tsconfig.json && node scripts/set-bin-mode.mjs",
28
- "check": "tsc -p tsconfig.json --noEmit",
29
- "dev": "tsc -p tsconfig.json --watch",
27
+ "preflight:node": "node scripts/node-preflight.mjs",
28
+ "build": "npm run preflight:node && npm run clean && tsc -p tsconfig.json && node scripts/set-bin-mode.mjs",
29
+ "check": "npm run preflight:node && tsc -p tsconfig.json --noEmit",
30
+ "dev": "npm run preflight:node && tsc -p tsconfig.json --watch",
30
31
  "start": "node dist/index.js",
31
32
  "test": "npm run test:unit && npm run test:release",
32
- "test:unit": "vitest run --config vitest.config.ts",
33
- "test:release": "node --test test/release-lane.test.mjs test/release-identity.test.mjs",
33
+ "test:unit": "npm run preflight:node && vitest run --config vitest.config.ts",
34
+ "test:release": "npm run preflight:node && node --test test/release-lane.test.mjs",
34
35
  "onboarding:smoke": "node scripts/onboarding-smoke.mjs",
35
- "release:prepare": "node scripts/release-identity.mjs prepare",
36
36
  "release:exercise": "npm run build && node scripts/release-exercise.mjs",
37
37
  "release:check": "npm run verify:public-source && npm run verify:release && npm run verify:lock && npm run check && npm test && npm run build && npm run onboarding:smoke && npm run verify:package",
38
38
  "verify:artifact": "node scripts/verify-packed-artifact.mjs",
39
39
  "verify:public-source": "node scripts/verify-public-source.mjs",
40
40
  "verify:package": "node scripts/verify-local-package.mjs",
41
41
  "verify:release": "node scripts/verify-release-readiness.mjs",
42
- "verify:lock": "node scripts/verify-lock-registry.mjs",
43
- "verify:release-identity": "node scripts/release-identity.mjs verify"
42
+ "verify:lock": "node scripts/verify-lock-registry.mjs"
44
43
  },
45
44
  "files": [
46
45
  "dist",
@@ -388,6 +388,8 @@ export interface AssimilateDeps {
388
388
  // on healthy hosts; degraded-path surfaces a stderr warning and
389
389
  // still launches claude (never blocks).
390
390
  probeMcpReady: () => Promise<boolean>;
391
+ /** Save the resolved CLI for a newly-created sibling worktree. */
392
+ setCliPreferenceForWorktree: (cli: BorgCli, worktree: string) => Promise<void>;
391
393
  resolveCli: (explicit?: BorgCli) => Promise<BorgCli>;
392
394
  prepareCodexRemoteLaunch: () => Promise<CodexRemoteLaunch>;
393
395
  setCodexWakeTarget: (cubeId: string, droneId: string, target: { threadId: string; socketPath: string }) => Promise<void>;
@@ -708,13 +710,10 @@ export async function runAssimilate(
708
710
  ): Promise<number> {
709
711
  const mode = args.mode ?? 'assimilate';
710
712
  // ----- Input validation (before any subprocess work) -----
711
- if (args.role !== undefined) {
712
- const v = validateName(args.role);
713
- if (!v.ok) {
714
- deps.stderr(v.error + '\n');
715
- return 1;
716
- }
717
- }
713
+ // A role is a lookup key, not a path component. matchRoleByName() below
714
+ // applies the shared roleSlug() normalization, so displayed names such as
715
+ // "Builder" and "Code Reviewer" must reach that resolver. Keep the strict
716
+ // identifier validator for worktree names, which do become path components.
718
717
  if (args.flags.worktree !== undefined) {
719
718
  const v = validateName(args.flags.worktree);
720
719
  if (!v.ok) {
@@ -1867,6 +1866,24 @@ export async function runAssimilate(
1867
1866
  }
1868
1867
  };
1869
1868
 
1869
+ // CLI resolution happens before the server attach because agent_kind is part
1870
+ // of that request. The resolver therefore saved the preference against the
1871
+ // invoking checkout. Once a sibling exists, save the same choice under its
1872
+ // own project key so a later --here launch in that worktree can read it.
1873
+ if (spawnedWorktreePath) {
1874
+ try {
1875
+ await deps.setCliPreferenceForWorktree(cli, spawnedWorktreePath);
1876
+ } catch (err) {
1877
+ const message = err instanceof Error ? err.message : String(err);
1878
+ deps.stderr(
1879
+ `Borg could not save the ${cli} preference for sibling worktree ${spawnedWorktreePath}: ${message}. ` +
1880
+ 'The worktree was removed; correct the local configuration and retry.\n',
1881
+ );
1882
+ rollbackWorktree();
1883
+ return 1;
1884
+ }
1885
+ }
1886
+
1870
1887
  try {
1871
1888
  deps.mkdirp(scratchRoot);
1872
1889
  deps.provisionLaunchAccess?.(cli, seatWorktree, launchAccessPaths);
@@ -60,6 +60,7 @@ import {
60
60
  hasPersistedActiveCube as cubesHasPersistedActive,
61
61
  setActiveCube as cubesSetActive,
62
62
  inboxPathForDrone,
63
+ setProjectCliPreference,
63
64
  setCodexWakeTarget,
64
65
  } from './cubes.js';
65
66
  import {
@@ -575,6 +576,7 @@ export function buildDefaultAssimilateDeps(
575
576
  }
576
577
  }, () => process.stdin.isTTY === true)
577
578
  ),
579
+ setCliPreferenceForWorktree: (cli, worktree) => setProjectCliPreference(cli, worktree),
578
580
  prepareCodexRemoteLaunch: () => prepareCodexRemoteLaunch(defaultCodexRemoteDeps()),
579
581
  setCodexWakeTarget,
580
582
  findLoadedCodexThread,
@@ -39,6 +39,23 @@ export interface LaunchMenuInputs {
39
39
  hasLaunchAllTargets: boolean;
40
40
  }
41
41
 
42
+ /**
43
+ * Resolve and configure the CLI that will actually launch.
44
+ *
45
+ * This callback deliberately runs after the one-shot menu choice. A bare
46
+ * launch may resolve one default CLI first and then launch a different
47
+ * installed CLI for this invocation; only the final CLI may be self-healed.
48
+ */
49
+ export function configureSelectedLaunchCli(
50
+ defaultCli: BorgCli,
51
+ action: LaunchMenuAction | undefined,
52
+ configure: (cli: BorgCli) => void,
53
+ ): BorgCli {
54
+ const cli = action?.kind === 'launch' ? action.cli : defaultCli;
55
+ configure(cli);
56
+ return cli;
57
+ }
58
+
42
59
  const PRETTY: Record<BorgCli, string> = { claude: 'Claude', codex: 'Codex', opencode: 'OpenCode' };
43
60
 
44
61
  /**
package/src/claude.ts CHANGED
@@ -24,7 +24,7 @@ import { basename } from 'node:path';
24
24
  import { createInterface } from 'node:readline/promises';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import chalk from 'chalk';
27
- import { findProjectRoot, getActiveCube, inboxPathForDrone, setCodexWakeTarget, pruneDeadCodexWakeTargets } from './cubes.js';
27
+ import { findProjectRoot, getActiveCube, inboxPathForDrone, setCodexWakeTarget, pruneDeadCodexWakeTargets, type BorgCli } from './cubes.js';
28
28
  import { monitorStateRootForWorktree } from './inbox-monitor.js';
29
29
  import { formatSeatReattachRefusal, inspectLiveInboxMonitor } from './seat-reattach-guard.js';
30
30
  import { handleVersionFlag, getPackageVersion } from './version.js';
@@ -48,10 +48,12 @@ import { runLaunchAll } from './launch-all-cmd.js';
48
48
  import { buildDefaultLaunchAllDeps } from './launch-all-deps.js';
49
49
  import { discoverDroneCandidates } from './launch-all-discovery.js';
50
50
  import {
51
+ configureSelectedLaunchCli,
51
52
  explicitCliLaunchHint,
52
53
  runBareLaunchMenu,
53
54
  shouldResolveExplicitCliLaunchHintTargets,
54
55
  shouldShowLaunchMenu,
56
+ type LaunchMenuAction,
55
57
  } from './bare-launch-menu.js';
56
58
  import { setTerminalTitle } from './terminal-title.js';
57
59
  import { initConsolePrefix, consolePrefix } from './console-prefix.js';
@@ -81,6 +83,7 @@ import {
81
83
  removeSessionStartHook,
82
84
  } from './config-utils.js';
83
85
  import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
86
+ import { configureResolvedCli } from './resolved-cli-config.js';
84
87
  import { installBorgPlugin } from './opencode-plugin.js';
85
88
  import { allocateOpenCodePort, connectOpenCodeDrone, createOpenCodeLaunchKickoff, injectInitialKickoff, openCodeLaunchBinding } from './opencode-drone.js';
86
89
  import { buildOpenCodeLaunchArgs, defaultApprovalIo, resolveLaunchBorgApprovals } from './cli-tool-approval.js';
@@ -322,7 +325,7 @@ async function main() {
322
325
  parsedCli.cli,
323
326
  defaultCliChoiceDeps(prompt, () => process.stdin.isTTY === true)
324
327
  );
325
- ensureDetectedCliConfigured();
328
+ let launchAction: LaunchMenuAction | undefined;
326
329
 
327
330
  // Active cube for this directory — needed for the launch menu's option-3
328
331
  // availability, the terminal title, and the inbox-Monitor clause below.
@@ -383,9 +386,13 @@ async function main() {
383
386
  // option 1 → configured default; option 2 → the other agent, ONE-SHOT
384
387
  // (we deliberately do NOT call setProjectCliPreference — the saved
385
388
  // preference is changed only via `borg --cli <agent>`).
386
- cli = action.cli;
389
+ launchAction = action;
387
390
  }
388
391
 
392
+ // Configure only the CLI that will actually launch. This must follow the
393
+ // one-shot menu: the resolved default can differ from the menu selection.
394
+ cli = configureSelectedLaunchCli(cli, launchAction, ensureResolvedCliConfigured);
395
+
389
396
  if (active && !parsedCli.force) {
390
397
  const inboxPath = inboxPathForDrone(active.cubeId, active.droneId);
391
398
  const stateRoot = monitorStateRootForWorktree(findProjectRoot(process.cwd()));
@@ -616,40 +623,25 @@ async function main() {
616
623
  });
617
624
  }
618
625
 
619
- function ensureDetectedCliConfigured(): void {
620
- const found = detectCliAvailability();
621
- if (found.claude) {
622
- try {
623
- ensureCliMcpConfigured('claude');
624
- // gh#673 P2 (WI-1): the orientation hook lives PROJECT-LOCAL in
625
- // <root>/.claude/settings.local.json ensured on every bare
626
- // `borg` launch so pre-P2 worktrees self-heal. The legacy GLOBAL
627
- // hook is then removed: safe because this ensure precedes every
628
- // borg-launched agent spawn (other projects get their local hook
629
- // at their own next launch/assimilate), and P1's BORG_SESSION
630
- // gate already no-ops the global hook in non-borg sessions.
631
- addProjectSessionStartHook(findProjectRoot(process.cwd()));
632
- removeSessionStartHook();
633
- addUserPromptSubmitHook();
634
- } catch (err: any) {
635
- console.error(`${consolePrefix()}${chalk.yellow(`warning: Claude Code integration check failed: ${err?.message ?? err}`)}`);
636
- }
637
- }
638
- if (found.codex) {
639
- try {
640
- ensureCliMcpConfigured('codex');
641
- addCodexSessionStartHook();
642
- addCodexUserPromptSubmitHook();
643
- } catch (err: any) {
644
- console.error(`${consolePrefix()}${chalk.yellow(`warning: Codex integration check failed: ${err?.message ?? err}`)}`);
645
- }
646
- }
647
- if (found.opencode) {
648
- try {
649
- ensureCliMcpConfigured('opencode');
650
- } catch (err: any) {
651
- console.error(`${consolePrefix()}${chalk.yellow(`warning: OpenCode integration check failed: ${err?.message ?? err}`)}`);
652
- }
626
+ function ensureResolvedCliConfigured(cli: BorgCli): void {
627
+ const label = cli === 'claude' ? 'Claude Code' : cli === 'codex' ? 'Codex' : 'OpenCode';
628
+ try {
629
+ configureResolvedCli(cli, {
630
+ ensureMcp: ensureCliMcpConfigured,
631
+ addClaudeProjectSessionStartHook: () => {
632
+ // gh#673 P2 (WI-1): the orientation hook lives PROJECT-LOCAL in
633
+ // <root>/.claude/settings.local.json ensured on every bare
634
+ // `borg` launch so pre-P2 worktrees self-heal. The legacy GLOBAL
635
+ // hook is then removed after the local hook is in place.
636
+ addProjectSessionStartHook(findProjectRoot(process.cwd()));
637
+ },
638
+ removeClaudeGlobalSessionStartHook: removeSessionStartHook,
639
+ addClaudeUserPromptSubmitHook: addUserPromptSubmitHook,
640
+ addCodexSessionStartHook,
641
+ addCodexUserPromptSubmitHook,
642
+ });
643
+ } catch (err: any) {
644
+ console.error(`${consolePrefix()}${chalk.yellow(`warning: ${label} integration check failed: ${err?.message ?? err}`)}`);
653
645
  }
654
646
  }
655
647
 
package/src/cubes.ts CHANGED
@@ -611,9 +611,14 @@ export async function readAllProjectIdentities(): Promise<
611
611
  );
612
612
  }
613
613
 
614
- export async function setProjectCliPreference(cli: BorgCli): Promise<void> {
614
+ /**
615
+ * Save the CLI preference for the current project, or for an explicitly named
616
+ * worktree. The explicit path is used when assimilate has just created a
617
+ * sibling worktree but the process still began in the invoking checkout.
618
+ */
619
+ export async function setProjectCliPreference(cli: BorgCli, dir?: string): Promise<void> {
615
620
  const existing = (await readLaunchFile()) ?? { projects: {} };
616
- existing.projects[findProjectRoot()] = { cli };
621
+ existing.projects[findProjectRoot(dir)] = { cli };
617
622
  await writeLaunchFile(existing);
618
623
  }
619
624
 
@@ -115,7 +115,13 @@ export function parseAssimilateArgs(rawArgs: string[]): ParseResult {
115
115
  if (enrollmentRequested) {
116
116
  return { ok: false, error: ENROLLMENT_POSITIONAL_INPUT_ERROR };
117
117
  }
118
- return { ok: false, error: `unexpected extra argument: ${arg} (already have role "${role}")` };
118
+ return {
119
+ ok: false,
120
+ error:
121
+ `unexpected extra argument: ${arg} (already have role "${role}"). ` +
122
+ 'Multi-word role names must be quoted (for example, `borg assimilate "Code Reviewer"`) ' +
123
+ 'or written with hyphens.',
124
+ };
119
125
  }
120
126
  if (enrollmentRequested && !validateName(arg).ok) {
121
127
  return { ok: false, error: ENROLLMENT_POSITIONAL_INPUT_ERROR };
@@ -243,7 +243,7 @@ export function markArrivalAnnouncedThisProcess(): void {
243
243
  export function getDronePlaybook(): string {
244
244
  const arrivalInstruction = arrivalAnnouncedThisProcess
245
245
  ? ''
246
- : '\n**When this MCP session first starts:** post one `ARRIVAL: <your-label> (<your-role>) online on <hostname> at <project-path>` (run `hostname`; use cwd for the path). After the post succeeds, the client suppresses this instruction until the MCP process restarts; an explicit `/mcp` reconnect may show it again.\n';
246
+ : '\n**When this MCP session first starts:** post one `ARRIVAL: <your-label> (<your-role>) online on <hostname>` (run `hostname`). After the post succeeds, the client suppresses this instruction until the MCP process restarts; an explicit `/mcp` reconnect may show it again.\n';
247
247
  return `## How to operate as a Drone
248
248
 
249
249
  You're a Drone in a Cube. Coordinate with other drones through the activity log.
@@ -261,6 +261,12 @@ You're a Drone in a Cube. Coordinate with other drones through the activity log.
261
261
 
262
262
  **How coordination works:** the Cube gives primitives, not workflows. Your role's \`detailed_description\` (above) is your playbook — its conventions + signals come from there, not the system. The log is the coordination channel. Different cubes, different conventions.
263
263
 
264
+ **Communication discipline for non-human seats:**
265
+ - **Console:** write nothing except harness-required output. Surface something to the operator only when blocked and needing unblocking; do not narrate plans, progress, method, or results.
266
+ - **Log:** a post must change what another seat does. Otherwise, do not write it. Keep posts short: lifecycle signal + SHA and nothing else; defect + location/evidence; correction to your live claim; or a genuine blocking question.
267
+ - **Do not post:** plans, work-in-progress/progress narration, method or reasoning, restatements/agreement/credit, self-examination, framing phrases, or coordination commentary.
268
+ - **Evidence boundary:** state what a verdict did not exercise and any unavailable control in the same short clause. The human seat is excluded so its dispatches can explain constraints without being misapplied.
269
+
264
270
  **Default: act autonomously, coordinate through the log.** Don't wait for user input. Need input → post the question, continue other work, other drones respond. The human supervisor is reachable through your cube's coordinating / human-seat role (the role your cube designates for direction + integration), or the Queen role when the seat is delegated to a drone — one continuous seat. Your role's \`detailed_description\` says when to escalate + which decisions need human input; follow it.
265
271
 
266
272
  **Operating loop — each wake, in order:**
@@ -0,0 +1,40 @@
1
+ import type { BorgCli } from './cubes.js';
2
+
3
+ export interface ResolvedCliConfigDeps {
4
+ ensureMcp(cli: BorgCli): void;
5
+ addClaudeProjectSessionStartHook(): void;
6
+ removeClaudeGlobalSessionStartHook(): void;
7
+ addClaudeUserPromptSubmitHook(): void;
8
+ addCodexSessionStartHook(): void;
9
+ addCodexUserPromptSubmitHook(): void;
10
+ }
11
+ /**
12
+ * Apply only the integration writes for the CLI the launcher resolved.
13
+ * In particular, a Claude installation must not cause Codex or OpenCode
14
+ * configuration to be repaired as a side effect of a bare `borg` launch.
15
+ */
16
+ export function configureResolvedCli(
17
+ cli: BorgCli,
18
+ deps: ResolvedCliConfigDeps,
19
+ ): void {
20
+ if (cli === 'claude') {
21
+ deps.ensureMcp('claude');
22
+ // The project-local SessionStart hook is the launch-time self-heal for
23
+ // the CLI actually being launched. Remove only the obsolete global hook
24
+ // after the local hook is in place, then keep the Claude prompt hook in
25
+ // its existing global location.
26
+ deps.addClaudeProjectSessionStartHook();
27
+ deps.removeClaudeGlobalSessionStartHook();
28
+ deps.addClaudeUserPromptSubmitHook();
29
+ return;
30
+ }
31
+
32
+ if (cli === 'codex') {
33
+ deps.ensureMcp('codex');
34
+ deps.addCodexSessionStartHook();
35
+ deps.addCodexUserPromptSubmitHook();
36
+ return;
37
+ }
38
+
39
+ deps.ensureMcp('opencode');
40
+ }
@@ -0,0 +1,87 @@
1
+ import type { BorgCli } from './cubes.js';
2
+
3
+ export interface SetupAgentChoice {
4
+ title: string;
5
+ value: BorgCli;
6
+ selected: boolean;
7
+ disabled?: boolean;
8
+ }
9
+
10
+ export type SetupAgentSelection =
11
+ | { kind: 'selected'; agents: BorgCli[] }
12
+ | { kind: 'empty' }
13
+ | { kind: 'cancelled' };
14
+
15
+ export interface EmptySetupOutcome {
16
+ kind: 'empty';
17
+ agentConfigurationChanged: false;
18
+ localServerInitializationStarted: false;
19
+ recovery: {
20
+ kind: 'rerun-setup';
21
+ command: 'borg setup';
22
+ };
23
+ }
24
+
25
+ const CLI_TITLES: Record<BorgCli, string> = {
26
+ claude: 'Claude Code',
27
+ codex: 'Codex',
28
+ opencode: 'OpenCode',
29
+ };
30
+
31
+ /** Build the first-run choices from the CLIs that are actually installed. */
32
+ export function setupAgentChoices(
33
+ detected: readonly BorgCli[],
34
+ alreadyConfigured: ReadonlySet<BorgCli> = new Set(),
35
+ ): SetupAgentChoice[] {
36
+ return [...new Set(detected)].map((cli) => ({
37
+ title: alreadyConfigured.has(cli)
38
+ ? `${CLI_TITLES[cli]} (already configured)`
39
+ : CLI_TITLES[cli],
40
+ value: cli,
41
+ selected: !alreadyConfigured.has(cli),
42
+ ...(alreadyConfigured.has(cli) ? { disabled: true } : {}),
43
+ }));
44
+ }
45
+
46
+ /** The agent names whose newly selected setup needs a restart notice. */
47
+ export function setupRestartInstruction(selected: readonly BorgCli[]): string {
48
+ const labels = selected.map((cli) => CLI_TITLES[cli]);
49
+ return `🔄 Restart ${labels.join(' / ')} (or open a new session) for the changes to take effect.`;
50
+ }
51
+
52
+ /** Describe the accepted zero-selection result and its recovery path. */
53
+ export function emptySetupOutcome(): EmptySetupOutcome {
54
+ return {
55
+ kind: 'empty',
56
+ agentConfigurationChanged: false,
57
+ localServerInitializationStarted: false,
58
+ recovery: {
59
+ kind: 'rerun-setup',
60
+ command: 'borg setup',
61
+ },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Keep only detected agents and return them in detection order. The selected
67
+ * set is invocation-local; it is deliberately never persisted.
68
+ */
69
+ export function normalizeSetupAgentSelection(
70
+ detected: readonly BorgCli[],
71
+ selected: readonly unknown[] | undefined,
72
+ ): BorgCli[] {
73
+ if (!Array.isArray(selected)) return [];
74
+ const selectedSet = new Set(selected);
75
+ return [...new Set(detected)].filter((cli) => selectedSet.has(cli));
76
+ }
77
+
78
+ /** Turn the prompt result into the terminal outcome used by the wizard. */
79
+ export function resolveSetupAgentSelection(
80
+ detected: readonly BorgCli[],
81
+ selected: readonly unknown[] | undefined,
82
+ cancelled = false,
83
+ ): SetupAgentSelection {
84
+ if (cancelled) return { kind: 'cancelled' };
85
+ const agents = normalizeSetupAgentSelection(detected, selected);
86
+ return agents.length === 0 ? { kind: 'empty' } : { kind: 'selected', agents };
87
+ }
package/src/setup.ts CHANGED
@@ -39,6 +39,13 @@ import { initDebugFromArgv } from './debug.js';
39
39
  import { defaultApprovalIo, setupApprovalWarnings } from './cli-tool-approval.js';
40
40
  import { offerFirstRunServerInstall } from './first-run-server.js';
41
41
  import { setupNextStepsText } from './cli-help.js';
42
+ import {
43
+ emptySetupOutcome,
44
+ resolveSetupAgentSelection,
45
+ setupAgentChoices,
46
+ setupRestartInstruction,
47
+ } from './setup-selection.js';
48
+ import type { BorgCli } from './cubes.js';
42
49
 
43
50
  /**
44
51
  * Main setup wizard
@@ -69,18 +76,8 @@ async function main() {
69
76
  process.exit(1);
70
77
  }
71
78
 
72
- // Resolve the separately published local server before setup writes agent
73
- // configuration. Decline/non-interactive/failure paths therefore leave no
74
- // partial setup state behind.
75
- console.log(chalk.blue('◼ Local Server'));
76
- const serverInstall = await offerFirstRunServerInstall(undefined, undefined, { initializeServer: true });
77
- if (serverInstall.kind !== 'present' && serverInstall.kind !== 'installed') {
78
- process.exit(serverInstall.kind === 'declined' ? 0 : 1);
79
- }
80
- console.log('');
81
- console.log('◼ Local server initialized');
82
-
83
- // Step 1: Configure every detected agent CLI
79
+ // Step 1: Choose which detected agent CLIs to configure. The choice is
80
+ // first-run/invocation-local; it is never persisted as an opt-out.
84
81
  console.log(chalk.blue('◼ Agent CLI Integration'));
85
82
 
86
83
  const yes = parseYesFlag(process.argv);
@@ -88,6 +85,12 @@ async function main() {
88
85
  const claudeDetected = claudeCliPath !== null;
89
86
  const codexDetected = codexCliPath !== null;
90
87
  const opencodeDetected = opencodeCliPath !== null;
88
+ const detectedClis: BorgCli[] = [
89
+ ...(claudeDetected ? ['claude' as const] : []),
90
+ ...(codexDetected ? ['codex' as const] : []),
91
+ ...(opencodeDetected ? ['opencode' as const] : []),
92
+ ];
93
+
91
94
  const claudeMcpConfigured = isMcpServerConfigured();
92
95
  const codexMcpConfigured = isCodexMcpServerConfigured();
93
96
  const opencodeMcpConfigured = isOpenCodeMcpServerConfigured();
@@ -95,14 +98,73 @@ async function main() {
95
98
  const claudeUpsHookPending = claudeDetected && !isUserPromptSubmitHookRegistered();
96
99
  const codexSessionHookPending = codexDetected && !isCodexSessionStartHookRegistered();
97
100
  const codexUpsHookPending = codexDetected && !isCodexUserPromptSubmitHookRegistered();
98
- const claudeHookPending = claudeLegacyHookPending || claudeUpsHookPending;
99
- const codexHookPending = codexSessionHookPending || codexUpsHookPending;
101
+ const alreadyConfigured = new Set<BorgCli>([
102
+ ...(claudeDetected && claudeMcpConfigured && !claudeLegacyHookPending && !claudeUpsHookPending
103
+ ? ['claude' as const]
104
+ : []),
105
+ ...(codexDetected && codexMcpConfigured && !codexSessionHookPending && !codexUpsHookPending
106
+ ? ['codex' as const]
107
+ : []),
108
+ ...(opencodeDetected && opencodeMcpConfigured ? ['opencode' as const] : []),
109
+ ]);
110
+
111
+ const allDetectedMutationPending = setupMutationPending({
112
+ claude: claudeDetected,
113
+ codex: codexDetected,
114
+ opencode: opencodeDetected,
115
+ claudeMcpConfigured,
116
+ codexMcpConfigured,
117
+ opencodeMcpConfigured,
118
+ claudeHookPending: claudeLegacyHookPending || claudeUpsHookPending,
119
+ codexHookPending: codexSessionHookPending || codexUpsHookPending,
120
+ });
121
+
122
+ let selectedClis = detectedClis;
123
+ if (allDetectedMutationPending && !yes && process.stdin.isTTY === true) {
124
+ let cancelled = false;
125
+ const answer = await prompts(
126
+ {
127
+ type: 'multiselect',
128
+ name: 'selected',
129
+ message: 'Which detected agent CLIs should Borg configure?',
130
+ hint: 'Space toggles selection; Enter accepts the checked agents',
131
+ choices: setupAgentChoices(detectedClis, alreadyConfigured),
132
+ instructions: false,
133
+ },
134
+ {
135
+ onCancel: () => {
136
+ cancelled = true;
137
+ },
138
+ },
139
+ );
140
+ const selection = resolveSetupAgentSelection(detectedClis, answer.selected, cancelled);
141
+ if (selection.kind === 'cancelled') {
142
+ console.log(chalk.yellow('\n◼ Setup cancelled — no changes made.\n'));
143
+ return;
144
+ }
145
+ if (selection.kind === 'empty') {
146
+ const outcome = emptySetupOutcome();
147
+ console.log(chalk.yellow(
148
+ `\n◼ No agent CLIs selected — agent configuration and local-server initialization were skipped. ` +
149
+ `Run \`${outcome.recovery.command}\` again to choose an agent and continue setup.\n`,
150
+ ));
151
+ return;
152
+ }
153
+ selectedClis = selection.agents;
154
+ }
155
+
156
+ const selected = new Set(selectedClis);
157
+ const claudeSelected = selected.has('claude');
158
+ const codexSelected = selected.has('codex');
159
+ const opencodeSelected = selected.has('opencode');
160
+ const claudeHookPending = claudeSelected && (claudeLegacyHookPending || claudeUpsHookPending);
161
+ const codexHookPending = codexSelected && (codexSessionHookPending || codexUpsHookPending);
100
162
 
101
163
  if (
102
164
  setupMutationPending({
103
- claude: claudeDetected,
104
- codex: codexDetected,
105
- opencode: opencodeDetected,
165
+ claude: claudeSelected,
166
+ codex: codexSelected,
167
+ opencode: opencodeSelected,
106
168
  claudeMcpConfigured,
107
169
  codexMcpConfigured,
108
170
  opencodeMcpConfigured,
@@ -112,7 +174,7 @@ async function main() {
112
174
  ) {
113
175
  console.log(
114
176
  formatConfigMutationDisclosure(
115
- configMutationTargets({ claude: claudeDetected, codex: codexDetected, opencode: opencodeDetected })
177
+ configMutationTargets({ claude: claudeSelected, codex: codexSelected, opencode: opencodeSelected })
116
178
  )
117
179
  );
118
180
  const mutationDecision = await confirmConfigMutation({
@@ -135,7 +197,19 @@ async function main() {
135
197
  }
136
198
  console.log('');
137
199
 
138
- if (claudeCliPath) {
200
+ // Resolve the separately published local server only after the complete
201
+ // agent selection and the existing single config-mutation confirmation.
202
+ // Esc/zero-selection/decline therefore leave both agent and server setup
203
+ // untouched.
204
+ console.log(chalk.blue('◼ Local Server'));
205
+ const serverInstall = await offerFirstRunServerInstall(undefined, undefined, { initializeServer: true });
206
+ if (serverInstall.kind !== 'present' && serverInstall.kind !== 'installed') {
207
+ process.exit(serverInstall.kind === 'declined' ? 0 : 1);
208
+ }
209
+ console.log('');
210
+ console.log('◼ Local server initialized');
211
+
212
+ if (claudeSelected) {
139
213
  try {
140
214
  ensureCliMcpConfigured('claude');
141
215
  if (claudeLegacyHookPending) removeSessionStartHook();
@@ -146,7 +220,7 @@ async function main() {
146
220
  process.exit(1);
147
221
  }
148
222
  }
149
- if (codexCliPath) {
223
+ if (codexSelected) {
150
224
  try {
151
225
  ensureCliMcpConfigured('codex');
152
226
  if (codexSessionHookPending) addCodexSessionStartHook();
@@ -157,7 +231,7 @@ async function main() {
157
231
  process.exit(1);
158
232
  }
159
233
  }
160
- if (opencodeCliPath) {
234
+ if (opencodeSelected) {
161
235
  try {
162
236
  ensureCliMcpConfigured('opencode');
163
237
  console.log(chalk.green('◼ borg configured for OpenCode'));
@@ -172,8 +246,8 @@ async function main() {
172
246
  codexArgs: [],
173
247
  });
174
248
  for (const warning of await setupApprovalWarnings(approvalIo, {
175
- codex: codexDetected,
176
- opencode: opencodeDetected,
249
+ codex: codexSelected,
250
+ opencode: opencodeSelected,
177
251
  })) {
178
252
  console.log(chalk.yellow(`warning: ${warning}`));
179
253
  }
@@ -185,7 +259,7 @@ async function main() {
185
259
 
186
260
  // Success message
187
261
  console.log(chalk.green.bold('\nSetup complete!\n'));
188
- console.log(chalk.yellow('🔄 Restart Claude Code / Codex / OpenCode (or open a new session) for the changes to take effect.\n'));
262
+ console.log(chalk.yellow(`${setupRestartInstruction(selectedClis)}\n`));
189
263
  if (!serverInstall.suppressClientNextSteps) {
190
264
  console.log(chalk.gray(setupNextStepsText()));
191
265
  }