@bridge_gpt/mcp-server 0.2.24 → 0.2.26

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 (36) hide show
  1. package/README.md +98 -28
  2. package/build/agents.generated.js +1 -1
  3. package/build/bridge-api-urls.js +31 -0
  4. package/build/commands.generated.js +5 -5
  5. package/build/conductor/epic-reconcile.js +7 -1
  6. package/build/conductor/epic-runtime.js +5 -0
  7. package/build/conductor-bundle-artifacts.js +802 -0
  8. package/build/conductor-bundle-cli.js +256 -0
  9. package/build/connect-github-api.js +365 -0
  10. package/build/connect-github.js +415 -0
  11. package/build/decision-page-schema.js +34 -5
  12. package/build/decision-page-template.js +117 -35
  13. package/build/docs.generated.js +2 -1
  14. package/build/doctor.js +148 -1
  15. package/build/env-flags.js +31 -0
  16. package/build/index.js +3467 -498
  17. package/build/init.js +7 -3
  18. package/build/install-bridge.js +624 -38
  19. package/build/install-doctor.js +64 -0
  20. package/build/mcp-host-config.js +521 -0
  21. package/build/mcp-host-targets.js +194 -0
  22. package/build/mcp-install-state.js +175 -0
  23. package/build/pipelines.generated.js +127 -132
  24. package/build/readme.generated.js +1 -1
  25. package/build/start-tickets.js +166 -18
  26. package/build/tool-surface-gating.js +396 -0
  27. package/build/version.generated.js +1 -1
  28. package/docs/install/github-app.md +80 -17
  29. package/docs/install/mcp-tool-integrations.md +2 -2
  30. package/package.json +5 -5
  31. package/pipelines/learn-repository.json +111 -119
  32. package/public/css/main.min.css +258 -65
  33. package/public/css/main.min.css.map +1 -1
  34. package/public/js/main.min.js +188 -92
  35. package/public/js/main.min.js.map +1 -1
  36. package/smoke-test/SMOKE-TEST.md +4 -4
@@ -36,8 +36,26 @@
36
36
  * reload the just-written `.mcp.json`, and field derivation needs an agent
37
37
  * runtime the shell does not have.
38
38
  *
39
- * BOOTSTRAP-INVITE MODE (BAPI-606) the one exception to "this command consumes
40
- * a key, it does not create one". With `--invite` (or `BAPI_INVITE`) there is no
39
+ * That spawn command embeds the entire agent prompt and runs to multiple KB, which
40
+ * no macOS terminal will accept as one typed line. So it is never typed: the full
41
+ * command is written to a restricted (0600) launch script and only a short
42
+ * `. '<path>'` runner is spawned (BAPI-626, via `materializeWorkerLaunchCommand`).
43
+ * Materialization happens BEFORE any install side effect, because a command that
44
+ * cannot be launched is a whole-run failure, not a Step 5 warning.
45
+ *
46
+ * ONBOARDING BRANCHES — `have-key` (consume an existing key) vs. `need-key` (this
47
+ * command creates the project AND its first admin key). There are two need-key
48
+ * methods, differing only in how the first token is obtained: `bootstrap-invite`
49
+ * (BAPI-606) redeems a pre-issued invite; `self-serve` (BAPI-618) mints one from an
50
+ * email. Both then feed the SAME redemption protocol. Selection is pure and
51
+ * deterministic from flags/env, except that a BARE interactive run is asked which
52
+ * branch it wants (BAPI-626) — without that question a first-time user cannot
53
+ * discover self-serve at all. Either need-key method NAMES a new project, so the
54
+ * repository prompt asks for a new project name rather than an existing
55
+ * registration (see `RepoNamePromptMode`).
56
+ *
57
+ * BOOTSTRAP-INVITE MODE (BAPI-606) — one of the two exceptions to "this command
58
+ * consumes a key, it does not create one". With `--invite` (or `BAPI_INVITE`) there is no
41
59
  * API key yet, so the pre-flight ping of Step 2 CANNOT be made: the exchange is
42
60
  * what mints the key, and it REPLACES that ping. The order becomes:
43
61
  *
@@ -70,10 +88,18 @@ import readline from "readline";
70
88
  import { runInit, buildBridgeApiEntry } from "./init.js";
71
89
  import { VERSION } from "./version.generated.js";
72
90
  import { validateRepoName } from "./bridge-config.js";
91
+ import { MCP_HOST_TARGETS, HOST_PLATFORM_ORDER, allHostTargets, isHostPlatformId, detectDefaultPlatforms, } from "./mcp-host-targets.js";
92
+ import { provisionHostTarget, createDefaultVendorProcessDeps, } from "./mcp-host-config.js";
93
+ import { writeMcpInstallState } from "./mcp-install-state.js";
94
+ import { ensureGitignored as ensureGitignoredShared, } from "./git-ignore-utils.js";
73
95
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
74
- import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, } from "./credential-store.js";
96
+ import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, resolveBapiCredentials, } from "./credential-store.js";
97
+ // BAPI-631: the optional GitHub connect offer reuses the standalone command's flow and
98
+ // API primitives verbatim — no duplicated polling, browser, or picker logic here.
99
+ import { fetchGithubConfigurationState } from "./connect-github-api.js";
100
+ import { createDefaultConnectGithubDeps, runGithubConnectionFlow } from "./connect-github.js";
75
101
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
76
- import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "./start-tickets.js";
102
+ import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, materializeWorkerLaunchCommand, MAX_TERMINAL_COMMAND_BYTES, } from "./start-tickets.js";
77
103
  /** Redaction sentinel — the API-key value is NEVER printed; this stands in. */
78
104
  export const REDACTED_API_KEY = "<REDACTED>";
79
105
  /**
@@ -126,8 +152,10 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command
126
152
  "claim the job was queued — report the sanitized result and leave indexing pending. " +
127
153
  "On NO (or any unavailable/non-interactive resolution): do not index; print the exact copy-paste " +
128
154
  "continuation command '/parse-repository' on its own line and state that indexing remains pending. " +
129
- "Never request, echo, or transport any credential — only ever direct the human to the setup UI via " +
130
- "the command's configure_in pointer. " +
155
+ "Never request, echo, or transport any credential — only ever direct the human to that " +
156
+ "integration's own configure_in pointer, verbatim. The pointer is per-integration and is NOT " +
157
+ "always the setup UI: GitHub's is a terminal command (connect-github), while Jira, SFCC, and " +
158
+ "Bitbucket point at the setup UI. Follow whatever the report says rather than assuming. " +
131
159
  "End with an explicit summary line stating how many config fields the apply_install_manifest call " +
132
160
  "applied (e.g. 'Applied 8 of 9 derived fields') and whether indexing was queued or left pending — " +
133
161
  "if 0 fields were applied, say so loudly and explain what is still pending.";
@@ -146,13 +174,20 @@ export function getInstallBridgeUsage() {
146
174
  "routing credential, then opens a fresh agent session to derive the remaining",
147
175
  "config, present a capability report, and offer optional repository indexing.",
148
176
  "",
177
+ "Run it bare — `install-bridge` with no flags — in a terminal and it asks",
178
+ `\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT.trim()}\` first. Answer yes (or press Enter) for the`,
179
+ "existing-key flow below; answer no and it asks for an email and creates a new",
180
+ "Bridge workspace for you (the self-serve flow). That question is asked ONLY for a",
181
+ "bare interactive run: passing ANY flag, setting BAPI_API_KEY, or running without",
182
+ "an interactive terminal keeps the existing deterministic behavior and no prompt.",
183
+ "",
149
184
  "Inputs (the only two irreducible ones):",
150
185
  " --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
151
186
  " then an interactive (no-echo) prompt. Generate one in the",
152
187
  " Bridge API web UI Security page — this command consumes a",
153
- " key, it does not create one (--invite is the one exception:",
154
- " it CREATES the project and its first admin key). NEVER",
155
- " printed or logged.",
188
+ " key, it does not create one (--email and --invite are the",
189
+ " exceptions: they CREATE the project and its first admin key).",
190
+ " NEVER printed or logged.",
156
191
  " --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
157
192
  " priority and short-circuit before any network call. When",
158
193
  " neither is set, a compatible server resolves the unique",
@@ -160,10 +195,13 @@ export function getInstallBridgeUsage() {
160
195
  " server is older, the key is unresolvable, or resolution",
161
196
  " fails, it falls back to an inferred default you confirm",
162
197
  " interactively (and to a required --repo when stdin is",
163
- " non-interactive). MUST match the server-side repo",
164
- " registration (it keys the credential store as bapi:<repo>).",
165
- " With --invite it is the name your NEW project is created",
166
- " under (globally unique).",
198
+ " non-interactive). In the existing-key flow it MUST match the",
199
+ " server-side repo registration (it keys the credential store",
200
+ " as bapi:<repo>). In either new-project flow (--email,",
201
+ " --invite, or a negative answer to the key question above) it",
202
+ " instead NAMES the project this run creates, so you are asked",
203
+ " to name a new project rather than match an existing one; the",
204
+ " name must be globally unique.",
167
205
  "",
168
206
  "Self-serve onboarding (no account, no API key, no pre-issued invite):",
169
207
  " --email <addr> Create a brand-new Bridge workspace from just an email —",
@@ -171,8 +209,10 @@ export function getInstallBridgeUsage() {
171
209
  " It requests a fresh workspace for that email, then creates",
172
210
  " the project and mints your own admin API key in one command.",
173
211
  " Falls back to the BAPI_SIGNUP_EMAIL env var, then a visible",
174
- " interactive prompt. The email is NOT a secret (it is shown",
175
- " as you type), but it is never printed to a log. Mutually",
212
+ " interactive prompt which is also what a negative answer to",
213
+ " the bare-run key question above reaches. The email is NOT a",
214
+ " secret (it is shown as you type), but it is never printed to",
215
+ " a log. Mutually",
176
216
  " exclusive with --api-key and --invite. No email verification",
177
217
  " is performed and no message is sent to the address — it only",
178
218
  " labels the new workspace.",
@@ -196,6 +236,18 @@ export function getInstallBridgeUsage() {
196
236
  " THE TOKEN to your shell history and to the process list.",
197
237
  "",
198
238
  "Flags:",
239
+ " --tools <ids> Comma-separated AI-coding tools to configure,",
240
+ " bypassing the interactive picker. Accepted ids:",
241
+ ` ${HOST_PLATFORM_ORDER.join(", ")}.`,
242
+ " Both --tools=claude-code,codex and",
243
+ " --tools claude-code,codex are accepted. On an",
244
+ " interactive terminal WITHOUT this flag you are",
245
+ " asked which tools you use (Claude Code plus any",
246
+ " detected editors are pre-checked). A non-",
247
+ " interactive run without --tools writes the legacy",
248
+ " automatic set (Claude Code plus any detected",
249
+ " Cursor / Copilot VS Code). --tools= (empty) is an",
250
+ " explicit empty selection and writes nothing.",
199
251
  " --force Overwrite an existing real BAPI_API_KEY in a",
200
252
  " host config (or in the credential store) without",
201
253
  " prompting.",
@@ -235,6 +287,7 @@ export function parseInstallBridgeArgs(argv) {
235
287
  let agentName = DEFAULT_AGENT_NAME;
236
288
  let invite;
237
289
  let email;
290
+ let tools;
238
291
  // Track SUPPLIED-ness separately from the values: `--invite` is legitimately
239
292
  // valueless (prompt path) and `--api-key ""` is still a contradiction with it.
240
293
  let inviteSupplied = false;
@@ -309,6 +362,17 @@ export function parseInstallBridgeArgs(argv) {
309
362
  i = r.nextIndex;
310
363
  continue;
311
364
  }
365
+ if (arg === "--tools" || arg.startsWith("--tools=")) {
366
+ const r = readValue(arg, "--tools", i);
367
+ if ("error" in r)
368
+ return { status: "error", message: r.error };
369
+ const parsed = parseToolsSelection(r.value);
370
+ if ("error" in parsed)
371
+ return { status: "error", message: parsed.error };
372
+ tools = parsed.tools;
373
+ i = r.nextIndex;
374
+ continue;
375
+ }
312
376
  if (arg === "--agent" || arg.startsWith("--agent=")) {
313
377
  const r = readValue(arg, "--agent", i);
314
378
  if ("error" in r)
@@ -359,9 +423,36 @@ export function parseInstallBridgeArgs(argv) {
359
423
  }
360
424
  return {
361
425
  status: "ok",
362
- options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email },
426
+ options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email, tools },
363
427
  };
364
428
  }
429
+ /**
430
+ * Parse a `--tools` value into a validated platform-ID selection. A blank value
431
+ * (`--tools=`) is an EXPLICIT empty selection (`[]`), not an error. Comma-split
432
+ * IDs are trimmed, deduped in registry order, and each is validated against the
433
+ * host registry allowlist so an unvalidated string can never choose a path or
434
+ * command. Unknown IDs are rejected by name (the value is safe to echo — it is
435
+ * a platform ID, never a secret).
436
+ */
437
+ export function parseToolsSelection(value) {
438
+ const raw = value
439
+ .split(",")
440
+ .map((s) => s.trim())
441
+ .filter((s) => s.length > 0);
442
+ const seen = new Set();
443
+ for (const id of raw) {
444
+ if (!isHostPlatformId(id)) {
445
+ const allowed = HOST_PLATFORM_ORDER.join(", ");
446
+ return {
447
+ error: `Invalid --tools value: '${id}' (allowed tools: ${allowed}).`,
448
+ };
449
+ }
450
+ seen.add(id);
451
+ }
452
+ // Dedupe + deterministic registry order.
453
+ const tools = HOST_PLATFORM_ORDER.filter((id) => seen.has(id));
454
+ return { tools };
455
+ }
365
456
  /**
366
457
  * No-echo secret prompt on stderr (so it never lands in piped stdout).
367
458
  *
@@ -420,6 +511,66 @@ export function promptSecretViaReadline(promptText, input = process.stdin, outpu
420
511
  muted = true;
421
512
  });
422
513
  }
514
+ /**
515
+ * Offer to connect GitHub, if it is not already connected (BAPI-631).
516
+ *
517
+ * Entirely best-effort and non-destructive: by the time this runs the install itself is
518
+ * already complete and durable, so nothing here may fail the run. Every branch that is
519
+ * not "the user said yes and it worked" simply proceeds to the agent session.
520
+ *
521
+ * Reuses the shared connect-github flow rather than duplicating the API, polling,
522
+ * browser, or picker logic — there is exactly one implementation of that handshake.
523
+ */
524
+ async function offerGithubConnection(repoName, deps, log) {
525
+ // No prompt surface → no offer. Never assume consent on a non-interactive run.
526
+ if (!deps.isTTY || !deps.promptLine)
527
+ return;
528
+ try {
529
+ const credDeps = {
530
+ env: deps.env,
531
+ homedir: deps.homedir,
532
+ platform: deps.platform,
533
+ readFile: deps.readFile,
534
+ stat: deps.stat,
535
+ stderr: () => { },
536
+ };
537
+ // Resolve through the shared resolver rather than reusing an in-memory key from
538
+ // this run: the offered flow is the same shell-spawned surface the standalone
539
+ // command uses, and it must resolve credentials the same way (project MCP config
540
+ // env is NOT visible to a spawned shell).
541
+ const cred = await resolveBapiCredentials(repoName, credDeps);
542
+ if (!cred.ok)
543
+ return;
544
+ const api = {
545
+ fetch: deps.fetch,
546
+ baseUrl: deps.env.BAPI_BASE_URL?.trim() || DEFAULT_BAPI_BASE_URL,
547
+ apiKey: cred.credentials.apiKey,
548
+ };
549
+ const state = await fetchGithubConfigurationState(api, repoName);
550
+ if (state === "configured")
551
+ return;
552
+ if (state === "unavailable") {
553
+ // Do NOT fabricate "unconfigured" from a probe that simply failed — offering to
554
+ // connect an already-connected repo is worse than staying quiet.
555
+ log(" note: could not read GitHub configuration status; skipping the GitHub offer.");
556
+ return;
557
+ }
558
+ const answer = (await deps.promptLine("Connect GitHub? (Y/n): ")).trim().toLowerCase();
559
+ if (answer === "n" || answer === "no")
560
+ return; // declining is a normal outcome
561
+ const connectDeps = createDefaultConnectGithubDeps();
562
+ const code = await runGithubConnectionFlow(connectDeps, api, repoName);
563
+ if (code !== 0) {
564
+ log(" note: GitHub was not connected. Your install is complete — connect GitHub later with " +
565
+ `'npx -y @bridge_gpt/mcp-server@latest connect-github --repo ${repoName}'.`);
566
+ }
567
+ }
568
+ catch {
569
+ // The install is already durable; a failure here is never fatal to it.
570
+ log(" note: the GitHub connection offer could not run. Your install is complete — connect " +
571
+ `GitHub later with 'npx -y @bridge_gpt/mcp-server@latest connect-github --repo ${repoName}'.`);
572
+ }
573
+ }
423
574
  /** Echoed single-line prompt on stderr (used for repo confirmation / value). */
424
575
  function promptLineViaReadline(promptText) {
425
576
  return new Promise((resolve) => {
@@ -524,6 +675,8 @@ export function createDefaultInstallBridgeDeps() {
524
675
  randomBytes: (size) => cryptoRandomBytes(size),
525
676
  promptSecret: isTTY ? promptSecretViaReadline : undefined,
526
677
  promptLine: isTTY ? promptLineViaReadline : undefined,
678
+ promptMultiSelect: isTTY ? promptMultiSelectViaReadline : undefined,
679
+ vendor: createDefaultVendorProcessDeps(spawn),
527
680
  fetch: productionFetch,
528
681
  resolveRepoViaServer: (baseUrl, apiKey) => resolveRepoViaServer(productionFetch, baseUrl, apiKey),
529
682
  spawnPrewarm: spawnPrewarmDefault,
@@ -666,6 +819,68 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
666
819
  return { kind: "need-key", method: "self-serve" };
667
820
  return { kind: "have-key" };
668
821
  }
822
+ /** The exact visible text of the bare-TTY onboarding selector (BAPI-626). */
823
+ export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key? [Y/n] ";
824
+ /**
825
+ * Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
826
+ *
827
+ * The pure resolver defaults every un-signalled invocation to `have-key`, which is
828
+ * correct for a script but wrong for a human: a first-time user with nothing yet
829
+ * runs a bare `install-bridge`, gets the hidden API-key prompt, and has no way to
830
+ * discover that the self-serve email path exists. So a BARE INTERACTIVE run — and
831
+ * only that — is asked which branch it wants.
832
+ *
833
+ * "Bare" is deliberately strict. Any of these keeps the existing deterministic
834
+ * behaviour with NO prompt:
835
+ *
836
+ * - stdin is not a TTY, or no `promptLine` seam is available (scripts, CI);
837
+ * - any CLI argument was supplied (the user already stated an intent);
838
+ * - a non-blank `BAPI_API_KEY` is present (that IS the existing-key intent);
839
+ * - the pure resolver already chose a need-key branch explicitly.
840
+ *
841
+ * A prompt failure becomes a typed, secret-free failure rather than an exception:
842
+ * the caller turns it into an exit code, and terminal/internal error text never
843
+ * reaches the user.
844
+ */
845
+ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv) {
846
+ const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
847
+ // An explicit need-key intent is already unambiguous — never re-ask it.
848
+ if (branch.kind === "need-key")
849
+ return { ok: true, branch };
850
+ const hasEnvApiKey = (deps.env.BAPI_API_KEY ?? "").trim().length > 0;
851
+ const isBareInvocation = argv.length === 0;
852
+ if (!deps.isTTY || !deps.promptLine || !isBareInvocation || hasEnvApiKey) {
853
+ return { ok: true, branch };
854
+ }
855
+ const promptLine = deps.promptLine;
856
+ try {
857
+ // Bounded so a prompt seam that returns the same invalid value forever (a
858
+ // misbehaving pipe that passes the TTY check) cannot spin indefinitely.
859
+ for (let attempt = 0; attempt < 5; attempt += 1) {
860
+ const answer = (await promptLine(INSTALL_BRIDGE_KEY_SELECTOR_PROMPT)).trim().toLowerCase();
861
+ // Blank = accept the bracketed default (Y), matching the prompt's own contract.
862
+ if (answer.length === 0 || answer === "y" || answer === "yes") {
863
+ return { ok: true, branch: { kind: "have-key" } };
864
+ }
865
+ if (answer === "n" || answer === "no") {
866
+ return { ok: true, branch: { kind: "need-key", method: "self-serve" } };
867
+ }
868
+ deps.log("Please answer y or n (press Enter for yes).");
869
+ }
870
+ return {
871
+ ok: false,
872
+ error: "No valid answer to the Bridge API key question. Re-run and answer y or n.",
873
+ };
874
+ }
875
+ catch {
876
+ // Secret-free by construction: the caught value is never surfaced.
877
+ return {
878
+ ok: false,
879
+ error: "Could not read your answer from the terminal. Re-run with --api-key <key> if you have a " +
880
+ "Bridge API key, or --email <addr> to create a new Bridge workspace.",
881
+ };
882
+ }
883
+ }
669
884
  /**
670
885
  * Return the explicitly configured repository name (`--repo`, then
671
886
  * `BAPI_REPO_NAME`), trimmed, or `undefined` when neither is supplied. Pure: no
@@ -682,7 +897,18 @@ export function resolveConfiguredRepoName(options, env) {
682
897
  }
683
898
  return undefined;
684
899
  }
685
- export async function resolveRepoName(options, deps) {
900
+ /**
901
+ * Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env → inferred default
902
+ * (from .bridge/config, else the cwd basename) confirmed interactively. Fails
903
+ * fast (no inference) when neither is supplied and stdin is non-interactive —
904
+ * the repo identity keys the credential store, so it is never silently inferred
905
+ * non-interactively.
906
+ *
907
+ * `mode` selects only the WORDING (prompt and non-interactive error); resolution
908
+ * order, inference, and validation are identical in both. It defaults to
909
+ * `existing-registration` to preserve the behaviour of pre-BAPI-626 callers.
910
+ */
911
+ export async function resolveRepoName(options, deps, mode = "existing-registration") {
686
912
  const configured = resolveConfiguredRepoName(options, deps.env);
687
913
  if (configured !== undefined) {
688
914
  return { ok: true, value: configured };
@@ -692,9 +918,13 @@ export async function resolveRepoName(options, deps) {
692
918
  if (!deps.isTTY || !deps.promptLine) {
693
919
  return {
694
920
  ok: false,
695
- error: "A repo name is required. Pass --repo or set the BAPI_REPO_NAME environment variable " +
696
- "(no interactive terminal is available to confirm an inferred name). It must match the " +
697
- "server-side repository registration.",
921
+ error: mode === "new-project"
922
+ ? "A project name is required. Pass --repo or set the BAPI_REPO_NAME environment " +
923
+ "variable (no interactive terminal is available to confirm an inferred name). It " +
924
+ "names the new Bridge project this run creates and must be globally unique."
925
+ : "A repo name is required. Pass --repo or set the BAPI_REPO_NAME environment variable " +
926
+ "(no interactive terminal is available to confirm an inferred name). It must match the " +
927
+ "server-side repository registration.",
698
928
  };
699
929
  }
700
930
  // Infer a sensible default: existing .bridge/config, else the cwd basename.
@@ -709,18 +939,182 @@ export async function resolveRepoName(options, deps) {
709
939
  inferred = validated.value;
710
940
  }
711
941
  if (inferred) {
712
- const answer = (await deps.promptLine(`Repo name [${inferred}] (must match server-side registration): `)).trim();
942
+ const promptText = mode === "new-project"
943
+ ? `Name your new Bridge project [${inferred}]: `
944
+ : `Repo name [${inferred}] (must match server-side registration): `;
945
+ const answer = (await deps.promptLine(promptText)).trim();
713
946
  const chosen = answer.length > 0 ? answer : inferred;
714
947
  if (chosen.length > 0)
715
948
  return { ok: true, value: chosen };
716
949
  }
717
950
  else {
718
- const answer = (await deps.promptLine("Repo name (must match server-side registration): ")).trim();
951
+ const promptText = mode === "new-project"
952
+ ? "Name your new Bridge project: "
953
+ : "Repo name (must match server-side registration): ";
954
+ const answer = (await deps.promptLine(promptText)).trim();
719
955
  if (answer.length > 0)
720
956
  return { ok: true, value: answer };
721
957
  }
722
958
  return { ok: false, error: "No repo name provided." };
723
959
  }
960
+ // ---------------------------------------------------------------------------
961
+ // Per-host config write (Step 2)
962
+ // ---------------------------------------------------------------------------
963
+ /** A per-host MCP config target (mirrors runInit's configTargets shape). */
964
+ /** Stable wording of the interactive tool-selection prompt (BAPI-635). */
965
+ export const INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT = "Which AI coding tools do you use on this project?";
966
+ /**
967
+ * Interactive numbered multi-select prompt on stderr (TTY only). Displays each
968
+ * option with a checked/unchecked marker seeded from `defaults`, accepts a
969
+ * comma-separated list of numbers to TOGGLE, and accepts the current selection on
970
+ * a bare Enter. Reprints on an invalid token rather than corrupting state.
971
+ * Resolves safely (to the seeded defaults) on EOF / synchronous close so a
972
+ * readline regression can never hang or discard the answer.
973
+ */
974
+ export function promptMultiSelectViaReadline(promptText, options, defaults, input = process.stdin, output = process.stderr) {
975
+ return new Promise((resolve) => {
976
+ const selected = new Set(defaults);
977
+ const render = () => {
978
+ output.write(`\n${promptText}\n`);
979
+ options.forEach((opt, idx) => {
980
+ const mark = selected.has(opt.id) ? "[x]" : "[ ]";
981
+ output.write(` ${idx + 1}. ${mark} ${opt.label}\n`);
982
+ });
983
+ output.write("Enter numbers to toggle (comma-separated), or press Enter to accept: ");
984
+ };
985
+ const rl = readline.createInterface({ input, output });
986
+ let answered = false;
987
+ const finish = () => {
988
+ answered = true;
989
+ rl.close();
990
+ resolve(options.filter((o) => selected.has(o.id)).map((o) => o.id));
991
+ };
992
+ // EOF / synchronous close must resolve rather than deadlock the top-level
993
+ // await; `answered` guards the synchronous close from discarding a real answer.
994
+ rl.on("close", () => {
995
+ if (!answered)
996
+ resolve(options.filter((o) => selected.has(o.id)).map((o) => o.id));
997
+ });
998
+ const ask = () => {
999
+ render();
1000
+ rl.question("", (answer) => {
1001
+ const trimmed = answer.trim();
1002
+ if (trimmed.length === 0) {
1003
+ finish();
1004
+ return;
1005
+ }
1006
+ const tokens = trimmed.split(",").map((t) => t.trim());
1007
+ const nums = [];
1008
+ let bad = false;
1009
+ for (const tok of tokens) {
1010
+ const n = Number(tok);
1011
+ if (!Number.isInteger(n) || n < 1 || n > options.length) {
1012
+ bad = true;
1013
+ break;
1014
+ }
1015
+ nums.push(n);
1016
+ }
1017
+ if (bad) {
1018
+ output.write(`Invalid selection. Enter numbers between 1 and ${options.length}.\n`);
1019
+ ask();
1020
+ return;
1021
+ }
1022
+ for (const n of nums) {
1023
+ const opt = options[n - 1];
1024
+ if (selected.has(opt.id))
1025
+ selected.delete(opt.id);
1026
+ else
1027
+ selected.add(opt.id);
1028
+ }
1029
+ finish();
1030
+ });
1031
+ };
1032
+ ask();
1033
+ });
1034
+ }
1035
+ /**
1036
+ * Resolve the selected host platforms with strict precedence (BAPI-635):
1037
+ * 1. explicit `--tools` (including an explicit EMPTY selection — never falls
1038
+ * back to detection),
1039
+ * 2. interactive multi-select on a TTY (seeded from registry detection, with
1040
+ * Claude Code always checked as a default),
1041
+ * 3. the legacy non-TTY automatic set: Claude Code plus only the currently
1042
+ * detected Copilot VS Code and Cursor automatic targets (never Codex or
1043
+ * Copilot CLI just because a global directory exists).
1044
+ */
1045
+ export async function resolveSelectedHostPlatforms(deps, options) {
1046
+ // 1. Explicit --tools (empty array is an explicit empty selection).
1047
+ if (options.tools !== undefined) {
1048
+ return options.tools;
1049
+ }
1050
+ const ctx = await buildDetectionContext(deps);
1051
+ const detected = new Set(detectDefaultPlatforms(ctx));
1052
+ // 2. Interactive multi-select on a TTY.
1053
+ if (deps.isTTY && deps.promptMultiSelect) {
1054
+ const optionList = allHostTargets().map((t) => ({ id: t.id, label: t.label }));
1055
+ // Claude Code is always a checked default; add every detected platform.
1056
+ const defaults = HOST_PLATFORM_ORDER.filter((id) => id === "claude-code" || detected.has(id));
1057
+ const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList, defaults);
1058
+ return HOST_PLATFORM_ORDER.filter((id) => chosen.includes(id));
1059
+ }
1060
+ // 3. Legacy non-TTY automatic set: Claude + detected Cursor / Copilot VS Code.
1061
+ const legacy = ["claude-code"];
1062
+ if (detected.has("cursor"))
1063
+ legacy.push("cursor");
1064
+ if (detected.has("copilot-vscode"))
1065
+ legacy.push("copilot-vscode");
1066
+ return HOST_PLATFORM_ORDER.filter((id) => legacy.includes(id));
1067
+ }
1068
+ /**
1069
+ * Build a registry detection context from install deps. install-bridge has only
1070
+ * async `stat`, but the registry's `detect` callbacks are synchronous, so we
1071
+ * pre-probe the candidate marker paths (the same ones the registry consults) and
1072
+ * expose them through a synchronous `exists` set. Paths are built in the registry's
1073
+ * POSIX-join form so the lookup matches exactly what `detect` passes to `exists`.
1074
+ */
1075
+ async function buildDetectionContext(deps) {
1076
+ const cwd = deps.cwd;
1077
+ const homedir = deps.homedir();
1078
+ const posixJoin = (base, rel) => `${base.endsWith("/") ? base.slice(0, -1) : base}/${rel}`;
1079
+ const candidates = [
1080
+ posixJoin(cwd, ".cursor"),
1081
+ posixJoin(cwd, ".vscode"),
1082
+ posixJoin(cwd, ".windsurf"),
1083
+ posixJoin(cwd, ".windsurfrules"),
1084
+ posixJoin(homedir, ".codex"),
1085
+ ];
1086
+ const present = new Set();
1087
+ await Promise.all(candidates.map(async (p) => {
1088
+ try {
1089
+ await deps.stat(p);
1090
+ present.add(p);
1091
+ }
1092
+ catch {
1093
+ // absent — leave out of the set.
1094
+ }
1095
+ }));
1096
+ return {
1097
+ cwd,
1098
+ homedir,
1099
+ env: deps.env,
1100
+ exists: (p) => present.has(p),
1101
+ };
1102
+ }
1103
+ /**
1104
+ * Resolve which project-local JSON host configs to write for the selected
1105
+ * platforms. Only the project-scoped JSON targets (Claude Code, Cursor, Copilot
1106
+ * VS Code) are returned here; global targets (Codex, Copilot CLI) and manual
1107
+ * targets (Windsurf) are handled by the registry-driven emitter in
1108
+ * `runInstallBridgeCli`. The returned shape is unchanged so the existing
1109
+ * read-merge-write path and overwrite-consent detection are preserved.
1110
+ */
1111
+ function hostConfigTargetsForPlatforms(platforms) {
1112
+ const set = new Set(platforms);
1113
+ return HOST_PLATFORM_ORDER.filter((id) => set.has(id))
1114
+ .map((id) => MCP_HOST_TARGETS[id])
1115
+ .filter((t) => t.scope === "project" && t.format === "json")
1116
+ .map((t) => ({ relPath: t.relPath, topLevelKey: t.topLevelKey }));
1117
+ }
724
1118
  /** Resolve which project-local host configs to write, mirroring runInit detection. */
725
1119
  async function resolveHostConfigTargets(deps) {
726
1120
  const targets = [
@@ -822,6 +1216,58 @@ async function writeHostConfigs(deps, targets, entry) {
822
1216
  }
823
1217
  return written;
824
1218
  }
1219
+ /**
1220
+ * Provision the selected GLOBAL (Codex, Copilot CLI) and MANUAL (Windsurf)
1221
+ * targets through the registry-driven emitter (BAPI-635). Project JSON targets
1222
+ * are handled by {@link writeHostConfigs}; this covers everything else. Global
1223
+ * config paths are never added to the repository .gitignore. Returns secret-free
1224
+ * log lines and whether Codex was auto-provisioned (so the legacy Codex manual
1225
+ * hint can be suppressed).
1226
+ */
1227
+ async function provisionSelectedGlobalTargets(deps, platforms, entry) {
1228
+ const logLines = [];
1229
+ const provisionDeps = {
1230
+ fs: {
1231
+ readFile: deps.readFile,
1232
+ writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
1233
+ mkdir: async (p, o) => {
1234
+ await deps.mkdir(p, o);
1235
+ },
1236
+ },
1237
+ vendor: deps.vendor,
1238
+ cwd: deps.cwd,
1239
+ homedir: deps.homedir(),
1240
+ env: deps.env,
1241
+ };
1242
+ const set = new Set(platforms);
1243
+ for (const id of HOST_PLATFORM_ORDER) {
1244
+ if (!set.has(id))
1245
+ continue;
1246
+ const target = MCP_HOST_TARGETS[id];
1247
+ // Skip project JSON targets — those are handled by writeHostConfigs.
1248
+ if (target.scope === "project" && target.format === "json")
1249
+ continue;
1250
+ const outcome = await provisionHostTarget(target, entry, provisionDeps);
1251
+ switch (outcome.status) {
1252
+ case "vendor-written":
1253
+ case "direct-written":
1254
+ case "created":
1255
+ logLines.push(` configured ${target.label} (${outcome.displayPath})`);
1256
+ break;
1257
+ case "manual-required":
1258
+ logLines.push(` ${target.label}: add the bridge-api MCP server manually to ${outcome.displayPath} ` +
1259
+ "(the API key is redacted in printed instructions).");
1260
+ break;
1261
+ case "skipped-invalid":
1262
+ logLines.push(` ${target.label}: skipped ${outcome.displayPath} — existing config is not valid; left untouched.`);
1263
+ break;
1264
+ case "failed":
1265
+ logLines.push(` ${target.label}: could not be configured automatically; configure it manually.`);
1266
+ break;
1267
+ }
1268
+ }
1269
+ return logLines;
1270
+ }
825
1271
  /** Build the `/jira/ping` URL exactly like the MCP `ping` tool / buildGetUrl. */
826
1272
  export function buildPingUrl(baseUrl, repoName) {
827
1273
  const url = new URL(`${baseUrl.replace(/\/+$/, "")}/jira/ping`);
@@ -1159,7 +1605,29 @@ export function buildDryRunPreview(plan) {
1159
1605
  `Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY removed): ${plan.prewarmCommand}`,
1160
1606
  MCP_TIMEOUT_GUIDANCE,
1161
1607
  `Step 4 — persist routing credential: target ${plan.credentialTarget} at ${plan.credentialStorePath}`,
1162
- `Step 5 — spawn agent session: ${plan.spawnCommand}`,
1608
+ ...buildLaunchStepPreview(plan),
1609
+ ];
1610
+ }
1611
+ /**
1612
+ * The Step 5 preview lines, shared by both previews so the launch description
1613
+ * cannot drift between the have-key and need-key flows.
1614
+ *
1615
+ * A --dry-run never writes the launch script, so the preview describes the
1616
+ * materialization rather than performing it — but it still shows the full command,
1617
+ * because the command is what the user is previewing and it is secret-free.
1618
+ */
1619
+ function buildLaunchStepPreview(plan) {
1620
+ return [
1621
+ // BAPI-631: described, never performed in --dry-run — a preview must not open a
1622
+ // browser or reach the network. It is also strictly optional, so it carries no step
1623
+ // number of its own and never changes the 5-step count.
1624
+ "Step 4b — optional GitHub connect (SKIPPED in --dry-run): read GitHub's configured state",
1625
+ " via the install manifest and, only when it is unconfigured and the terminal is",
1626
+ " interactive, offer 'Connect GitHub? (Y/n)' before the agent session starts.",
1627
+ "Step 5 — spawn agent session: the full command below is stored in a restricted launch script",
1628
+ " (mode 0600, under the system temp dir) and only a short sourced runner is spawned",
1629
+ " (the script itself is NOT written in --dry-run):",
1630
+ ` ${plan.spawnCommand}`,
1163
1631
  ];
1164
1632
  }
1165
1633
  /**
@@ -1214,7 +1682,7 @@ function buildBootstrapDryRunPreview(plan) {
1214
1682
  `Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY / BAPI_INVITE removed): ${plan.prewarmCommand}`,
1215
1683
  MCP_TIMEOUT_GUIDANCE,
1216
1684
  `Step 4 — promote ${pendingTarget} → ${plan.credentialTarget} at ${plan.credentialStorePath} (only after the exchange succeeds)`,
1217
- `Step 5 — spawn agent session: ${plan.spawnCommand}`,
1685
+ ...buildLaunchStepPreview(plan),
1218
1686
  ];
1219
1687
  }
1220
1688
  /**
@@ -1301,12 +1769,21 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1301
1769
  return 1;
1302
1770
  }
1303
1771
  const options = parsed.options;
1304
- // Onboarding branch (pure, deterministic): have-key vs. a need-key method
1305
- // (`bootstrap-invite` = redeem a pre-issued invite, `self-serve` = mint one from
1306
- // an email, BAPI-618). BOTH need-key methods share the downstream redemption
1307
- // protocol, so `bootstrapInviteMode` is true for both; `selfServeSignupMode`
1308
- // discriminates the one extra step (mint-from-email) the self-serve path adds.
1309
- const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
1772
+ // Onboarding branch: have-key vs. a need-key method (`bootstrap-invite` = redeem
1773
+ // a pre-issued invite, `self-serve` = mint one from an email, BAPI-618). BOTH
1774
+ // need-key methods share the downstream redemption protocol, so
1775
+ // `bootstrapInviteMode` is true for both; `selfServeSignupMode` discriminates the
1776
+ // one extra step (mint-from-email) the self-serve path adds.
1777
+ //
1778
+ // The selection is pure and deterministic for every explicit, env-driven, and
1779
+ // non-TTY invocation; only a BARE interactive run is asked which branch it wants
1780
+ // (BAPI-626 — otherwise a first-time user can never reach self-serve).
1781
+ const branchResult = await resolveInstallBridgeOnboardingBranchForRun(options, deps, argv);
1782
+ if (!branchResult.ok) {
1783
+ errorLog(`Error: ${branchResult.error}`);
1784
+ return 1;
1785
+ }
1786
+ const branch = branchResult.branch;
1310
1787
  const bootstrapInviteMode = branch.kind === "need-key";
1311
1788
  const selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
1312
1789
  // ---- Resolve inputs (may prompt when interactive) ----
@@ -1348,15 +1825,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1348
1825
  const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
1349
1826
  const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
1350
1827
  // ---- Resolve the repository name ----
1351
- // Bootstrap-invite: unchanged (choose-a-name for the new project). Have-key:
1828
+ // Need-key (invite or self-serve): choose-a-name for the project this run is
1829
+ // about to create, so the prompt says exactly that. Have-key:
1352
1830
  // `--repo`/`BAPI_REPO_NAME` short-circuit deterministically; otherwise resolve
1353
1831
  // it server-side from the API key (BAPI-616), and on ANY non-resolution outcome
1354
1832
  // (unresolved / not-deployed / error) fall back to the existing local
1355
- // prompt/inference — never a hard failure.
1833
+ // prompt/inference — never a hard failure. That fallback keeps the
1834
+ // existing-registration wording: there the name must match a real project.
1356
1835
  let repoName;
1357
1836
  let attemptedServerResolution = false;
1358
1837
  if (bootstrapInviteMode) {
1359
- const repoResult = await resolveRepoName(options, deps);
1838
+ const repoResult = await resolveRepoName(options, deps, "new-project");
1360
1839
  if (!repoResult.ok) {
1361
1840
  errorLog(`Error: ${repoResult.error}`);
1362
1841
  return 1;
@@ -1387,7 +1866,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1387
1866
  // Feature-detect + degrade: 404 (old server), 409 (unresolved/ambiguous/
1388
1867
  // client-scoped), and network/other errors all fall back to the existing
1389
1868
  // local resolution WITHOUT a cause-specific message or leaked detail.
1390
- const repoResult = await resolveRepoName(options, deps);
1869
+ const repoResult = await resolveRepoName(options, deps, "existing-registration");
1391
1870
  if (!repoResult.ok) {
1392
1871
  errorLog(`Error: ${repoResult.error}`);
1393
1872
  return 1;
@@ -1402,7 +1881,13 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1402
1881
  env: deps.env,
1403
1882
  homedir: deps.homedir,
1404
1883
  });
1405
- const targets = await resolveHostConfigTargets(deps);
1884
+ // BAPI-635: resolve the AI-coding-tool selection (explicit --tools, then TTY
1885
+ // multi-select, then the legacy non-TTY automatic set). The selected project
1886
+ // JSON targets drive the existing read-merge-write path; selected global
1887
+ // (Codex / Copilot CLI) and manual (Windsurf) targets are provisioned by the
1888
+ // registry-driven emitter after the connectivity check.
1889
+ const selectedPlatforms = await resolveSelectedHostPlatforms(deps, options);
1890
+ const targets = hostConfigTargetsForPlatforms(selectedPlatforms);
1406
1891
  // Read-only detection (safe in dry-run) of global-config editors we can't write.
1407
1892
  const manualEditors = await detectManualEditors(deps);
1408
1893
  const plan = {
@@ -1432,6 +1917,31 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1432
1917
  log(line);
1433
1918
  return 0;
1434
1919
  }
1920
+ // ---- Materialize the Step 5 launch command BEFORE any install side effect ----
1921
+ // `spawnCommand` embeds the whole INSTALL_BRIDGE_AGENT_PROMPT and is multiple KB
1922
+ // — far past what osascript can type into a Terminal/iTerm tab, which is why the
1923
+ // spawn silently delivered a truncated line. It must travel via a launch script.
1924
+ //
1925
+ // This runs HERE, before the scaffold/mint/credential/config writes, because a
1926
+ // command that cannot be launched safely is a whole-run failure, not a Step 5
1927
+ // warning: the deterministic setup would otherwise complete and leave the user
1928
+ // in the "configured but never configured by the agent" state the warning below
1929
+ // explicitly calls out. Failing first means nothing is half-done.
1930
+ const materialized = await materializeWorkerLaunchCommand(deps.startTicketsDeps, "install", spawnCommand);
1931
+ if (!materialized.ok) {
1932
+ errorLog(`Error: ${materialized.error}`);
1933
+ return 1;
1934
+ }
1935
+ const launchCommand = materialized.command;
1936
+ // Independent final guard on the line ACTUALLY handed to the terminal — not on
1937
+ // the original command. It catches the two ways a "successful" materialization
1938
+ // can still be unlaunchable: no writer seam (inline command preserved verbatim),
1939
+ // and a runner whose script path is unexpectedly long.
1940
+ if (Buffer.byteLength(launchCommand, "utf8") >= MAX_TERMINAL_COMMAND_BYTES) {
1941
+ errorLog("Error: the agent session command is too long to send to the terminal safely. " +
1942
+ "Check that the system temporary directory is writable so the launch script can be used.");
1943
+ return 1;
1944
+ }
1435
1945
  const credentialWriteDeps = {
1436
1946
  env: deps.env,
1437
1947
  homedir: deps.homedir,
@@ -1652,13 +2162,82 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1652
2162
  const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
1653
2163
  // ---- Step 3 — write per-host MCP config with real values ----
1654
2164
  log("Step 3/5 — writing per-host MCP config…");
2165
+ // BAPI-635 (Step 8): every project-local, secret-bearing config MUST be
2166
+ // gitignored BEFORE the real API key is written. A project-target gitignore
2167
+ // failure is FATAL before the secret write (fixed, secret-free message).
2168
+ const gitignoreDeps = {
2169
+ readFile: deps.readFile,
2170
+ writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
2171
+ mkdir: (p, o) => deps.mkdir(p, o),
2172
+ };
2173
+ for (const target of targets) {
2174
+ try {
2175
+ await ensureGitignoredShared(deps.cwd, target.relPath, gitignoreDeps);
2176
+ }
2177
+ catch {
2178
+ errorLog("Error: could not add a project MCP config to .gitignore before writing your key. " +
2179
+ "Aborting so the API key is never written to an un-ignored file.");
2180
+ return 1;
2181
+ }
2182
+ }
1655
2183
  const written = await writeHostConfigs(deps, targets, entry);
1656
2184
  for (const relPath of written)
1657
2185
  log(` wrote ${relPath}`);
1658
- // Only print global-editor manual setup when Windsurf/Codex is actually detected.
1659
- const manualInstructions = buildManualHostInstructions(entry, manualEditors);
2186
+ // BAPI-635: provision selected GLOBAL targets (Codex, Copilot CLI) and MANUAL
2187
+ // targets (Windsurf) via the registry-driven emitter. Global paths are never
2188
+ // added to the repository .gitignore.
2189
+ const globalLogLines = await provisionSelectedGlobalTargets(deps, selectedPlatforms, entry);
2190
+ for (const line of globalLogLines)
2191
+ log(line);
2192
+ // Legacy manual-editor instructions cover editors that are DETECTED but were
2193
+ // NOT part of the selection (so the emitter above did not handle them). The two
2194
+ // editors are suppressed INDEPENDENTLY: Codex is dropped when it was selected
2195
+ // (auto-provisioned or emitted above), Windsurf is dropped only when it was
2196
+ // selected — auto-provisioning Codex must never hide the Windsurf snippet.
2197
+ const legacyManualEditors = {
2198
+ windsurf: manualEditors.windsurf && !selectedPlatforms.includes("windsurf"),
2199
+ codex: manualEditors.codex && !selectedPlatforms.includes("codex"),
2200
+ };
2201
+ const manualInstructions = buildManualHostInstructions(entry, legacyManualEditors);
1660
2202
  if (manualInstructions)
1661
2203
  log(manualInstructions);
2204
+ // BAPI-635 (Step 7): when both Claude Code and Copilot CLI are selected, warn
2205
+ // that they use different, non-shared config surfaces.
2206
+ if (selectedPlatforms.includes("claude-code") && selectedPlatforms.includes("copilot-cli")) {
2207
+ log(" Note: Claude Code uses the project .mcp.json while GitHub Copilot CLI uses only its " +
2208
+ "global ~/.copilot/mcp-config.json — the two are configured separately.");
2209
+ }
2210
+ // BAPI-635 (Step 7): Claude trust reminder — a written project MCP config is
2211
+ // not a live connection until approved in Claude Code's trust dialog.
2212
+ if (selectedPlatforms.includes("claude-code")) {
2213
+ log(" Claude Code: the project MCP server is pending approval in Claude Code's trust dialog; " +
2214
+ "restart or reload an already-running session for it to take effect.");
2215
+ }
2216
+ // BAPI-635 (Step 8 + Step 3): persist the secret-free install state, ignoring
2217
+ // it before the write. Project-local paths only; global paths are never in it.
2218
+ try {
2219
+ await ensureGitignoredShared(deps.cwd, ".bridge/install-state.json", gitignoreDeps);
2220
+ // writeMcpInstallState catches its own I/O errors and returns { ok: false }
2221
+ // (it does NOT throw), so the failure warning must inspect the return value —
2222
+ // a try/catch alone would silently swallow a real persistence failure.
2223
+ const stateResult = await writeMcpInstallState(deps.cwd, { selectedPlatforms, projectConfigPaths: targets.map((t) => t.relPath) }, {
2224
+ readFile: deps.readFile,
2225
+ writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
2226
+ rename: deps.rename,
2227
+ mkdir: async (p, o) => {
2228
+ await deps.mkdir(p, o);
2229
+ },
2230
+ unlink: deps.unlink,
2231
+ });
2232
+ if (!stateResult.ok) {
2233
+ // Install-state persistence is advisory — never fail the install over it.
2234
+ errorLog("Warning: could not persist the install-state file (non-fatal).");
2235
+ }
2236
+ }
2237
+ catch {
2238
+ // ensureGitignored for the state file can still throw — also advisory.
2239
+ errorLog("Warning: could not persist the install-state file (non-fatal).");
2240
+ }
1662
2241
  // ---- Step 3b — pre-warm the @${VERSION}-pinned _npx bucket (BAPI-451 W3) ----
1663
2242
  // The launcher just written is pinned to @${VERSION}, a DIFFERENT _npx bucket
1664
2243
  // than the @latest bucket this `npx … install-bridge` invocation warmed. Spawn
@@ -1721,10 +2300,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1721
2300
  "'npx -y @bridge_gpt/mcp-server doctor'.");
1722
2301
  }
1723
2302
  }
2303
+ // ---- optional GitHub connect offer (BAPI-631) ----
2304
+ // Placed AFTER the credential is durable (the flow needs a resolvable key) and BEFORE
2305
+ // the agent spawn, for two reasons: the spawned session's capability report should
2306
+ // observe GitHub as configured if the user connects it here, and running it after the
2307
+ // spawn would put two prompts on the same terminal at once.
2308
+ await offerGithubConnection(repoName, deps, log);
1724
2309
  // ---- Step 5 — spawn a fresh agent session for the agentic remainder ----
1725
2310
  log(`Step 5/5 — opening a ${agent.name} session for /install-bridge configuration + capability report…`);
1726
2311
  const terminal = detectTerminal(undefined, deps.env);
1727
- const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, spawnCommand, {
2312
+ // Only the validated short runner reaches the terminal never the inline prompt.
2313
+ const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, launchCommand, {
1728
2314
  key: "install",
1729
2315
  worktreePath: deps.cwd,
1730
2316
  });