@loucompanion/forge-bridge 0.1.1-dev.7d43998c4521 → 0.1.1-dev.af36c7fa102b

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 (34) hide show
  1. package/README.md +1 -0
  2. package/dist/bridge/bridge.service.js +16 -3
  3. package/dist/bridge/bridge.types.d.ts +7 -0
  4. package/dist/cli/cli.service.d.ts +27 -2
  5. package/dist/cli/cli.service.js +399 -16
  6. package/dist/cli/local-config.d.ts +4 -1
  7. package/dist/cli/local-config.js +42 -7
  8. package/dist/session/index.d.ts +2 -0
  9. package/dist/session/index.js +1 -0
  10. package/dist/session/internals.d.ts +4 -2
  11. package/dist/session/internals.js +2 -1
  12. package/dist/session/phase-completion.d.ts +54 -0
  13. package/dist/session/phase-completion.js +692 -0
  14. package/dist/session/provider-cli/index.d.ts +2 -2
  15. package/dist/session/provider-cli/index.js +1 -1
  16. package/dist/session/provider-cli/provider-cli.base.d.ts +2 -1
  17. package/dist/session/provider-cli/provider-cli.service.d.ts +45 -1
  18. package/dist/session/provider-cli/provider-cli.service.js +602 -10
  19. package/dist/session/provider-cli/provider-cli.types.d.ts +1 -0
  20. package/dist/session/session.service.js +52 -10
  21. package/dist/session/session.types.d.ts +11 -0
  22. package/dist/session/session.types.js +47 -1
  23. package/dist/version.d.ts +1 -1
  24. package/dist/version.js +1 -1
  25. package/dist/worktree/artifact-injector.d.ts +11 -0
  26. package/dist/worktree/artifact-injector.js +107 -0
  27. package/dist/worktree/dependency-warmer.d.ts +32 -0
  28. package/dist/worktree/dependency-warmer.js +297 -0
  29. package/dist/worktree/index.d.ts +2 -0
  30. package/dist/worktree/index.js +1 -0
  31. package/dist/worktree/worktree.service.d.ts +4 -0
  32. package/dist/worktree/worktree.service.js +173 -11
  33. package/dist/worktree/worktree.types.d.ts +5 -1
  34. package/package.json +1 -1
package/README.md CHANGED
@@ -31,6 +31,7 @@ The bridge can also read connection settings from environment variables:
31
31
  - `FORGE_DEVICE_NAME`
32
32
  - `FORGE_REPO_ROOT`
33
33
  - `FORGE_CLI_PROVIDERS`
34
+ - `FORGE_CODEX_MODELS` / `FORGE_CURSOR_MODELS` (optional comma-separated model ids advertised in runner capabilities)
34
35
 
35
36
  ## Repo-local development
36
37
 
@@ -168,7 +168,7 @@ export class ForgeBridgeService {
168
168
  }
169
169
  capabilities() {
170
170
  return {
171
- schemaVersion: 1,
171
+ schemaVersion: 2,
172
172
  activeSessionCount: this.activeSessions.size,
173
173
  cliProviders: this.config.cliProviders,
174
174
  providerStatuses: this.config.providerStatuses,
@@ -176,7 +176,10 @@ export class ForgeBridgeService {
176
176
  repoMappings: this.config.repoMappings,
177
177
  os: this.config.os,
178
178
  sandboxProfiles: this.config.sandboxProfiles,
179
- supportsApiSecretGrant: this.config.supportsApiSecretGrant
179
+ supportsApiSecretGrant: this.config.supportsApiSecretGrant,
180
+ supportsManagedWorktrees: this.config.supportsManagedWorktrees,
181
+ supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
182
+ supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts
180
183
  };
181
184
  }
182
185
  async invokeAck(methodName, payload) {
@@ -264,12 +267,15 @@ export function defaultBridgeServiceConfig(overrides = {}) {
264
267
  defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? defaults.defaultRepoRoot,
265
268
  cleanupWorktrees: process.env.FORGE_CLEANUP_WORKTREES !== "false" && defaults.cleanupWorktrees,
266
269
  cliProviders,
267
- providerStatuses: Object.fromEntries(cliProviders.map((provider) => [provider, { signedIn: true, version: "" }])),
270
+ providerStatuses: providerStatusesFor(cliProviders),
268
271
  projectLabels: splitCsv(process.env.FORGE_PROJECT_LABELS ?? ""),
269
272
  repoMappings: [],
270
273
  os: process.platform,
271
274
  sandboxProfiles: ["default"],
272
275
  supportsApiSecretGrant: true,
276
+ supportsManagedWorktrees: true,
277
+ supportsManagedForgeArtifacts: true,
278
+ supportsRunnerAuthoredPhaseArtifacts: true,
273
279
  version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
274
280
  protocolVersion: 1,
275
281
  minServerVersion: null,
@@ -294,6 +300,13 @@ function normalizeServerUrl(serverUrl) {
294
300
  function positiveFiniteNumber(value, fallback) {
295
301
  return Number.isFinite(value) && value > 0 ? value : fallback;
296
302
  }
303
+ function providerStatusesFor(providers) {
304
+ return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
305
+ }
306
+ function configuredModelsFor(provider) {
307
+ const envName = `FORGE_${provider.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_MODELS`;
308
+ return splitCsv(process.env[envName] ?? "");
309
+ }
297
310
  function splitCsv(value) {
298
311
  return value
299
312
  .split(",")
@@ -13,6 +13,9 @@ export interface BridgeServiceConfig {
13
13
  readonly os: string;
14
14
  readonly sandboxProfiles: readonly string[];
15
15
  readonly supportsApiSecretGrant: boolean;
16
+ readonly supportsManagedWorktrees: boolean;
17
+ readonly supportsManagedForgeArtifacts: boolean;
18
+ readonly supportsRunnerAuthoredPhaseArtifacts: boolean;
16
19
  readonly version: string;
17
20
  readonly protocolVersion: number;
18
21
  readonly minServerVersion?: string | null;
@@ -28,6 +31,7 @@ export interface BridgeRepoMapping {
28
31
  export interface BridgeProviderStatus {
29
32
  readonly signedIn?: boolean | null;
30
33
  readonly version?: string | null;
34
+ readonly models?: readonly string[] | null;
31
35
  }
32
36
  export interface BridgeCapabilities {
33
37
  readonly schemaVersion?: number | null;
@@ -39,6 +43,9 @@ export interface BridgeCapabilities {
39
43
  readonly os?: string | null;
40
44
  readonly sandboxProfiles?: readonly string[] | null;
41
45
  readonly supportsApiSecretGrant?: boolean | null;
46
+ readonly supportsManagedWorktrees?: boolean | null;
47
+ readonly supportsManagedForgeArtifacts?: boolean | null;
48
+ readonly supportsRunnerAuthoredPhaseArtifacts?: boolean | null;
42
49
  }
43
50
  export interface BridgeHelloRequest {
44
51
  readonly version: string;
@@ -5,7 +5,8 @@ import type { CliIo, CliService } from "./cli.base.js";
5
5
  export declare class ForgeBridgeCliService implements CliService {
6
6
  private readonly sessions;
7
7
  private readonly bridgeFactory;
8
- constructor(sessions?: SessionService, bridgeFactory?: (config: BridgeServiceConfig) => BridgeService);
8
+ private readonly pairRunner;
9
+ constructor(sessions?: SessionService, bridgeFactory?: (config: BridgeServiceConfig) => BridgeService, pairRunner?: PairRunnerClient);
9
10
  run(argv: readonly string[], io?: CliIo): Promise<number>;
10
11
  }
11
12
  export declare function parseArgs(argv: readonly string[]): {
@@ -13,11 +14,18 @@ export declare function parseArgs(argv: readonly string[]): {
13
14
  readonly command: "run-once";
14
15
  readonly dispatchPath: string;
15
16
  readonly resultPath: string;
17
+ } | {
18
+ readonly ok: true;
19
+ readonly command: "mcp";
20
+ readonly argv: readonly string[];
16
21
  } | {
17
22
  readonly ok: true;
18
23
  readonly command: "connect";
19
24
  readonly config: BridgeServiceConfig;
20
- } | {
25
+ } | ({
26
+ readonly ok: true;
27
+ readonly command: "pair";
28
+ } & PairArgs) | {
21
29
  readonly ok: true;
22
30
  readonly command: "add-repo";
23
31
  readonly label: string;
@@ -41,5 +49,22 @@ export declare function parseArgs(argv: readonly string[]): {
41
49
  readonly ok: false;
42
50
  readonly error: string;
43
51
  };
52
+ interface PairArgs {
53
+ readonly serverUrl: string;
54
+ readonly code?: string;
55
+ readonly name: string;
56
+ readonly fingerprint: string;
57
+ readonly repoRoot?: string;
58
+ readonly cleanupWorktrees: boolean;
59
+ readonly cliProviders: readonly string[];
60
+ readonly projectLabels: readonly string[];
61
+ }
62
+ interface PairRunnerClaim {
63
+ readonly runnerId: string;
64
+ readonly deviceToken: string;
65
+ }
66
+ type PairRunnerClient = (args: PairArgs) => Promise<PairRunnerClaim>;
67
+ export declare function parseCodexModelCatalog(raw: string): string[];
68
+ export declare function parseCursorModelList(raw: string): string[];
44
69
  type CleanupMode = "stale" | "merged" | "all";
45
70
  export {};
@@ -1,20 +1,25 @@
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";
5
7
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
6
8
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
7
- import { addRepoMapping, applyLocalConfigDefaults, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath } from "./local-config.js";
9
+ import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
8
10
  import { BridgeSessionService } from "../session/session.service.js";
11
+ import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
9
12
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
10
13
  const apiKeySecretGrantEnv = "FORGE_API_KEY_SECRET_GRANT";
11
14
  const execFileAsync = promisify(execFile);
12
15
  export class ForgeBridgeCliService {
13
16
  sessions;
14
17
  bridgeFactory;
15
- constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config)) {
18
+ pairRunner;
19
+ constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config), pairRunner = claimBridgeRunner) {
16
20
  this.sessions = sessions;
17
21
  this.bridgeFactory = bridgeFactory;
22
+ this.pairRunner = pairRunner;
18
23
  }
19
24
  async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
20
25
  const args = parseArgs(argv);
@@ -23,10 +28,11 @@ export class ForgeBridgeCliService {
23
28
  return 2;
24
29
  }
25
30
  if (args.command === "connect") {
31
+ const config = await withDetectedCliProviders(args.config);
26
32
  const missing = [
27
- ["server", args.config.serverUrl],
28
- ["runner id", args.config.runnerId],
29
- ["device token", args.config.deviceToken]
33
+ ["server", config.serverUrl],
34
+ ["runner id", config.runnerId],
35
+ ["device token", config.deviceToken]
30
36
  ].find(([, value]) => !value);
31
37
  if (missing) {
32
38
  io.stderr.write(`forge-bridge connect requires ${missing[0]}.\n`);
@@ -37,7 +43,7 @@ export class ForgeBridgeCliService {
37
43
  process.once("SIGINT", stop);
38
44
  process.once("SIGTERM", stop);
39
45
  try {
40
- await this.bridgeFactory(args.config).run(controller.signal);
46
+ await this.bridgeFactory(config).run(controller.signal);
41
47
  return 0;
42
48
  }
43
49
  finally {
@@ -58,6 +64,27 @@ export class ForgeBridgeCliService {
58
64
  return 2;
59
65
  }
60
66
  }
67
+ if (args.command === "pair") {
68
+ try {
69
+ const current = readLocalConfigSync();
70
+ const code = await resolvePairingCode(args.code);
71
+ const repoRoot = await resolvePairRepoRoot(args.repoRoot, current.defaultRepoRoot);
72
+ await fs.mkdir(repoRoot, { recursive: true });
73
+ const pairArgs = await withDetectedPairCliProviders({ ...args, code, repoRoot });
74
+ const claim = await this.pairRunner(pairArgs);
75
+ const next = pairedConfig(current, pairArgs, claim);
76
+ await writeLocalConfig(next);
77
+ io.stdout.write(`Paired runner ${claim.runnerId}.\n`);
78
+ io.stdout.write(`Repo folder: ${repoRoot}\n`);
79
+ io.stdout.write(`Config: ${resolveLocalConfigPath()}\n`);
80
+ io.stdout.write("Start the bridge with: forge-bridge connect\n");
81
+ return 0;
82
+ }
83
+ catch (error) {
84
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
85
+ return 1;
86
+ }
87
+ }
61
88
  if (args.command === "remove-repo") {
62
89
  try {
63
90
  const result = await removeRepoMapping(args.label);
@@ -76,6 +103,15 @@ export class ForgeBridgeCliService {
76
103
  if (args.command === "cleanup") {
77
104
  return await cleanupRepos(args.config, args.mode, args.force, io);
78
105
  }
106
+ if (args.command === "mcp") {
107
+ const parsed = parseCompletionMcpArgs(args.argv);
108
+ if (!parsed.ok) {
109
+ io.stderr.write(`${parsed.error}\n`);
110
+ return 2;
111
+ }
112
+ await runCompletionMcpServer(parsed.config);
113
+ return 0;
114
+ }
79
115
  let redactionSecrets = readEnvSecretValuesForRedaction();
80
116
  try {
81
117
  const dispatchJson = await fs.readFile(args.dispatchPath, "utf8");
@@ -157,6 +193,9 @@ export function parseArgs(argv) {
157
193
  if (argv[0] === "connect") {
158
194
  return parseConnectArgs(argv);
159
195
  }
196
+ if (argv[0] === "pair") {
197
+ return parsePairArgs(argv);
198
+ }
160
199
  if (argv[0] === "add-repo") {
161
200
  if (!argv[1] || !argv[2] || argv.length > 3) {
162
201
  return { ok: false, error: "Usage: forge-bridge add-repo <label> <path>" };
@@ -185,10 +224,13 @@ export function parseArgs(argv) {
185
224
  if (argv[0] === "cleanup") {
186
225
  return parseCleanupArgs(argv);
187
226
  }
227
+ if (argv[0] === "mcp") {
228
+ return { ok: true, command: "mcp", argv };
229
+ }
188
230
  if (argv[0] !== "run-once") {
189
231
  return {
190
232
  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]"
233
+ error: usage()
192
234
  };
193
235
  }
194
236
  let dispatchPath = null;
@@ -223,7 +265,7 @@ function parseConnectArgs(argv) {
223
265
  let heartbeatIntervalMs = null;
224
266
  const cliProviders = [];
225
267
  const projectLabels = [];
226
- let cleanupWorktrees = true;
268
+ let cleanupWorktrees = null;
227
269
  for (let index = 1; index < argv.length; index += 1) {
228
270
  const arg = argv[index];
229
271
  if (arg === "--server") {
@@ -253,6 +295,9 @@ function parseConnectArgs(argv) {
253
295
  else if (arg === "--no-cleanup-worktrees") {
254
296
  cleanupWorktrees = false;
255
297
  }
298
+ else if (arg === "--cleanup-worktrees") {
299
+ cleanupWorktrees = true;
300
+ }
256
301
  else {
257
302
  return { ok: false, error: `Unknown argument: ${arg}` };
258
303
  }
@@ -261,7 +306,7 @@ function parseConnectArgs(argv) {
261
306
  if (serverUrl === null && !process.env.FORGE_URL?.trim() && !localConfig.serverUrl?.trim()) {
262
307
  return { ok: false, error: "forge-bridge connect requires --server or FORGE_URL." };
263
308
  }
264
- const overrides = { cleanupWorktrees };
309
+ const overrides = {};
265
310
  if (serverUrl !== null)
266
311
  overrides.serverUrl = serverUrl;
267
312
  if (runnerId !== null)
@@ -276,12 +321,79 @@ function parseConnectArgs(argv) {
276
321
  overrides.projectLabels = projectLabels;
277
322
  if (heartbeatIntervalMs !== null)
278
323
  overrides.heartbeatIntervalMs = heartbeatIntervalMs;
324
+ if (cleanupWorktrees !== null)
325
+ overrides.cleanupWorktrees = cleanupWorktrees;
279
326
  return {
280
327
  ok: true,
281
328
  command: "connect",
282
329
  config: configuredBridgeServiceConfig(overrides, localConfig)
283
330
  };
284
331
  }
332
+ function parsePairArgs(argv) {
333
+ const localConfig = readLocalConfigSync();
334
+ const defaultConfig = configuredBridgeServiceConfig({}, localConfig);
335
+ let serverUrl = defaultConfig.serverUrl;
336
+ let code = null;
337
+ let name = null;
338
+ let fingerprint = null;
339
+ let repoRoot = null;
340
+ let cleanupWorktrees = null;
341
+ const cliProviders = [];
342
+ const projectLabels = [];
343
+ for (let index = 1; index < argv.length; index += 1) {
344
+ const arg = argv[index];
345
+ if (arg === "--server") {
346
+ serverUrl = argv[++index] ?? "";
347
+ }
348
+ else if (arg === "--code") {
349
+ code = argv[++index] ?? null;
350
+ }
351
+ else if (arg === "--name") {
352
+ name = argv[++index] ?? null;
353
+ }
354
+ else if (arg === "--fingerprint") {
355
+ fingerprint = argv[++index] ?? null;
356
+ }
357
+ else if (arg === "--repo-root") {
358
+ repoRoot = argv[++index] ?? null;
359
+ }
360
+ else if (arg === "--cli-provider") {
361
+ cliProviders.push(...splitCsv(argv[++index] ?? ""));
362
+ }
363
+ else if (arg === "--project-label") {
364
+ projectLabels.push(...splitCsv(argv[++index] ?? ""));
365
+ }
366
+ else if (arg === "--cleanup-worktrees") {
367
+ cleanupWorktrees = true;
368
+ }
369
+ else if (arg === "--no-cleanup-worktrees") {
370
+ cleanupWorktrees = false;
371
+ }
372
+ else if (!arg.startsWith("-") && code === null) {
373
+ code = arg;
374
+ }
375
+ else {
376
+ return { ok: false, error: `Unknown argument: ${arg}` };
377
+ }
378
+ }
379
+ if (!serverUrl.trim()) {
380
+ return { ok: false, error: "forge-bridge pair requires --server or configured serverUrl." };
381
+ }
382
+ const defaultName = `Local bridge ${os.hostname()}`;
383
+ const user = os.userInfo().username;
384
+ return {
385
+ ok: true,
386
+ command: "pair",
387
+ serverUrl,
388
+ code: normalizePairingCode(code),
389
+ name: name?.trim() || defaultName,
390
+ fingerprint: fingerprint?.trim() || `local-bridge:${os.hostname()}:${user}`,
391
+ repoRoot: repoRoot?.trim() ? path.resolve(repoRoot) : undefined,
392
+ cleanupWorktrees: cleanupWorktrees ?? localConfig.cleanupWorktrees ?? false,
393
+ cliProviders: cliProviders.length > 0 ? cliProviders : localConfig.cliProviders && localConfig.cliProviders.length > 0 ? [...localConfig.cliProviders] : ["codex"],
394
+ projectLabels: projectLabels.length > 0 ? projectLabels : localConfig.projectLabels ? [...localConfig.projectLabels] : []
395
+ };
396
+ }
285
397
  function parseCleanupArgs(argv) {
286
398
  let mode = "stale";
287
399
  let force = false;
@@ -317,6 +429,100 @@ function splitCsv(value) {
317
429
  .map((entry) => entry.trim())
318
430
  .filter((entry) => entry.length > 0);
319
431
  }
432
+ async function claimBridgeRunner(args) {
433
+ if (!args.code) {
434
+ throw new Error("Pairing code is required.");
435
+ }
436
+ const response = await fetch(new URL("/api/runners/claim", normalizeServerUrl(args.serverUrl)), {
437
+ method: "POST",
438
+ headers: { "Content-Type": "application/json" },
439
+ body: JSON.stringify({
440
+ code: args.code,
441
+ name: args.name,
442
+ fingerprint: args.fingerprint,
443
+ cliProviders: args.cliProviders,
444
+ projectLabels: args.projectLabels
445
+ })
446
+ });
447
+ if (!response.ok) {
448
+ throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
449
+ }
450
+ const value = await response.json();
451
+ if (!value.runnerId || !value.deviceToken) {
452
+ throw new Error("Pairing response did not include runner credentials.");
453
+ }
454
+ return {
455
+ runnerId: value.runnerId,
456
+ deviceToken: value.deviceToken
457
+ };
458
+ }
459
+ function pairedConfig(current, args, claim) {
460
+ return {
461
+ ...current,
462
+ schemaVersion: 2,
463
+ serverUrl: args.serverUrl,
464
+ runnerId: claim.runnerId,
465
+ deviceToken: claim.deviceToken,
466
+ deviceName: args.name,
467
+ defaultRepoRoot: args.repoRoot ?? current.defaultRepoRoot,
468
+ cleanupWorktrees: args.cleanupWorktrees,
469
+ cliProviders: [...args.cliProviders],
470
+ providerStatuses: current.providerStatuses ?? providerStatusesFor(args.cliProviders),
471
+ projectLabels: [...args.projectLabels],
472
+ supportsApiSecretGrant: current.supportsApiSecretGrant ?? true,
473
+ supportsManagedWorktrees: current.supportsManagedWorktrees ?? true,
474
+ supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
475
+ supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
476
+ repoMappings: current.repoMappings ?? []
477
+ };
478
+ }
479
+ async function resolvePairingCode(code) {
480
+ const normalized = normalizePairingCode(code);
481
+ if (normalized) {
482
+ return normalized;
483
+ }
484
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
485
+ throw new Error("Pairing code is required. Run `forge-bridge pair <code>` or use an interactive terminal.");
486
+ }
487
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
488
+ try {
489
+ const answer = await prompt.question("Pairing code: ");
490
+ const clean = normalizePairingCode(answer);
491
+ if (!clean) {
492
+ throw new Error("Pairing code is required.");
493
+ }
494
+ return clean;
495
+ }
496
+ finally {
497
+ prompt.close();
498
+ }
499
+ }
500
+ async function resolvePairRepoRoot(repoRoot, configuredRoot) {
501
+ const fallback = path.resolve(repoRoot ?? configuredRoot ?? path.join(os.homedir(), "ForgeRepos"));
502
+ if (repoRoot?.trim()) {
503
+ return fallback;
504
+ }
505
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
506
+ return fallback;
507
+ }
508
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
509
+ try {
510
+ const answer = await prompt.question(`Repo folder [${fallback}]: `);
511
+ return path.resolve(answer.trim() || fallback);
512
+ }
513
+ finally {
514
+ prompt.close();
515
+ }
516
+ }
517
+ function normalizePairingCode(code) {
518
+ return code?.replace(/[\s-]/g, "").trim() ?? "";
519
+ }
520
+ function normalizeServerUrl(serverUrl) {
521
+ return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
522
+ }
523
+ function usage() {
524
+ return "Usage: forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path> | forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase> | forge-bridge pair [code] [--server <url>] | 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]";
525
+ }
320
526
  async function writeJsonAtomic(outputPath, value) {
321
527
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
322
528
  const tempPath = `${outputPath}.${process.pid}.${Date.now()}.tmp`;
@@ -343,7 +549,8 @@ async function writeStatus(config, checkUpdates, io) {
343
549
  }
344
550
  io.stdout.write("Detected CLIs:\n");
345
551
  for (const cli of detected) {
346
- io.stdout.write(` ${cli.provider}: ${cli.found ? cli.version || "found" : "not found"}\n`);
552
+ const models = cli.models.length > 0 ? `, ${cli.models.length} models` : "";
553
+ io.stdout.write(` ${cli.provider}: ${cli.found ? `${cli.version || "found"}${models}` : "not found"}\n`);
347
554
  }
348
555
  if (checkUpdates) {
349
556
  io.stdout.write("Update check: no update channel configured.\n");
@@ -385,16 +592,192 @@ async function cleanupRepos(config, mode, force, io) {
385
592
  async function detectCliProviders(providers) {
386
593
  const results = [];
387
594
  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: "" });
595
+ let detected = null;
596
+ for (const probe of cliProbeCommandsFor(provider)) {
597
+ try {
598
+ detected = await execVersionCommand(probe);
599
+ break;
600
+ }
601
+ catch {
602
+ // Try the next provider-specific probe.
603
+ }
394
604
  }
605
+ results.push({
606
+ provider,
607
+ found: detected !== null,
608
+ version: detected ? versionLine(`${detected.stdout || detected.stderr}`) : "",
609
+ models: detected ? await detectCliModels(provider) : []
610
+ });
395
611
  }
396
612
  return results;
397
613
  }
614
+ function cliProbeCommandsFor(provider) {
615
+ const normalized = provider.toLowerCase();
616
+ if (normalized === "codex") {
617
+ return [
618
+ { command: "codex", args: ["-V"] },
619
+ { command: "codex", args: ["--version"] }
620
+ ];
621
+ }
622
+ if (normalized === "cursor") {
623
+ return [
624
+ { command: "cursor-agent", args: ["-v"] },
625
+ { command: "agent", args: ["-v"] },
626
+ { command: "cursor-agent", args: ["--version"] },
627
+ { command: "agent", args: ["--version"] }
628
+ ];
629
+ }
630
+ return [
631
+ { command: provider, args: ["--version"] },
632
+ { command: provider, args: ["-V"] }
633
+ ];
634
+ }
635
+ async function execVersionCommand(probe, timeout = 2000) {
636
+ try {
637
+ return await execFileAsync(probe.command, probe.args, { timeout });
638
+ }
639
+ catch (error) {
640
+ if (process.platform !== "win32") {
641
+ throw error;
642
+ }
643
+ return await execFileAsync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", windowsCommandLine(probe)], { timeout });
644
+ }
645
+ }
646
+ function windowsCommandLine(probe) {
647
+ return [probe.command, ...probe.args].map(quoteWindowsCommandLineArg).join(" ");
648
+ }
649
+ function quoteWindowsCommandLineArg(value) {
650
+ if (value.length > 0 && !/[\s"]/.test(value)) {
651
+ return value;
652
+ }
653
+ return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
654
+ }
655
+ function versionLine(output) {
656
+ const lines = output
657
+ .trim()
658
+ .split(/\r?\n/)
659
+ .map((line) => line.trim())
660
+ .filter((line) => line.length > 0);
661
+ return lines.at(-1) ?? "";
662
+ }
663
+ async function detectCliModels(provider) {
664
+ const normalized = provider.toLowerCase();
665
+ try {
666
+ if (normalized === "codex") {
667
+ const result = await execFileAsync("codex", ["debug", "models"], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 });
668
+ return parseCodexModelCatalog(result.stdout);
669
+ }
670
+ if (normalized === "cursor") {
671
+ const result = await execVersionCommand({ command: "cursor-agent", args: ["models"] }, 10_000);
672
+ return parseCursorModelList(`${result.stdout || result.stderr}`);
673
+ }
674
+ }
675
+ catch {
676
+ return [];
677
+ }
678
+ return [];
679
+ }
680
+ export function parseCodexModelCatalog(raw) {
681
+ try {
682
+ const parsed = JSON.parse(raw);
683
+ if (!isRecord(parsed) || !Array.isArray(parsed.models)) {
684
+ return [];
685
+ }
686
+ const entries = parsed.models
687
+ .map((entry, index) => {
688
+ if (!isRecord(entry) || typeof entry.slug !== "string") {
689
+ return null;
690
+ }
691
+ const visibility = typeof entry.visibility === "string" ? entry.visibility.toLowerCase() : "list";
692
+ if (visibility !== "list") {
693
+ return null;
694
+ }
695
+ const priority = typeof entry.priority === "number" && Number.isFinite(entry.priority) ? entry.priority : index;
696
+ return { slug: entry.slug, priority, index };
697
+ })
698
+ .filter((entry) => entry !== null)
699
+ .sort((left, right) => left.priority - right.priority || left.index - right.index)
700
+ .map((entry) => entry.slug);
701
+ return uniqueModels(entries);
702
+ }
703
+ catch {
704
+ return [];
705
+ }
706
+ }
707
+ export function parseCursorModelList(raw) {
708
+ return uniqueModels(raw
709
+ .split(/\r?\n/)
710
+ .map((line) => line.trim())
711
+ .filter((line) => line.length > 0 && !line.toLowerCase().startsWith("available models"))
712
+ .map((line) => line.split(/\s+-\s+/, 1)[0]?.trim() ?? "")
713
+ .filter((model) => model.length > 0));
714
+ }
715
+ function uniqueModels(models) {
716
+ const seen = new Set();
717
+ const result = [];
718
+ for (const model of models) {
719
+ const trimmed = model.trim();
720
+ const key = trimmed.toLowerCase();
721
+ if (!trimmed || seen.has(key)) {
722
+ continue;
723
+ }
724
+ seen.add(key);
725
+ result.push(trimmed);
726
+ }
727
+ return result;
728
+ }
729
+ function isRecord(value) {
730
+ return typeof value === "object" && value !== null;
731
+ }
732
+ async function withDetectedCliProviders(config) {
733
+ const detected = await detectCliProviders(configuredProviderCandidates(config.cliProviders));
734
+ const found = detected.filter((result) => result.found);
735
+ if (found.length === 0) {
736
+ return config;
737
+ }
738
+ const cliProviders = uniqueProviders([...config.cliProviders, ...found.map((result) => result.provider)]);
739
+ const providerStatuses = { ...config.providerStatuses };
740
+ for (const result of found) {
741
+ const existing = providerStatuses[result.provider];
742
+ providerStatuses[result.provider] = {
743
+ signedIn: existing?.signedIn ?? true,
744
+ version: result.version || existing?.version || "",
745
+ models: result.models.length > 0
746
+ ? result.models
747
+ : existing?.models ?? providerStatusesFor([result.provider])[result.provider]?.models ?? []
748
+ };
749
+ }
750
+ return {
751
+ ...config,
752
+ cliProviders,
753
+ providerStatuses
754
+ };
755
+ }
756
+ async function withDetectedPairCliProviders(args) {
757
+ const detectedConfig = await withDetectedCliProviders({
758
+ ...defaultBridgeServiceConfig({
759
+ serverUrl: args.serverUrl,
760
+ cliProviders: [...args.cliProviders],
761
+ providerStatuses: providerStatusesFor(args.cliProviders),
762
+ projectLabels: [...args.projectLabels],
763
+ cleanupWorktrees: args.cleanupWorktrees,
764
+ defaultRepoRoot: args.repoRoot ?? defaultBridgeServiceConfig().defaultRepoRoot
765
+ })
766
+ });
767
+ return {
768
+ ...args,
769
+ cliProviders: detectedConfig.cliProviders
770
+ };
771
+ }
772
+ function configuredProviderCandidates(providers) {
773
+ return uniqueProviders([...providers, "codex", "cursor"]);
774
+ }
775
+ function uniqueProviders(providers) {
776
+ return providers
777
+ .map((provider) => provider.trim())
778
+ .filter((provider) => provider.length > 0)
779
+ .filter((provider, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === provider.toLowerCase()) === index);
780
+ }
398
781
  async function runGit(cwd, args) {
399
782
  await execFileAsync("git", args, { cwd, timeout: 10_000 });
400
783
  }