@bridge_gpt/mcp-server 0.2.28 → 0.2.30

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.
@@ -57,10 +57,23 @@
57
57
  * repository prompt asks for a new project name rather than an existing
58
58
  * registration (see `RepoNamePromptMode`).
59
59
  *
60
- * BOOTSTRAP-INVITE MODE (BAPI-606) — one of the two exceptions to "this command
61
- * consumes a key, it does not create one". With `--invite` (or `BAPI_INVITE`) there is no
62
- * API key yet, so the pre-flight ping of Step 2 CANNOT be made: the exchange is
63
- * 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:
64
77
  *
65
78
  * resolve bootstrap-invite token (no-echo prompt by DEFAULT — the delivered
66
79
  * one-liner is secret-free) → resolve repo name → scaffold → generate
@@ -137,18 +150,27 @@ export function buildPrewarmCommandPreview() {
137
150
  * (`maybe_auto_parse_if_ready`), with no prompt on any path.
138
151
  */
139
152
  export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command in the install-spawn context (tell the command it is running " +
140
- "in the install-spawn context so it SKIPS its Stage 8, Stage 9, and Stage 10 offers — this " +
141
- "session's only closing interaction is the concise capability report and learn recommendation " +
142
- "below). " +
143
- "Complete the command's read-after-write concise capability report first: the single 'What Bridge " +
153
+ "in the install-spawn context so it SKIPS its Stage 8, Stage 9, and Stage 10 offers). " +
154
+ "The command chooses its own mode from the install manifest's `configured` flag do NOT force a " +
155
+ "mode: when `configured` is false it runs its fresh-configuration flow, and when `configured` is " +
156
+ "true it runs JOIN MODE. In JOIN MODE the command must NOT derive, approve, apply, or offer any " +
157
+ "configuration and applies zero fields for any caller; a joining member's only closing interaction " +
158
+ "is the welcome plus the concise capability report (no /learn-repository prompt for a member). " +
159
+ "Complete the command's concise capability report: the single 'What Bridge " +
144
160
  "can help with' section, rendered exactly as the server's concise_tool_capabilities field gives it " +
145
161
  "(Regularly useful, then Occasionally useful, each tool's display_name only, plus each tier's " +
146
162
  "server-computed '+N more' where non-zero). Do not render the obsolete five-section report, and do " +
147
163
  "not locally filter, count, or fall back to the complete tool_capabilities catalog. " +
164
+ "The install-spawn skip set is exactly Stage 8, Stage 9, and Stage 10. The command-owned Stage 11 " +
165
+ "(invite teammates) is NOT in that skip set: it is independently gated and may run only when the " +
166
+ "caller's role is admin AND customer_type is b2b AND an interactive response is available; " +
167
+ "otherwise it is skipped silently. If Stage 11 mints a teammate key, show that plaintext key " +
168
+ "exactly once and never repeat it in any summary, retry, or diagnostic. " +
148
169
  "Do NOT ask any indexing question, call parse_repository, mention /parse-repository, or claim " +
149
170
  "indexing has started or is pending — indexing is decided entirely by the server-side readiness " +
150
171
  "funnel with no visibility from this session, so say nothing about it at all. " +
151
- "After the capability report, recommend /learn-repository as the next step: explain briefly that it " +
172
+ "After the capability report on the fresh-configuration path, recommend /learn-repository as the " +
173
+ "next step: explain briefly that it " +
152
174
  "learns repository-specific architecture, review, testing, correctness, and validation-manual " +
153
175
  "configuration by researching the actual codebase. Do NOT run /learn-repository yourself — only " +
154
176
  "recommend it; running it is the human's next explicit invocation. " +
@@ -156,9 +178,11 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command
156
178
  "integration's own configure_in pointer, verbatim. The pointer is per-integration and is NOT " +
157
179
  "always the setup UI: GitHub's is a terminal command (connect-github), while Jira, SFCC, and " +
158
180
  "Bitbucket point at the setup UI. Follow whatever the report says rather than assuming. " +
159
- "End with an explicit summary line stating how many config fields the apply_install_manifest call " +
181
+ "On the fresh-configuration path, end with an explicit summary line stating how many config fields " +
182
+ "the apply_install_manifest call " +
160
183
  "applied (e.g. 'Applied 8 of 9 derived fields') — if 0 fields were applied, say so loudly and " +
161
- "explain what is still pending.";
184
+ "explain what is still pending. In JOIN MODE there is no apply — state instead that the project " +
185
+ "was already configured and zero changes were made, without a fabricated applied count.";
162
186
  /** Default base URL when `BAPI_BASE_URL` is unset (mirrors index.ts). */
163
187
  export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
164
188
  /** Default docs dir when `BAPI_DOCS_DIR` is unset (mirrors index.ts). */
@@ -172,10 +196,13 @@ export function getInstallBridgeUsage() {
172
196
  "One-command Bridge API project bootstrap. Scaffolds the project, writes the",
173
197
  "per-host MCP config with your credentials, verifies connectivity, persists the",
174
198
  "routing credential, then — on a TTY, only after a Y/N consent prompt — opens a",
175
- "fresh session in your selected tool to derive the remaining config, present a",
176
- "concise capability report, and recommend /learn-repository. Indexing is never",
177
- "asked about it starts automatically once the repository reaches full parse",
178
- "readiness.",
199
+ "fresh session in your selected tool. If the project is not yet configured it",
200
+ "derives the remaining config, presents a concise capability report, and",
201
+ "recommends /learn-repository. If the project is already configured it instead",
202
+ "joins you to it without proposing or applying any changes and just shows the",
203
+ "concise capability report (a b2b admin is additionally offered a teammate-invite",
204
+ "step). So not every run applies config fields. Indexing is never asked about —",
205
+ "it starts automatically once the repository reaches full parse readiness.",
179
206
  "",
180
207
  "Run it bare — `install-bridge` with no flags — in a terminal and it asks",
181
208
  `\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT.trim()}\` first. Answer yes (or press Enter) for the`,
@@ -185,11 +212,15 @@ export function getInstallBridgeUsage() {
185
212
  "an interactive terminal keeps the existing deterministic behavior and no prompt.",
186
213
  "",
187
214
  "Inputs (the only two irreducible ones):",
188
- " --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
189
- " then an interactive (no-echo) prompt. Generate one in the",
190
- " Bridge API web UI Security page — this command consumes a",
191
- " key, it does not create one (--email and --invite are the",
192
- " exceptions: they CREATE the project and its first admin key).",
215
+ " --api-key <key> Bridge API key OR bootstrap invite. Falls back to the",
216
+ " BAPI_API_KEY env var, then an interactive (no-echo) prompt.",
217
+ " Generate a key in the Bridge API web UI Security page — this",
218
+ " command consumes a key, it does not create one. A value",
219
+ " detected as a bootstrap invite (bapi_inv_…), from any of the",
220
+ " three sources above, is instead redeemed to CREATE a new",
221
+ " project and its first admin key — the same as --invite —",
222
+ " so it skips repository lookup entirely (--email and --invite",
223
+ " remain the explicit, preferred entry points for a new project).",
193
224
  " NEVER printed or logged.",
194
225
  " --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
195
226
  " priority and short-circuit before any network call. When",
@@ -244,10 +275,12 @@ export function getInstallBridgeUsage() {
244
275
  ` ${HOST_PLATFORM_ORDER.join(", ")}.`,
245
276
  " Both --tools=claude-code,codex and",
246
277
  " --tools claude-code,codex are accepted. On an",
247
- " interactive terminal WITHOUT this flag the",
248
- " checklist starts EMPTY no tool is pre-selected",
249
- " (not even Claude Code) and you must select at",
250
- " least one. A non-interactive (non-TTY) run without",
278
+ " interactive terminal WITHOUT this flag you get a",
279
+ " numbered picker: enter one or more tool numbers,",
280
+ " comma-separated (e.g. 1,3), and press Enter once ",
281
+ " no tool is pre-selected (not even Claude Code) and",
282
+ " you must select at least one. A non-interactive",
283
+ " (non-TTY) run without",
251
284
  " --tools writes the legacy automatic set (Claude",
252
285
  " Code plus any detected Cursor / Copilot VS Code)",
253
286
  " and never opens an agent terminal (it prints",
@@ -710,8 +743,16 @@ export function createDefaultInstallBridgeDeps() {
710
743
  };
711
744
  }
712
745
  /**
713
- * Resolve the API key: `--api-key` → `BAPI_API_KEY` env → interactive no-echo
714
- * prompt. Fails (secret-free) when none is available and stdin is non-interactive.
746
+ * Resolve the ordinary credential entry: `--api-key` → `BAPI_API_KEY` env →
747
+ * interactive no-echo prompt. Fails (secret-free) when none is available and
748
+ * stdin is non-interactive.
749
+ *
750
+ * Despite the name, this is CREDENTIAL-AGNOSTIC (BAPI-661): the returned value may
751
+ * be a full Bridge API key or a bootstrap-invite token (`bapi_inv_…`) — the caller
752
+ * classifies it with {@link classifyEnteredCredential} immediately after this
753
+ * resolves and routes an invite into the existing redemption workflow. This
754
+ * function itself performs no classification; it only resolves which raw string
755
+ * was entered.
715
756
  */
716
757
  export async function resolveApiKey(options, deps) {
717
758
  if (typeof options.apiKey === "string" && options.apiKey.trim().length > 0) {
@@ -722,16 +763,20 @@ export async function resolveApiKey(options, deps) {
722
763
  return { ok: true, value: fromEnv.trim() };
723
764
  }
724
765
  if (deps.isTTY && deps.promptSecret) {
725
- const entered = (await deps.promptSecret("Bridge API key (input hidden): ")).trim();
766
+ const entered = (await deps.promptSecret("Bridge API key or invite (input hidden): ")).trim();
726
767
  if (entered.length > 0) {
727
768
  return { ok: true, value: entered };
728
769
  }
729
- return { ok: false, error: "No API key entered." };
770
+ return {
771
+ ok: false,
772
+ error: "No Bridge API key or invite entered. Pass --api-key, set the BAPI_API_KEY environment " +
773
+ "variable, or try the hidden prompt again.",
774
+ };
730
775
  }
731
776
  return {
732
777
  ok: false,
733
- error: "An API key is required. Pass --api-key or set the BAPI_API_KEY environment variable " +
734
- "(no interactive terminal is available to prompt for it).",
778
+ error: "A Bridge API key or invite is required. Pass --api-key or set the BAPI_API_KEY " +
779
+ "environment variable (no interactive terminal is available to prompt for it).",
735
780
  };
736
781
  }
737
782
  /**
@@ -836,8 +881,8 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
836
881
  return { kind: "need-key", method: "self-serve" };
837
882
  return { kind: "have-key" };
838
883
  }
839
- /** The exact visible text of the bare-TTY onboarding selector (BAPI-626). */
840
- export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key? [Y/n] ";
884
+ /** The exact visible text of the bare-TTY onboarding selector (BAPI-626, BAPI-661). */
885
+ export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key or invite? [Y/n] ";
841
886
  /**
842
887
  * Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
843
888
  *
@@ -980,87 +1025,78 @@ export async function resolveRepoName(options, deps, mode = "existing-registrati
980
1025
  /** A per-host MCP config target (mirrors runInit's configTargets shape). */
981
1026
  /** Stable wording of the interactive tool-selection prompt (BAPI-635). */
982
1027
  export const INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT = "Which AI coding tools do you use on this project?";
1028
+ /** A one-based selection token is exactly one or more digits, no sign/decimal/leading-zero. */
1029
+ const SELECTION_TOKEN_PATTERN = /^[1-9][0-9]*$/;
983
1030
  /**
984
- * Interactive numbered multi-select prompt on stderr (TTY only). Displays a
985
- * legend and usage line plus each option with a checked/unchecked marker seeded
986
- * from `defaults`, accepts a comma-separated list of numbers that TOGGLE the rows
987
- * and RE-PROMPT (acceptance is a separate bare Enter), and accepts only a NON-EMPTY
988
- * selection on a bare Enter — an empty one prints "Select at least one tool." and
989
- * re-prompts (AC-1/AC-3). Reprints on an invalid token rather than corrupting
990
- * state. Resolves safely (to the current set, including an empty one) on EOF /
991
- * synchronous close so a readline regression can never hang or discard the answer.
1031
+ * Interactive single-shot numbered picker on stderr (TTY only). Renders the
1032
+ * heading and each option exactly once, then accepts ONE strict
1033
+ * comma-separated line of one-based numbers and resolves immediately — there
1034
+ * is no preselected/toggle state and no separate confirmation step (BAPI-663).
1035
+ *
1036
+ * A blank line or a line containing any invalid token (non-numeric, zero,
1037
+ * signed, leading-zero, decimal, out-of-range) is rejected atomically the
1038
+ * whole line is discarded, one error line is printed, and the picker
1039
+ * re-prompts without re-rendering the option list. A valid line is
1040
+ * deduplicated and returned in `options` order regardless of entry order.
1041
+ *
1042
+ * Resolves to `[]` on EOF / synchronous close before any line has been
1043
+ * committed, so a readline regression can never hang. Returns `[]`
1044
+ * immediately, without creating a readline interface, when `options` is
1045
+ * empty.
992
1046
  */
993
- export function promptMultiSelectViaReadline(promptText, options, defaults, input = process.stdin, output = process.stderr) {
1047
+ export function promptMultiSelectViaReadline(promptText, options, input = process.stdin, output = process.stderr) {
1048
+ if (options.length === 0)
1049
+ return Promise.resolve([]);
994
1050
  return new Promise((resolve) => {
995
- const selected = new Set(defaults);
996
- const render = () => {
997
- output.write(`\n${promptText}\n`);
998
- output.write("[x] = selected · [ ] = not selected\n");
999
- options.forEach((opt, idx) => {
1000
- const mark = selected.has(opt.id) ? "[x]" : "[ ]";
1001
- output.write(` ${idx + 1}. ${mark} ${opt.label}\n`);
1002
- });
1003
- output.write("Type numbers to toggle, e.g. 1,3 — then Enter. Enter with ≥1 selected accepts.\n");
1004
- output.write("Enter numbers to toggle (comma-separated), or press Enter to accept: ");
1005
- };
1006
1051
  const rl = readline.createInterface({ input, output });
1007
- let answered = false;
1008
- const finish = () => {
1009
- answered = true;
1052
+ let settled = false;
1053
+ const finish = (result) => {
1054
+ if (settled)
1055
+ return;
1056
+ settled = true;
1010
1057
  rl.close();
1011
- resolve(options.filter((o) => selected.has(o.id)).map((o) => o.id));
1058
+ resolve(result);
1012
1059
  };
1013
1060
  // EOF / synchronous close must resolve rather than deadlock the top-level
1014
- // await; `answered` guards the synchronous close from discarding a real answer.
1015
- rl.on("close", () => {
1016
- if (!answered)
1017
- resolve(options.filter((o) => selected.has(o.id)).map((o) => o.id));
1061
+ // await; the `settled` guard stops a synchronous `close` (fired by `rl.close()`
1062
+ // above) from replacing an already-committed answer with the EOF fallback.
1063
+ rl.on("close", () => finish([]));
1064
+ output.write(`\n${promptText}\n`);
1065
+ options.forEach((opt, idx) => {
1066
+ output.write(` ${idx + 1}. ${opt.label}\n`);
1018
1067
  });
1019
- const ask = () => {
1020
- render();
1021
- rl.question("", (answer) => {
1068
+ output.write("Enter the number(s) of the tools you use (e.g. 1,3), then Enter.\n");
1069
+ const prompt = () => {
1070
+ rl.question("> ", (answer) => {
1022
1071
  const trimmed = answer.trim();
1023
1072
  if (trimmed.length === 0) {
1024
- // AC-3: a bare Enter accepts ONLY a non-empty selection; an empty one
1025
- // re-prompts rather than resolving to nothing. EOF/close (handled above)
1026
- // still resolves the current — possibly empty — set so input can never hang.
1027
- if (selected.size === 0) {
1028
- output.write("Select at least one tool.\n");
1029
- ask();
1030
- return;
1031
- }
1032
- finish();
1073
+ output.write("Select at least one tool.\n");
1074
+ prompt();
1033
1075
  return;
1034
1076
  }
1035
1077
  const tokens = trimmed.split(",").map((t) => t.trim());
1036
- const nums = [];
1037
- let bad = false;
1078
+ const indices = [];
1038
1079
  for (const tok of tokens) {
1080
+ if (!SELECTION_TOKEN_PATTERN.test(tok)) {
1081
+ output.write(`Invalid selection. Enter numbers between 1 and ${options.length}.\n`);
1082
+ prompt();
1083
+ return;
1084
+ }
1039
1085
  const n = Number(tok);
1040
- if (!Number.isInteger(n) || n < 1 || n > options.length) {
1041
- bad = true;
1042
- break;
1086
+ if (n < 1 || n > options.length) {
1087
+ output.write(`Invalid selection. Enter numbers between 1 and ${options.length}.\n`);
1088
+ prompt();
1089
+ return;
1043
1090
  }
1044
- nums.push(n);
1045
- }
1046
- if (bad) {
1047
- output.write(`Invalid selection. Enter numbers between 1 and ${options.length}.\n`);
1048
- ask();
1049
- return;
1050
- }
1051
- for (const n of nums) {
1052
- const opt = options[n - 1];
1053
- if (selected.has(opt.id))
1054
- selected.delete(opt.id);
1055
- else
1056
- selected.add(opt.id);
1091
+ indices.push(n);
1057
1092
  }
1058
- // Toggling re-renders the updated checklist and re-prompts; acceptance is a
1059
- // separate, deliberate bare Enter (AC-1/AC-3) rather than an implicit accept.
1060
- ask();
1093
+ const chosen = new Set(indices);
1094
+ finish(options
1095
+ .filter((_, idx) => chosen.has(idx + 1))
1096
+ .map((o) => o.id));
1061
1097
  });
1062
1098
  };
1063
- ask();
1099
+ prompt();
1064
1100
  });
1065
1101
  }
1066
1102
  /**
@@ -1080,13 +1116,11 @@ export async function resolveSelectedHostPlatforms(deps, options) {
1080
1116
  }
1081
1117
  const ctx = await buildDetectionContext(deps);
1082
1118
  const detected = new Set(detectDefaultPlatforms(ctx));
1083
- // 2. Interactive multi-select on a TTY. AC-2: no row is pre-selected — the
1084
- // picker seeds an EMPTY default set (dropping the former forced Claude Code
1085
- // inclusion and detected-platform seeding) and requires an explicit selection.
1119
+ // 2. Interactive single-shot numbered picker on a TTY. No row is pre-selected
1120
+ // — the picker has no default state and requires an explicit selection.
1086
1121
  if (deps.isTTY && deps.promptMultiSelect) {
1087
1122
  const optionList = allHostTargets().map((t) => ({ id: t.id, label: t.label }));
1088
- const defaults = [];
1089
- const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList, defaults);
1123
+ const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList);
1090
1124
  return HOST_PLATFORM_ORDER.filter((id) => chosen.includes(id));
1091
1125
  }
1092
1126
  // 3. Legacy non-TTY automatic set: Claude + detected Cursor / Copilot VS Code.
@@ -1195,20 +1229,41 @@ export async function chooseInstallBridgeLaunchAgent(agents, deps) {
1195
1229
  * completes.
1196
1230
  *
1197
1231
  * Condition-specific warning prefixes are added by the caller, never inside here.
1232
+ *
1233
+ * `toolLabels` (BAPI-661) names the actually-selected platform(s) in the
1234
+ * `"configured"` message so "open it in an AI coding tool" becomes "open it in
1235
+ * GitHub Copilot (VS Code)" when that is what was selected — an empty array (the
1236
+ * explicit empty-selection call site, or any caller with nothing to name) falls
1237
+ * back to the prior generic "an AI coding tool" wording unchanged.
1198
1238
  */
1199
- export function buildManualInstallBridgeContinuation(kind) {
1239
+ export function buildManualInstallBridgeContinuation(kind, toolLabels) {
1200
1240
  if (kind === "empty-selection") {
1201
1241
  return [
1202
1242
  "No AI coding tools were configured, so nothing was set up for this project.",
1203
1243
  "Re-run install-bridge and select at least one tool to configure it.",
1204
1244
  ].join("\n");
1205
1245
  }
1246
+ const toolPhrase = formatToolLabelPhrase(toolLabels);
1206
1247
  return [
1207
- "To finish configuring this project, open it in an AI coding tool that has the",
1248
+ `To finish configuring this project, open it in ${toolPhrase} that has the`,
1208
1249
  "Bridge MCP server configured and run /install-bridge.",
1209
1250
  "Until the project is configured, your Bridge MCP tools stay limited.",
1210
1251
  ].join("\n");
1211
1252
  }
1253
+ /**
1254
+ * Format 0, 1, or many tool labels into a readable phrase for the continuation
1255
+ * message. Empty falls back to the generic "an AI coding tool"; one label is used
1256
+ * bare; two or more join with a serial (Oxford) comma and "and".
1257
+ */
1258
+ function formatToolLabelPhrase(labels) {
1259
+ if (labels.length === 0)
1260
+ return "an AI coding tool";
1261
+ if (labels.length === 1)
1262
+ return labels[0];
1263
+ if (labels.length === 2)
1264
+ return `${labels[0]} and ${labels[1]}`;
1265
+ return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`;
1266
+ }
1212
1267
  /** The exact consent-prompt prefix, shared with tests so the copy cannot drift. */
1213
1268
  export const INSTALL_BRIDGE_LAUNCH_CONSENT_PROMPT_PREFIX = "Bridge can configure and set up this project for you automatically. Open a ";
1214
1269
  /**
@@ -1287,6 +1342,17 @@ function hostConfigTargetsForPlatforms(platforms) {
1287
1342
  .filter((t) => t.scope === "project" && t.format === "json")
1288
1343
  .map((t) => ({ relPath: t.relPath, topLevelKey: t.topLevelKey }));
1289
1344
  }
1345
+ /**
1346
+ * Derive the human-facing labels for a set of selected platforms, in registry
1347
+ * (`HOST_PLATFORM_ORDER`) order rather than the caller's input order, so a
1348
+ * continuation message names selected tools deterministically (BAPI-661).
1349
+ * Deduplicates by platform ID and reads every label from `MCP_HOST_TARGETS` —
1350
+ * never a second, hand-maintained label map.
1351
+ */
1352
+ export function labelsForPlatforms(platforms) {
1353
+ const set = new Set(platforms);
1354
+ return HOST_PLATFORM_ORDER.filter((id) => set.has(id)).map((id) => MCP_HOST_TARGETS[id].label);
1355
+ }
1290
1356
  /** Resolve which project-local host configs to write, mirroring runInit detection. */
1291
1357
  async function resolveHostConfigTargets(deps) {
1292
1358
  const targets = [
@@ -1447,9 +1513,19 @@ export function buildPingUrl(baseUrl, repoName) {
1447
1513
  return url.toString();
1448
1514
  }
1449
1515
  /**
1450
- * Verify connectivity. Distinguishes a rejected key (401/403) from an unknown
1451
- * repo / not-found (404) where the response allows. The key is sent in the
1452
- * `X-API-Key` header and NEVER appears in any returned message.
1516
+ * Fixed, secret-free fallback for an HTTP 403 whose body carries no usable
1517
+ * `detail` (absent, malformed, unreadable, or a non-string `detail`). Deliberately
1518
+ * does NOT claim the key is invalid or expired — a 403 means the credential was
1519
+ * understood but denied access, which is a different (and often more actionable)
1520
+ * failure than an invalid/expired key.
1521
+ */
1522
+ const CONNECTIVITY_ACCESS_DENIED_FALLBACK = "The Bridge API denied access to this repository (HTTP 403). Verify the repo_name and that this " +
1523
+ "credential is authorized for it.";
1524
+ /**
1525
+ * Verify connectivity. Distinguishes a rejected/expired credential (401) from a
1526
+ * repository-specific access denial (403, BAPI-661) from an unknown repo /
1527
+ * not-found (404) where the response allows. The key is sent in the `X-API-Key`
1528
+ * header and NEVER appears in any returned message.
1453
1529
  */
1454
1530
  export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1455
1531
  const url = buildPingUrl(baseUrl, repoName);
@@ -1474,7 +1550,7 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1474
1550
  }
1475
1551
  if (resp.ok)
1476
1552
  return { ok: true };
1477
- if (resp.status === 401 || resp.status === 403) {
1553
+ if (resp.status === 401) {
1478
1554
  return {
1479
1555
  ok: false,
1480
1556
  message: `The Bridge API rejected the credential (HTTP ${resp.status}). The API key may be invalid or expired ` +
@@ -1482,6 +1558,27 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1482
1558
  "permission error.)",
1483
1559
  };
1484
1560
  }
1561
+ if (resp.status === 403) {
1562
+ // BAPI-661: a 403 means the credential was understood but access to THIS repo
1563
+ // was denied — the server's `detail` (e.g. "repository '<name>' is not
1564
+ // registered; verify the repo_name") is a safe, specific, non-secret message
1565
+ // that is far more actionable than the blanket invalid-key diagnosis 401 uses.
1566
+ // Only a nonblank string `detail` is ever surfaced; any other shape (missing
1567
+ // body, invalid JSON, a non-string/blank detail, or a body-read exception)
1568
+ // collapses to the fixed fallback so nothing unvalidated ever reaches the user.
1569
+ let detail;
1570
+ try {
1571
+ const body = (await resp.json());
1572
+ detail = body?.detail;
1573
+ }
1574
+ catch {
1575
+ return { ok: false, message: CONNECTIVITY_ACCESS_DENIED_FALLBACK };
1576
+ }
1577
+ if (typeof detail === "string" && detail.trim().length > 0) {
1578
+ return { ok: false, message: detail.trim() };
1579
+ }
1580
+ return { ok: false, message: CONNECTIVITY_ACCESS_DENIED_FALLBACK };
1581
+ }
1485
1582
  if (resp.status === 404) {
1486
1583
  return {
1487
1584
  ok: false,
@@ -1651,6 +1748,21 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
1651
1748
  * treated as a `failed` mint rather than fed into the redemption path.
1652
1749
  */
1653
1750
  export const BOOTSTRAP_INVITE_TOKEN_PREFIX = "bapi_inv_";
1751
+ /**
1752
+ * Classify a value entered through the ORDINARY credential input (`--api-key`,
1753
+ * `BAPI_API_KEY`, or the have-key hidden prompt) as a bootstrap-invite token or a
1754
+ * full API key (BAPI-661). This lets a user who was handed an invite but reaches
1755
+ * for the ordinary key input still get redeemed correctly, instead of the invite
1756
+ * being sent as an (invalid) API key to repository resolution / connectivity.
1757
+ *
1758
+ * Pure and total: trims first, then does a case-sensitive prefix check against
1759
+ * {@link BOOTSTRAP_INVITE_TOKEN_PREFIX}. Blank and every non-matching value is
1760
+ * `"api-key"` — the existing resolvers already reject a blank value, so this
1761
+ * function does not need to special-case it. Never logs or returns the value.
1762
+ */
1763
+ export function classifyEnteredCredential(value) {
1764
+ return value.trim().startsWith(BOOTSTRAP_INVITE_TOKEN_PREFIX) ? "invite" : "api-key";
1765
+ }
1654
1766
  /** The self-serve mint endpoint: `<base>/setup/bootstrap/self-serve`. No query string — ever. */
1655
1767
  export function buildSelfServeMintUrl(baseUrl) {
1656
1768
  return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve`;
@@ -1996,8 +2108,14 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1996
2108
  return 1;
1997
2109
  }
1998
2110
  const branch = branchResult.branch;
1999
- const bootstrapInviteMode = branch.kind === "need-key";
2000
- const selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
2111
+ // Mutable (BAPI-661): an ordinary have-key credential that classifies as a
2112
+ // bootstrap invite (see the have-key arm below) flips `bootstrapInviteMode` to
2113
+ // true AFTER branch selection, before repository-name resolution — everything
2114
+ // downstream that branches on these two flags then follows the existing
2115
+ // bootstrap-invite protocol automatically. Explicit `--invite`/`BAPI_INVITE` and
2116
+ // `--email`/`BAPI_SIGNUP_EMAIL` never pass through this reclassification.
2117
+ let bootstrapInviteMode = branch.kind === "need-key";
2118
+ let selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
2001
2119
  // ---- Resolve inputs (may prompt when interactive) ----
2002
2120
  // Resolve the credential/entry input first so a fully-empty non-interactive
2003
2121
  // invocation fails with the (more relevant) missing-input message before the repo
@@ -2030,7 +2148,20 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2030
2148
  errorLog(`Error: ${keyResult.error}`);
2031
2149
  return 1;
2032
2150
  }
2033
- apiKey = keyResult.value;
2151
+ // BAPI-661: an invite pasted into the ordinary credential input (flag, env, or
2152
+ // hidden prompt) is redeemed like an explicit --invite, not sent as an API key
2153
+ // to repository resolution / connectivity. Reclassifying BEFORE that
2154
+ // resolution is what lets the rest of the function reuse the existing
2155
+ // bootstrap-invite protocol verbatim.
2156
+ if (classifyEnteredCredential(keyResult.value) === "invite") {
2157
+ inviteToken = keyResult.value.trim();
2158
+ apiKey = "";
2159
+ bootstrapInviteMode = true;
2160
+ selfServeSignupMode = false;
2161
+ }
2162
+ else {
2163
+ apiKey = keyResult.value;
2164
+ }
2034
2165
  }
2035
2166
  // baseUrl is resolved BEFORE repository input because the have-key branch needs
2036
2167
  // it for the server-resolution request.
@@ -2077,7 +2208,11 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2077
2208
  else {
2078
2209
  // Feature-detect + degrade: 404 (old server), 409 (unresolved/ambiguous/
2079
2210
  // client-scoped), and network/other errors all fall back to the existing
2080
- // local resolution WITHOUT a cause-specific message or leaked detail.
2211
+ // local resolution WITHOUT a cause-specific message or leaked detail, but
2212
+ // (BAPI-661) WITH a visible signal that the fallback is happening, so a
2213
+ // guessed name is never confirmed silently as if it had been verified.
2214
+ log("Couldn't auto-resolve your repo from the key; falling back to a guessed name — confirm " +
2215
+ "it matches the setup UI.");
2081
2216
  const repoResult = await resolveRepoName(options, deps, "existing-registration");
2082
2217
  if (!repoResult.ok) {
2083
2218
  errorLog(`Error: ${repoResult.error}`);
@@ -2159,7 +2294,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2159
2294
  // Return BEFORE any config/credential write, materialization, prewarm, or spawn, so a
2160
2295
  // bare `--tools=` (or an EOF-resolved picker) cleanly does nothing but guide a re-run.
2161
2296
  if (planLaunch.kind === "manual" && planLaunch.reason === "empty-selection") {
2162
- log(buildManualInstallBridgeContinuation("empty-selection"));
2297
+ // Explicit empty selection: no labels to name, so the generic fallback is
2298
+ // intentional here — pass [] rather than `labelsForPlatforms(selectedPlatforms)`.
2299
+ log(buildManualInstallBridgeContinuation("empty-selection", []));
2163
2300
  return 0;
2164
2301
  }
2165
2302
  // ---- Finalize the launch agent for spawn-capable decisions ----
@@ -2609,7 +2746,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2609
2746
  // deterministic setup is complete, so this is a non-fatal warning (exit 0), with
2610
2747
  // the same shared continuation so the remediation is consistent.
2611
2748
  errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}).`);
2612
- log(buildManualInstallBridgeContinuation("configured"));
2749
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2613
2750
  return 0;
2614
2751
  }
2615
2752
  log("");
@@ -2623,13 +2760,13 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2623
2760
  return 0;
2624
2761
  }
2625
2762
  // Declined (or non-TTY suppression): the deterministic setup is complete and durable.
2626
- log(buildManualInstallBridgeContinuation("configured"));
2763
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2627
2764
  return 0;
2628
2765
  }
2629
2766
  // No launchable session (a no-launchable selection, or a non-TTY / declined chooser):
2630
2767
  // finalAgentName is null, so nothing was materialized. The configured hosts are
2631
2768
  // durable — print the shared continuation + limited-tools note (never the
2632
2769
  // empty-selection variant, which already returned above).
2633
- log(buildManualInstallBridgeContinuation("configured"));
2770
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2634
2771
  return 0;
2635
2772
  }