borgmcp 2.14.2 → 2.16.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 (124) hide show
  1. package/dist/agent-runtime.d.ts +5 -0
  2. package/dist/agent-runtime.d.ts.map +1 -1
  3. package/dist/agent-runtime.js +14 -0
  4. package/dist/agent-runtime.js.map +1 -1
  5. package/dist/assimilate-cmd.d.ts +5 -0
  6. package/dist/assimilate-cmd.d.ts.map +1 -1
  7. package/dist/assimilate-cmd.js +53 -14
  8. package/dist/assimilate-cmd.js.map +1 -1
  9. package/dist/assimilate-deps.d.ts.map +1 -1
  10. package/dist/assimilate-deps.js +19 -4
  11. package/dist/assimilate-deps.js.map +1 -1
  12. package/dist/bare-launch-menu.d.ts +8 -0
  13. package/dist/bare-launch-menu.d.ts.map +1 -1
  14. package/dist/bare-launch-menu.js +12 -0
  15. package/dist/bare-launch-menu.js.map +1 -1
  16. package/dist/claude.d.ts.map +1 -1
  17. package/dist/claude.js +28 -40
  18. package/dist/claude.js.map +1 -1
  19. package/dist/cleanup-cmd.d.ts.map +1 -1
  20. package/dist/cleanup-cmd.js +2 -2
  21. package/dist/cleanup-cmd.js.map +1 -1
  22. package/dist/cli-tool-approval.d.ts.map +1 -1
  23. package/dist/cli-tool-approval.js +3 -2
  24. package/dist/cli-tool-approval.js.map +1 -1
  25. package/dist/codex-remote.d.ts.map +1 -1
  26. package/dist/codex-remote.js +4 -3
  27. package/dist/codex-remote.js.map +1 -1
  28. package/dist/config-utils.d.ts +16 -1
  29. package/dist/config-utils.d.ts.map +1 -1
  30. package/dist/config-utils.js +176 -17
  31. package/dist/config-utils.js.map +1 -1
  32. package/dist/credential-paths.d.ts.map +1 -1
  33. package/dist/credential-paths.js +2 -3
  34. package/dist/credential-paths.js.map +1 -1
  35. package/dist/cubes.d.ts +6 -1
  36. package/dist/cubes.d.ts.map +1 -1
  37. package/dist/cubes.js +9 -4
  38. package/dist/cubes.js.map +1 -1
  39. package/dist/foreign-path-reminder.d.ts +12 -0
  40. package/dist/foreign-path-reminder.d.ts.map +1 -0
  41. package/dist/foreign-path-reminder.js +122 -0
  42. package/dist/foreign-path-reminder.js.map +1 -0
  43. package/dist/launch-access.d.ts +22 -0
  44. package/dist/launch-access.d.ts.map +1 -0
  45. package/dist/launch-access.js +27 -0
  46. package/dist/launch-access.js.map +1 -0
  47. package/dist/launch-all-deps.d.ts.map +1 -1
  48. package/dist/launch-all-deps.js +2 -2
  49. package/dist/launch-all-deps.js.map +1 -1
  50. package/dist/lifecycle-log-guard.js +2 -2
  51. package/dist/lifecycle-log-guard.js.map +1 -1
  52. package/dist/local-server-cursor.js +2 -2
  53. package/dist/local-server-cursor.js.map +1 -1
  54. package/dist/opencode-plugin.js +2 -2
  55. package/dist/opencode-plugin.js.map +1 -1
  56. package/dist/parse-assimilate-args.d.ts.map +1 -1
  57. package/dist/parse-assimilate-args.js +6 -1
  58. package/dist/parse-assimilate-args.js.map +1 -1
  59. package/dist/private-root.d.ts +17 -0
  60. package/dist/private-root.d.ts.map +1 -1
  61. package/dist/private-root.js +62 -2
  62. package/dist/private-root.js.map +1 -1
  63. package/dist/regen-format.js +1 -1
  64. package/dist/regen-format.js.map +1 -1
  65. package/dist/resolved-cli-config.d.ts +16 -0
  66. package/dist/resolved-cli-config.d.ts.map +1 -0
  67. package/dist/resolved-cli-config.js +26 -0
  68. package/dist/resolved-cli-config.js.map +1 -0
  69. package/dist/seat-store.d.ts.map +1 -1
  70. package/dist/seat-store.js +4 -3
  71. package/dist/seat-store.js.map +1 -1
  72. package/dist/seats.d.ts.map +1 -1
  73. package/dist/seats.js +2 -2
  74. package/dist/seats.js.map +1 -1
  75. package/dist/self-path.d.ts +2 -0
  76. package/dist/self-path.d.ts.map +1 -1
  77. package/dist/self-path.js +4 -0
  78. package/dist/self-path.js.map +1 -1
  79. package/dist/server-trust.d.ts.map +1 -1
  80. package/dist/server-trust.js +3 -3
  81. package/dist/server-trust.js.map +1 -1
  82. package/dist/setup-confirm.d.ts +1 -1
  83. package/dist/setup-confirm.js +1 -1
  84. package/dist/setup-selection.d.ts +38 -0
  85. package/dist/setup-selection.d.ts.map +1 -0
  86. package/dist/setup-selection.js +51 -0
  87. package/dist/setup-selection.js.map +1 -0
  88. package/dist/setup.js +80 -21
  89. package/dist/setup.js.map +1 -1
  90. package/dist/stream-owner.js +2 -2
  91. package/dist/stream-owner.js.map +1 -1
  92. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  93. package/docs/LOCAL_SERVER.md +23 -0
  94. package/docs/RELEASING.md +130 -282
  95. package/package.json +9 -9
  96. package/src/agent-runtime.ts +17 -0
  97. package/src/assimilate-cmd.ts +73 -14
  98. package/src/assimilate-deps.ts +23 -3
  99. package/src/bare-launch-menu.ts +17 -0
  100. package/src/claude.ts +31 -37
  101. package/src/cleanup-cmd.ts +2 -2
  102. package/src/cli-tool-approval.ts +3 -2
  103. package/src/codex-remote.ts +4 -2
  104. package/src/config-utils.ts +186 -18
  105. package/src/credential-paths.ts +2 -3
  106. package/src/cubes.ts +9 -4
  107. package/src/foreign-path-reminder.ts +141 -0
  108. package/src/launch-access.ts +40 -0
  109. package/src/launch-all-deps.ts +2 -2
  110. package/src/lifecycle-log-guard.ts +2 -2
  111. package/src/local-server-cursor.ts +2 -2
  112. package/src/opencode-plugin.ts +2 -2
  113. package/src/parse-assimilate-args.ts +7 -1
  114. package/src/private-root.ts +62 -2
  115. package/src/regen-format.ts +1 -1
  116. package/src/resolved-cli-config.ts +40 -0
  117. package/src/seat-store.ts +4 -3
  118. package/src/seats.ts +2 -2
  119. package/src/self-path.ts +5 -0
  120. package/src/server-trust.ts +3 -3
  121. package/src/setup-confirm.ts +1 -1
  122. package/src/setup-selection.ts +87 -0
  123. package/src/setup.ts +98 -24
  124. package/src/stream-owner.ts +2 -2
@@ -1,8 +1,68 @@
1
+ import { lstatSync, realpathSync } from 'node:fs';
1
2
  import { chmod, lstat, mkdir } from 'node:fs/promises';
2
3
  import { homedir } from 'node:os';
3
- import { isAbsolute, join, resolve } from 'node:path';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
5
 
5
- export const borgConfigRoot = (): string => join(homedir(), '.config', 'borgmcp');
6
+ /**
7
+ * Optional alternate home root for isolated client runs. The value is the
8
+ * replacement home directory, not the `.config/borgmcp` directory itself, so
9
+ * every client-owned path (credentials, seats, worktrees, and agent config)
10
+ * stays under one root.
11
+ */
12
+ export const BORG_STATE_ROOT_ENV = 'BORG_STATE_ROOT';
13
+
14
+ function invalidStateRoot(): Error {
15
+ return new Error(`${BORG_STATE_ROOT_ENV} must be an absolute canonical path`);
16
+ }
17
+
18
+ /** Return whether a path and every existing ancestor are free of symlinks. */
19
+ export function isCanonicalPath(root: string): boolean {
20
+ if (!isAbsolute(root) || resolve(root) !== root) return false;
21
+ let candidate = root;
22
+ while (true) {
23
+ try {
24
+ const metadata = lstatSync(candidate);
25
+ return !metadata.isSymbolicLink() && realpathSync(candidate) === candidate;
26
+ } catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
28
+ const parent = dirname(candidate);
29
+ if (parent === candidate) return false;
30
+ candidate = parent;
31
+ }
32
+ }
33
+ }
34
+
35
+ function configuredStateRoot(env: NodeJS.ProcessEnv = process.env): string | null {
36
+ const configured = env[BORG_STATE_ROOT_ENV];
37
+ if (configured === undefined) return null;
38
+ if (configured.length === 0 || !isCanonicalPath(configured)) {
39
+ throw invalidStateRoot();
40
+ }
41
+ return configured;
42
+ }
43
+
44
+ /** Resolve the effective home root used by all Borg-owned local state. */
45
+ export function borgHomeRoot(env: NodeJS.ProcessEnv = process.env): string {
46
+ return configuredStateRoot(env) ?? realpathSync(homedir());
47
+ }
48
+
49
+ export const borgConfigRoot = (): string => join(borgHomeRoot(), '.config', 'borgmcp');
50
+
51
+ /**
52
+ * Environment used when a native agent CLI registers Borg. The CLI must write
53
+ * its own config under the same effective root that config-utils reads; the
54
+ * eventual MCP child receives BORG_STATE_ROOT separately via its registration.
55
+ */
56
+ export function borgAgentConfigEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
57
+ if (env[BORG_STATE_ROOT_ENV] === undefined) return { ...env };
58
+ const root = borgHomeRoot(env);
59
+ return {
60
+ ...env,
61
+ HOME: root,
62
+ CODEX_HOME: join(root, '.codex'),
63
+ XDG_CONFIG_HOME: join(root, '.config'),
64
+ };
65
+ }
6
66
 
7
67
  /** Ensure Borg's local state root exists with owner-only directory permissions. */
8
68
  export async function ensurePrivateBorgConfigRoot(root = borgConfigRoot()): Promise<void> {
@@ -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.
@@ -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
+ }
package/src/seat-store.ts CHANGED
@@ -32,9 +32,10 @@
32
32
  */
33
33
 
34
34
  import { constants } from 'node:fs';
35
- import { open, link, lstat, mkdir, readFile, realpath, rename, stat, unlink } from 'node:fs/promises';
35
+ import { open, link, lstat, mkdir, readFile, rename, stat, unlink } from 'node:fs/promises';
36
36
  import { dirname, isAbsolute, resolve } from 'node:path';
37
37
  import { randomBytes } from 'node:crypto';
38
+ import { isCanonicalPath } from './private-root.js';
38
39
 
39
40
  const LOCK_WAIT_MS = 10;
40
41
  const LOCK_ATTEMPTS = 500;
@@ -58,7 +59,7 @@ async function assertSecureRoot(
58
59
  root: string,
59
60
  rootMode: SecureStoreOptions['rootMode'] = 'private',
60
61
  ): Promise<void> {
61
- if (!isAbsolute(root) || resolve(root) !== root) {
62
+ if (!isCanonicalPath(root)) {
62
63
  throw new Error(`Borg credential store path ${root} is not canonical`);
63
64
  }
64
65
  let metadata: Awaited<ReturnType<typeof lstat>>;
@@ -83,7 +84,7 @@ async function assertSecureRoot(
83
84
  if (uid !== null && metadata.uid !== uid) {
84
85
  throw new Error(`Borg credential store root ${root} is not owned by the current user`);
85
86
  }
86
- if (await realpath(root) !== root) {
87
+ if (!isCanonicalPath(root)) {
87
88
  throw new Error(`Borg credential store root ${root} is not canonical or contains a symlink`);
88
89
  }
89
90
  }
package/src/seats.ts CHANGED
@@ -17,13 +17,13 @@
17
17
  * hydration reader; every other observation is digest-only.
18
18
  */
19
19
 
20
- import os from 'os';
21
20
  import path from 'path';
22
21
  import { createHash } from 'node:crypto';
23
22
  import { readStoreFile, withStore } from './seat-store.js';
24
23
  import { LegacySessionCredentialCollisionError } from './server-errors.js';
24
+ import { borgConfigRoot } from './private-root.js';
25
25
 
26
- export const SEATS_FILE = path.join(os.homedir(), '.config', 'borgmcp', 'seats.json');
26
+ export const SEATS_FILE = path.join(borgConfigRoot(), 'seats.json');
27
27
  const SEATS_VERSION = 1 as const;
28
28
  const REF_RE = /^borg-server-session:[a-f0-9]{64}$/;
29
29
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
package/src/self-path.ts CHANGED
@@ -49,3 +49,8 @@ export function resolveClearRewakePath(): string {
49
49
  export function resolveLogAuditPath(): string {
50
50
  return resolveSelfBinPath('log-audit.js');
51
51
  }
52
+
53
+ /** Absolute path to the optional pre-tool foreign-path reminder hook. */
54
+ export function resolveForeignPathReminderPath(): string {
55
+ return resolveSelfBinPath('foreign-path-reminder.js');
56
+ }
@@ -4,7 +4,6 @@ import { constants } from 'node:fs';
4
4
  import { access, lstat, mkdir, open, rename, rm, unlink, writeFile } from 'node:fs/promises';
5
5
  import { request as httpsRequest } from 'node:https';
6
6
  import { connect as tlsConnect } from 'node:tls';
7
- import { homedir } from 'node:os';
8
7
  import { join, resolve } from 'node:path';
9
8
  import { Readable } from 'node:stream';
10
9
  import { BorgServerTrustError } from './server-errors.js';
@@ -27,6 +26,7 @@ import type {
27
26
  EnrollmentArtifactBinding,
28
27
  EnrollmentTrustPointer,
29
28
  } from './enrollment-types.js';
29
+ import { borgHomeRoot } from './private-root.js';
30
30
 
31
31
  // CR5 TLS LATTICE: OpenSSL/Node TLS certificate-verification error codes. A raw
32
32
  // CA / cert-chain / SAN failure from the pinned transport is a potential MITM and
@@ -102,12 +102,12 @@ function invalidateOriginTrustCache(origin: string): void {
102
102
  }
103
103
 
104
104
  function serverDataDirectory(): string {
105
- return resolve(process.env.BORG_SERVER_DATA_DIR ?? join(homedir(), '.borg', 'server'));
105
+ return resolve(process.env.BORG_SERVER_DATA_DIR ?? join(borgHomeRoot(), '.borg', 'server'));
106
106
  }
107
107
 
108
108
  function remoteTrustDirectory(origin: string): string {
109
109
  const key = createHash('sha256').update(origin).digest('hex');
110
- return join(homedir(), '.borg', 'server-trust', key);
110
+ return join(borgHomeRoot(), '.borg', 'server-trust', key);
111
111
  }
112
112
 
113
113
  function trustGenerationsDirectory(origin: string): string {
@@ -4,7 +4,7 @@
4
4
  * Step-1 of the setup wizard writes the user's GLOBAL agent config
5
5
  * (registers the borg MCP server + hooks). Before gh#818 this happened
6
6
  * silently on first run. This module adds informed-consent disclosure:
7
- * it lists WHICH files will be written (well-known `os.homedir()` paths)
7
+ * it lists WHICH files will be written (well-known home-relative paths)
8
8
  * and asks to continue before the first mutation.
9
9
  *
10
10
  * Pure / dep-injected (mirrors `resolveCliChoice` + `setup-action.ts`) so
@@ -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
  }
@@ -1,10 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { promises as fs } from 'node:fs';
3
- import { homedir } from 'node:os';
4
3
  import path from 'node:path';
4
+ import { borgConfigRoot } from './private-root.js';
5
5
 
6
6
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
- const STREAM_LOCKS_DIR = path.join(homedir(), '.config', 'borgmcp', 'stream-locks');
7
+ const STREAM_LOCKS_DIR = path.join(borgConfigRoot(), 'stream-locks');
8
8
  const OWNER_FILE = 'owner.json';
9
9
  const TAKEOVER_FILE = 'takeover.json';
10
10
  const SCHEMA_VERSION = 1;