@bridge_gpt/mcp-server 0.2.27 → 0.2.29

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.
@@ -30,11 +30,14 @@
30
30
  *
31
31
  * then SPAWNS a fresh agent session (Step 5) for the CONFIGURE-ONLY agentic
32
32
  * remainder: `/install-bridge` config-field derivation → the read-after-write
33
- * capability report → a single optional indexing-consent question. It does NOT
34
- * chain `/learn-repository` and does NOT index without explicit consent. The
35
- * fresh session is required because a CLI cannot force the running editor to
36
- * reload the just-written `.mcp.json`, and field derivation needs an agent
37
- * runtime the shell does not have.
33
+ * concise capability report → a `/learn-repository` recommendation (BAPI-658).
34
+ * It does NOT chain into running `/learn-repository` itself that stays the
35
+ * human's next explicit invocation and it never asks about, decides, or
36
+ * claims anything about repository indexing: indexing is entirely automatic,
37
+ * gated server-side by the existing readiness funnel, with no prompt on any
38
+ * path. The fresh session is required because a CLI cannot force the running
39
+ * editor to reload the just-written `.mcp.json`, and field derivation needs an
40
+ * agent runtime the shell does not have.
38
41
  *
39
42
  * That spawn command embeds the entire agent prompt and runs to multiple KB, which
40
43
  * no macOS terminal will accept as one typed line. So it is never typed: the full
@@ -54,10 +57,23 @@
54
57
  * repository prompt asks for a new project name rather than an existing
55
58
  * registration (see `RepoNamePromptMode`).
56
59
  *
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
59
- * API key yet, so the pre-flight ping of Step 2 CANNOT be made: the exchange is
60
- * what mints the key, and it REPLACES that ping. The order becomes:
60
+ * ORDINARY-ENTRY INVITE RECLASSIFICATION (BAPI-661) — the have-key branch's own
61
+ * credential input (`--api-key`, `BAPI_API_KEY`, or its hidden prompt) is
62
+ * CREDENTIAL-AGNOSTIC: it accepts a full API key OR a bootstrap-invite token,
63
+ * because a user handed only an invite has no way to know it belongs in a
64
+ * different flag. `classifyEnteredCredential` inspects the resolved value
65
+ * immediately after input resolution (before repository-name resolution); a
66
+ * `bapi_inv_…` value flips the branch's `bootstrapInviteMode`/`inviteToken` and
67
+ * the run falls into the SAME bootstrap-invite protocol described below, verbatim.
68
+ * Explicit `--invite`/`BAPI_INVITE` and `--email`/`BAPI_SIGNUP_EMAIL` are never
69
+ * reclassified — they are already unambiguous and remain authoritative.
70
+ *
71
+ * BOOTSTRAP-INVITE MODE (BAPI-606) — one of the exceptions to "this command
72
+ * consumes a key, it does not create one" (the ordinary-entry reclassification
73
+ * above is the other). With `--invite` (or `BAPI_INVITE`, or a reclassified
74
+ * ordinary-entry value) there is no API key yet, so the pre-flight ping of Step 2
75
+ * CANNOT be made: the exchange is what mints the key, and it REPLACES that ping.
76
+ * The order becomes:
61
77
  *
62
78
  * resolve bootstrap-invite token (no-echo prompt by DEFAULT — the delivered
63
79
  * one-liner is secret-free) → resolve repo name → scaffold → generate
@@ -88,7 +104,7 @@ import readline from "readline";
88
104
  import { runInit, buildBridgeApiEntry } from "./init.js";
89
105
  import { VERSION } from "./version.generated.js";
90
106
  import { validateRepoName } from "./bridge-config.js";
91
- import { MCP_HOST_TARGETS, HOST_PLATFORM_ORDER, allHostTargets, isHostPlatformId, detectDefaultPlatforms, } from "./mcp-host-targets.js";
107
+ import { MCP_HOST_TARGETS, HOST_PLATFORM_ORDER, allHostTargets, agentForPlatform, isHostPlatformId, detectDefaultPlatforms, } from "./mcp-host-targets.js";
92
108
  import { provisionHostTarget, createDefaultVendorProcessDeps, } from "./mcp-host-config.js";
93
109
  import { writeMcpInstallState } from "./mcp-install-state.js";
94
110
  import { ensureGitignored as ensureGitignoredShared, } from "./git-ignore-utils.js";
@@ -98,7 +114,7 @@ import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPe
98
114
  // API primitives verbatim — no duplicated polling, browser, or picker logic here.
99
115
  import { fetchGithubConfigurationState } from "./connect-github-api.js";
100
116
  import { createDefaultConnectGithubDeps, runGithubConnectionFlow } from "./connect-github.js";
101
- import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
117
+ import { resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
102
118
  import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, materializeWorkerLaunchCommand, MAX_TERMINAL_COMMAND_BYTES, } from "./start-tickets.js";
103
119
  /** Redaction sentinel — the API-key value is NEVER printed; this stands in. */
104
120
  export const REDACTED_API_KEY = "<REDACTED>";
@@ -125,44 +141,37 @@ export function buildPrewarmCommandPreview() {
125
141
  }
126
142
  /**
127
143
  * The natural-language prompt handed to the spawned agent session. It is
128
- * CONFIGURE-ONLY: it derives and applies configuration, presents the capability
129
- * report, and ends with exactly one indexing-consent question. It never chains
130
- * /learn-repository and never indexes before explicit consent.
131
- *
132
- * The index-consent beat is AGENT-OWNED (asked inside this spawned session), not
133
- * parent-CLI-owned, because `spawnTerminalTab()` opens an asynchronous terminal
134
- * session: the parent CLI returns immediately and cannot reliably ask a question
135
- * that must appear AFTER the spawned session's capability report. Asking it via
136
- * the parent's `deps.promptLine` would display the question before the report
137
- * even exists. `deps.promptLine` therefore stays the owner of the parent-CLI
138
- * prompts (repository / overwrite / invite confirmation) and is intentionally
139
- * NOT used for this post-report question.
144
+ * CONFIGURE-ONLY: it derives and applies configuration, presents the concise
145
+ * capability report, and closes with a `/learn-repository` recommendation
146
+ * (BAPI-658). It never chains straight into running /learn-repository itself —
147
+ * that remains the human's next explicit invocation — and it never asks about,
148
+ * decides, or claims anything about repository indexing: indexing is entirely
149
+ * automatic, gated by the existing readiness funnel
150
+ * (`maybe_auto_parse_if_ready`), with no prompt on any path.
140
151
  */
141
152
  export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command in the install-spawn context (tell the command it is running " +
142
- "in the install-spawn context so it SKIPS its Stage 8 and Stage 9 offers — this session's only " +
143
- "closing interaction is the single indexing question below). Do NOT run /learn-repository. Do NOT " +
144
- "call parse_repository (or otherwise start indexing) before the capability report and explicit " +
145
- "consent below. " +
146
- "Complete the command's read-after-write five-section capability report first: 'Connected ', " +
147
- "'Not yet connected ✗', 'Tools you can use now', 'Tools you'll unlock', and 'Recommended next " +
148
- "step + why'. " +
149
- "Only AFTER that report is fully presented, ask exactly one question using this visible prompt: " +
150
- "'[Y/n] Index repository now?'. Only an explicit affirmative answer (e.g. 'y'/'yes') starts " +
151
- "indexing; a blank answer, a negative answer, EOF, an unavailable interaction, and any " +
152
- "non-interactive/headless run all resolve to NO. " +
153
- "On an affirmative answer: call the parse_repository MCP tool exactly once, describe the accepted " +
154
- "job as QUEUED, and direct later progress checks to get_parse_status or /check-parse-status " +
155
- "(do NOT poll it to completion). If parse_repository returns a blocking refusal or error, do NOT " +
156
- "claim the job was queued — report the sanitized result and leave indexing pending. " +
157
- "On NO (or any unavailable/non-interactive resolution): do not index; print the exact copy-paste " +
158
- "continuation command '/parse-repository' on its own line and state that indexing remains pending. " +
153
+ "in the install-spawn context so it SKIPS its Stage 8, Stage 9, and Stage 10 offers — this " +
154
+ "session's only closing interaction is the concise capability report and learn recommendation " +
155
+ "below). " +
156
+ "Complete the command's read-after-write concise capability report first: the single 'What Bridge " +
157
+ "can help with' section, rendered exactly as the server's concise_tool_capabilities field gives it " +
158
+ "(Regularly useful, then Occasionally useful, each tool's display_name only, plus each tier's " +
159
+ "server-computed '+N more' where non-zero). Do not render the obsolete five-section report, and do " +
160
+ "not locally filter, count, or fall back to the complete tool_capabilities catalog. " +
161
+ "Do NOT ask any indexing question, call parse_repository, mention /parse-repository, or claim " +
162
+ "indexing has started or is pending indexing is decided entirely by the server-side readiness " +
163
+ "funnel with no visibility from this session, so say nothing about it at all. " +
164
+ "After the capability report, recommend /learn-repository as the next step: explain briefly that it " +
165
+ "learns repository-specific architecture, review, testing, correctness, and validation-manual " +
166
+ "configuration by researching the actual codebase. Do NOT run /learn-repository yourself only " +
167
+ "recommend it; running it is the human's next explicit invocation. " +
159
168
  "Never request, echo, or transport any credential — only ever direct the human to that " +
160
169
  "integration's own configure_in pointer, verbatim. The pointer is per-integration and is NOT " +
161
170
  "always the setup UI: GitHub's is a terminal command (connect-github), while Jira, SFCC, and " +
162
171
  "Bitbucket point at the setup UI. Follow whatever the report says rather than assuming. " +
163
172
  "End with an explicit summary line stating how many config fields the apply_install_manifest call " +
164
- "applied (e.g. 'Applied 8 of 9 derived fields') and whether indexing was queued or left pending " +
165
- "if 0 fields were applied, say so loudly and explain what is still pending.";
173
+ "applied (e.g. 'Applied 8 of 9 derived fields') if 0 fields were applied, say so loudly and " +
174
+ "explain what is still pending.";
166
175
  /** Default base URL when `BAPI_BASE_URL` is unset (mirrors index.ts). */
167
176
  export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
168
177
  /** Default docs dir when `BAPI_DOCS_DIR` is unset (mirrors index.ts). */
@@ -175,8 +184,11 @@ export function getInstallBridgeUsage() {
175
184
  "",
176
185
  "One-command Bridge API project bootstrap. Scaffolds the project, writes the",
177
186
  "per-host MCP config with your credentials, verifies connectivity, persists the",
178
- "routing credential, then opens a fresh agent session to derive the remaining",
179
- "config, present a capability report, and offer optional repository indexing.",
187
+ "routing credential, then on a TTY, only after a Y/N consent prompt — opens a",
188
+ "fresh session in your selected tool to derive the remaining config, present a",
189
+ "concise capability report, and recommend /learn-repository. Indexing is never",
190
+ "asked about — it starts automatically once the repository reaches full parse",
191
+ "readiness.",
180
192
  "",
181
193
  "Run it bare — `install-bridge` with no flags — in a terminal and it asks",
182
194
  `\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT.trim()}\` first. Answer yes (or press Enter) for the`,
@@ -186,11 +198,15 @@ export function getInstallBridgeUsage() {
186
198
  "an interactive terminal keeps the existing deterministic behavior and no prompt.",
187
199
  "",
188
200
  "Inputs (the only two irreducible ones):",
189
- " --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
190
- " then an interactive (no-echo) prompt. Generate one in the",
191
- " Bridge API web UI Security page — this command consumes a",
192
- " key, it does not create one (--email and --invite are the",
193
- " exceptions: they CREATE the project and its first admin key).",
201
+ " --api-key <key> Bridge API key OR bootstrap invite. Falls back to the",
202
+ " BAPI_API_KEY env var, then an interactive (no-echo) prompt.",
203
+ " Generate a key in the Bridge API web UI Security page — this",
204
+ " command consumes a key, it does not create one. A value",
205
+ " detected as a bootstrap invite (bapi_inv_…), from any of the",
206
+ " three sources above, is instead redeemed to CREATE a new",
207
+ " project and its first admin key — the same as --invite —",
208
+ " so it skips repository lookup entirely (--email and --invite",
209
+ " remain the explicit, preferred entry points for a new project).",
194
210
  " NEVER printed or logged.",
195
211
  " --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
196
212
  " priority and short-circuit before any network call. When",
@@ -245,24 +261,35 @@ export function getInstallBridgeUsage() {
245
261
  ` ${HOST_PLATFORM_ORDER.join(", ")}.`,
246
262
  " Both --tools=claude-code,codex and",
247
263
  " --tools claude-code,codex are accepted. On an",
248
- " interactive terminal WITHOUT this flag you are",
249
- " asked which tools you use (Claude Code plus any",
250
- " detected editors are pre-checked). A non-",
251
- " interactive run without --tools writes the legacy",
252
- " automatic set (Claude Code plus any detected",
253
- " Cursor / Copilot VS Code). --tools= (empty) is an",
254
- " explicit empty selection and writes nothing.",
264
+ " interactive terminal WITHOUT this flag the",
265
+ " checklist starts EMPTY no tool is pre-selected",
266
+ " (not even Claude Code) and you must select at",
267
+ " least one. A non-interactive (non-TTY) run without",
268
+ " --tools writes the legacy automatic set (Claude",
269
+ " Code plus any detected Cursor / Copilot VS Code)",
270
+ " and never opens an agent terminal (it prints",
271
+ " manual continuation instead). --tools= (empty) is",
272
+ " an explicit empty selection: it writes nothing and",
273
+ " launches nothing.",
255
274
  " --force Overwrite an existing real BAPI_API_KEY in a",
256
275
  " host config (or in the credential store) without",
257
276
  " prompting.",
258
277
  " --dry-run Preview every step (scaffold targets, config",
259
278
  " files + keys with the key REDACTED, ping",
260
- " target, credential target, spawn command)",
261
- " without writing, pinging, or spawning anything.",
262
- " With --invite it also never calls the exchange",
263
- " endpoint and never generates or stores a secret.",
264
- " --agent claude|cursor-agent Agent to launch for the agentic remainder",
265
- " (default: claude).",
279
+ " target, credential target, and the consent-gated",
280
+ " launch outcome) without writing, pinging,",
281
+ " prompting, or spawning anything. With --invite it",
282
+ " also never calls the exchange endpoint and never",
283
+ " generates or stores a secret.",
284
+ " --agent claude|cursor-agent Explicit launch override for the post-install",
285
+ " session — it always wins, and there is NO Claude",
286
+ " default. Without it the launch tool is derived",
287
+ " from your selection: Claude Code opens `claude`,",
288
+ " Cursor opens `cursor-agent`; if several launchable",
289
+ " tools are selected the wizard asks which single one",
290
+ " to open; and a selection whose tools have no",
291
+ " agentic CLI (e.g. Copilot) opens nothing and prints",
292
+ " how to finish configuring later.",
266
293
  " -h, --help Show this help.",
267
294
  "",
268
295
  "Environment: BAPI_BASE_URL (default https://bridgegpt-api.com) and BAPI_DOCS_DIR",
@@ -288,7 +315,10 @@ export function parseInstallBridgeArgs(argv) {
288
315
  let repo;
289
316
  let force = false;
290
317
  let dryRun = false;
291
- let agentName = DEFAULT_AGENT_NAME;
318
+ // Undefined until `--agent` is explicitly supplied — install-bridge never falls
319
+ // back to DEFAULT_AGENT_NAME (BAPI-657). An omitted agent is derived from the tool
320
+ // selection downstream; only an explicit `--agent` value lands here.
321
+ let agentName;
292
322
  let invite;
293
323
  let email;
294
324
  let tools;
@@ -697,8 +727,16 @@ export function createDefaultInstallBridgeDeps() {
697
727
  };
698
728
  }
699
729
  /**
700
- * Resolve the API key: `--api-key` → `BAPI_API_KEY` env → interactive no-echo
701
- * prompt. Fails (secret-free) when none is available and stdin is non-interactive.
730
+ * Resolve the ordinary credential entry: `--api-key` → `BAPI_API_KEY` env →
731
+ * interactive no-echo prompt. Fails (secret-free) when none is available and
732
+ * stdin is non-interactive.
733
+ *
734
+ * Despite the name, this is CREDENTIAL-AGNOSTIC (BAPI-661): the returned value may
735
+ * be a full Bridge API key or a bootstrap-invite token (`bapi_inv_…`) — the caller
736
+ * classifies it with {@link classifyEnteredCredential} immediately after this
737
+ * resolves and routes an invite into the existing redemption workflow. This
738
+ * function itself performs no classification; it only resolves which raw string
739
+ * was entered.
702
740
  */
703
741
  export async function resolveApiKey(options, deps) {
704
742
  if (typeof options.apiKey === "string" && options.apiKey.trim().length > 0) {
@@ -709,16 +747,20 @@ export async function resolveApiKey(options, deps) {
709
747
  return { ok: true, value: fromEnv.trim() };
710
748
  }
711
749
  if (deps.isTTY && deps.promptSecret) {
712
- const entered = (await deps.promptSecret("Bridge API key (input hidden): ")).trim();
750
+ const entered = (await deps.promptSecret("Bridge API key or invite (input hidden): ")).trim();
713
751
  if (entered.length > 0) {
714
752
  return { ok: true, value: entered };
715
753
  }
716
- return { ok: false, error: "No API key entered." };
754
+ return {
755
+ ok: false,
756
+ error: "No Bridge API key or invite entered. Pass --api-key, set the BAPI_API_KEY environment " +
757
+ "variable, or try the hidden prompt again.",
758
+ };
717
759
  }
718
760
  return {
719
761
  ok: false,
720
- error: "An API key is required. Pass --api-key or set the BAPI_API_KEY environment variable " +
721
- "(no interactive terminal is available to prompt for it).",
762
+ error: "A Bridge API key or invite is required. Pass --api-key or set the BAPI_API_KEY " +
763
+ "environment variable (no interactive terminal is available to prompt for it).",
722
764
  };
723
765
  }
724
766
  /**
@@ -823,8 +865,8 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
823
865
  return { kind: "need-key", method: "self-serve" };
824
866
  return { kind: "have-key" };
825
867
  }
826
- /** The exact visible text of the bare-TTY onboarding selector (BAPI-626). */
827
- export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key? [Y/n] ";
868
+ /** The exact visible text of the bare-TTY onboarding selector (BAPI-626, BAPI-661). */
869
+ export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key or invite? [Y/n] ";
828
870
  /**
829
871
  * Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
830
872
  *
@@ -968,22 +1010,27 @@ export async function resolveRepoName(options, deps, mode = "existing-registrati
968
1010
  /** Stable wording of the interactive tool-selection prompt (BAPI-635). */
969
1011
  export const INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT = "Which AI coding tools do you use on this project?";
970
1012
  /**
971
- * Interactive numbered multi-select prompt on stderr (TTY only). Displays each
972
- * option with a checked/unchecked marker seeded from `defaults`, accepts a
973
- * comma-separated list of numbers to TOGGLE, and accepts the current selection on
974
- * a bare Enter. Reprints on an invalid token rather than corrupting state.
975
- * Resolves safely (to the seeded defaults) on EOF / synchronous close so a
976
- * readline regression can never hang or discard the answer.
1013
+ * Interactive numbered multi-select prompt on stderr (TTY only). Displays a
1014
+ * legend and usage line plus each option with a checked/unchecked marker seeded
1015
+ * from `defaults`, accepts a comma-separated list of numbers that TOGGLE the rows
1016
+ * and RE-PROMPT (acceptance is a separate bare Enter), and accepts only a NON-EMPTY
1017
+ * selection on a bare Enter an empty one prints "Select at least one tool." and
1018
+ * re-prompts (AC-1/AC-3). Reprints on an invalid token rather than corrupting
1019
+ * state. Resolves safely (to the current set, including an empty one) on EOF /
1020
+ * synchronous close so a readline regression can never hang or discard the answer.
977
1021
  */
978
1022
  export function promptMultiSelectViaReadline(promptText, options, defaults, input = process.stdin, output = process.stderr) {
979
1023
  return new Promise((resolve) => {
980
1024
  const selected = new Set(defaults);
981
1025
  const render = () => {
982
1026
  output.write(`\n${promptText}\n`);
1027
+ output.write("[x] = selected · [ ] = not selected\n");
983
1028
  options.forEach((opt, idx) => {
984
1029
  const mark = selected.has(opt.id) ? "[x]" : "[ ]";
985
1030
  output.write(` ${idx + 1}. ${mark} ${opt.label}\n`);
986
1031
  });
1032
+ output.write("Type numbers to toggle, e.g. 1,3 — then Enter.\n");
1033
+ output.write("Press Enter on an empty line to confirm — re-typing a number un-selects it.\n");
987
1034
  output.write("Enter numbers to toggle (comma-separated), or press Enter to accept: ");
988
1035
  };
989
1036
  const rl = readline.createInterface({ input, output });
@@ -1004,6 +1051,14 @@ export function promptMultiSelectViaReadline(promptText, options, defaults, inpu
1004
1051
  rl.question("", (answer) => {
1005
1052
  const trimmed = answer.trim();
1006
1053
  if (trimmed.length === 0) {
1054
+ // AC-3: a bare Enter accepts ONLY a non-empty selection; an empty one
1055
+ // re-prompts rather than resolving to nothing. EOF/close (handled above)
1056
+ // still resolves the current — possibly empty — set so input can never hang.
1057
+ if (selected.size === 0) {
1058
+ output.write("Select at least one tool.\n");
1059
+ ask();
1060
+ return;
1061
+ }
1007
1062
  finish();
1008
1063
  return;
1009
1064
  }
@@ -1030,7 +1085,9 @@ export function promptMultiSelectViaReadline(promptText, options, defaults, inpu
1030
1085
  else
1031
1086
  selected.add(opt.id);
1032
1087
  }
1033
- finish();
1088
+ // Toggling re-renders the updated checklist and re-prompts; acceptance is a
1089
+ // separate, deliberate bare Enter (AC-1/AC-3) rather than an implicit accept.
1090
+ ask();
1034
1091
  });
1035
1092
  };
1036
1093
  ask();
@@ -1040,8 +1097,8 @@ export function promptMultiSelectViaReadline(promptText, options, defaults, inpu
1040
1097
  * Resolve the selected host platforms with strict precedence (BAPI-635):
1041
1098
  * 1. explicit `--tools` (including an explicit EMPTY selection — never falls
1042
1099
  * back to detection),
1043
- * 2. interactive multi-select on a TTY (seeded from registry detection, with
1044
- * Claude Code always checked as a default),
1100
+ * 2. interactive multi-select on a TTY (AC-2: no row pre-selected — the picker
1101
+ * starts empty and requires an explicit ≥1 selection),
1045
1102
  * 3. the legacy non-TTY automatic set: Claude Code plus only the currently
1046
1103
  * detected Copilot VS Code and Cursor automatic targets (never Codex or
1047
1104
  * Copilot CLI just because a global directory exists).
@@ -1053,11 +1110,12 @@ export async function resolveSelectedHostPlatforms(deps, options) {
1053
1110
  }
1054
1111
  const ctx = await buildDetectionContext(deps);
1055
1112
  const detected = new Set(detectDefaultPlatforms(ctx));
1056
- // 2. Interactive multi-select on a TTY.
1113
+ // 2. Interactive multi-select on a TTY. AC-2: no row is pre-selected — the
1114
+ // picker seeds an EMPTY default set (dropping the former forced Claude Code
1115
+ // inclusion and detected-platform seeding) and requires an explicit selection.
1057
1116
  if (deps.isTTY && deps.promptMultiSelect) {
1058
1117
  const optionList = allHostTargets().map((t) => ({ id: t.id, label: t.label }));
1059
- // Claude Code is always a checked default; add every detected platform.
1060
- const defaults = HOST_PLATFORM_ORDER.filter((id) => id === "claude-code" || detected.has(id));
1118
+ const defaults = [];
1061
1119
  const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList, defaults);
1062
1120
  return HOST_PLATFORM_ORDER.filter((id) => chosen.includes(id));
1063
1121
  }
@@ -1069,6 +1127,167 @@ export async function resolveSelectedHostPlatforms(deps, options) {
1069
1127
  legacy.push("copilot-vscode");
1070
1128
  return HOST_PLATFORM_ORDER.filter((id) => legacy.includes(id));
1071
1129
  }
1130
+ /**
1131
+ * Resolve the post-install launch decision from the selected host platforms and an
1132
+ * OPTIONAL explicit `--agent` override (BAPI-657). Pure and total:
1133
+ *
1134
+ * - An explicit `--agent` is an ABSOLUTE override (ratified AC-4): a valid explicit
1135
+ * agent always returns `spawn(agent)`, regardless of which tools were selected —
1136
+ * the flag is honored even for a host outside the selection.
1137
+ * - Otherwise the selection drives it: map each selected host through
1138
+ * {@link agentForPlatform}, drop non-launchable hosts, dedup in registry order,
1139
+ * then return `manual("empty-selection")` for an empty selection,
1140
+ * `manual("no-launchable-agent")` for zero launchable agents, `spawn` for one, or
1141
+ * `choose-one` for more than one.
1142
+ *
1143
+ * Never prompts, logs, resolves an {@link AgentSpec}, or touches the filesystem.
1144
+ */
1145
+ export function resolveInstallBridgeLaunchDecision(selectedPlatforms, explicitAgent) {
1146
+ if (explicitAgent) {
1147
+ return { kind: "spawn", agent: explicitAgent };
1148
+ }
1149
+ if (selectedPlatforms.length === 0) {
1150
+ return { kind: "manual", reason: "empty-selection" };
1151
+ }
1152
+ const agents = [];
1153
+ for (const id of HOST_PLATFORM_ORDER) {
1154
+ if (!selectedPlatforms.includes(id))
1155
+ continue;
1156
+ const agent = agentForPlatform(id);
1157
+ if (agent && !agents.includes(agent))
1158
+ agents.push(agent);
1159
+ }
1160
+ if (agents.length === 0)
1161
+ return { kind: "manual", reason: "no-launchable-agent" };
1162
+ if (agents.length === 1)
1163
+ return { kind: "spawn", agent: agents[0] };
1164
+ return { kind: "choose-one", agents };
1165
+ }
1166
+ /**
1167
+ * The human-facing tool label for a launch agent, derived by scanning the host
1168
+ * registry's {@link HostTargetDefinition.launchAgent} metadata rather than keeping a
1169
+ * duplicate agent→label map (BAPI-657). Returns e.g. `"Claude Code"` for `"claude"`
1170
+ * and `"Cursor"` for `"cursor-agent"`, falling back to the raw agent name if no host
1171
+ * advertises it (never expected for a registered launch agent).
1172
+ */
1173
+ export function toolLabelForLaunchAgent(agent) {
1174
+ const target = allHostTargets().find((t) => t.launchAgent === agent);
1175
+ return target?.label ?? agent;
1176
+ }
1177
+ /**
1178
+ * Interactive numbered chooser for when MORE THAN ONE launchable tool was selected
1179
+ * (AC-11). Mirrors the connect-github picker: prints each candidate by its host-tool
1180
+ * label, asks for an exact numeric choice with NO default, validates the range, and
1181
+ * returns the chosen agent. Returns `null` — never a silent first-candidate default —
1182
+ * for every unavailable/invalid path: non-TTY, a missing `promptLine` seam, blank/EOF
1183
+ * input, an out-of-range or malformed answer, or a prompt that throws. Single-purpose:
1184
+ * it must run only AFTER the multi-select promise has resolved and closed its readline
1185
+ * interface so two readers never contend for stdin.
1186
+ */
1187
+ export async function chooseInstallBridgeLaunchAgent(agents, deps) {
1188
+ if (!deps.isTTY || !deps.promptLine)
1189
+ return null;
1190
+ const promptLine = deps.promptLine;
1191
+ try {
1192
+ deps.log("");
1193
+ deps.log("More than one selected tool can host the configuration session:");
1194
+ agents.forEach((agent, i) => {
1195
+ deps.log(` ${String(i + 1).padStart(2, " ")}. ${toolLabelForLaunchAgent(agent)}`);
1196
+ });
1197
+ // No default: a stray Enter must not silently pick a tool.
1198
+ const answer = (await promptLine(`Which tool should open? [1-${agents.length}]: `)).trim();
1199
+ const index = Number(answer);
1200
+ if (!/^\d+$/.test(answer) ||
1201
+ !Number.isInteger(index) ||
1202
+ index < 1 ||
1203
+ index > agents.length) {
1204
+ return null;
1205
+ }
1206
+ return agents[index - 1];
1207
+ }
1208
+ catch {
1209
+ // Never surface caught exception text; an unavailable chooser is a no-spawn.
1210
+ return null;
1211
+ }
1212
+ }
1213
+ /**
1214
+ * Shared, copy-pasteable "we didn't open a session for you" guidance so every
1215
+ * no-spawn path (no-launchable selection, non-TTY suppression, chooser failure,
1216
+ * consent decline, terminal-spawn failure) emits ONE consistent remediation
1217
+ * (AC-5 / AC-7). Two variants:
1218
+ *
1219
+ * - `"empty-selection"` — no MCP host config was written at all, so it does NOT tell
1220
+ * the user to run `/install-bridge` (there is no configured host to run it in); it
1221
+ * tells them to re-run `install-bridge` and select at least one tool.
1222
+ * - `"configured"` — at least one host WAS configured, so it tells the user to open
1223
+ * the project in an applicable AI coding tool and run `/install-bridge`, plus the
1224
+ * required notice that their Bridge MCP tools stay limited until configuration
1225
+ * completes.
1226
+ *
1227
+ * Condition-specific warning prefixes are added by the caller, never inside here.
1228
+ *
1229
+ * `toolLabels` (BAPI-661) names the actually-selected platform(s) in the
1230
+ * `"configured"` message so "open it in an AI coding tool" becomes "open it in
1231
+ * GitHub Copilot (VS Code)" when that is what was selected — an empty array (the
1232
+ * explicit empty-selection call site, or any caller with nothing to name) falls
1233
+ * back to the prior generic "an AI coding tool" wording unchanged.
1234
+ */
1235
+ export function buildManualInstallBridgeContinuation(kind, toolLabels) {
1236
+ if (kind === "empty-selection") {
1237
+ return [
1238
+ "No AI coding tools were configured, so nothing was set up for this project.",
1239
+ "Re-run install-bridge and select at least one tool to configure it.",
1240
+ ].join("\n");
1241
+ }
1242
+ const toolPhrase = formatToolLabelPhrase(toolLabels);
1243
+ return [
1244
+ `To finish configuring this project, open it in ${toolPhrase} that has the`,
1245
+ "Bridge MCP server configured and run /install-bridge.",
1246
+ "Until the project is configured, your Bridge MCP tools stay limited.",
1247
+ ].join("\n");
1248
+ }
1249
+ /**
1250
+ * Format 0, 1, or many tool labels into a readable phrase for the continuation
1251
+ * message. Empty falls back to the generic "an AI coding tool"; one label is used
1252
+ * bare; two or more join with a serial (Oxford) comma and "and".
1253
+ */
1254
+ function formatToolLabelPhrase(labels) {
1255
+ if (labels.length === 0)
1256
+ return "an AI coding tool";
1257
+ if (labels.length === 1)
1258
+ return labels[0];
1259
+ if (labels.length === 2)
1260
+ return `${labels[0]} and ${labels[1]}`;
1261
+ return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`;
1262
+ }
1263
+ /** The exact consent-prompt prefix, shared with tests so the copy cannot drift. */
1264
+ export const INSTALL_BRIDGE_LAUNCH_CONSENT_PROMPT_PREFIX = "Bridge can configure and set up this project for you automatically. Open a ";
1265
+ /**
1266
+ * Ask for explicit TTY consent immediately before opening the selected tool (AC-7).
1267
+ * Returns `"spawn"` only when a live terminal user affirmatively agrees; returns
1268
+ * `"no-spawn"` WITHOUT prompting on a non-TTY run or a missing `promptLine` seam, and
1269
+ * on an `n`/`no` answer. Mirrors {@link offerGithubConnection}'s Y/N semantics: a
1270
+ * blank line, `y`, or `yes` accept; `n` or `no` decline. A prompt that throws safely
1271
+ * declines without surfacing the caught exception text. Uses only the per-call
1272
+ * `promptLine` seam, so it must run AFTER the tool-selection, chooser, and GitHub
1273
+ * flows have closed their readline interfaces — never while another prompt is active.
1274
+ */
1275
+ export async function requestInstallBridgeLaunchConsent(toolLabel, deps) {
1276
+ if (!deps.isTTY || !deps.promptLine)
1277
+ return "no-spawn";
1278
+ try {
1279
+ const answer = (await deps.promptLine(`${INSTALL_BRIDGE_LAUNCH_CONSENT_PROMPT_PREFIX}${toolLabel} session to do that now? (Y/n) `))
1280
+ .trim()
1281
+ .toLowerCase();
1282
+ if (answer === "n" || answer === "no")
1283
+ return "no-spawn";
1284
+ return "spawn";
1285
+ }
1286
+ catch {
1287
+ // Never surface caught exception text; treat a prompt failure as a decline.
1288
+ return "no-spawn";
1289
+ }
1290
+ }
1072
1291
  /**
1073
1292
  * Build a registry detection context from install deps. install-bridge has only
1074
1293
  * async `stat`, but the registry's `detect` callbacks are synchronous, so we
@@ -1119,6 +1338,17 @@ function hostConfigTargetsForPlatforms(platforms) {
1119
1338
  .filter((t) => t.scope === "project" && t.format === "json")
1120
1339
  .map((t) => ({ relPath: t.relPath, topLevelKey: t.topLevelKey }));
1121
1340
  }
1341
+ /**
1342
+ * Derive the human-facing labels for a set of selected platforms, in registry
1343
+ * (`HOST_PLATFORM_ORDER`) order rather than the caller's input order, so a
1344
+ * continuation message names selected tools deterministically (BAPI-661).
1345
+ * Deduplicates by platform ID and reads every label from `MCP_HOST_TARGETS` —
1346
+ * never a second, hand-maintained label map.
1347
+ */
1348
+ export function labelsForPlatforms(platforms) {
1349
+ const set = new Set(platforms);
1350
+ return HOST_PLATFORM_ORDER.filter((id) => set.has(id)).map((id) => MCP_HOST_TARGETS[id].label);
1351
+ }
1122
1352
  /** Resolve which project-local host configs to write, mirroring runInit detection. */
1123
1353
  async function resolveHostConfigTargets(deps) {
1124
1354
  const targets = [
@@ -1279,9 +1509,19 @@ export function buildPingUrl(baseUrl, repoName) {
1279
1509
  return url.toString();
1280
1510
  }
1281
1511
  /**
1282
- * Verify connectivity. Distinguishes a rejected key (401/403) from an unknown
1283
- * repo / not-found (404) where the response allows. The key is sent in the
1284
- * `X-API-Key` header and NEVER appears in any returned message.
1512
+ * Fixed, secret-free fallback for an HTTP 403 whose body carries no usable
1513
+ * `detail` (absent, malformed, unreadable, or a non-string `detail`). Deliberately
1514
+ * does NOT claim the key is invalid or expired — a 403 means the credential was
1515
+ * understood but denied access, which is a different (and often more actionable)
1516
+ * failure than an invalid/expired key.
1517
+ */
1518
+ const CONNECTIVITY_ACCESS_DENIED_FALLBACK = "The Bridge API denied access to this repository (HTTP 403). Verify the repo_name and that this " +
1519
+ "credential is authorized for it.";
1520
+ /**
1521
+ * Verify connectivity. Distinguishes a rejected/expired credential (401) from a
1522
+ * repository-specific access denial (403, BAPI-661) from an unknown repo /
1523
+ * not-found (404) where the response allows. The key is sent in the `X-API-Key`
1524
+ * header and NEVER appears in any returned message.
1285
1525
  */
1286
1526
  export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1287
1527
  const url = buildPingUrl(baseUrl, repoName);
@@ -1306,7 +1546,7 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1306
1546
  }
1307
1547
  if (resp.ok)
1308
1548
  return { ok: true };
1309
- if (resp.status === 401 || resp.status === 403) {
1549
+ if (resp.status === 401) {
1310
1550
  return {
1311
1551
  ok: false,
1312
1552
  message: `The Bridge API rejected the credential (HTTP ${resp.status}). The API key may be invalid or expired ` +
@@ -1314,6 +1554,27 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1314
1554
  "permission error.)",
1315
1555
  };
1316
1556
  }
1557
+ if (resp.status === 403) {
1558
+ // BAPI-661: a 403 means the credential was understood but access to THIS repo
1559
+ // was denied — the server's `detail` (e.g. "repository '<name>' is not
1560
+ // registered; verify the repo_name") is a safe, specific, non-secret message
1561
+ // that is far more actionable than the blanket invalid-key diagnosis 401 uses.
1562
+ // Only a nonblank string `detail` is ever surfaced; any other shape (missing
1563
+ // body, invalid JSON, a non-string/blank detail, or a body-read exception)
1564
+ // collapses to the fixed fallback so nothing unvalidated ever reaches the user.
1565
+ let detail;
1566
+ try {
1567
+ const body = (await resp.json());
1568
+ detail = body?.detail;
1569
+ }
1570
+ catch {
1571
+ return { ok: false, message: CONNECTIVITY_ACCESS_DENIED_FALLBACK };
1572
+ }
1573
+ if (typeof detail === "string" && detail.trim().length > 0) {
1574
+ return { ok: false, message: detail.trim() };
1575
+ }
1576
+ return { ok: false, message: CONNECTIVITY_ACCESS_DENIED_FALLBACK };
1577
+ }
1317
1578
  if (resp.status === 404) {
1318
1579
  return {
1319
1580
  ok: false,
@@ -1483,6 +1744,21 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
1483
1744
  * treated as a `failed` mint rather than fed into the redemption path.
1484
1745
  */
1485
1746
  export const BOOTSTRAP_INVITE_TOKEN_PREFIX = "bapi_inv_";
1747
+ /**
1748
+ * Classify a value entered through the ORDINARY credential input (`--api-key`,
1749
+ * `BAPI_API_KEY`, or the have-key hidden prompt) as a bootstrap-invite token or a
1750
+ * full API key (BAPI-661). This lets a user who was handed an invite but reaches
1751
+ * for the ordinary key input still get redeemed correctly, instead of the invite
1752
+ * being sent as an (invalid) API key to repository resolution / connectivity.
1753
+ *
1754
+ * Pure and total: trims first, then does a case-sensitive prefix check against
1755
+ * {@link BOOTSTRAP_INVITE_TOKEN_PREFIX}. Blank and every non-matching value is
1756
+ * `"api-key"` — the existing resolvers already reject a blank value, so this
1757
+ * function does not need to special-case it. Never logs or returns the value.
1758
+ */
1759
+ export function classifyEnteredCredential(value) {
1760
+ return value.trim().startsWith(BOOTSTRAP_INVITE_TOKEN_PREFIX) ? "invite" : "api-key";
1761
+ }
1486
1762
  /** The self-serve mint endpoint: `<base>/setup/bootstrap/self-serve`. No query string — ever. */
1487
1763
  export function buildSelfServeMintUrl(baseUrl) {
1488
1764
  return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve`;
@@ -1597,7 +1873,7 @@ export function buildDryRunPreview(plan) {
1597
1873
  `Repo name: ${plan.repoName}${plan.attemptedServerResolution ? " (resolved server-side from your API key)" : ""}`,
1598
1874
  `Base URL (ping): ${plan.baseUrl}`,
1599
1875
  `Docs dir: ${plan.docsDir}`,
1600
- `Agent: ${plan.agentName}`,
1876
+ `Agent: ${describePlannedLaunchAgent(plan.launch)}`,
1601
1877
  "",
1602
1878
  "Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
1603
1879
  `Step 2 — connectivity ping (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
@@ -1612,26 +1888,66 @@ export function buildDryRunPreview(plan) {
1612
1888
  ...buildLaunchStepPreview(plan),
1613
1889
  ];
1614
1890
  }
1891
+ /**
1892
+ * One-line description of the planned launch agent for the dry-run header (BAPI-657).
1893
+ * Never claims an unconditional Claude spawn: it reflects the discriminated launch
1894
+ * outcome — a single consent-gated tool, a choose-one among launchable tools, or a
1895
+ * no-automatic-launch manual outcome.
1896
+ */
1897
+ function describePlannedLaunchAgent(launch) {
1898
+ if (launch.kind === "spawn") {
1899
+ return `${toolLabelForLaunchAgent(launch.agent)} (${launch.agent}) — opens only after Y/N consent`;
1900
+ }
1901
+ if (launch.kind === "choose-one") {
1902
+ const labels = launch.agents.map((a) => toolLabelForLaunchAgent(a)).join(", ");
1903
+ return `choose one of ${labels} — the chosen tool opens only after Y/N consent`;
1904
+ }
1905
+ return launch.reason === "empty-selection"
1906
+ ? "none — no tools selected"
1907
+ : "none — no launchable tool selected; manual continuation is printed";
1908
+ }
1615
1909
  /**
1616
1910
  * The Step 5 preview lines, shared by both previews so the launch description
1617
- * cannot drift between the have-key and need-key flows.
1911
+ * cannot drift between the have-key and need-key flows (BAPI-657). The Step 5 body
1912
+ * is dispatched on the discriminated launch outcome: a consent-gated single-tool
1913
+ * spawn (with the full secret-free command shown), a consent-gated choose-one, or a
1914
+ * no-automatic-launch manual continuation — never an unconditional Claude spawn.
1618
1915
  *
1619
- * A --dry-run never writes the launch script, so the preview describes the
1620
- * materialization rather than performing it — but it still shows the full command,
1621
- * because the command is what the user is previewing and it is secret-free.
1916
+ * A --dry-run never writes the launch script, prompts for consent, or spawns, so
1917
+ * these lines describe the outcome rather than performing it.
1622
1918
  */
1623
1919
  function buildLaunchStepPreview(plan) {
1624
- return [
1920
+ const githubLines = [
1625
1921
  // BAPI-631: described, never performed in --dry-run — a preview must not open a
1626
1922
  // browser or reach the network. It is also strictly optional, so it carries no step
1627
1923
  // number of its own and never changes the 5-step count.
1628
1924
  "Step 4b — optional GitHub connect (SKIPPED in --dry-run): read GitHub's configured state",
1629
1925
  " via the install manifest and, only when it is unconfigured and the terminal is",
1630
1926
  " interactive, offer 'Connect GitHub? (Y/n)' before the agent session starts.",
1631
- "Step 5 — spawn agent session: the full command below is stored in a restricted launch script",
1632
- " (mode 0600, under the system temp dir) and only a short sourced runner is spawned",
1633
- " (the script itself is NOT written in --dry-run):",
1634
- ` ${plan.spawnCommand}`,
1927
+ ];
1928
+ if (plan.launch.kind === "spawn") {
1929
+ return [
1930
+ ...githubLines,
1931
+ `Step 5 — agent session (${toolLabelForLaunchAgent(plan.launch.agent)}): on a TTY the wizard first asks`,
1932
+ " 'Open a … session to do that now? (Y/n)'; only on consent is the full command below",
1933
+ " stored in a restricted launch script (mode 0600, under the system temp dir) and a short",
1934
+ " sourced runner spawned (the script itself is NOT written in --dry-run):",
1935
+ ` ${plan.launch.spawnCommand}`,
1936
+ ];
1937
+ }
1938
+ if (plan.launch.kind === "choose-one") {
1939
+ const labels = plan.launch.agents.map((a) => toolLabelForLaunchAgent(a)).join(", ");
1940
+ return [
1941
+ ...githubLines,
1942
+ `Step 5 — agent session: more than one selected tool can host it (${labels}); on a TTY the wizard`,
1943
+ " asks which single tool to open, then asks Y/N consent before spawning that one session.",
1944
+ ];
1945
+ }
1946
+ return [
1947
+ ...githubLines,
1948
+ plan.launch.reason === "empty-selection"
1949
+ ? "Step 5 — no agent session: no tools were selected, so nothing is configured or opened; re-run and select at least one tool."
1950
+ : "Step 5 — no automatic launch: no selected tool has an agentic CLI. The deterministic setup completes and copy-pasteable /install-bridge continuation is printed (your Bridge MCP tools stay limited until configured).",
1635
1951
  ];
1636
1952
  }
1637
1953
  /**
@@ -1668,7 +1984,7 @@ function buildBootstrapDryRunPreview(plan) {
1668
1984
  repoLine,
1669
1985
  `Base URL: ${plan.baseUrl}`,
1670
1986
  `Docs dir: ${plan.docsDir}`,
1671
- `Agent: ${plan.agentName}`,
1987
+ `Agent: ${describePlannedLaunchAgent(plan.launch)}`,
1672
1988
  "",
1673
1989
  "Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
1674
1990
  ...selfServeStep,
@@ -1788,8 +2104,14 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1788
2104
  return 1;
1789
2105
  }
1790
2106
  const branch = branchResult.branch;
1791
- const bootstrapInviteMode = branch.kind === "need-key";
1792
- const selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
2107
+ // Mutable (BAPI-661): an ordinary have-key credential that classifies as a
2108
+ // bootstrap invite (see the have-key arm below) flips `bootstrapInviteMode` to
2109
+ // true AFTER branch selection, before repository-name resolution — everything
2110
+ // downstream that branches on these two flags then follows the existing
2111
+ // bootstrap-invite protocol automatically. Explicit `--invite`/`BAPI_INVITE` and
2112
+ // `--email`/`BAPI_SIGNUP_EMAIL` never pass through this reclassification.
2113
+ let bootstrapInviteMode = branch.kind === "need-key";
2114
+ let selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
1793
2115
  // ---- Resolve inputs (may prompt when interactive) ----
1794
2116
  // Resolve the credential/entry input first so a fully-empty non-interactive
1795
2117
  // invocation fails with the (more relevant) missing-input message before the repo
@@ -1822,7 +2144,20 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1822
2144
  errorLog(`Error: ${keyResult.error}`);
1823
2145
  return 1;
1824
2146
  }
1825
- apiKey = keyResult.value;
2147
+ // BAPI-661: an invite pasted into the ordinary credential input (flag, env, or
2148
+ // hidden prompt) is redeemed like an explicit --invite, not sent as an API key
2149
+ // to repository resolution / connectivity. Reclassifying BEFORE that
2150
+ // resolution is what lets the rest of the function reuse the existing
2151
+ // bootstrap-invite protocol verbatim.
2152
+ if (classifyEnteredCredential(keyResult.value) === "invite") {
2153
+ inviteToken = keyResult.value.trim();
2154
+ apiKey = "";
2155
+ bootstrapInviteMode = true;
2156
+ selfServeSignupMode = false;
2157
+ }
2158
+ else {
2159
+ apiKey = keyResult.value;
2160
+ }
1826
2161
  }
1827
2162
  // baseUrl is resolved BEFORE repository input because the have-key branch needs
1828
2163
  // it for the server-resolution request.
@@ -1869,7 +2204,11 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1869
2204
  else {
1870
2205
  // Feature-detect + degrade: 404 (old server), 409 (unresolved/ambiguous/
1871
2206
  // client-scoped), and network/other errors all fall back to the existing
1872
- // local resolution WITHOUT a cause-specific message or leaked detail.
2207
+ // local resolution WITHOUT a cause-specific message or leaked detail, but
2208
+ // (BAPI-661) WITH a visible signal that the fallback is happening, so a
2209
+ // guessed name is never confirmed silently as if it had been verified.
2210
+ log("Couldn't auto-resolve your repo from the key; falling back to a guessed name — confirm " +
2211
+ "it matches the setup UI.");
1873
2212
  const repoResult = await resolveRepoName(options, deps, "existing-registration");
1874
2213
  if (!repoResult.ok) {
1875
2214
  errorLog(`Error: ${repoResult.error}`);
@@ -1879,33 +2218,58 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1879
2218
  }
1880
2219
  }
1881
2220
  }
1882
- const agent = resolveAgentSpec(options.agentName) ?? resolveAgentSpec(DEFAULT_AGENT_NAME);
1883
- const spawnCommand = deps.buildShellCommand(agent, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform);
1884
2221
  const credentialStorePath = getPrimaryCredentialStorePath({
1885
2222
  env: deps.env,
1886
2223
  homedir: deps.homedir,
1887
2224
  });
1888
- // BAPI-635: resolve the AI-coding-tool selection (explicit --tools, then TTY
1889
- // multi-select, then the legacy non-TTY automatic set). The selected project
1890
- // JSON targets drive the existing read-merge-write path; selected global
1891
- // (Codex / Copilot CLI) and manual (Windsurf) targets are provisioned by the
2225
+ // BAPI-635: resolve the AI-coding-tool selection FIRST BAPI-657 makes this
2226
+ // selection (not a hidden --agent default) drive which post-install session opens.
2227
+ // Explicit --tools, then the TTY multi-select, then the legacy non-TTY automatic
2228
+ // set. The selected project JSON targets drive the read-merge-write path; selected
2229
+ // global (Codex / Copilot CLI) and manual (Windsurf) targets are provisioned by the
1892
2230
  // registry-driven emitter after the connectivity check.
1893
2231
  const selectedPlatforms = await resolveSelectedHostPlatforms(deps, options);
1894
2232
  const targets = hostConfigTargetsForPlatforms(selectedPlatforms);
1895
2233
  // Read-only detection (safe in dry-run) of global-config editors we can't write.
1896
2234
  const manualEditors = await detectManualEditors(deps);
2235
+ // BAPI-657: the pure, selection-driven launch decision. An explicit --agent is an
2236
+ // absolute override (ratified AC-4); otherwise the selected tools map to an agent
2237
+ // (Claude Code → claude, Cursor → cursor-agent), yielding spawn / choose-one /
2238
+ // manual. Performs no prompting, resolveAgentSpec, or I/O.
2239
+ const launchDecision = resolveInstallBridgeLaunchDecision(selectedPlatforms, options.agentName);
2240
+ // Derive the plan's launch data. For a single-agent spawn we resolve the spec and
2241
+ // build the (secret-free) command now so the dry-run preview can show it; a registry
2242
+ // miss here is a hard error — install-bridge NEVER falls back to DEFAULT_AGENT_NAME.
2243
+ let planLaunch;
2244
+ if (launchDecision.kind === "spawn") {
2245
+ const spec = resolveAgentSpec(launchDecision.agent);
2246
+ if (!spec) {
2247
+ errorLog(`Error: no launch agent is registered for '${launchDecision.agent}'.`);
2248
+ return 1;
2249
+ }
2250
+ planLaunch = {
2251
+ kind: "spawn",
2252
+ agent: launchDecision.agent,
2253
+ spawnCommand: deps.buildShellCommand(spec, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform),
2254
+ };
2255
+ }
2256
+ else if (launchDecision.kind === "choose-one") {
2257
+ planLaunch = { kind: "choose-one", agents: launchDecision.agents };
2258
+ }
2259
+ else {
2260
+ planLaunch = { kind: "manual", reason: launchDecision.reason };
2261
+ }
1897
2262
  const plan = {
1898
2263
  repoName,
1899
2264
  baseUrl,
1900
2265
  docsDir,
1901
- agentName: options.agentName,
2266
+ launch: planLaunch,
1902
2267
  configTargets: targets.map((t) => t.relPath),
1903
2268
  manualEditors: manualEditorNames(manualEditors),
1904
2269
  credentialTarget: `bapi:${repoName}`,
1905
2270
  credentialStorePath,
1906
2271
  pingUrl: buildPingUrl(baseUrl, repoName),
1907
2272
  prewarmCommand: buildPrewarmCommandPreview(),
1908
- spawnCommand,
1909
2273
  ...(bootstrapInviteMode
1910
2274
  ? { bootstrapInvite: true, exchangeUrl: buildBootstrapExchangeUrl(baseUrl) }
1911
2275
  : {}),
@@ -1921,46 +2285,74 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1921
2285
  log(line);
1922
2286
  return 0;
1923
2287
  }
1924
- // ---- Materialize the Step 5 launch command BEFORE any install side effect ----
1925
- // `spawnCommand` embeds the whole INSTALL_BRIDGE_AGENT_PROMPT and is multiple KB
1926
- // far past what osascript can type into a Terminal/iTerm tab, which is why the
1927
- // spawn silently delivered a truncated line. It must travel via a launch script.
1928
- //
1929
- // This runs HERE, before the scaffold/mint/credential/config writes, because a
1930
- // command that cannot be launched safely is a whole-run failure, not a Step 5
1931
- // warning: the deterministic setup would otherwise complete and leave the user
1932
- // in the "configured but never configured by the agent" state the warning below
1933
- // explicitly calls out. Failing first means nothing is half-done.
1934
- const materialized = await materializeWorkerLaunchCommand(deps.startTicketsDeps, "install", spawnCommand);
1935
- if (!materialized.ok) {
1936
- errorLog(`Error: ${materialized.error}`);
1937
- return 1;
2288
+ // ---- Resolved-empty selection: a handled no-op (exit 0), never a Claude fallback ----
2289
+ // AC-3: with zero configured tools there is nothing to write and no session to open.
2290
+ // Return BEFORE any config/credential write, materialization, prewarm, or spawn, so a
2291
+ // bare `--tools=` (or an EOF-resolved picker) cleanly does nothing but guide a re-run.
2292
+ if (planLaunch.kind === "manual" && planLaunch.reason === "empty-selection") {
2293
+ // Explicit empty selection: no labels to name, so the generic fallback is
2294
+ // intentional here pass [] rather than `labelsForPlatforms(selectedPlatforms)`.
2295
+ log(buildManualInstallBridgeContinuation("empty-selection", []));
2296
+ return 0;
1938
2297
  }
1939
- const launchCommand = materialized.command;
1940
- // Independent final guard on the line ACTUALLY handed to the terminal not on
1941
- // the original command. It catches the two ways a "successful" materialization
1942
- // can still be unlaunchable: no writer seam (inline command preserved verbatim),
1943
- // and a runner whose script path is unexpectedly long.
1944
- if (Buffer.byteLength(launchCommand, "utf8") >= MAX_TERMINAL_COMMAND_BYTES) {
1945
- errorLog("Error: the agent session command is too long to send to the terminal safely. " +
1946
- "Check that the system temporary directory is writable so the launch script can be used.");
1947
- return 1;
2298
+ // ---- Finalize the launch agent for spawn-capable decisions ----
2299
+ // choose-one: pick exactly one via the numbered chooser (TTY only); a non-TTY run or
2300
+ // a failed/aborted chooser degrades to the manual continuation (finalAgentName stays
2301
+ // null). This runs AFTER the multi-select readline closed and BEFORE the GitHub /
2302
+ // consent flows, so no two stdin readers ever overlap.
2303
+ let finalAgentName = null;
2304
+ let finalSpawnCommand = null;
2305
+ if (planLaunch.kind === "spawn") {
2306
+ finalAgentName = planLaunch.agent;
2307
+ finalSpawnCommand = planLaunch.spawnCommand;
2308
+ }
2309
+ else if (planLaunch.kind === "choose-one") {
2310
+ const chosen = await chooseInstallBridgeLaunchAgent(planLaunch.agents, deps);
2311
+ if (chosen) {
2312
+ const spec = resolveAgentSpec(chosen);
2313
+ if (!spec) {
2314
+ errorLog(`Error: no launch agent is registered for '${chosen}'.`);
2315
+ return 1;
2316
+ }
2317
+ finalAgentName = chosen;
2318
+ finalSpawnCommand = deps.buildShellCommand(spec, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform);
2319
+ }
2320
+ // chosen === null → non-TTY / unavailable / invalid → manual continuation below.
2321
+ }
2322
+ // planLaunch.kind === "manual" (no-launchable) → finalAgentName stays null.
2323
+ // ---- Materialize the Step 5 launch command + kick off the pre-warm — ONLY for a
2324
+ // spawn-capable path. A manual/no-launchable/chooser-declined path opens no session,
2325
+ // so it neither materializes a launch script nor warms a bucket it will never use.
2326
+ //
2327
+ // Materialization runs HERE, before the scaffold/mint/credential/config writes,
2328
+ // because a command that cannot be launched safely is a whole-run failure, not a
2329
+ // Step 5 warning: the deterministic setup would otherwise complete and leave the user
2330
+ // in the "configured but never configured by the agent" state. Failing first means
2331
+ // nothing is half-done. The @VERSION-pinned launcher lives in a DIFFERENT _npx bucket
2332
+ // than the @latest bucket this invocation warmed, so the pre-warm (fire-and-forget,
2333
+ // strictly fail-open) overlaps the ping/exchange/scaffold/writes and is awaited just
2334
+ // before the consent-gated spawn.
2335
+ let launchCommand = null;
2336
+ let prewarmPromise = null;
2337
+ if (finalAgentName && finalSpawnCommand) {
2338
+ const materialized = await materializeWorkerLaunchCommand(deps.startTicketsDeps, "install", finalSpawnCommand);
2339
+ if (!materialized.ok) {
2340
+ errorLog(`Error: ${materialized.error}`);
2341
+ return 1;
2342
+ }
2343
+ launchCommand = materialized.command;
2344
+ // Independent final guard on the line ACTUALLY handed to the terminal — not on the
2345
+ // original command. It catches the two ways a "successful" materialization can still
2346
+ // be unlaunchable: no writer seam (inline command preserved verbatim), and a runner
2347
+ // whose script path is unexpectedly long.
2348
+ if (Buffer.byteLength(launchCommand, "utf8") >= MAX_TERMINAL_COMMAND_BYTES) {
2349
+ errorLog("Error: the agent session command is too long to send to the terminal safely. " +
2350
+ "Check that the system temporary directory is writable so the launch script can be used.");
2351
+ return 1;
2352
+ }
2353
+ log(" pre-warming the version-pinned launcher bucket (in the background)…");
2354
+ prewarmPromise = deps.spawnPrewarm("npx", buildPrewarmArgs(), deps.env);
1948
2355
  }
1949
- // ---- Step 3b (kickoff) — pre-warm the @${VERSION}-pinned _npx bucket ----
1950
- // The @${VERSION}-pinned launcher written below lives in a DIFFERENT _npx bucket
1951
- // than the @latest bucket this `npx … install-bridge` invocation warmed, so the
1952
- // first real MCP launch would otherwise pay a cold install inline (which can
1953
- // exceed the client's connect deadline). Nothing before the Step 5 terminal spawn
1954
- // depends on the warmed bucket — only the spawned session's first MCP launch does
1955
- // — so START the warm HERE (fire-and-forget) and AWAIT it just before that spawn.
1956
- // Its multi-second `npx` resolve then overlaps the network round-trips (ping /
1957
- // exchange), the scaffold, and the config/credential writes instead of adding to
1958
- // them serially. Strictly fail-open: the default impl always RESOLVES (never
1959
- // rejects), so a later abort-return that skips the await leaves no unhandled
1960
- // rejection. Positioned AFTER the launch-command guards above so an aborted run
1961
- // that never reaches an install side effect also never spawns the pre-warm.
1962
- log(" pre-warming the version-pinned launcher bucket (in the background)…");
1963
- const prewarmPromise = deps.spawnPrewarm("npx", buildPrewarmArgs(), deps.env);
1964
2356
  const credentialWriteDeps = {
1965
2357
  env: deps.env,
1966
2358
  homedir: deps.homedir,
@@ -2307,47 +2699,70 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2307
2699
  // spawn would put two prompts on the same terminal at once.
2308
2700
  await offerGithubConnection(repoName, deps, log);
2309
2701
  // ---- Step 3b (await) — settle the background pre-warm before the session opens ----
2310
- // The warm was kicked off right after the launch-command guards (above); await it
2311
- // HERE so the bucket is settled before the spawned session's first MCP launch, but
2312
- // AFTER all the work it overlapped with. Strictly fail-open the exit code is
2313
- // never affected, mirroring the original inline Step 3b behavior.
2314
- const prewarm = await prewarmPromise;
2315
- if (prewarm.ok) {
2316
- log(" launcher bucket warmed (the first MCP launch will not pay a cold install).");
2317
- log(` ${MCP_TIMEOUT_GUIDANCE}`);
2702
+ // The warm was kicked off (only on a spawn-capable path) right after the
2703
+ // launch-command guards; await it HERE so the bucket is settled before the spawned
2704
+ // session's first MCP launch, but AFTER all the work it overlapped with — and BEFORE
2705
+ // the consent prompt, so no spawn ever races an in-flight warm. Strictly fail-open —
2706
+ // the exit code is never affected. Skipped entirely when no session will be opened.
2707
+ if (prewarmPromise) {
2708
+ const prewarm = await prewarmPromise;
2709
+ if (prewarm.ok) {
2710
+ log(" launcher bucket warmed (the first MCP launch will not pay a cold install).");
2711
+ log(` ${MCP_TIMEOUT_GUIDANCE}`);
2712
+ }
2713
+ else {
2714
+ // Escalate to a stronger, still secret-free warning when pre-warm didn't
2715
+ // complete cleanly — but the install itself still succeeds (exit unaffected).
2716
+ errorLog(`Warning: could not pre-warm the version-pinned launcher bucket${prewarm.warning ? ` (${prewarm.warning})` : ""}. ` +
2717
+ `The first MCP launch may pay a one-time cold install and could be slow. ${MCP_TIMEOUT_GUIDANCE}`);
2718
+ }
2318
2719
  }
2319
- else {
2320
- // Escalate to a stronger, still secret-free warning when pre-warm didn't
2321
- // complete cleanly but the install itself still succeeds (exit unaffected).
2322
- errorLog(`Warning: could not pre-warm the version-pinned launcher bucket${prewarm.warning ? ` (${prewarm.warning})` : ""}. ` +
2323
- `The first MCP launch may pay a one-time cold install and could be slow. ${MCP_TIMEOUT_GUIDANCE}`);
2324
- }
2325
- // ---- Step 5 — spawn a fresh agent session for the agentic remainder ----
2326
- log(`Step 5/5 opening a ${agent.name} session for /install-bridge configuration + capability report…`);
2327
- const terminal = detectTerminal(undefined, deps.env);
2328
- // Only the validated short runner reaches the terminal never the inline prompt.
2329
- const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, launchCommand, {
2330
- key: "install",
2331
- worktreePath: deps.cwd,
2332
- });
2333
- if (!spawnResult.ok) {
2334
- // Steps 1–4 (scaffold, config, connectivity-verified, credential persist) all
2335
- // succeeded and are durable; only the best-effort Step 5 tab spawn failed. The
2336
- // deterministic setup is complete, so this is a non-fatal warning (exit 0) — the
2337
- // user can run the agentic remainder by hand. Mirrors start-tickets treating a
2338
- // spawn failure as non-fatal to the run.
2339
- errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}). ` +
2340
- "The project is NOT configured yet run /install-bridge manually in this project to derive " +
2341
- "and apply the config fields and see the capability report, then choose whether to run " +
2342
- "/parse-repository to index the repository.");
2720
+ // ---- Step 5 — consent-gate, then spawn the selected tool (or print continuation) ----
2721
+ // AC-7: for a spawn-capable path, ask Y/N first (`requestInstallBridgeLaunchConsent`
2722
+ // resolves to no-spawn without prompting on a non-TTY run). A decline, a no-launchable
2723
+ // selection, a non-TTY run, or a declined/aborted chooser all skip the spawn and fall
2724
+ // through to the shared /install-bridge continuation + limited-tools notice below so
2725
+ // every no-session outcome gets identical, accurate remediation.
2726
+ if (finalAgentName && launchCommand) {
2727
+ const consent = await requestInstallBridgeLaunchConsent(toolLabelForLaunchAgent(finalAgentName), deps);
2728
+ if (consent === "spawn") {
2729
+ log(`Step 5/5 opening a ${finalAgentName} session for /install-bridge configuration + concise capability report…`);
2730
+ const terminal = detectTerminal(undefined, deps.env);
2731
+ // Only the validated short runner reaches the terminal — never the inline prompt.
2732
+ // The branded "Bridge Install" title labels the tab/badge instead of "install
2733
+ // Implementation" (AC-6).
2734
+ const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, launchCommand, {
2735
+ key: "install",
2736
+ worktreePath: deps.cwd,
2737
+ title: "Bridge Install",
2738
+ });
2739
+ if (!spawnResult.ok) {
2740
+ // Steps 1–4 (scaffold, config, connectivity-verified, credential persist) all
2741
+ // succeeded and are durable; only the best-effort Step 5 tab spawn failed. The
2742
+ // deterministic setup is complete, so this is a non-fatal warning (exit 0), with
2743
+ // the same shared continuation so the remediation is consistent.
2744
+ errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}).`);
2745
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2746
+ return 0;
2747
+ }
2748
+ log("");
2749
+ log(`install-bridge setup steps complete. A fresh ${finalAgentName} session is now applying ` +
2750
+ "configuration, presenting the concise capability report, and recommending /learn-repository.");
2751
+ log("NOTE: the install is not finished until that session's apply reports applied fields — " +
2752
+ "it will pause to ask you to approve the project description. Indexing starts automatically " +
2753
+ "once the repository reaches full parse readiness — there is no indexing question to answer. " +
2754
+ "Verify afterwards on the project's Get Started page (install status panel) or via the " +
2755
+ "session's 'Applied N of M' summary.");
2756
+ return 0;
2757
+ }
2758
+ // Declined (or non-TTY suppression): the deterministic setup is complete and durable.
2759
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2343
2760
  return 0;
2344
2761
  }
2345
- log("");
2346
- log(`install-bridge setup steps complete. A fresh ${agent.name} session is now applying ` +
2347
- "configuration, presenting the capability report, and ending with one indexing-consent question.");
2348
- log("NOTE: the install is not finished until that session's apply reports applied fields — " +
2349
- "it will pause to ask you to approve the project description, and it will close by asking " +
2350
- "'[Y/n] Index repository now?'. Verify afterwards on the project's Get Started page " +
2351
- "(install status panel) or via the session's 'Applied N of M' summary.");
2762
+ // No launchable session (a no-launchable selection, or a non-TTY / declined chooser):
2763
+ // finalAgentName is null, so nothing was materialized. The configured hosts are
2764
+ // durable — print the shared continuation + limited-tools note (never the
2765
+ // empty-selection variant, which already returned above).
2766
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2352
2767
  return 0;
2353
2768
  }