@bridge_gpt/mcp-server 0.2.9 → 0.2.10

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.
@@ -0,0 +1,692 @@
1
+ /**
2
+ * install-bridge — the one-command Bridge API project bootstrap (BAPI-429).
3
+ *
4
+ * npx -y @bridge_gpt/mcp-server@latest install-bridge [--repo <name>] [--api-key <key>] [--force] [--dry-run] [--agent <name>]
5
+ *
6
+ * Collapses the previously five-step manual setup into a single CLI subcommand.
7
+ * It does the DETERMINISTIC work in the shell:
8
+ *
9
+ * Step 1 Scaffold via `runInit` (commands, agents, pipelines, secret-free
10
+ * per-host MCP config placeholders, .bridge/config).
11
+ * Step 2 Verify connectivity over HTTP (`GET <base>/jira/ping?repo_name=…`
12
+ * with the `X-API-Key` header) BEFORE writing the key anywhere durable
13
+ * (R5). Halt on failure — so a bad key never lands in the config and
14
+ * traps later retries behind overwrite-consent.
15
+ * Step 3 Write the per-host MCP config (.mcp.json / .cursor/mcp.json /
16
+ * .vscode/mcp.json) with REAL values (BAPI_REPO_NAME / BAPI_API_KEY /
17
+ * BAPI_BASE_URL / BAPI_DOCS_DIR), read-merge-write so unrelated servers
18
+ * survive, pinning the launcher to the exact running version. Detected
19
+ * Windsurf / Codex (global configs we never write into automatically)
20
+ * get printed manual instructions.
21
+ * Step 4 Persist the routing credential to the user-scoped store
22
+ * (`~/.config/bridge/credentials.json`, target `bapi:<repo>`) via
23
+ * `upsertBapiCredential`. Non-blocking (fail-open) — mirrors Stage 6.
24
+ *
25
+ * then SPAWNS a fresh agent session (Step 5) for the agentic remainder
26
+ * (`/install-bridge` config-field derivation, then `/learn-repository`). The
27
+ * fresh session is required because a CLI cannot force the running editor to
28
+ * reload the just-written `.mcp.json`, and field derivation needs an agent
29
+ * runtime the shell does not have.
30
+ *
31
+ * SECRET DISCIPLINE: the API key is NEVER printed or logged — not in stdout,
32
+ * stderr, error messages, or --dry-run output (it is redacted to `<REDACTED>`).
33
+ * The ONLY place the key is durably written is the per-host MCP config (Step 3,
34
+ * gitignored) and the user-scoped credential store (Step 4).
35
+ */
36
+ import { readFile, writeFile, mkdir, stat, rename, chmod, unlink } from "fs/promises";
37
+ import os from "os";
38
+ import path from "path";
39
+ import readline from "readline";
40
+ import { runInit, buildBridgeApiEntry } from "./init.js";
41
+ import { validateRepoName } from "./bridge-config.js";
42
+ import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
43
+ import { upsertBapiCredential, getPrimaryCredentialStorePath, } from "./credential-store.js";
44
+ import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
45
+ import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "./start-tickets.js";
46
+ /** Redaction sentinel — the API-key value is NEVER printed; this stands in. */
47
+ export const REDACTED_API_KEY = "<REDACTED>";
48
+ /** The natural-language sequential prompt handed to the spawned agent session. */
49
+ export const INSTALL_BRIDGE_AGENT_PROMPT = "Please execute the /install-bridge command. Once it completes successfully, execute the /learn-repository command.";
50
+ /** Default base URL when `BAPI_BASE_URL` is unset (mirrors index.ts). */
51
+ export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
52
+ /** Default docs dir when `BAPI_DOCS_DIR` is unset (mirrors index.ts). */
53
+ export const DEFAULT_BAPI_DOCS_DIR = "docs/tmp";
54
+ /** User-facing usage text. */
55
+ export function getInstallBridgeUsage() {
56
+ return [
57
+ "Usage:",
58
+ " npx -y @bridge_gpt/mcp-server@latest install-bridge [flags]",
59
+ "",
60
+ "One-command Bridge API project bootstrap. Scaffolds the project, writes the",
61
+ "per-host MCP config with your credentials, verifies connectivity, persists the",
62
+ "routing credential, then opens a fresh agent session to derive the remaining",
63
+ "config and run /learn-repository.",
64
+ "",
65
+ "Inputs (the only two irreducible ones):",
66
+ " --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
67
+ " then an interactive (no-echo) prompt. Generate one in the",
68
+ " Bridge API web UI Security page — this command consumes a",
69
+ " key, it does not create one. NEVER printed or logged.",
70
+ " --repo <name> Repository name. Falls back to BAPI_REPO_NAME, then an",
71
+ " inferred default you confirm interactively. MUST match the",
72
+ " server-side repo registration (it keys the credential",
73
+ " store as bapi:<repo>). Required (no inference) when stdin",
74
+ " is non-interactive.",
75
+ "",
76
+ "Flags:",
77
+ " --force Overwrite an existing real BAPI_API_KEY in a",
78
+ " host config without prompting.",
79
+ " --dry-run Preview every step (scaffold targets, config",
80
+ " files + keys with the key REDACTED, ping",
81
+ " target, credential target, spawn command)",
82
+ " without writing, pinging, or spawning anything.",
83
+ " --agent claude|cursor-agent Agent to launch for the agentic remainder",
84
+ " (default: claude).",
85
+ " -h, --help Show this help.",
86
+ "",
87
+ "Environment: BAPI_BASE_URL (default https://bridgegpt-api.com) and BAPI_DOCS_DIR",
88
+ "(default docs/tmp) are read from the environment with the shown fallbacks.",
89
+ ].join("\n");
90
+ }
91
+ /**
92
+ * Parse argv strictly. Supports `--api-key`, `--repo`, `--agent` (each with a
93
+ * `--flag value` or `--flag=value` form), the boolean `--force` / `--dry-run`,
94
+ * and `-h`/`--help`. Unknown flags and positional args are rejected. The API key
95
+ * value is captured but never echoed (no error message includes it).
96
+ */
97
+ export function parseInstallBridgeArgs(argv) {
98
+ if (argv.includes("-h") || argv.includes("--help")) {
99
+ return { status: "help", usage: getInstallBridgeUsage() };
100
+ }
101
+ let apiKey;
102
+ let repo;
103
+ let force = false;
104
+ let dryRun = false;
105
+ let agentName = DEFAULT_AGENT_NAME;
106
+ /** Read a `--flag value` or `--flag=value` value, advancing the index. */
107
+ const readValue = (arg, flag, i) => {
108
+ if (arg.startsWith(`${flag}=`)) {
109
+ return { value: arg.slice(flag.length + 1), nextIndex: i };
110
+ }
111
+ if (i + 1 >= argv.length) {
112
+ return { error: `${flag} requires a value.` };
113
+ }
114
+ return { value: argv[i + 1], nextIndex: i + 1 };
115
+ };
116
+ for (let i = 0; i < argv.length; i++) {
117
+ const arg = argv[i];
118
+ if (arg === "--force") {
119
+ force = true;
120
+ continue;
121
+ }
122
+ if (arg === "--dry-run") {
123
+ dryRun = true;
124
+ continue;
125
+ }
126
+ if (arg === "--api-key" || arg.startsWith("--api-key=")) {
127
+ const r = readValue(arg, "--api-key", i);
128
+ if ("error" in r)
129
+ return { status: "error", message: r.error };
130
+ apiKey = r.value;
131
+ i = r.nextIndex;
132
+ continue;
133
+ }
134
+ if (arg === "--repo" || arg.startsWith("--repo=")) {
135
+ const r = readValue(arg, "--repo", i);
136
+ if ("error" in r)
137
+ return { status: "error", message: r.error };
138
+ repo = r.value;
139
+ i = r.nextIndex;
140
+ continue;
141
+ }
142
+ if (arg === "--agent" || arg.startsWith("--agent=")) {
143
+ const r = readValue(arg, "--agent", i);
144
+ if ("error" in r)
145
+ return { status: "error", message: r.error };
146
+ if (!isAgentName(r.value)) {
147
+ return {
148
+ status: "error",
149
+ message: `Invalid --agent value: '${r.value}' (allowed agents: ${formatValidAgentNames()}).`,
150
+ };
151
+ }
152
+ agentName = r.value;
153
+ i = r.nextIndex;
154
+ continue;
155
+ }
156
+ if (arg.startsWith("-")) {
157
+ return { status: "error", message: `Unknown flag: ${arg}` };
158
+ }
159
+ return {
160
+ status: "error",
161
+ message: `Unexpected positional argument: '${arg}'. install-bridge does not accept positional arguments.`,
162
+ };
163
+ }
164
+ return { status: "ok", options: { apiKey, repo, force, dryRun, agentName } };
165
+ }
166
+ /** No-echo secret prompt on stderr (so it never lands in piped stdout). */
167
+ function promptSecretViaReadline(promptText) {
168
+ return new Promise((resolve) => {
169
+ const rl = readline.createInterface({
170
+ input: process.stdin,
171
+ output: process.stderr,
172
+ terminal: true,
173
+ });
174
+ // Suppress echo of typed characters: override the internal writer so only the
175
+ // prompt (written explicitly below) is shown, never the keystrokes.
176
+ const mutable = rl;
177
+ let muted = false;
178
+ mutable._writeToOutput = (s) => {
179
+ if (!muted)
180
+ process.stderr.write(s);
181
+ };
182
+ process.stderr.write(promptText);
183
+ muted = true;
184
+ rl.question("", (answer) => {
185
+ rl.close();
186
+ process.stderr.write("\n");
187
+ resolve(answer.trim());
188
+ });
189
+ });
190
+ }
191
+ /** Echoed single-line prompt on stderr (used for repo confirmation / value). */
192
+ function promptLineViaReadline(promptText) {
193
+ return new Promise((resolve) => {
194
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
195
+ rl.question(promptText, (answer) => {
196
+ rl.close();
197
+ resolve(answer.trim());
198
+ });
199
+ });
200
+ }
201
+ /** Build default deps from the live process. */
202
+ export function createDefaultInstallBridgeDeps() {
203
+ const isTTY = Boolean(process.stdin.isTTY);
204
+ return {
205
+ env: process.env,
206
+ cwd: process.cwd(),
207
+ platform: process.platform,
208
+ homedir: os.homedir,
209
+ isTTY,
210
+ readFile: (p) => readFile(p, "utf-8"),
211
+ writeFile: (p, data, options) => writeFile(p, data, options),
212
+ mkdir: (p, options) => mkdir(p, options),
213
+ stat: (p) => stat(p),
214
+ rename: (a, b) => rename(a, b),
215
+ chmod: (p, m) => chmod(p, m),
216
+ unlink: (p) => unlink(p),
217
+ promptSecret: isTTY ? promptSecretViaReadline : undefined,
218
+ promptLine: isTTY ? promptLineViaReadline : undefined,
219
+ fetch: (...args) => fetch(...args),
220
+ runInit,
221
+ upsertCredential: upsertBapiCredential,
222
+ buildShellCommand: buildGenericAgentShellCommand,
223
+ spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
224
+ startTicketsDeps: createDefaultStartTicketsDeps(),
225
+ log: (m) => console.log(m),
226
+ errorLog: (m) => console.error(m),
227
+ };
228
+ }
229
+ /**
230
+ * Resolve the API key: `--api-key` → `BAPI_API_KEY` env → interactive no-echo
231
+ * prompt. Fails (secret-free) when none is available and stdin is non-interactive.
232
+ */
233
+ export async function resolveApiKey(options, deps) {
234
+ if (typeof options.apiKey === "string" && options.apiKey.trim().length > 0) {
235
+ return { ok: true, value: options.apiKey.trim() };
236
+ }
237
+ const fromEnv = deps.env.BAPI_API_KEY;
238
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
239
+ return { ok: true, value: fromEnv.trim() };
240
+ }
241
+ if (deps.isTTY && deps.promptSecret) {
242
+ const entered = (await deps.promptSecret("Bridge API key (input hidden): ")).trim();
243
+ if (entered.length > 0) {
244
+ return { ok: true, value: entered };
245
+ }
246
+ return { ok: false, error: "No API key entered." };
247
+ }
248
+ return {
249
+ ok: false,
250
+ error: "An API key is required. Pass --api-key or set the BAPI_API_KEY environment variable " +
251
+ "(no interactive terminal is available to prompt for it).",
252
+ };
253
+ }
254
+ /**
255
+ * Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env → inferred default
256
+ * (from .bridge/config, else the cwd basename) confirmed interactively. Fails
257
+ * fast (no inference) when neither is supplied and stdin is non-interactive —
258
+ * the repo identity keys the credential store and must match server-side
259
+ * registration, so it is never silently inferred non-interactively.
260
+ */
261
+ export async function resolveRepoName(options, deps) {
262
+ if (typeof options.repo === "string" && options.repo.trim().length > 0) {
263
+ return { ok: true, value: options.repo.trim() };
264
+ }
265
+ const fromEnv = deps.env.BAPI_REPO_NAME;
266
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
267
+ return { ok: true, value: fromEnv.trim() };
268
+ }
269
+ // Non-interactive: fail fast and require --repo. Inference is an interactive
270
+ // convenience only (the user must confirm it).
271
+ if (!deps.isTTY || !deps.promptLine) {
272
+ return {
273
+ ok: false,
274
+ error: "A repo name is required. Pass --repo or set the BAPI_REPO_NAME environment variable " +
275
+ "(no interactive terminal is available to confirm an inferred name). It must match the " +
276
+ "server-side repository registration.",
277
+ };
278
+ }
279
+ // Infer a sensible default: existing .bridge/config, else the cwd basename.
280
+ let inferred = await resolveStartTicketsRepoName({
281
+ env: deps.env,
282
+ cwd: deps.cwd,
283
+ readFile: deps.readFile,
284
+ });
285
+ if (!inferred) {
286
+ const validated = validateRepoName(path.basename(deps.cwd));
287
+ if (validated.ok)
288
+ inferred = validated.value;
289
+ }
290
+ if (inferred) {
291
+ const answer = (await deps.promptLine(`Repo name [${inferred}] (must match server-side registration): `)).trim();
292
+ const chosen = answer.length > 0 ? answer : inferred;
293
+ if (chosen.length > 0)
294
+ return { ok: true, value: chosen };
295
+ }
296
+ else {
297
+ const answer = (await deps.promptLine("Repo name (must match server-side registration): ")).trim();
298
+ if (answer.length > 0)
299
+ return { ok: true, value: answer };
300
+ }
301
+ return { ok: false, error: "No repo name provided." };
302
+ }
303
+ /** Resolve which project-local host configs to write, mirroring runInit detection. */
304
+ async function resolveHostConfigTargets(deps) {
305
+ const targets = [
306
+ { relPath: ".mcp.json", topLevelKey: "mcpServers" },
307
+ ];
308
+ const dirExists = async (rel) => {
309
+ try {
310
+ await deps.stat(path.join(deps.cwd, rel));
311
+ return true;
312
+ }
313
+ catch {
314
+ return false;
315
+ }
316
+ };
317
+ if (await dirExists(".vscode")) {
318
+ targets.push({ relPath: ".vscode/mcp.json", topLevelKey: "servers" });
319
+ }
320
+ if ((await dirExists(".cursor")) || deps.env.CURSOR_TRACE_DIR) {
321
+ targets.push({ relPath: ".cursor/mcp.json", topLevelKey: "mcpServers" });
322
+ }
323
+ return targets;
324
+ }
325
+ /** Is this a placeholder (or absent) API-key value rather than a real secret? */
326
+ function isPlaceholderApiKey(value) {
327
+ if (typeof value !== "string")
328
+ return true;
329
+ const trimmed = value.trim();
330
+ if (trimmed.length === 0)
331
+ return true;
332
+ return trimmed === "YOUR_API_KEY" || trimmed.startsWith("YOUR_");
333
+ }
334
+ /**
335
+ * Build the per-host `bridge-api` MCP entry carrying REAL values. Starts from the
336
+ * secret-free, version-pinned `buildBridgeApiEntry` (so the launcher pin is
337
+ * identical to `--init`) and overlays the real repo name, base URL, docs dir, and
338
+ * the API key.
339
+ */
340
+ export function buildInstallBridgeServerEntry(cwd, repoName, apiKey, baseUrl, docsDir) {
341
+ const entry = buildBridgeApiEntry(cwd);
342
+ const env = {
343
+ ...entry.env,
344
+ BAPI_REPO_NAME: repoName,
345
+ BAPI_BASE_URL: baseUrl,
346
+ BAPI_DOCS_DIR: docsDir,
347
+ BAPI_API_KEY: apiKey,
348
+ };
349
+ return { command: entry.command, args: entry.args, env };
350
+ }
351
+ /** Read + parse an existing host config; `null` if absent/unparseable. */
352
+ async function readHostConfig(deps, fullPath) {
353
+ let raw;
354
+ try {
355
+ raw = await deps.readFile(fullPath);
356
+ }
357
+ catch {
358
+ return null;
359
+ }
360
+ try {
361
+ const parsed = JSON.parse(raw);
362
+ return parsed && typeof parsed === "object" ? parsed : null;
363
+ }
364
+ catch {
365
+ return null;
366
+ }
367
+ }
368
+ /**
369
+ * Detect whether any resolved host config already carries a real (non-placeholder)
370
+ * `BAPI_API_KEY` for the `bridge-api` server — the trigger for overwrite consent.
371
+ */
372
+ async function detectExistingRealKey(deps, targets) {
373
+ for (const target of targets) {
374
+ const parsed = await readHostConfig(deps, path.join(deps.cwd, target.relPath));
375
+ const entry = parsed?.[target.topLevelKey]?.["bridge-api"];
376
+ if (entry?.env && !isPlaceholderApiKey(entry.env.BAPI_API_KEY)) {
377
+ return true;
378
+ }
379
+ }
380
+ return false;
381
+ }
382
+ /**
383
+ * Write the `bridge-api` entry into each host config via read-merge-write,
384
+ * preserving unrelated servers and top-level keys. Returns the list of written
385
+ * relative paths.
386
+ */
387
+ async function writeHostConfigs(deps, targets, entry) {
388
+ const written = [];
389
+ for (const target of targets) {
390
+ const fullPath = path.join(deps.cwd, target.relPath);
391
+ const parsed = (await readHostConfig(deps, fullPath)) ?? {};
392
+ if (!parsed[target.topLevelKey] || typeof parsed[target.topLevelKey] !== "object") {
393
+ parsed[target.topLevelKey] = {};
394
+ }
395
+ parsed[target.topLevelKey]["bridge-api"] = entry;
396
+ await deps.mkdir(path.dirname(fullPath), { recursive: true });
397
+ await deps.writeFile(fullPath, JSON.stringify(parsed, null, 2) + "\n", {
398
+ encoding: "utf-8",
399
+ });
400
+ written.push(target.relPath);
401
+ }
402
+ return written;
403
+ }
404
+ /** Build the `/jira/ping` URL exactly like the MCP `ping` tool / buildGetUrl. */
405
+ export function buildPingUrl(baseUrl, repoName) {
406
+ const url = new URL(`${baseUrl.replace(/\/+$/, "")}/jira/ping`);
407
+ url.searchParams.set("repo_name", repoName);
408
+ return url.toString();
409
+ }
410
+ /**
411
+ * Verify connectivity. Distinguishes a rejected key (401/403) from an unknown
412
+ * repo / not-found (404) where the response allows. The key is sent in the
413
+ * `X-API-Key` header and NEVER appears in any returned message.
414
+ */
415
+ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
416
+ const url = buildPingUrl(baseUrl, repoName);
417
+ let resp;
418
+ try {
419
+ // Bound the request: if the server accepts the connection but stalls, the
420
+ // AbortError surfaces through the catch below as the secret-free "could not
421
+ // reach" message rather than hanging the CLI indefinitely.
422
+ resp = await deps.fetch(url, {
423
+ headers: { "X-API-Key": apiKey },
424
+ signal: AbortSignal.timeout(10_000),
425
+ });
426
+ }
427
+ catch (err) {
428
+ const msg = err instanceof Error ? err.message : String(err);
429
+ return {
430
+ ok: false,
431
+ message: `Could not reach the Bridge API at ${baseUrl} (${msg}). Check BAPI_BASE_URL and your network.`,
432
+ };
433
+ }
434
+ if (resp.ok)
435
+ return { ok: true };
436
+ if (resp.status === 401 || resp.status === 403) {
437
+ return {
438
+ ok: false,
439
+ message: `The Bridge API rejected the credential (HTTP ${resp.status}). The API key may be invalid or expired ` +
440
+ "— generate a fresh one in the Bridge API web UI Security page. (An expired token can also surface as a " +
441
+ "permission error.)",
442
+ };
443
+ }
444
+ if (resp.status === 404) {
445
+ return {
446
+ ok: false,
447
+ message: `The Bridge API could not find repo '${repoName}' (HTTP 404). Confirm --repo matches the server-side ` +
448
+ "repository registration exactly.",
449
+ };
450
+ }
451
+ return {
452
+ ok: false,
453
+ message: `Connectivity check failed (HTTP ${resp.status}). Verify your repo, API key, and BAPI_BASE_URL.`,
454
+ };
455
+ }
456
+ /**
457
+ * Render the --dry-run preview lines. The API key is ALWAYS redacted — the
458
+ * spawnCommand and config preview never embed the secret.
459
+ */
460
+ export function buildDryRunPreview(plan) {
461
+ return [
462
+ "install-bridge --dry-run (no writes, no network, no spawns)",
463
+ `Repo name: ${plan.repoName}`,
464
+ `Base URL (ping): ${plan.baseUrl}`,
465
+ `Docs dir: ${plan.docsDir}`,
466
+ `Agent: ${plan.agentName}`,
467
+ "",
468
+ "Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
469
+ `Step 2 — connectivity ping (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
470
+ "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned):",
471
+ ...plan.configTargets.map((t) => ` ${t}: BAPI_REPO_NAME=${plan.repoName}, BAPI_API_KEY=${REDACTED_API_KEY}, BAPI_BASE_URL=${plan.baseUrl}, BAPI_DOCS_DIR=${plan.docsDir}`),
472
+ ...(plan.manualEditors.length > 0
473
+ ? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
474
+ : []),
475
+ `Step 4 — persist routing credential: target ${plan.credentialTarget} at ${plan.credentialStorePath}`,
476
+ `Step 5 — spawn agent session: ${plan.spawnCommand}`,
477
+ ];
478
+ }
479
+ /**
480
+ * Detect global-config editors that install-bridge cannot safely write into
481
+ * (their configs live outside the project). Windsurf is detected from the
482
+ * project-local `.windsurf` dir / `.windsurfrules` file (mirroring `runInit`'s
483
+ * Phase 2 gate); Codex is detected from the global `~/.codex` directory. The
484
+ * manual-instructions block is only shown when at least one is detected, so a
485
+ * user with neither never sees it.
486
+ */
487
+ export async function detectManualEditors(deps) {
488
+ const exists = async (p) => {
489
+ try {
490
+ await deps.stat(p);
491
+ return true;
492
+ }
493
+ catch {
494
+ return false;
495
+ }
496
+ };
497
+ const windsurf = (await exists(path.join(deps.cwd, ".windsurf"))) ||
498
+ (await exists(path.join(deps.cwd, ".windsurfrules")));
499
+ const codex = await exists(path.join(deps.homedir(), ".codex"));
500
+ return { windsurf, codex };
501
+ }
502
+ /** Human-readable list of the detected manual editors (for the dry-run preview). */
503
+ export function manualEditorNames(editors) {
504
+ const names = [];
505
+ if (editors.windsurf)
506
+ names.push("Windsurf");
507
+ if (editors.codex)
508
+ names.push("Codex");
509
+ return names;
510
+ }
511
+ /**
512
+ * Build the manual-instruction block for the DETECTED global-config editors only
513
+ * (key always redacted). Returns `null` when neither Windsurf nor Codex is
514
+ * detected, so the caller prints nothing for users who don't use them.
515
+ */
516
+ function buildManualHostInstructions(entry, editors) {
517
+ if (!editors.windsurf && !editors.codex)
518
+ return null;
519
+ const redactedEnv = { ...entry.env, BAPI_API_KEY: REDACTED_API_KEY };
520
+ const lines = [
521
+ "",
522
+ "Detected an editor whose MCP config is global and cannot be written automatically.",
523
+ "Add the server manually (replace <REDACTED> with your key):",
524
+ ];
525
+ if (editors.windsurf) {
526
+ const windsurfSnippet = JSON.stringify({ mcpServers: { "bridge-api": { command: entry.command, args: entry.args, env: redactedEnv } } }, null, 2);
527
+ lines.push("", " Windsurf → ~/.codeium/windsurf/mcp_config.json:", windsurfSnippet);
528
+ }
529
+ if (editors.codex) {
530
+ lines.push("", " Codex → ~/.codex/config.toml (add an [mcp_servers.bridge-api] table with the", " same command/args/env shown above, BAPI_API_KEY set to your key).");
531
+ }
532
+ return lines.join("\n");
533
+ }
534
+ // ---------------------------------------------------------------------------
535
+ // CLI entry
536
+ // ---------------------------------------------------------------------------
537
+ /**
538
+ * CLI entry for `install-bridge`. Returns a process exit code. Help returns 0;
539
+ * parser/resolution errors return 1; a ping failure halts (return 1) BEFORE the
540
+ * credential persist and agent spawn. The API key is never logged on any path.
541
+ */
542
+ export async function runInstallBridgeCli(argv, overrides = {}) {
543
+ const deps = { ...createDefaultInstallBridgeDeps(), ...overrides };
544
+ const { log, errorLog } = deps;
545
+ const parsed = parseInstallBridgeArgs(argv);
546
+ if (parsed.status === "help") {
547
+ log(parsed.usage);
548
+ return 0;
549
+ }
550
+ if (parsed.status === "error") {
551
+ errorLog(`Error: ${parsed.message}`);
552
+ errorLog("");
553
+ errorLog(getInstallBridgeUsage());
554
+ return 1;
555
+ }
556
+ const options = parsed.options;
557
+ // ---- Resolve inputs (may prompt when interactive) ----
558
+ // Resolve the API key first so a fully-empty non-interactive invocation fails
559
+ // with the (more security-relevant) missing-key message before the repo one.
560
+ const keyResult = await resolveApiKey(options, deps);
561
+ if (!keyResult.ok) {
562
+ errorLog(`Error: ${keyResult.error}`);
563
+ return 1;
564
+ }
565
+ const apiKey = keyResult.value;
566
+ const repoResult = await resolveRepoName(options, deps);
567
+ if (!repoResult.ok) {
568
+ errorLog(`Error: ${repoResult.error}`);
569
+ return 1;
570
+ }
571
+ const repoName = repoResult.value;
572
+ const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
573
+ const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
574
+ const agent = resolveAgentSpec(options.agentName) ?? resolveAgentSpec(DEFAULT_AGENT_NAME);
575
+ const spawnCommand = deps.buildShellCommand(agent, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform);
576
+ const credentialStorePath = getPrimaryCredentialStorePath({
577
+ env: deps.env,
578
+ homedir: deps.homedir,
579
+ });
580
+ const targets = await resolveHostConfigTargets(deps);
581
+ // Read-only detection (safe in dry-run) of global-config editors we can't write.
582
+ const manualEditors = await detectManualEditors(deps);
583
+ const plan = {
584
+ repoName,
585
+ baseUrl,
586
+ docsDir,
587
+ agentName: options.agentName,
588
+ configTargets: targets.map((t) => t.relPath),
589
+ manualEditors: manualEditorNames(manualEditors),
590
+ credentialTarget: `bapi:${repoName}`,
591
+ credentialStorePath,
592
+ pingUrl: buildPingUrl(baseUrl, repoName),
593
+ spawnCommand,
594
+ };
595
+ // ---- --dry-run: preview every step, strictly no side effects ----
596
+ if (options.dryRun) {
597
+ for (const line of buildDryRunPreview(plan))
598
+ log(line);
599
+ return 0;
600
+ }
601
+ const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
602
+ // ---- Overwrite consent: a real existing key requires --force or a prompt ----
603
+ const hasRealKey = await detectExistingRealKey(deps, targets);
604
+ if (hasRealKey && !options.force) {
605
+ if (deps.isTTY && deps.promptLine) {
606
+ const answer = (await deps.promptLine("A host config already contains a BAPI_API_KEY. Overwrite it? [y/N]: ")).trim().toLowerCase();
607
+ if (answer !== "y" && answer !== "yes") {
608
+ errorLog("Aborted: existing API key left unchanged (re-run with --force to overwrite).");
609
+ return 1;
610
+ }
611
+ }
612
+ else {
613
+ errorLog("Error: a host config already contains a BAPI_API_KEY. Re-run with --force to overwrite it " +
614
+ "(refusing to overwrite a credential non-interactively without consent).");
615
+ return 1;
616
+ }
617
+ }
618
+ // ---- Step 1 — scaffold (secret-free placeholders only) ----
619
+ log("Step 1/5 — scaffolding project (commands, agents, pipelines, config placeholders)…");
620
+ await deps.runInit(deps.cwd);
621
+ // ---- Step 2 — verify connectivity BEFORE writing the key anywhere durable ----
622
+ // R5: ping before persisting anything durably. Pinging before writeHostConfigs
623
+ // (and the credential store) means a bad key on a first-time install halts
624
+ // WITHOUT leaving an invalid key in the config — which would otherwise trip the
625
+ // overwrite-consent gate on every retry (a trapped state).
626
+ log("Step 2/5 — verifying connectivity…");
627
+ const ping = await verifyConnectivity(deps, baseUrl, repoName, apiKey);
628
+ if (!ping.ok) {
629
+ errorLog(`Error: ${ping.message}`);
630
+ return 1;
631
+ }
632
+ log(" connectivity OK");
633
+ // ---- Step 3 — write per-host MCP config with real values ----
634
+ log("Step 3/5 — writing per-host MCP config…");
635
+ const written = await writeHostConfigs(deps, targets, entry);
636
+ for (const relPath of written)
637
+ log(` wrote ${relPath}`);
638
+ // Only print global-editor manual setup when Windsurf/Codex is actually detected.
639
+ const manualInstructions = buildManualHostInstructions(entry, manualEditors);
640
+ if (manualInstructions)
641
+ log(manualInstructions);
642
+ // ---- Step 4 — persist routing credential (non-blocking / fail-open) ----
643
+ log("Step 4/5 — persisting routing credential…");
644
+ try {
645
+ const writeDeps = {
646
+ env: deps.env,
647
+ homedir: deps.homedir,
648
+ platform: deps.platform,
649
+ readFile: deps.readFile,
650
+ mkdir: deps.mkdir,
651
+ writeFile: (p, d, o) => deps.writeFile(p, d, o),
652
+ rename: deps.rename,
653
+ chmod: deps.chmod,
654
+ unlink: deps.unlink,
655
+ };
656
+ const result = await deps.upsertCredential(repoName, apiKey, writeDeps);
657
+ if (result.ok) {
658
+ log(` stored routing credential for ${result.target} at ${result.path}`);
659
+ }
660
+ else {
661
+ log(` warning: could not persist the routing credential (${result.kind}). ` +
662
+ `start-tickets model routing may not resolve the key for ${plan.credentialTarget}; ` +
663
+ "set BAPI_API_KEY in the shell or re-run install-bridge.");
664
+ }
665
+ }
666
+ catch {
667
+ // Fail-open: persistence is best-effort (mirrors Stage 6). The secret is never
668
+ // included in the warning.
669
+ log(" warning: could not persist the routing credential (unexpected error). " +
670
+ "start-tickets model routing may need BAPI_API_KEY in the shell.");
671
+ }
672
+ // ---- Step 5 — spawn a fresh agent session for the agentic remainder ----
673
+ log(`Step 5/5 — opening a ${agent.name} session for /install-bridge + /learn-repository…`);
674
+ const terminal = detectTerminal(undefined, deps.env);
675
+ const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, spawnCommand, {
676
+ key: "install",
677
+ worktreePath: deps.cwd,
678
+ });
679
+ if (!spawnResult.ok) {
680
+ // Steps 1–4 (scaffold, config, connectivity-verified, credential persist) all
681
+ // succeeded and are durable; only the best-effort Step 5 tab spawn failed. The
682
+ // install itself is complete, so this is a non-fatal warning (exit 0) — the
683
+ // user can run the agentic remainder by hand. Mirrors start-tickets treating a
684
+ // spawn failure as non-fatal to the run.
685
+ errorLog(`Warning: setup completed, but the agent session could not be opened (${spawnResult.error}). ` +
686
+ "Run /install-bridge then /learn-repository manually in this project.");
687
+ return 0;
688
+ }
689
+ log("");
690
+ log("install-bridge complete. A fresh agent session is running /install-bridge then /learn-repository.");
691
+ return 0;
692
+ }