@bridge_gpt/mcp-server 0.2.25 → 0.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/init.js CHANGED
@@ -303,9 +303,13 @@ export async function runInit(cwd) {
303
303
  console.log(` Skipped (unchanged): ${skippedFiles.size}`);
304
304
  console.log(` ${dirNames}`);
305
305
  // ---- Phase 4b: Scaffold shipped documentation assets ----
306
- // The capability catalog (docs/mcp-tool-integrations.md) the /install-bridge
307
- // capability report cites for each gate's human "why". Keyed by target path
308
- // RELATIVE to the consumer project root; content is never secret.
306
+ // Every doc asset the /install-bridge capability report points a human at,
307
+ // so no pointer it prints dangles inside an installed project: the
308
+ // capability catalog (docs/mcp-tool-integrations.md) that explains each
309
+ // gate's human "why", and the SFCC integration guide
310
+ // (docs/install/sfcc-integration.md) that the report's SFCC configure_in
311
+ // pointer names by path. Keyed by target path RELATIVE to the consumer
312
+ // project root; content is never secret.
309
313
  const docsWritten = new Set();
310
314
  const docsSkipped = new Set();
311
315
  for (const [relTarget, content] of Object.entries(DOCS)) {
@@ -88,6 +88,10 @@ import readline from "readline";
88
88
  import { runInit, buildBridgeApiEntry } from "./init.js";
89
89
  import { VERSION } from "./version.generated.js";
90
90
  import { validateRepoName } from "./bridge-config.js";
91
+ import { MCP_HOST_TARGETS, HOST_PLATFORM_ORDER, allHostTargets, isHostPlatformId, detectDefaultPlatforms, } from "./mcp-host-targets.js";
92
+ import { provisionHostTarget, createDefaultVendorProcessDeps, } from "./mcp-host-config.js";
93
+ import { writeMcpInstallState } from "./mcp-install-state.js";
94
+ import { ensureGitignored as ensureGitignoredShared, } from "./git-ignore-utils.js";
91
95
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
92
96
  import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, resolveBapiCredentials, } from "./credential-store.js";
93
97
  // BAPI-631: the optional GitHub connect offer reuses the standalone command's flow and
@@ -232,6 +236,18 @@ export function getInstallBridgeUsage() {
232
236
  " THE TOKEN to your shell history and to the process list.",
233
237
  "",
234
238
  "Flags:",
239
+ " --tools <ids> Comma-separated AI-coding tools to configure,",
240
+ " bypassing the interactive picker. Accepted ids:",
241
+ ` ${HOST_PLATFORM_ORDER.join(", ")}.`,
242
+ " Both --tools=claude-code,codex and",
243
+ " --tools claude-code,codex are accepted. On an",
244
+ " interactive terminal WITHOUT this flag you are",
245
+ " asked which tools you use (Claude Code plus any",
246
+ " detected editors are pre-checked). A non-",
247
+ " interactive run without --tools writes the legacy",
248
+ " automatic set (Claude Code plus any detected",
249
+ " Cursor / Copilot VS Code). --tools= (empty) is an",
250
+ " explicit empty selection and writes nothing.",
235
251
  " --force Overwrite an existing real BAPI_API_KEY in a",
236
252
  " host config (or in the credential store) without",
237
253
  " prompting.",
@@ -271,6 +287,7 @@ export function parseInstallBridgeArgs(argv) {
271
287
  let agentName = DEFAULT_AGENT_NAME;
272
288
  let invite;
273
289
  let email;
290
+ let tools;
274
291
  // Track SUPPLIED-ness separately from the values: `--invite` is legitimately
275
292
  // valueless (prompt path) and `--api-key ""` is still a contradiction with it.
276
293
  let inviteSupplied = false;
@@ -345,6 +362,17 @@ export function parseInstallBridgeArgs(argv) {
345
362
  i = r.nextIndex;
346
363
  continue;
347
364
  }
365
+ if (arg === "--tools" || arg.startsWith("--tools=")) {
366
+ const r = readValue(arg, "--tools", i);
367
+ if ("error" in r)
368
+ return { status: "error", message: r.error };
369
+ const parsed = parseToolsSelection(r.value);
370
+ if ("error" in parsed)
371
+ return { status: "error", message: parsed.error };
372
+ tools = parsed.tools;
373
+ i = r.nextIndex;
374
+ continue;
375
+ }
348
376
  if (arg === "--agent" || arg.startsWith("--agent=")) {
349
377
  const r = readValue(arg, "--agent", i);
350
378
  if ("error" in r)
@@ -395,9 +423,36 @@ export function parseInstallBridgeArgs(argv) {
395
423
  }
396
424
  return {
397
425
  status: "ok",
398
- options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email },
426
+ options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email, tools },
399
427
  };
400
428
  }
429
+ /**
430
+ * Parse a `--tools` value into a validated platform-ID selection. A blank value
431
+ * (`--tools=`) is an EXPLICIT empty selection (`[]`), not an error. Comma-split
432
+ * IDs are trimmed, deduped in registry order, and each is validated against the
433
+ * host registry allowlist so an unvalidated string can never choose a path or
434
+ * command. Unknown IDs are rejected by name (the value is safe to echo — it is
435
+ * a platform ID, never a secret).
436
+ */
437
+ export function parseToolsSelection(value) {
438
+ const raw = value
439
+ .split(",")
440
+ .map((s) => s.trim())
441
+ .filter((s) => s.length > 0);
442
+ const seen = new Set();
443
+ for (const id of raw) {
444
+ if (!isHostPlatformId(id)) {
445
+ const allowed = HOST_PLATFORM_ORDER.join(", ");
446
+ return {
447
+ error: `Invalid --tools value: '${id}' (allowed tools: ${allowed}).`,
448
+ };
449
+ }
450
+ seen.add(id);
451
+ }
452
+ // Dedupe + deterministic registry order.
453
+ const tools = HOST_PLATFORM_ORDER.filter((id) => seen.has(id));
454
+ return { tools };
455
+ }
401
456
  /**
402
457
  * No-echo secret prompt on stderr (so it never lands in piped stdout).
403
458
  *
@@ -620,6 +675,8 @@ export function createDefaultInstallBridgeDeps() {
620
675
  randomBytes: (size) => cryptoRandomBytes(size),
621
676
  promptSecret: isTTY ? promptSecretViaReadline : undefined,
622
677
  promptLine: isTTY ? promptLineViaReadline : undefined,
678
+ promptMultiSelect: isTTY ? promptMultiSelectViaReadline : undefined,
679
+ vendor: createDefaultVendorProcessDeps(spawn),
623
680
  fetch: productionFetch,
624
681
  resolveRepoViaServer: (baseUrl, apiKey) => resolveRepoViaServer(productionFetch, baseUrl, apiKey),
625
682
  spawnPrewarm: spawnPrewarmDefault,
@@ -900,6 +957,164 @@ export async function resolveRepoName(options, deps, mode = "existing-registrati
900
957
  }
901
958
  return { ok: false, error: "No repo name provided." };
902
959
  }
960
+ // ---------------------------------------------------------------------------
961
+ // Per-host config write (Step 2)
962
+ // ---------------------------------------------------------------------------
963
+ /** A per-host MCP config target (mirrors runInit's configTargets shape). */
964
+ /** Stable wording of the interactive tool-selection prompt (BAPI-635). */
965
+ export const INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT = "Which AI coding tools do you use on this project?";
966
+ /**
967
+ * Interactive numbered multi-select prompt on stderr (TTY only). Displays each
968
+ * option with a checked/unchecked marker seeded from `defaults`, accepts a
969
+ * comma-separated list of numbers to TOGGLE, and accepts the current selection on
970
+ * a bare Enter. Reprints on an invalid token rather than corrupting state.
971
+ * Resolves safely (to the seeded defaults) on EOF / synchronous close so a
972
+ * readline regression can never hang or discard the answer.
973
+ */
974
+ export function promptMultiSelectViaReadline(promptText, options, defaults, input = process.stdin, output = process.stderr) {
975
+ return new Promise((resolve) => {
976
+ const selected = new Set(defaults);
977
+ const render = () => {
978
+ output.write(`\n${promptText}\n`);
979
+ options.forEach((opt, idx) => {
980
+ const mark = selected.has(opt.id) ? "[x]" : "[ ]";
981
+ output.write(` ${idx + 1}. ${mark} ${opt.label}\n`);
982
+ });
983
+ output.write("Enter numbers to toggle (comma-separated), or press Enter to accept: ");
984
+ };
985
+ const rl = readline.createInterface({ input, output });
986
+ let answered = false;
987
+ const finish = () => {
988
+ answered = true;
989
+ rl.close();
990
+ resolve(options.filter((o) => selected.has(o.id)).map((o) => o.id));
991
+ };
992
+ // EOF / synchronous close must resolve rather than deadlock the top-level
993
+ // await; `answered` guards the synchronous close from discarding a real answer.
994
+ rl.on("close", () => {
995
+ if (!answered)
996
+ resolve(options.filter((o) => selected.has(o.id)).map((o) => o.id));
997
+ });
998
+ const ask = () => {
999
+ render();
1000
+ rl.question("", (answer) => {
1001
+ const trimmed = answer.trim();
1002
+ if (trimmed.length === 0) {
1003
+ finish();
1004
+ return;
1005
+ }
1006
+ const tokens = trimmed.split(",").map((t) => t.trim());
1007
+ const nums = [];
1008
+ let bad = false;
1009
+ for (const tok of tokens) {
1010
+ const n = Number(tok);
1011
+ if (!Number.isInteger(n) || n < 1 || n > options.length) {
1012
+ bad = true;
1013
+ break;
1014
+ }
1015
+ nums.push(n);
1016
+ }
1017
+ if (bad) {
1018
+ output.write(`Invalid selection. Enter numbers between 1 and ${options.length}.\n`);
1019
+ ask();
1020
+ return;
1021
+ }
1022
+ for (const n of nums) {
1023
+ const opt = options[n - 1];
1024
+ if (selected.has(opt.id))
1025
+ selected.delete(opt.id);
1026
+ else
1027
+ selected.add(opt.id);
1028
+ }
1029
+ finish();
1030
+ });
1031
+ };
1032
+ ask();
1033
+ });
1034
+ }
1035
+ /**
1036
+ * Resolve the selected host platforms with strict precedence (BAPI-635):
1037
+ * 1. explicit `--tools` (including an explicit EMPTY selection — never falls
1038
+ * back to detection),
1039
+ * 2. interactive multi-select on a TTY (seeded from registry detection, with
1040
+ * Claude Code always checked as a default),
1041
+ * 3. the legacy non-TTY automatic set: Claude Code plus only the currently
1042
+ * detected Copilot VS Code and Cursor automatic targets (never Codex or
1043
+ * Copilot CLI just because a global directory exists).
1044
+ */
1045
+ export async function resolveSelectedHostPlatforms(deps, options) {
1046
+ // 1. Explicit --tools (empty array is an explicit empty selection).
1047
+ if (options.tools !== undefined) {
1048
+ return options.tools;
1049
+ }
1050
+ const ctx = await buildDetectionContext(deps);
1051
+ const detected = new Set(detectDefaultPlatforms(ctx));
1052
+ // 2. Interactive multi-select on a TTY.
1053
+ if (deps.isTTY && deps.promptMultiSelect) {
1054
+ const optionList = allHostTargets().map((t) => ({ id: t.id, label: t.label }));
1055
+ // Claude Code is always a checked default; add every detected platform.
1056
+ const defaults = HOST_PLATFORM_ORDER.filter((id) => id === "claude-code" || detected.has(id));
1057
+ const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList, defaults);
1058
+ return HOST_PLATFORM_ORDER.filter((id) => chosen.includes(id));
1059
+ }
1060
+ // 3. Legacy non-TTY automatic set: Claude + detected Cursor / Copilot VS Code.
1061
+ const legacy = ["claude-code"];
1062
+ if (detected.has("cursor"))
1063
+ legacy.push("cursor");
1064
+ if (detected.has("copilot-vscode"))
1065
+ legacy.push("copilot-vscode");
1066
+ return HOST_PLATFORM_ORDER.filter((id) => legacy.includes(id));
1067
+ }
1068
+ /**
1069
+ * Build a registry detection context from install deps. install-bridge has only
1070
+ * async `stat`, but the registry's `detect` callbacks are synchronous, so we
1071
+ * pre-probe the candidate marker paths (the same ones the registry consults) and
1072
+ * expose them through a synchronous `exists` set. Paths are built in the registry's
1073
+ * POSIX-join form so the lookup matches exactly what `detect` passes to `exists`.
1074
+ */
1075
+ async function buildDetectionContext(deps) {
1076
+ const cwd = deps.cwd;
1077
+ const homedir = deps.homedir();
1078
+ const posixJoin = (base, rel) => `${base.endsWith("/") ? base.slice(0, -1) : base}/${rel}`;
1079
+ const candidates = [
1080
+ posixJoin(cwd, ".cursor"),
1081
+ posixJoin(cwd, ".vscode"),
1082
+ posixJoin(cwd, ".windsurf"),
1083
+ posixJoin(cwd, ".windsurfrules"),
1084
+ posixJoin(homedir, ".codex"),
1085
+ ];
1086
+ const present = new Set();
1087
+ await Promise.all(candidates.map(async (p) => {
1088
+ try {
1089
+ await deps.stat(p);
1090
+ present.add(p);
1091
+ }
1092
+ catch {
1093
+ // absent — leave out of the set.
1094
+ }
1095
+ }));
1096
+ return {
1097
+ cwd,
1098
+ homedir,
1099
+ env: deps.env,
1100
+ exists: (p) => present.has(p),
1101
+ };
1102
+ }
1103
+ /**
1104
+ * Resolve which project-local JSON host configs to write for the selected
1105
+ * platforms. Only the project-scoped JSON targets (Claude Code, Cursor, Copilot
1106
+ * VS Code) are returned here; global targets (Codex, Copilot CLI) and manual
1107
+ * targets (Windsurf) are handled by the registry-driven emitter in
1108
+ * `runInstallBridgeCli`. The returned shape is unchanged so the existing
1109
+ * read-merge-write path and overwrite-consent detection are preserved.
1110
+ */
1111
+ function hostConfigTargetsForPlatforms(platforms) {
1112
+ const set = new Set(platforms);
1113
+ return HOST_PLATFORM_ORDER.filter((id) => set.has(id))
1114
+ .map((id) => MCP_HOST_TARGETS[id])
1115
+ .filter((t) => t.scope === "project" && t.format === "json")
1116
+ .map((t) => ({ relPath: t.relPath, topLevelKey: t.topLevelKey }));
1117
+ }
903
1118
  /** Resolve which project-local host configs to write, mirroring runInit detection. */
904
1119
  async function resolveHostConfigTargets(deps) {
905
1120
  const targets = [
@@ -1001,6 +1216,58 @@ async function writeHostConfigs(deps, targets, entry) {
1001
1216
  }
1002
1217
  return written;
1003
1218
  }
1219
+ /**
1220
+ * Provision the selected GLOBAL (Codex, Copilot CLI) and MANUAL (Windsurf)
1221
+ * targets through the registry-driven emitter (BAPI-635). Project JSON targets
1222
+ * are handled by {@link writeHostConfigs}; this covers everything else. Global
1223
+ * config paths are never added to the repository .gitignore. Returns secret-free
1224
+ * log lines and whether Codex was auto-provisioned (so the legacy Codex manual
1225
+ * hint can be suppressed).
1226
+ */
1227
+ async function provisionSelectedGlobalTargets(deps, platforms, entry) {
1228
+ const logLines = [];
1229
+ const provisionDeps = {
1230
+ fs: {
1231
+ readFile: deps.readFile,
1232
+ writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
1233
+ mkdir: async (p, o) => {
1234
+ await deps.mkdir(p, o);
1235
+ },
1236
+ },
1237
+ vendor: deps.vendor,
1238
+ cwd: deps.cwd,
1239
+ homedir: deps.homedir(),
1240
+ env: deps.env,
1241
+ };
1242
+ const set = new Set(platforms);
1243
+ for (const id of HOST_PLATFORM_ORDER) {
1244
+ if (!set.has(id))
1245
+ continue;
1246
+ const target = MCP_HOST_TARGETS[id];
1247
+ // Skip project JSON targets — those are handled by writeHostConfigs.
1248
+ if (target.scope === "project" && target.format === "json")
1249
+ continue;
1250
+ const outcome = await provisionHostTarget(target, entry, provisionDeps);
1251
+ switch (outcome.status) {
1252
+ case "vendor-written":
1253
+ case "direct-written":
1254
+ case "created":
1255
+ logLines.push(` configured ${target.label} (${outcome.displayPath})`);
1256
+ break;
1257
+ case "manual-required":
1258
+ logLines.push(` ${target.label}: add the bridge-api MCP server manually to ${outcome.displayPath} ` +
1259
+ "(the API key is redacted in printed instructions).");
1260
+ break;
1261
+ case "skipped-invalid":
1262
+ logLines.push(` ${target.label}: skipped ${outcome.displayPath} — existing config is not valid; left untouched.`);
1263
+ break;
1264
+ case "failed":
1265
+ logLines.push(` ${target.label}: could not be configured automatically; configure it manually.`);
1266
+ break;
1267
+ }
1268
+ }
1269
+ return logLines;
1270
+ }
1004
1271
  /** Build the `/jira/ping` URL exactly like the MCP `ping` tool / buildGetUrl. */
1005
1272
  export function buildPingUrl(baseUrl, repoName) {
1006
1273
  const url = new URL(`${baseUrl.replace(/\/+$/, "")}/jira/ping`);
@@ -1614,7 +1881,13 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1614
1881
  env: deps.env,
1615
1882
  homedir: deps.homedir,
1616
1883
  });
1617
- const targets = await resolveHostConfigTargets(deps);
1884
+ // BAPI-635: resolve the AI-coding-tool selection (explicit --tools, then TTY
1885
+ // multi-select, then the legacy non-TTY automatic set). The selected project
1886
+ // JSON targets drive the existing read-merge-write path; selected global
1887
+ // (Codex / Copilot CLI) and manual (Windsurf) targets are provisioned by the
1888
+ // registry-driven emitter after the connectivity check.
1889
+ const selectedPlatforms = await resolveSelectedHostPlatforms(deps, options);
1890
+ const targets = hostConfigTargetsForPlatforms(selectedPlatforms);
1618
1891
  // Read-only detection (safe in dry-run) of global-config editors we can't write.
1619
1892
  const manualEditors = await detectManualEditors(deps);
1620
1893
  const plan = {
@@ -1889,13 +2162,82 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1889
2162
  const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
1890
2163
  // ---- Step 3 — write per-host MCP config with real values ----
1891
2164
  log("Step 3/5 — writing per-host MCP config…");
2165
+ // BAPI-635 (Step 8): every project-local, secret-bearing config MUST be
2166
+ // gitignored BEFORE the real API key is written. A project-target gitignore
2167
+ // failure is FATAL before the secret write (fixed, secret-free message).
2168
+ const gitignoreDeps = {
2169
+ readFile: deps.readFile,
2170
+ writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
2171
+ mkdir: (p, o) => deps.mkdir(p, o),
2172
+ };
2173
+ for (const target of targets) {
2174
+ try {
2175
+ await ensureGitignoredShared(deps.cwd, target.relPath, gitignoreDeps);
2176
+ }
2177
+ catch {
2178
+ errorLog("Error: could not add a project MCP config to .gitignore before writing your key. " +
2179
+ "Aborting so the API key is never written to an un-ignored file.");
2180
+ return 1;
2181
+ }
2182
+ }
1892
2183
  const written = await writeHostConfigs(deps, targets, entry);
1893
2184
  for (const relPath of written)
1894
2185
  log(` wrote ${relPath}`);
1895
- // Only print global-editor manual setup when Windsurf/Codex is actually detected.
1896
- const manualInstructions = buildManualHostInstructions(entry, manualEditors);
2186
+ // BAPI-635: provision selected GLOBAL targets (Codex, Copilot CLI) and MANUAL
2187
+ // targets (Windsurf) via the registry-driven emitter. Global paths are never
2188
+ // added to the repository .gitignore.
2189
+ const globalLogLines = await provisionSelectedGlobalTargets(deps, selectedPlatforms, entry);
2190
+ for (const line of globalLogLines)
2191
+ log(line);
2192
+ // Legacy manual-editor instructions cover editors that are DETECTED but were
2193
+ // NOT part of the selection (so the emitter above did not handle them). The two
2194
+ // editors are suppressed INDEPENDENTLY: Codex is dropped when it was selected
2195
+ // (auto-provisioned or emitted above), Windsurf is dropped only when it was
2196
+ // selected — auto-provisioning Codex must never hide the Windsurf snippet.
2197
+ const legacyManualEditors = {
2198
+ windsurf: manualEditors.windsurf && !selectedPlatforms.includes("windsurf"),
2199
+ codex: manualEditors.codex && !selectedPlatforms.includes("codex"),
2200
+ };
2201
+ const manualInstructions = buildManualHostInstructions(entry, legacyManualEditors);
1897
2202
  if (manualInstructions)
1898
2203
  log(manualInstructions);
2204
+ // BAPI-635 (Step 7): when both Claude Code and Copilot CLI are selected, warn
2205
+ // that they use different, non-shared config surfaces.
2206
+ if (selectedPlatforms.includes("claude-code") && selectedPlatforms.includes("copilot-cli")) {
2207
+ log(" Note: Claude Code uses the project .mcp.json while GitHub Copilot CLI uses only its " +
2208
+ "global ~/.copilot/mcp-config.json — the two are configured separately.");
2209
+ }
2210
+ // BAPI-635 (Step 7): Claude trust reminder — a written project MCP config is
2211
+ // not a live connection until approved in Claude Code's trust dialog.
2212
+ if (selectedPlatforms.includes("claude-code")) {
2213
+ log(" Claude Code: the project MCP server is pending approval in Claude Code's trust dialog; " +
2214
+ "restart or reload an already-running session for it to take effect.");
2215
+ }
2216
+ // BAPI-635 (Step 8 + Step 3): persist the secret-free install state, ignoring
2217
+ // it before the write. Project-local paths only; global paths are never in it.
2218
+ try {
2219
+ await ensureGitignoredShared(deps.cwd, ".bridge/install-state.json", gitignoreDeps);
2220
+ // writeMcpInstallState catches its own I/O errors and returns { ok: false }
2221
+ // (it does NOT throw), so the failure warning must inspect the return value —
2222
+ // a try/catch alone would silently swallow a real persistence failure.
2223
+ const stateResult = await writeMcpInstallState(deps.cwd, { selectedPlatforms, projectConfigPaths: targets.map((t) => t.relPath) }, {
2224
+ readFile: deps.readFile,
2225
+ writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
2226
+ rename: deps.rename,
2227
+ mkdir: async (p, o) => {
2228
+ await deps.mkdir(p, o);
2229
+ },
2230
+ unlink: deps.unlink,
2231
+ });
2232
+ if (!stateResult.ok) {
2233
+ // Install-state persistence is advisory — never fail the install over it.
2234
+ errorLog("Warning: could not persist the install-state file (non-fatal).");
2235
+ }
2236
+ }
2237
+ catch {
2238
+ // ensureGitignored for the state file can still throw — also advisory.
2239
+ errorLog("Warning: could not persist the install-state file (non-fatal).");
2240
+ }
1899
2241
  // ---- Step 3b — pre-warm the @${VERSION}-pinned _npx bucket (BAPI-451 W3) ----
1900
2242
  // The launcher just written is pinned to @${VERSION}, a DIFFERENT _npx bucket
1901
2243
  // than the @latest bucket this `npx … install-bridge` invocation warmed. Spawn