@loucompanion/forge-bridge 0.1.1-dev.8dcbc6245e51 → 0.1.1-dev.9219f8477b8a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/bridge/bridge.service.d.ts +1 -0
  2. package/dist/bridge/bridge.service.js +219 -25
  3. package/dist/bridge/bridge.types.d.ts +19 -2
  4. package/dist/cli/cli.service.d.ts +27 -2
  5. package/dist/cli/cli.service.js +630 -25
  6. package/dist/cli/local-config.d.ts +4 -0
  7. package/dist/cli/local-config.js +42 -4
  8. package/dist/config.js +1 -1
  9. package/dist/session/index.d.ts +2 -0
  10. package/dist/session/index.js +1 -0
  11. package/dist/session/internals.d.ts +5 -3
  12. package/dist/session/internals.js +2 -1
  13. package/dist/session/phase-completion.d.ts +126 -0
  14. package/dist/session/phase-completion.js +3233 -0
  15. package/dist/session/provider-cli/index.d.ts +2 -2
  16. package/dist/session/provider-cli/index.js +1 -1
  17. package/dist/session/provider-cli/provider-cli.base.d.ts +5 -3
  18. package/dist/session/provider-cli/provider-cli.service.d.ts +64 -4
  19. package/dist/session/provider-cli/provider-cli.service.js +2787 -51
  20. package/dist/session/provider-cli/provider-cli.types.d.ts +7 -0
  21. package/dist/session/provider-cli/provider-usage.d.ts +25 -0
  22. package/dist/session/provider-cli/provider-usage.js +64 -0
  23. package/dist/session/qa-sidecar-validation.d.ts +3 -0
  24. package/dist/session/qa-sidecar-validation.js +44 -0
  25. package/dist/session/session.service.js +62 -11
  26. package/dist/session/session.types.d.ts +132 -0
  27. package/dist/session/session.types.js +261 -2
  28. package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
  29. package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/dist/worktree/artifact-injector.d.ts +2 -1
  33. package/dist/worktree/artifact-injector.js +125 -9
  34. package/dist/worktree/worktree.service.d.ts +3 -0
  35. package/dist/worktree/worktree.service.js +239 -8
  36. package/dist/worktree/worktree.types.d.ts +8 -1
  37. package/package.json +8 -3
@@ -1,32 +1,56 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { execFile } from "node:child_process";
3
+ import os from "node:os";
3
4
  import path from "node:path";
5
+ import { createInterface } from "node:readline/promises";
4
6
  import { promisify } from "node:util";
7
+ import { resolveCursorAgentDirectLaunch } from "../shared/cursor-agent/cursor-agent.launch.js";
5
8
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
6
9
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
7
- import { addRepoMapping, applyLocalConfigDefaults, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath } from "./local-config.js";
10
+ import { forgeBridgeVersion } from "../version.js";
11
+ import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
8
12
  import { BridgeSessionService } from "../session/session.service.js";
13
+ import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
9
14
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
10
15
  const apiKeySecretGrantEnv = "FORGE_API_KEY_SECRET_GRANT";
11
16
  const execFileAsync = promisify(execFile);
12
17
  export class ForgeBridgeCliService {
13
18
  sessions;
14
19
  bridgeFactory;
15
- constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config)) {
20
+ pairRunner;
21
+ constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config), pairRunner = claimBridgeRunner) {
16
22
  this.sessions = sessions;
17
23
  this.bridgeFactory = bridgeFactory;
24
+ this.pairRunner = pairRunner;
18
25
  }
19
26
  async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
27
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
28
+ io.stdout.write(helpText());
29
+ return 0;
30
+ }
31
+ if (argv[0] === "help") {
32
+ io.stdout.write(argv[1] ? commandHelpText(argv[1]) : helpText());
33
+ return 0;
34
+ }
35
+ if (argv[1] === "--help" || argv[1] === "-h") {
36
+ io.stdout.write(commandHelpText(argv[0]));
37
+ return 0;
38
+ }
39
+ if (argv[0] === "version" || argv[0] === "--version" || argv[0] === "-v") {
40
+ io.stdout.write(`forge-bridge ${defaultBridgeServiceConfig().version ?? forgeBridgeVersion}\n`);
41
+ return 0;
42
+ }
20
43
  const args = parseArgs(argv);
21
44
  if (!args.ok) {
22
45
  io.stderr.write(`${args.error}\n`);
23
46
  return 2;
24
47
  }
25
48
  if (args.command === "connect") {
49
+ const config = await withDetectedCliProviders(args.config);
26
50
  const missing = [
27
- ["server", args.config.serverUrl],
28
- ["runner id", args.config.runnerId],
29
- ["device token", args.config.deviceToken]
51
+ ["server", config.serverUrl],
52
+ ["runner id", config.runnerId],
53
+ ["device token", config.deviceToken]
30
54
  ].find(([, value]) => !value);
31
55
  if (missing) {
32
56
  io.stderr.write(`forge-bridge connect requires ${missing[0]}.\n`);
@@ -37,7 +61,8 @@ export class ForgeBridgeCliService {
37
61
  process.once("SIGINT", stop);
38
62
  process.once("SIGTERM", stop);
39
63
  try {
40
- await this.bridgeFactory(args.config).run(controller.signal);
64
+ io.stdout.write(connectStartupText(config));
65
+ await this.bridgeFactory(config).run(controller.signal);
41
66
  return 0;
42
67
  }
43
68
  finally {
@@ -58,6 +83,27 @@ export class ForgeBridgeCliService {
58
83
  return 2;
59
84
  }
60
85
  }
86
+ if (args.command === "pair") {
87
+ try {
88
+ const current = readLocalConfigSync();
89
+ const code = await resolvePairingCode(args.code);
90
+ const repoRoot = await resolvePairRepoRoot(args.repoRoot, current.defaultRepoRoot);
91
+ await fs.mkdir(repoRoot, { recursive: true });
92
+ const pairArgs = await withDetectedPairCliProviders({ ...args, code, repoRoot });
93
+ const claim = await this.pairRunner(pairArgs);
94
+ const next = pairedConfig(current, pairArgs, claim);
95
+ await writeLocalConfig(next);
96
+ io.stdout.write(`Paired runner ${claim.runnerId}.\n`);
97
+ io.stdout.write(`Repo folder: ${repoRoot}\n`);
98
+ io.stdout.write(`Config: ${resolveLocalConfigPath()}\n`);
99
+ io.stdout.write("Start the bridge with: forge-bridge connect\n");
100
+ return 0;
101
+ }
102
+ catch (error) {
103
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
104
+ return 1;
105
+ }
106
+ }
61
107
  if (args.command === "remove-repo") {
62
108
  try {
63
109
  const result = await removeRepoMapping(args.label);
@@ -76,6 +122,15 @@ export class ForgeBridgeCliService {
76
122
  if (args.command === "cleanup") {
77
123
  return await cleanupRepos(args.config, args.mode, args.force, io);
78
124
  }
125
+ if (args.command === "mcp") {
126
+ const parsed = parseCompletionMcpArgs(args.argv);
127
+ if (!parsed.ok) {
128
+ io.stderr.write(`${parsed.error}\n`);
129
+ return 2;
130
+ }
131
+ await runCompletionMcpServer(parsed.config);
132
+ return 0;
133
+ }
79
134
  let redactionSecrets = readEnvSecretValuesForRedaction();
80
135
  try {
81
136
  const dispatchJson = await fs.readFile(args.dispatchPath, "utf8");
@@ -139,24 +194,31 @@ function parseEnvSecretGrant(raw) {
139
194
  };
140
195
  }
141
196
  function exactSecrets(dispatch) {
142
- return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
197
+ return exactSecretValues(dispatch.apiKeySecretGrant?.secret, process.env.FORGE_DEVICE_TOKEN);
143
198
  }
144
199
  function readEnvSecretValuesForRedaction() {
200
+ const secrets = [];
145
201
  const raw = process.env[apiKeySecretGrantEnv];
146
- if (!raw) {
147
- return [];
148
- }
149
- try {
150
- return [parseEnvSecretGrant(raw).secret];
151
- }
152
- catch {
153
- return [];
202
+ if (raw) {
203
+ try {
204
+ secrets.push(parseEnvSecretGrant(raw).secret);
205
+ }
206
+ catch {
207
+ // Ignore malformed grant env here; command handling will report the parse error.
208
+ }
154
209
  }
210
+ return exactSecretValues(...secrets, process.env.FORGE_DEVICE_TOKEN);
211
+ }
212
+ function exactSecretValues(...values) {
213
+ return values.filter((value) => typeof value === "string" && value.length > 0);
155
214
  }
156
215
  export function parseArgs(argv) {
157
216
  if (argv[0] === "connect") {
158
217
  return parseConnectArgs(argv);
159
218
  }
219
+ if (argv[0] === "pair") {
220
+ return parsePairArgs(argv);
221
+ }
160
222
  if (argv[0] === "add-repo") {
161
223
  if (!argv[1] || !argv[2] || argv.length > 3) {
162
224
  return { ok: false, error: "Usage: forge-bridge add-repo <label> <path>" };
@@ -185,10 +247,13 @@ export function parseArgs(argv) {
185
247
  if (argv[0] === "cleanup") {
186
248
  return parseCleanupArgs(argv);
187
249
  }
250
+ if (argv[0] === "mcp") {
251
+ return { ok: true, command: "mcp", argv };
252
+ }
188
253
  if (argv[0] !== "run-once") {
189
254
  return {
190
255
  ok: false,
191
- error: "Usage: forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path> | forge-bridge connect --server <url> --runner-id <id> --device-token <token> | forge-bridge add-repo <label> <path> | forge-bridge remove-repo <label> | forge-bridge status [--check-updates] | forge-bridge cleanup [--stale|--merged|--all|--force]"
256
+ error: usage()
192
257
  };
193
258
  }
194
259
  let dispatchPath = null;
@@ -223,7 +288,7 @@ function parseConnectArgs(argv) {
223
288
  let heartbeatIntervalMs = null;
224
289
  const cliProviders = [];
225
290
  const projectLabels = [];
226
- let cleanupWorktrees = true;
291
+ let cleanupWorktrees = null;
227
292
  for (let index = 1; index < argv.length; index += 1) {
228
293
  const arg = argv[index];
229
294
  if (arg === "--server") {
@@ -253,6 +318,9 @@ function parseConnectArgs(argv) {
253
318
  else if (arg === "--no-cleanup-worktrees") {
254
319
  cleanupWorktrees = false;
255
320
  }
321
+ else if (arg === "--cleanup-worktrees") {
322
+ cleanupWorktrees = true;
323
+ }
256
324
  else {
257
325
  return { ok: false, error: `Unknown argument: ${arg}` };
258
326
  }
@@ -261,7 +329,7 @@ function parseConnectArgs(argv) {
261
329
  if (serverUrl === null && !process.env.FORGE_URL?.trim() && !localConfig.serverUrl?.trim()) {
262
330
  return { ok: false, error: "forge-bridge connect requires --server or FORGE_URL." };
263
331
  }
264
- const overrides = { cleanupWorktrees };
332
+ const overrides = {};
265
333
  if (serverUrl !== null)
266
334
  overrides.serverUrl = serverUrl;
267
335
  if (runnerId !== null)
@@ -276,12 +344,79 @@ function parseConnectArgs(argv) {
276
344
  overrides.projectLabels = projectLabels;
277
345
  if (heartbeatIntervalMs !== null)
278
346
  overrides.heartbeatIntervalMs = heartbeatIntervalMs;
347
+ if (cleanupWorktrees !== null)
348
+ overrides.cleanupWorktrees = cleanupWorktrees;
279
349
  return {
280
350
  ok: true,
281
351
  command: "connect",
282
352
  config: configuredBridgeServiceConfig(overrides, localConfig)
283
353
  };
284
354
  }
355
+ function parsePairArgs(argv) {
356
+ const localConfig = readLocalConfigSync();
357
+ const defaultConfig = configuredBridgeServiceConfig({}, localConfig);
358
+ let serverUrl = defaultConfig.serverUrl;
359
+ let code = null;
360
+ let name = null;
361
+ let fingerprint = null;
362
+ let repoRoot = null;
363
+ let cleanupWorktrees = null;
364
+ const cliProviders = [];
365
+ const projectLabels = [];
366
+ for (let index = 1; index < argv.length; index += 1) {
367
+ const arg = argv[index];
368
+ if (arg === "--server") {
369
+ serverUrl = argv[++index] ?? "";
370
+ }
371
+ else if (arg === "--code") {
372
+ code = argv[++index] ?? null;
373
+ }
374
+ else if (arg === "--name") {
375
+ name = argv[++index] ?? null;
376
+ }
377
+ else if (arg === "--fingerprint") {
378
+ fingerprint = argv[++index] ?? null;
379
+ }
380
+ else if (arg === "--repo-root") {
381
+ repoRoot = argv[++index] ?? null;
382
+ }
383
+ else if (arg === "--cli-provider") {
384
+ cliProviders.push(...splitCsv(argv[++index] ?? ""));
385
+ }
386
+ else if (arg === "--project-label") {
387
+ projectLabels.push(...splitCsv(argv[++index] ?? ""));
388
+ }
389
+ else if (arg === "--cleanup-worktrees") {
390
+ cleanupWorktrees = true;
391
+ }
392
+ else if (arg === "--no-cleanup-worktrees") {
393
+ cleanupWorktrees = false;
394
+ }
395
+ else if (!arg.startsWith("-") && code === null) {
396
+ code = arg;
397
+ }
398
+ else {
399
+ return { ok: false, error: `Unknown argument: ${arg}` };
400
+ }
401
+ }
402
+ if (!serverUrl.trim()) {
403
+ return { ok: false, error: "forge-bridge pair requires --server or configured serverUrl." };
404
+ }
405
+ const defaultName = `Local bridge ${os.hostname()}`;
406
+ const user = os.userInfo().username;
407
+ return {
408
+ ok: true,
409
+ command: "pair",
410
+ serverUrl,
411
+ code: normalizePairingCode(code),
412
+ name: name?.trim() || defaultName,
413
+ fingerprint: fingerprint?.trim() || `local-bridge:${os.hostname()}:${user}`,
414
+ repoRoot: repoRoot?.trim() ? path.resolve(repoRoot) : undefined,
415
+ cleanupWorktrees: cleanupWorktrees ?? localConfig.cleanupWorktrees ?? false,
416
+ cliProviders: cliProviders.length > 0 ? cliProviders : localConfig.cliProviders && localConfig.cliProviders.length > 0 ? [...localConfig.cliProviders] : ["codex"],
417
+ projectLabels: projectLabels.length > 0 ? projectLabels : localConfig.projectLabels ? [...localConfig.projectLabels] : []
418
+ };
419
+ }
285
420
  function parseCleanupArgs(argv) {
286
421
  let mode = "stale";
287
422
  let force = false;
@@ -317,6 +452,293 @@ function splitCsv(value) {
317
452
  .map((entry) => entry.trim())
318
453
  .filter((entry) => entry.length > 0);
319
454
  }
455
+ async function claimBridgeRunner(args) {
456
+ if (!args.code) {
457
+ throw new Error("Pairing code is required.");
458
+ }
459
+ const response = await fetch(new URL("/api/runners/claim", normalizeServerUrl(args.serverUrl)), {
460
+ method: "POST",
461
+ headers: { "Content-Type": "application/json" },
462
+ body: JSON.stringify({
463
+ code: args.code,
464
+ name: args.name,
465
+ fingerprint: args.fingerprint,
466
+ cliProviders: args.cliProviders,
467
+ projectLabels: args.projectLabels
468
+ })
469
+ });
470
+ if (!response.ok) {
471
+ throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
472
+ }
473
+ const value = await response.json();
474
+ if (!value.runnerId || !value.deviceToken) {
475
+ throw new Error("Pairing response did not include runner credentials.");
476
+ }
477
+ return {
478
+ runnerId: value.runnerId,
479
+ deviceToken: value.deviceToken
480
+ };
481
+ }
482
+ function pairedConfig(current, args, claim) {
483
+ return {
484
+ ...current,
485
+ schemaVersion: 2,
486
+ serverUrl: args.serverUrl,
487
+ runnerId: claim.runnerId,
488
+ deviceToken: claim.deviceToken,
489
+ deviceName: args.name,
490
+ defaultRepoRoot: args.repoRoot ?? current.defaultRepoRoot,
491
+ cleanupWorktrees: args.cleanupWorktrees,
492
+ cliProviders: [...args.cliProviders],
493
+ providerStatuses: current.providerStatuses ?? providerStatusesFor(args.cliProviders),
494
+ projectLabels: [...args.projectLabels],
495
+ supportsApiSecretGrant: current.supportsApiSecretGrant ?? true,
496
+ supportsManagedWorktrees: current.supportsManagedWorktrees ?? true,
497
+ supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
498
+ supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
499
+ supportsDraftRunOnce: current.supportsDraftRunOnce ?? true,
500
+ repoMappings: current.repoMappings ?? []
501
+ };
502
+ }
503
+ async function resolvePairingCode(code) {
504
+ const normalized = normalizePairingCode(code);
505
+ if (normalized) {
506
+ return normalized;
507
+ }
508
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
509
+ throw new Error("Pairing code is required. Run `forge-bridge pair <code>` or use an interactive terminal.");
510
+ }
511
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
512
+ try {
513
+ const answer = await prompt.question("Pairing code: ");
514
+ const clean = normalizePairingCode(answer);
515
+ if (!clean) {
516
+ throw new Error("Pairing code is required.");
517
+ }
518
+ return clean;
519
+ }
520
+ finally {
521
+ prompt.close();
522
+ }
523
+ }
524
+ async function resolvePairRepoRoot(repoRoot, configuredRoot) {
525
+ const fallback = path.resolve(repoRoot ?? configuredRoot ?? path.join(os.homedir(), "ForgeRepos"));
526
+ if (repoRoot?.trim()) {
527
+ return fallback;
528
+ }
529
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
530
+ return fallback;
531
+ }
532
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
533
+ try {
534
+ const answer = await prompt.question(`Repo folder [${fallback}]: `);
535
+ return path.resolve(answer.trim() || fallback);
536
+ }
537
+ finally {
538
+ prompt.close();
539
+ }
540
+ }
541
+ function normalizePairingCode(code) {
542
+ return code?.replace(/[\s-]/g, "").trim() ?? "";
543
+ }
544
+ function normalizeServerUrl(serverUrl) {
545
+ return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
546
+ }
547
+ function usage() {
548
+ return helpText();
549
+ }
550
+ function helpText() {
551
+ const version = defaultBridgeServiceConfig().version ?? forgeBridgeVersion;
552
+ return [
553
+ `🛠 Forge Bridge ${version} — Local provider runners for Forge.`,
554
+ "",
555
+ "Usage: forge-bridge [options] [command]",
556
+ "",
557
+ "Options:",
558
+ " -h, --help Display help for command",
559
+ " -v, --version Output the version number",
560
+ "",
561
+ "Commands:",
562
+ " Hint: commands suffixed with * have detailed help. Run <command> --help for details.",
563
+ " connect * Start the runner bridge and wait for Forge dispatches",
564
+ " pair * Pair this machine with Forge and save runner credentials",
565
+ " status * Show config, runner identity, repos, provider CLIs, and updates",
566
+ " add-repo * Map a Forge repo label/name to a local checkout path",
567
+ " remove-repo * Remove a saved repo mapping",
568
+ " cleanup * Prune managed bridge worktrees",
569
+ " version * Print the installed bridge package version",
570
+ " help * Display help for command",
571
+ " run-once * Internal: run one dispatch packet from JSON",
572
+ " mcp * Internal: run the phase-completion MCP server",
573
+ "",
574
+ "Examples:",
575
+ " npm install -g @loucompanion/forge-bridge@next",
576
+ " forge-bridge pair 1234-5678 --server https://srv1493013.tail66073d.ts.net:8443 --repo-root \"%USERPROFILE%\\ForgeRepos\"",
577
+ " forge-bridge connect",
578
+ " forge-bridge status --check-updates",
579
+ " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
580
+ " forge-bridge cleanup --stale",
581
+ "",
582
+ "Troubleshooting:",
583
+ " If Forge says the bridge is old: npm install -g @loucompanion/forge-bridge@next",
584
+ " If connect shows 502/500: leave it open; it reconnects with backoff while dev restarts.",
585
+ " If a provider is missing: run forge-bridge status --check-updates and verify CLI login.",
586
+ "",
587
+ "Docs:",
588
+ " https://github.com/coleman399/Forge/tree/develop/tools/forge-bridge#readme",
589
+ "",
590
+ ].join("\n");
591
+ }
592
+ function commandHelpText(command) {
593
+ switch (command) {
594
+ case "pair":
595
+ return [
596
+ "Forge Bridge — pair",
597
+ "Pair this machine with Forge and save runner credentials.",
598
+ "",
599
+ "Usage:",
600
+ " forge-bridge pair [code] [options]",
601
+ "",
602
+ "Options:",
603
+ " --server <url> Forge server URL.",
604
+ " --repo-root <path> Folder where bridge-managed repos/worktrees live.",
605
+ " --name <name> Display name for this runner.",
606
+ " --fingerprint <value> Stable device fingerprint override.",
607
+ " --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
608
+ " --cleanup-worktrees Enable managed worktree cleanup.",
609
+ " --no-cleanup-worktrees Disable managed worktree cleanup.",
610
+ "",
611
+ "Example:",
612
+ " forge-bridge pair 1234-5678 --server https://forge.example.com --repo-root \"%USERPROFILE%\\ForgeRepos\"",
613
+ "",
614
+ ].join("\n");
615
+ case "connect":
616
+ return [
617
+ "Forge Bridge — connect",
618
+ "Start the runner bridge and keep it connected for Forge dispatches.",
619
+ "",
620
+ "Usage:",
621
+ " forge-bridge connect [options]",
622
+ "",
623
+ "Options:",
624
+ " --server <url> Forge server URL; saved config is used when omitted.",
625
+ " --runner-id <id> Runner id; saved config is used when omitted.",
626
+ " --device-token <token> Device token; saved config is used when omitted.",
627
+ " --repo-root <path> Override repo/worktree folder for this run.",
628
+ " --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
629
+ " --heartbeat-interval-ms <n> Heartbeat interval in milliseconds.",
630
+ " --cleanup-worktrees Enable managed worktree cleanup for this run.",
631
+ " --no-cleanup-worktrees Disable managed worktree cleanup for this run.",
632
+ "",
633
+ "Example:",
634
+ " forge-bridge connect",
635
+ "",
636
+ ].join("\n");
637
+ case "status":
638
+ return [
639
+ "Forge Bridge — status",
640
+ "Show saved configuration, paired runner identity, repo mappings, detected provider CLIs, and optional update info.",
641
+ "",
642
+ "Usage:",
643
+ " forge-bridge status [--check-updates]",
644
+ "",
645
+ "Example:",
646
+ " forge-bridge status --check-updates",
647
+ "",
648
+ ].join("\n");
649
+ case "add-repo":
650
+ return [
651
+ "Forge Bridge — add-repo",
652
+ "Map a Forge repo label/name to an existing local checkout.",
653
+ "",
654
+ "Usage:",
655
+ " forge-bridge add-repo <label> <path>",
656
+ "",
657
+ "Example:",
658
+ " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
659
+ "",
660
+ ].join("\n");
661
+ case "remove-repo":
662
+ return [
663
+ "Forge Bridge — remove-repo",
664
+ "Remove a saved local repo mapping.",
665
+ "",
666
+ "Usage:",
667
+ " forge-bridge remove-repo <label>",
668
+ "",
669
+ ].join("\n");
670
+ case "cleanup":
671
+ return [
672
+ "Forge Bridge — cleanup",
673
+ "Prune managed bridge worktrees under the configured repo folder.",
674
+ "",
675
+ "Usage:",
676
+ " forge-bridge cleanup [--stale|--merged|--all] [--force]",
677
+ "",
678
+ "Examples:",
679
+ " forge-bridge cleanup --stale",
680
+ " forge-bridge cleanup --all --force",
681
+ "",
682
+ ].join("\n");
683
+ case "run-once":
684
+ return [
685
+ "Forge Bridge — run-once",
686
+ "Internal command used by Forge to run a single dispatch packet.",
687
+ "",
688
+ "Usage:",
689
+ " forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path>",
690
+ "",
691
+ ].join("\n");
692
+ case "mcp":
693
+ return [
694
+ "Forge Bridge — mcp",
695
+ "Internal MCP server used by provider CLIs to report Forge phase artifacts.",
696
+ "",
697
+ "Usage:",
698
+ " forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase>",
699
+ "",
700
+ ].join("\n");
701
+ case "version":
702
+ return [
703
+ "Forge Bridge — version",
704
+ "Print the installed bridge package version.",
705
+ "",
706
+ "Usage:",
707
+ " forge-bridge version",
708
+ "",
709
+ ].join("\n");
710
+ default:
711
+ return [
712
+ `Unknown forge-bridge command: ${command}`,
713
+ "",
714
+ "Run `forge-bridge help` to see available commands.",
715
+ "",
716
+ ].join("\n");
717
+ }
718
+ }
719
+ function connectStartupText(config) {
720
+ const runner = config.runnerId
721
+ ? `${config.runnerId}${config.deviceName ? ` (${config.deviceName})` : ""}`
722
+ : "not configured";
723
+ return [
724
+ `Forge Bridge ${config.version}`,
725
+ "Starting runner bridge...",
726
+ "",
727
+ "Startup:",
728
+ ` server: ${config.serverUrl || "not configured"}`,
729
+ ` runner: ${runner}`,
730
+ ` config: ${resolveLocalConfigPath()}`,
731
+ ` repo folder: ${config.defaultRepoRoot || "not configured"}`,
732
+ ` providers: ${formatList(config.cliProviders)}`,
733
+ ` cleanup worktrees: ${config.cleanupWorktrees ? "enabled" : "disabled"}`,
734
+ "",
735
+ "Press Ctrl+C to stop.",
736
+ "",
737
+ ].join("\n");
738
+ }
739
+ function formatList(values) {
740
+ return values.length > 0 ? values.join(", ") : "none";
741
+ }
320
742
  async function writeJsonAtomic(outputPath, value) {
321
743
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
322
744
  const tempPath = `${outputPath}.${process.pid}.${Date.now()}.tmp`;
@@ -343,7 +765,8 @@ async function writeStatus(config, checkUpdates, io) {
343
765
  }
344
766
  io.stdout.write("Detected CLIs:\n");
345
767
  for (const cli of detected) {
346
- io.stdout.write(` ${cli.provider}: ${cli.found ? cli.version || "found" : "not found"}\n`);
768
+ const models = cli.models.length > 0 ? `, ${cli.models.length} models` : "";
769
+ io.stdout.write(` ${cli.provider}: ${cli.found ? `${cli.version || "found"}${models}` : "not found"}\n`);
347
770
  }
348
771
  if (checkUpdates) {
349
772
  io.stdout.write("Update check: no update channel configured.\n");
@@ -385,16 +808,198 @@ async function cleanupRepos(config, mode, force, io) {
385
808
  async function detectCliProviders(providers) {
386
809
  const results = [];
387
810
  for (const provider of providers) {
388
- try {
389
- const result = await execFileAsync(provider, ["--version"], { timeout: 2000 });
390
- results.push({ provider, found: true, version: `${result.stdout || result.stderr}`.trim().split(/\r?\n/, 1)[0] ?? "" });
391
- }
392
- catch {
393
- results.push({ provider, found: false, version: "" });
811
+ let detected = null;
812
+ for (const probe of cliProbeCommandsFor(provider)) {
813
+ try {
814
+ detected = await execVersionCommand(probe);
815
+ break;
816
+ }
817
+ catch {
818
+ // Try the next provider-specific probe.
819
+ }
394
820
  }
821
+ results.push({
822
+ provider,
823
+ found: detected !== null,
824
+ version: detected ? versionLine(`${detected.stdout || detected.stderr}`) : "",
825
+ models: detected ? await detectCliModels(provider) : []
826
+ });
395
827
  }
396
828
  return results;
397
829
  }
830
+ function cliProbeCommandsFor(provider) {
831
+ const normalized = provider.toLowerCase();
832
+ if (normalized === "codex") {
833
+ return [
834
+ { command: "codex", args: ["-V"] },
835
+ { command: "codex", args: ["--version"] }
836
+ ];
837
+ }
838
+ if (normalized === "cursor") {
839
+ return [
840
+ { command: "cursor-agent", args: ["-v"] },
841
+ { command: "agent", args: ["-v"] },
842
+ { command: "cursor-agent", args: ["--version"] },
843
+ { command: "agent", args: ["--version"] }
844
+ ];
845
+ }
846
+ return [
847
+ { command: provider, args: ["--version"] },
848
+ { command: provider, args: ["-V"] }
849
+ ];
850
+ }
851
+ async function execVersionCommand(probe, timeout = 2000) {
852
+ const directLaunch = process.platform === "win32"
853
+ ? await resolveCursorAgentDirectLaunch(probe.command, probe.args)
854
+ : null;
855
+ if (directLaunch) {
856
+ return await execFileAsync(directLaunch.command, [...directLaunch.args], { timeout });
857
+ }
858
+ try {
859
+ return await execFileAsync(probe.command, probe.args, { timeout });
860
+ }
861
+ catch (error) {
862
+ if (process.platform !== "win32") {
863
+ throw error;
864
+ }
865
+ return await execFileAsync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", windowsCommandLine(probe)], { timeout });
866
+ }
867
+ }
868
+ function windowsCommandLine(probe) {
869
+ return [probe.command, ...probe.args].map(quoteWindowsCommandLineArg).join(" ");
870
+ }
871
+ function quoteWindowsCommandLineArg(value) {
872
+ if (value.length > 0 && !/[\s"]/.test(value)) {
873
+ return value;
874
+ }
875
+ return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
876
+ }
877
+ function versionLine(output) {
878
+ const lines = output
879
+ .trim()
880
+ .split(/\r?\n/)
881
+ .map((line) => line.trim())
882
+ .filter((line) => line.length > 0);
883
+ return lines.at(-1) ?? "";
884
+ }
885
+ async function detectCliModels(provider) {
886
+ const normalized = provider.toLowerCase();
887
+ try {
888
+ if (normalized === "codex") {
889
+ const result = await execFileAsync("codex", ["debug", "models"], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 });
890
+ return parseCodexModelCatalog(result.stdout);
891
+ }
892
+ if (normalized === "cursor") {
893
+ const result = await execVersionCommand({ command: "cursor-agent", args: ["models"] }, 10_000);
894
+ return parseCursorModelList(`${result.stdout || result.stderr}`);
895
+ }
896
+ }
897
+ catch {
898
+ return [];
899
+ }
900
+ return [];
901
+ }
902
+ export function parseCodexModelCatalog(raw) {
903
+ try {
904
+ const parsed = JSON.parse(raw);
905
+ if (!isRecord(parsed) || !Array.isArray(parsed.models)) {
906
+ return [];
907
+ }
908
+ const entries = parsed.models
909
+ .map((entry, index) => {
910
+ if (!isRecord(entry) || typeof entry.slug !== "string") {
911
+ return null;
912
+ }
913
+ const visibility = typeof entry.visibility === "string" ? entry.visibility.toLowerCase() : "list";
914
+ if (visibility !== "list") {
915
+ return null;
916
+ }
917
+ const priority = typeof entry.priority === "number" && Number.isFinite(entry.priority) ? entry.priority : index;
918
+ return { slug: entry.slug, priority, index };
919
+ })
920
+ .filter((entry) => entry !== null)
921
+ .sort((left, right) => left.priority - right.priority || left.index - right.index)
922
+ .map((entry) => entry.slug);
923
+ return uniqueModels(entries);
924
+ }
925
+ catch {
926
+ return [];
927
+ }
928
+ }
929
+ export function parseCursorModelList(raw) {
930
+ return uniqueModels(raw
931
+ .split(/\r?\n/)
932
+ .map((line) => line.trim())
933
+ .filter((line) => line.length > 0 && !line.toLowerCase().startsWith("available models"))
934
+ .map((line) => line.split(/\s+-\s+/, 1)[0]?.trim() ?? "")
935
+ .filter((model) => model.length > 0));
936
+ }
937
+ function uniqueModels(models) {
938
+ const seen = new Set();
939
+ const result = [];
940
+ for (const model of models) {
941
+ const trimmed = model.trim();
942
+ const key = trimmed.toLowerCase();
943
+ if (!trimmed || seen.has(key)) {
944
+ continue;
945
+ }
946
+ seen.add(key);
947
+ result.push(trimmed);
948
+ }
949
+ return result;
950
+ }
951
+ function isRecord(value) {
952
+ return typeof value === "object" && value !== null;
953
+ }
954
+ async function withDetectedCliProviders(config) {
955
+ const detected = await detectCliProviders(configuredProviderCandidates(config.cliProviders));
956
+ const found = detected.filter((result) => result.found);
957
+ if (found.length === 0) {
958
+ return config;
959
+ }
960
+ const cliProviders = uniqueProviders([...config.cliProviders, ...found.map((result) => result.provider)]);
961
+ const providerStatuses = { ...config.providerStatuses };
962
+ for (const result of found) {
963
+ const existing = providerStatuses[result.provider];
964
+ providerStatuses[result.provider] = {
965
+ signedIn: existing?.signedIn ?? true,
966
+ version: result.version || existing?.version || "",
967
+ models: result.models.length > 0
968
+ ? result.models
969
+ : existing?.models ?? providerStatusesFor([result.provider])[result.provider]?.models ?? []
970
+ };
971
+ }
972
+ return {
973
+ ...config,
974
+ cliProviders,
975
+ providerStatuses
976
+ };
977
+ }
978
+ async function withDetectedPairCliProviders(args) {
979
+ const detectedConfig = await withDetectedCliProviders({
980
+ ...defaultBridgeServiceConfig({
981
+ serverUrl: args.serverUrl,
982
+ cliProviders: [...args.cliProviders],
983
+ providerStatuses: providerStatusesFor(args.cliProviders),
984
+ projectLabels: [...args.projectLabels],
985
+ cleanupWorktrees: args.cleanupWorktrees,
986
+ defaultRepoRoot: args.repoRoot ?? defaultBridgeServiceConfig().defaultRepoRoot
987
+ })
988
+ });
989
+ return {
990
+ ...args,
991
+ cliProviders: detectedConfig.cliProviders
992
+ };
993
+ }
994
+ function configuredProviderCandidates(providers) {
995
+ return uniqueProviders([...providers, "codex", "cursor"]);
996
+ }
997
+ function uniqueProviders(providers) {
998
+ return providers
999
+ .map((provider) => provider.trim())
1000
+ .filter((provider) => provider.length > 0)
1001
+ .filter((provider, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === provider.toLowerCase()) === index);
1002
+ }
398
1003
  async function runGit(cwd, args) {
399
1004
  await execFileAsync("git", args, { cwd, timeout: 10_000 });
400
1005
  }