forge-delegate 1.0.9 → 1.1.0
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 +26 -6
- package/cli.mjs +100 -14
- package/launcher.mjs +231 -0
- package/lib.mjs +18 -1
- package/package.json +3 -2
- package/server.mjs +23 -6
package/README.md
CHANGED
|
@@ -30,9 +30,9 @@ so you're picking from actual available models rather than guessing.
|
|
|
30
30
|
npx -y forge-delegate setup --model "opencode/deepseek-v4.1-flash-free" --targets "claude,codex,opencode"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
That's it — npx
|
|
34
|
-
you chose
|
|
35
|
-
|
|
33
|
+
That's it — npx bootstraps the package, and `setup` registers the MCP server with the agents
|
|
34
|
+
you chose, sets your default model, and installs a small launcher that keeps the server up to
|
|
35
|
+
date. Works on Windows (cmd/PowerShell), macOS, and Linux — no bash needed.
|
|
36
36
|
|
|
37
37
|
Run `setup` **without flags** and it walks you through the choices interactively (defaults in
|
|
38
38
|
`[brackets]`, Enter accepts); pass any of the flags to skip that prompt. In an automated/non-terminal
|
|
@@ -47,6 +47,7 @@ run it just uses sensible defaults.
|
|
|
47
47
|
`setup` will:
|
|
48
48
|
1. Register the MCP server with each agent you chose (`--targets claude,codex,opencode`).
|
|
49
49
|
2. Set your default model (`--model`) so calls can omit `model`.
|
|
50
|
+
3. Install the auto-update launcher (published installs; skipped for a git checkout).
|
|
50
51
|
|
|
51
52
|
Routing and cost-policy guidance is injected by the MCP server itself into the agent's context
|
|
52
53
|
via MCP `instructions` — no memory-file (`CLAUDE.md`/`AGENTS.md`) editing needed.
|
|
@@ -173,20 +174,39 @@ Every delegation tool also accepts per-call `model`, `directory`, `agent`, `auto
|
|
|
173
174
|
## CLI
|
|
174
175
|
|
|
175
176
|
```text
|
|
177
|
+
forge-delegate --version Print the installed version
|
|
176
178
|
forge-delegate setup Register with Claude Code / Codex / opencode
|
|
177
179
|
forge-delegate serve Start the MCP server (used internally)
|
|
178
180
|
forge-delegate config get | set | path
|
|
179
|
-
forge-delegate status Show opencode logins
|
|
181
|
+
forge-delegate status Show version, update mode, opencode logins, usage
|
|
180
182
|
forge-delegate stats Show detailed usage statistics
|
|
181
183
|
```
|
|
182
184
|
|
|
185
|
+
`setup` on a published install writes `~/.forge-delegate/launcher.mjs` and registers that path
|
|
186
|
+
instead of `npx`. The launcher keeps a private install under `~/.forge-delegate/pkg`, asks npm
|
|
187
|
+
for the newest release at most every 6 hours, and installs newer **same-major** versions
|
|
188
|
+
automatically. Installs use `npm install --ignore-scripts`, go through a staging directory, and
|
|
189
|
+
fall back to the last good version if anything fails. A new version lands on the next agent
|
|
190
|
+
start; the download itself happens in the background so startup is never delayed.
|
|
191
|
+
|
|
192
|
+
Disable automatic updates with `FORGE_AUTO_UPDATE=0`. A major release (`2.0.0`) is never
|
|
193
|
+
installed automatically — re-run `npx -y forge-delegate setup` to take it deliberately.
|
|
194
|
+
|
|
195
|
+
The launcher is also the CLI entry point: `node ~/.forge-delegate/launcher.mjs status` reports
|
|
196
|
+
the running version and update mode, and `--version` prints the version.
|
|
197
|
+
|
|
198
|
+
npx installs configured by hand are not covered by the launcher: npx keeps its cached copy
|
|
199
|
+
forever, so clear it with `rm -rf ~/.npm/_npx` and restart. Running
|
|
200
|
+
`npx -y forge-delegate@latest setup` migrates an npx install to the launcher; `@latest` gets its
|
|
201
|
+
own npx cache slot, and if it still starts an old version, clear the cache first.
|
|
202
|
+
|
|
183
203
|
## Publishing (npm)
|
|
184
204
|
|
|
185
205
|
The primary install is `npx -y forge-delegate setup`, so the package must be on npm. Checklist:
|
|
186
206
|
|
|
187
207
|
1. `npm run prepublishOnly` — syntax-checks all entry files (runs automatically on publish).
|
|
188
|
-
2. `npm pack --dry-run` — confirm the tarball contains only `
|
|
189
|
-
`install.sh`, `docs/`, `README.md` (via the `files` field), not `node_modules`.
|
|
208
|
+
2. `npm pack --dry-run` — confirm the tarball contains only `launcher.mjs`, `cli.mjs`, `server.mjs`,
|
|
209
|
+
`lib.mjs`, `install.sh`, `docs/`, `skills/`, `README.md` (via the `files` field), not `node_modules`.
|
|
190
210
|
3. Bump `version` in `package.json` (and the root `version` in `package-lock.json`).
|
|
191
211
|
4. `npm login && npm publish` — the `bin` field makes `npx forge-delegate …` work.
|
|
192
212
|
|
package/cli.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { execSync } from "node:child_process";
|
|
8
|
-
import { loadConfig, saveConfig, configPath, detectOpencode, loadOpencodeAuth } from "./lib.mjs";
|
|
7
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
8
|
+
import { loadConfig, saveConfig, configPath, detectOpencode, loadOpencodeAuth, compareVersions, VERSION } from "./lib.mjs";
|
|
9
9
|
|
|
10
10
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const herePosix = here.replace(/\\/g, "/");
|
|
12
|
+
const ROOT = join(homedir(), ".forge-delegate");
|
|
13
|
+
const LAUNCHER = join(ROOT, "launcher.mjs");
|
|
11
14
|
|
|
12
15
|
function parseArgs(argv) {
|
|
13
16
|
const args = {};
|
|
@@ -25,14 +28,60 @@ function parseArgs(argv) {
|
|
|
25
28
|
return args;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
return isPublished ? "npx -y forge-delegate serve" : `node ${join(here, "cli.mjs")} serve`;
|
|
31
|
+
function isPublishedInstall() {
|
|
32
|
+
return herePosix.includes("node_modules/forge-delegate") || herePosix.includes("/.npm/_npx/");
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// How this copy was installed matters to the user: only the launcher path can
|
|
36
|
+
// update itself.
|
|
37
|
+
function installKind() {
|
|
38
|
+
if (herePosix.includes("/.forge-delegate/pkg/")) return "launcher";
|
|
39
|
+
if (herePosix.includes("/.npm/_npx/") || herePosix.includes("node_modules/forge-delegate")) return "package";
|
|
40
|
+
return "checkout";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readLauncherState() {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(join(ROOT, "launcher-state.json"), "utf8"));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Copy the launcher out of the package to a path that never changes, then
|
|
52
|
+
// pre-install this version so the first client launch is fast. A failure here
|
|
53
|
+
// is not fatal: the launcher installs on demand.
|
|
54
|
+
function ensureLauncher() {
|
|
55
|
+
const src = join(here, "launcher.mjs");
|
|
56
|
+
if (!existsSync(src)) return null;
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(ROOT, { recursive: true });
|
|
59
|
+
const want = readFileSync(src, "utf8");
|
|
60
|
+
let have = null;
|
|
61
|
+
try {
|
|
62
|
+
have = readFileSync(LAUNCHER, "utf8");
|
|
63
|
+
} catch {}
|
|
64
|
+
if (have !== want) writeFileSync(LAUNCHER, want);
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
execFileSync(process.execPath, [LAUNCHER, "--install-only", VERSION], {
|
|
70
|
+
stdio: "ignore",
|
|
71
|
+
timeout: 180_000,
|
|
72
|
+
});
|
|
73
|
+
} catch {}
|
|
74
|
+
return LAUNCHER;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function launchForThisInstall() {
|
|
78
|
+
if (!isPublishedInstall()) {
|
|
79
|
+
const cli = join(here, "cli.mjs");
|
|
80
|
+
return { command: `node "${cli}"`, array: ["node", cli, "serve"] };
|
|
81
|
+
}
|
|
82
|
+
const launcher = ensureLauncher();
|
|
83
|
+
if (launcher) return { command: `node "${launcher}"`, array: ["node", launcher] };
|
|
84
|
+
return { command: "npx -y forge-delegate serve", array: ["npx", "-y", "forge-delegate", "serve"] };
|
|
36
85
|
}
|
|
37
86
|
|
|
38
87
|
// The MCP server's `instructions` are injected once at session start and decay as the
|
|
@@ -143,8 +192,12 @@ async function setup() {
|
|
|
143
192
|
projectDir = projectDir ?? process.cwd();
|
|
144
193
|
targets = targets.split(",").map((s) => s.trim().toLowerCase());
|
|
145
194
|
|
|
146
|
-
const launch =
|
|
147
|
-
|
|
195
|
+
const { command: launch, array: launchArr } = launchForThisInstall();
|
|
196
|
+
if (launch.includes("launcher.mjs")) console.log(` ✓ automatic updates (launcher: ${LAUNCHER})`);
|
|
197
|
+
// 1.0.x wrote this cache; the launcher keeps its own state elsewhere.
|
|
198
|
+
try {
|
|
199
|
+
rmSync(join(ROOT, "update-check.json"), { force: true });
|
|
200
|
+
} catch {}
|
|
148
201
|
|
|
149
202
|
// 1. Set the default model first so it's saved even if registration hits an issue
|
|
150
203
|
if (model && model !== current.defaultModel) {
|
|
@@ -211,8 +264,32 @@ function configCmd(argv) {
|
|
|
211
264
|
|
|
212
265
|
async function status() {
|
|
213
266
|
const oc = await detectOpencode();
|
|
267
|
+
const kind = installKind();
|
|
268
|
+
let versionNote;
|
|
269
|
+
if (kind === "launcher") {
|
|
270
|
+
const state = readLauncherState();
|
|
271
|
+
versionNote = state?.autoUpdate === false ? " (auto-update off)" : " (auto-update on)";
|
|
272
|
+
if (state?.autoUpdate !== false) {
|
|
273
|
+
try {
|
|
274
|
+
const latest = JSON.parse(readFileSync(join(ROOT, "latest.json"), "utf8"));
|
|
275
|
+
if (latest?.version && compareVersions(latest.version, VERSION) > 0) {
|
|
276
|
+
const latestMajor = String(latest.version).split(".")[0];
|
|
277
|
+
const currentMajor = String(VERSION).split(".")[0];
|
|
278
|
+
versionNote +=
|
|
279
|
+
latestMajor === currentMajor
|
|
280
|
+
? ` — ${latest.version} pending`
|
|
281
|
+
: ` — ${latest.version} requires a manual upgrade`;
|
|
282
|
+
}
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
} else if (kind === "package") {
|
|
286
|
+
versionNote = " (npx install — manual updates)";
|
|
287
|
+
} else {
|
|
288
|
+
versionNote = " (checkout)";
|
|
289
|
+
}
|
|
290
|
+
console.log(`\nforge-delegate: ${VERSION}${versionNote}`);
|
|
214
291
|
console.log(
|
|
215
|
-
|
|
292
|
+
`opencode: ${oc.found ? (oc.version?.raw ?? "unknown version") : "not found on PATH"}` +
|
|
216
293
|
`${oc.found && !oc.supported ? " (too old — requires v2 or newer)" : ""}`
|
|
217
294
|
);
|
|
218
295
|
let providers = [];
|
|
@@ -227,6 +304,10 @@ async function status() {
|
|
|
227
304
|
console.log(`recorded delegations: ${lines} (see 'forge-delegate stats')`);
|
|
228
305
|
}
|
|
229
306
|
} catch {}
|
|
307
|
+
if (kind === "package") {
|
|
308
|
+
console.log("\nThis npx install never refreshes itself.");
|
|
309
|
+
console.log("Switch to automatic updates: npx -y forge-delegate@latest setup");
|
|
310
|
+
}
|
|
230
311
|
}
|
|
231
312
|
|
|
232
313
|
function stats() {
|
|
@@ -268,6 +349,7 @@ function help() {
|
|
|
268
349
|
forge-delegate — delegate coding work from Claude Code/Codex/opencode to other AI models
|
|
269
350
|
|
|
270
351
|
Usage:
|
|
352
|
+
forge-delegate --version Print the installed version
|
|
271
353
|
forge-delegate setup [--model provider/model] [--targets claude,codex,opencode]
|
|
272
354
|
[--scope user|project] [--project-dir <path>] [--no-skill]
|
|
273
355
|
Interactive setup; flags optional (run bare to be prompted)
|
|
@@ -277,9 +359,12 @@ Usage:
|
|
|
277
359
|
forge-delegate config set --model <m> [--agent <a>] [--variant <v>]
|
|
278
360
|
[--timeout <s>] [--dir <path>] [--autoApprove true|false]
|
|
279
361
|
forge-delegate config path Print config file path
|
|
280
|
-
forge-delegate status Show opencode logins
|
|
362
|
+
forge-delegate status Show version, update mode, opencode logins, usage
|
|
281
363
|
forge-delegate stats Show detailed usage statistics
|
|
282
364
|
|
|
365
|
+
Automatic updates (launcher installs) are on by default. Disable with:
|
|
366
|
+
FORGE_AUTO_UPDATE=0
|
|
367
|
+
|
|
283
368
|
Models come from your opencode installation:
|
|
284
369
|
opencode auth login <provider> add a provider
|
|
285
370
|
opencode models list available models
|
|
@@ -287,7 +372,8 @@ Models come from your opencode installation:
|
|
|
287
372
|
}
|
|
288
373
|
|
|
289
374
|
const cmd = process.argv[2] ?? "help";
|
|
290
|
-
if (cmd === "
|
|
375
|
+
if (cmd === "--version" || cmd === "-v" || cmd === "version") console.log(VERSION);
|
|
376
|
+
else if (cmd === "setup") await setup();
|
|
291
377
|
else if (cmd === "serve") await import("./server.mjs");
|
|
292
378
|
else if (cmd === "config") configCmd(process.argv.slice(3));
|
|
293
379
|
else if (cmd === "status") await status();
|
package/launcher.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// forge-delegate launcher.
|
|
3
|
+
//
|
|
4
|
+
// Clients run this file instead of npx. It keeps a private install under
|
|
5
|
+
// ~/.forge-delegate/pkg, refreshes it from npm when a newer same-major release
|
|
6
|
+
// exists, then starts `node cli.mjs serve`.
|
|
7
|
+
//
|
|
8
|
+
// An update never blocks startup: the new version is fetched in the background
|
|
9
|
+
// and used on the next launch. If the registry or npm is unavailable, the last
|
|
10
|
+
// good version runs. A cold start with nothing installed falls back to npx.
|
|
11
|
+
//
|
|
12
|
+
// `--install-only <version>` exists for `setup`, which pre-installs the running
|
|
13
|
+
// version so a client's first launch does not have to wait for npm.
|
|
14
|
+
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
22
|
+
const ROOT = join(homedir(), ".forge-delegate");
|
|
23
|
+
const PKG = join(ROOT, "pkg");
|
|
24
|
+
const STATE = join(ROOT, "launcher-state.json");
|
|
25
|
+
const LATEST = join(ROOT, "latest.json");
|
|
26
|
+
const SKILL = join(homedir(), ".claude", "skills", "delegate-work", "SKILL.md");
|
|
27
|
+
const REGISTRY = process.env.FORGE_REGISTRY ?? "https://registry.npmjs.org";
|
|
28
|
+
const TTL = 6 * 3600 * 1000;
|
|
29
|
+
const AUTO =
|
|
30
|
+
process.env.FORGE_AUTO_UPDATE !== "0" &&
|
|
31
|
+
process.env.FORGE_AUTO_UPDATE !== "false" &&
|
|
32
|
+
process.env.FORGE_NO_AUTO_UPDATE !== "1";
|
|
33
|
+
|
|
34
|
+
function parseVersion(v) {
|
|
35
|
+
const m = String(v ?? "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
36
|
+
return m ? [+m[1], +m[2], +m[3]] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compareVersions(a, b) {
|
|
40
|
+
const pa = parseVersion(a);
|
|
41
|
+
const pb = parseVersion(b);
|
|
42
|
+
if (!pa || !pb) return 0;
|
|
43
|
+
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readJson(path) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cliPath(version) {
|
|
56
|
+
if (!version) return null;
|
|
57
|
+
const cli = join(PKG, version, "node_modules", "forge-delegate", "cli.mjs");
|
|
58
|
+
return existsSync(cli) ? cli : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Newest first. A version directory only counts once its cli.mjs exists, so a
|
|
62
|
+
// half-finished install can never be selected.
|
|
63
|
+
function installedVersions() {
|
|
64
|
+
try {
|
|
65
|
+
return readdirSync(PKG)
|
|
66
|
+
.filter((name) => /^\d+\.\d+\.\d+$/.test(name) && cliPath(name))
|
|
67
|
+
.sort((a, b) => compareVersions(b, a));
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function latestVersion() {
|
|
74
|
+
const cached = readJson(LATEST);
|
|
75
|
+
if (cached?.version && Number.isFinite(cached.checkedAt) && Date.now() - cached.checkedAt < TTL) {
|
|
76
|
+
return cached.version;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`${REGISTRY}/forge-delegate/latest`, { signal: AbortSignal.timeout(2000) });
|
|
80
|
+
if (!res.ok) return null;
|
|
81
|
+
const { version } = await res.json();
|
|
82
|
+
if (!parseVersion(version)) return null;
|
|
83
|
+
mkdirSync(ROOT, { recursive: true });
|
|
84
|
+
writeFileSync(LATEST, JSON.stringify({ version, checkedAt: Date.now() }));
|
|
85
|
+
return version;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Install into a staging directory and rename it into place, so a failed or
|
|
92
|
+
// interrupted download never becomes the version the launcher picks.
|
|
93
|
+
function installVersion(version) {
|
|
94
|
+
if (cliPath(version)) return true;
|
|
95
|
+
mkdirSync(PKG, { recursive: true });
|
|
96
|
+
const stage = join(PKG, `.tmp-${process.pid}-${Date.now().toString(36)}`);
|
|
97
|
+
try {
|
|
98
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
99
|
+
const res = spawnSync(
|
|
100
|
+
npm,
|
|
101
|
+
[
|
|
102
|
+
"install",
|
|
103
|
+
"--prefix",
|
|
104
|
+
stage,
|
|
105
|
+
"--ignore-scripts",
|
|
106
|
+
"--no-audit",
|
|
107
|
+
"--no-fund",
|
|
108
|
+
"--silent",
|
|
109
|
+
`forge-delegate@${version}`,
|
|
110
|
+
],
|
|
111
|
+
{ stdio: "ignore", timeout: 180_000 }
|
|
112
|
+
);
|
|
113
|
+
if (res.status !== 0 || !existsSync(join(stage, "node_modules", "forge-delegate", "cli.mjs"))) return false;
|
|
114
|
+
try {
|
|
115
|
+
renameSync(stage, join(PKG, version));
|
|
116
|
+
} catch {
|
|
117
|
+
// Another process won the race; its copy is complete because it also
|
|
118
|
+
// installed into a staging directory before renaming.
|
|
119
|
+
}
|
|
120
|
+
return Boolean(cliPath(version));
|
|
121
|
+
} finally {
|
|
122
|
+
try {
|
|
123
|
+
rmSync(stage, { recursive: true, force: true });
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Keep the selected version plus one fallback.
|
|
129
|
+
function prune(keep) {
|
|
130
|
+
const versions = installedVersions();
|
|
131
|
+
const keepSet = new Set([keep, versions.find((v) => v !== keep)]);
|
|
132
|
+
for (const version of versions) {
|
|
133
|
+
if (keepSet.has(version)) continue;
|
|
134
|
+
try {
|
|
135
|
+
rmSync(join(PKG, version), { recursive: true, force: true });
|
|
136
|
+
} catch {}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function installOnly(version) {
|
|
141
|
+
if (!parseVersion(version)) return false;
|
|
142
|
+
const ok = installVersion(version);
|
|
143
|
+
if (ok) prune(version);
|
|
144
|
+
return ok;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The skill is a file on disk that only `setup` rewrites. Refresh it from the
|
|
148
|
+
// version being launched, but never create one the user did not ask for.
|
|
149
|
+
function syncSkill(cli) {
|
|
150
|
+
try {
|
|
151
|
+
const src = join(dirname(cli), "skills", "delegate-work", "SKILL.md");
|
|
152
|
+
if (!existsSync(src) || !existsSync(SKILL)) return;
|
|
153
|
+
const want = readFileSync(src, "utf8");
|
|
154
|
+
if (readFileSync(SKILL, "utf8") !== want) writeFileSync(SKILL, want);
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// `setup` copies this file once, but a later release may fix update logic.
|
|
159
|
+
// Refresh it from the version being launched; the running copy is already in
|
|
160
|
+
// memory, so replacing the file on disk is safe.
|
|
161
|
+
function syncLauncher(cli) {
|
|
162
|
+
try {
|
|
163
|
+
const src = join(dirname(cli), "launcher.mjs");
|
|
164
|
+
if (!existsSync(src) || !existsSync(SELF)) return;
|
|
165
|
+
const want = readFileSync(src, "utf8");
|
|
166
|
+
if (readFileSync(SELF, "utf8") !== want) writeFileSync(SELF, want);
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (process.argv[2] === "--install-only") {
|
|
171
|
+
process.exit(installOnly(process.argv[3]) ? 0 : 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const current = installedVersions()[0] ?? null;
|
|
175
|
+
let target = current;
|
|
176
|
+
|
|
177
|
+
if (AUTO) {
|
|
178
|
+
const latest = await latestVersion();
|
|
179
|
+
if (latest) {
|
|
180
|
+
const latestMajor = parseVersion(latest)[0];
|
|
181
|
+
const currentMajor = current ? parseVersion(current)[0] : latestMajor;
|
|
182
|
+
if (latestMajor === currentMajor && compareVersions(latest, current ?? "0.0.0") > 0) {
|
|
183
|
+
if (cliPath(latest)) {
|
|
184
|
+
target = latest;
|
|
185
|
+
} else if (current) {
|
|
186
|
+
// Update in the background and run the current version now, so an
|
|
187
|
+
// npm install can never hold up the MCP handshake.
|
|
188
|
+
const child = spawn(process.execPath, [SELF, "--install-only", latest], {
|
|
189
|
+
detached: true,
|
|
190
|
+
stdio: "ignore",
|
|
191
|
+
});
|
|
192
|
+
child.unref();
|
|
193
|
+
} else if (installVersion(latest)) {
|
|
194
|
+
// Nothing usable installed yet — this one has to be synchronous.
|
|
195
|
+
target = latest;
|
|
196
|
+
}
|
|
197
|
+
} else if (current && latestMajor > currentMajor) {
|
|
198
|
+
process.stderr.write(
|
|
199
|
+
`forge-delegate: ${latest} is a major update and needs a manual upgrade; running ${current}\n`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const cli = cliPath(target);
|
|
206
|
+
if (cli) {
|
|
207
|
+
syncLauncher(cli);
|
|
208
|
+
syncSkill(cli);
|
|
209
|
+
try {
|
|
210
|
+
mkdirSync(ROOT, { recursive: true });
|
|
211
|
+
writeFileSync(STATE, JSON.stringify({ version: target, autoUpdate: AUTO, updatedAt: Date.now() }));
|
|
212
|
+
} catch {}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const npx = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
216
|
+
// No args means "serve" (the MCP entry point). Any other arg is forwarded, so
|
|
217
|
+
// `node ~/.forge-delegate/launcher.mjs status` works without a global binary.
|
|
218
|
+
const args = process.argv.slice(2);
|
|
219
|
+
const cliArgs = args.length ? args : ["serve"];
|
|
220
|
+
const child = cli
|
|
221
|
+
? spawn(process.execPath, [cli, ...cliArgs], { stdio: "inherit" })
|
|
222
|
+
: spawn(npx, ["-y", "forge-delegate", ...cliArgs], { stdio: "inherit" });
|
|
223
|
+
|
|
224
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
225
|
+
process.on(signal, () => child.kill(signal));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
child.on("exit", (code, signal) => {
|
|
229
|
+
if (signal) process.kill(process.pid, signal);
|
|
230
|
+
else process.exit(code ?? 0);
|
|
231
|
+
});
|
package/lib.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
export const VERSION = JSON.parse(readFileSync(join(here, "package.json"), "utf8")).version;
|
|
5
9
|
|
|
6
10
|
export const OPENCODE_TIMEOUT = Number(process.env.FORGE_OPENCODE_TIMEOUT ?? 900_000);
|
|
7
11
|
|
|
@@ -51,6 +55,19 @@ const CATALOG_CACHE = join(CONFIG_DIR, "models-dev.json");
|
|
|
51
55
|
const CATALOG_TTL = 24 * 3600 * 1000;
|
|
52
56
|
const OLLAMA_BASE = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
|
53
57
|
|
|
58
|
+
function parseVersion(v) {
|
|
59
|
+
const m = String(v ?? "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
60
|
+
return m ? [+m[1], +m[2], +m[3]] : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function compareVersions(a, b) {
|
|
64
|
+
const pa = parseVersion(a);
|
|
65
|
+
const pb = parseVersion(b);
|
|
66
|
+
if (!pa || !pb) return 0;
|
|
67
|
+
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
function execFileText(cmd, args, opts = {}) {
|
|
55
72
|
return new Promise((resolve, reject) => {
|
|
56
73
|
execFile(cmd, args, { timeout: 15_000, maxBuffer: 4 * 1024 * 1024, ...opts }, (err, stdout) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-delegate",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "MCP server that relays coding work to other AI models (DeepSeek, Gemini, OpenAI, Ollama) so Claude Code saves tokens",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node cli.mjs setup",
|
|
11
11
|
"start": "node cli.mjs serve",
|
|
12
|
-
"prepublishOnly": "node --check cli.mjs && node --check server.mjs && node --check lib.mjs && node test-handshake.mjs"
|
|
12
|
+
"prepublishOnly": "node --check launcher.mjs && node --check cli.mjs && node --check server.mjs && node --check lib.mjs && node test-handshake.mjs"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
|
+
"launcher.mjs",
|
|
15
16
|
"cli.mjs",
|
|
16
17
|
"server.mjs",
|
|
17
18
|
"lib.mjs",
|
package/server.mjs
CHANGED
|
@@ -21,16 +21,26 @@ import {
|
|
|
21
21
|
saveConfig,
|
|
22
22
|
configPath,
|
|
23
23
|
resolveConfig,
|
|
24
|
+
VERSION,
|
|
24
25
|
} from "./lib.mjs";
|
|
25
26
|
|
|
26
27
|
const execFileAsync = promisify(execFile);
|
|
27
28
|
const RUNS_DIR = join(homedir(), ".forge-delegate", "runs");
|
|
28
29
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
-
const
|
|
30
|
+
const herePosix = here.replace(/\\/g, "/");
|
|
30
31
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
32
|
+
// Only a launcher install can refresh itself; the report tells the user (and
|
|
33
|
+
// the model) which kind they have.
|
|
34
|
+
function installKind() {
|
|
35
|
+
if (herePosix.includes("/.forge-delegate/pkg/")) return "launcher (automatic updates)";
|
|
36
|
+
if (herePosix.includes("/.npm/_npx/") || herePosix.includes("node_modules/forge-delegate"))
|
|
37
|
+
return "npx/package (manual updates)";
|
|
38
|
+
return "checkout";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The installed skill is a file on disk that only `setup` rewrites, so a published
|
|
42
|
+
// skill change goes unnoticed. Compare the two and say so, rather than running
|
|
43
|
+
// stale in silence.
|
|
34
44
|
function skillStatus() {
|
|
35
45
|
const src = join(here, "skills", "delegate-work", "SKILL.md");
|
|
36
46
|
if (!existsSync(src)) return null;
|
|
@@ -286,7 +296,7 @@ server.tool(
|
|
|
286
296
|
|
|
287
297
|
server.tool(
|
|
288
298
|
"get_delegate_config",
|
|
289
|
-
"Read the current forge-delegate configuration: defaultModel, defaultAgent, defaultVariant, autoApprove, timeoutMs, defaultDirectory, and named profiles. Call this to see what will be used when a model/directory is omitted.",
|
|
299
|
+
"Read the current forge-delegate configuration: defaultModel, defaultAgent, defaultVariant, autoApprove, timeoutMs, defaultDirectory, and named profiles — plus the installed version and whether it updates automatically. Call this to see what will be used when a model/directory is omitted, or when the user asks which version is running.",
|
|
290
300
|
{},
|
|
291
301
|
async () => {
|
|
292
302
|
const cfg = loadConfig();
|
|
@@ -294,7 +304,14 @@ server.tool(
|
|
|
294
304
|
const lines = Object.entries(rest).map(([k, v]) => ` ${k}: ${v ?? "(unset)"}`);
|
|
295
305
|
const prof = Object.entries(profiles).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join("\n") || " (none)";
|
|
296
306
|
const skill = SKILL_STATUS ? `\nskill (delegate-work): ${SKILL_STATUS.detail}${SKILL_STATUS.state === "ok" ? "" : " — run: npx -y forge-delegate setup"}` : "";
|
|
297
|
-
return {
|
|
307
|
+
return {
|
|
308
|
+
content: [
|
|
309
|
+
{
|
|
310
|
+
type: "text",
|
|
311
|
+
text: `version: ${VERSION}\ninstall: ${installKind()}\nconfig: ${configPath()}\n${lines.join("\n")}\nprofiles:\n${prof}${skill}`,
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
};
|
|
298
315
|
}
|
|
299
316
|
);
|
|
300
317
|
|