@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.
- package/README.md +1 -0
- package/dist/bridge/bridge.service.js +16 -3
- package/dist/bridge/bridge.types.d.ts +7 -0
- package/dist/cli/cli.service.d.ts +27 -2
- package/dist/cli/cli.service.js +399 -16
- package/dist/cli/local-config.d.ts +4 -1
- package/dist/cli/local-config.js +42 -7
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/internals.d.ts +4 -2
- package/dist/session/internals.js +2 -1
- package/dist/session/phase-completion.d.ts +54 -0
- package/dist/session/phase-completion.js +692 -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 +2 -1
- package/dist/session/provider-cli/provider-cli.service.d.ts +45 -1
- package/dist/session/provider-cli/provider-cli.service.js +602 -10
- package/dist/session/provider-cli/provider-cli.types.d.ts +1 -0
- package/dist/session/session.service.js +52 -10
- package/dist/session/session.types.d.ts +11 -0
- package/dist/session/session.types.js +47 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +11 -0
- package/dist/worktree/artifact-injector.js +107 -0
- package/dist/worktree/dependency-warmer.d.ts +32 -0
- package/dist/worktree/dependency-warmer.js +297 -0
- package/dist/worktree/index.d.ts +2 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/worktree.service.d.ts +4 -0
- package/dist/worktree/worktree.service.js +173 -11
- package/dist/worktree/worktree.types.d.ts +5 -1
- 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:
|
|
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:
|
|
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
|
-
|
|
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 {};
|
package/dist/cli/cli.service.js
CHANGED
|
@@ -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
|
-
|
|
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",
|
|
28
|
-
["runner id",
|
|
29
|
-
["device token",
|
|
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(
|
|
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:
|
|
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 =
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
}
|