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