@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.
- package/dist/bridge/bridge.service.d.ts +1 -0
- package/dist/bridge/bridge.service.js +219 -25
- package/dist/bridge/bridge.types.d.ts +19 -2
- package/dist/cli/cli.service.d.ts +27 -2
- package/dist/cli/cli.service.js +630 -25
- package/dist/cli/local-config.d.ts +4 -0
- package/dist/cli/local-config.js +42 -4
- package/dist/config.js +1 -1
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/internals.d.ts +5 -3
- package/dist/session/internals.js +2 -1
- package/dist/session/phase-completion.d.ts +126 -0
- package/dist/session/phase-completion.js +3233 -0
- package/dist/session/provider-cli/index.d.ts +2 -2
- package/dist/session/provider-cli/index.js +1 -1
- package/dist/session/provider-cli/provider-cli.base.d.ts +5 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +64 -4
- package/dist/session/provider-cli/provider-cli.service.js +2787 -51
- package/dist/session/provider-cli/provider-cli.types.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.d.ts +25 -0
- package/dist/session/provider-cli/provider-usage.js +64 -0
- package/dist/session/qa-sidecar-validation.d.ts +3 -0
- package/dist/session/qa-sidecar-validation.js +44 -0
- package/dist/session/session.service.js +62 -11
- package/dist/session/session.types.d.ts +132 -0
- package/dist/session/session.types.js +261 -2
- package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
- package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +2 -1
- package/dist/worktree/artifact-injector.js +125 -9
- package/dist/worktree/worktree.service.d.ts +3 -0
- package/dist/worktree/worktree.service.js +239 -8
- package/dist/worktree/worktree.types.d.ts +8 -1
- package/package.json +8 -3
package/dist/cli/cli.service.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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",
|
|
28
|
-
["runner id",
|
|
29
|
-
["device token",
|
|
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
|
-
|
|
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
|
|
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 (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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:
|
|
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 =
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
}
|