forge-delegate 1.0.10 → 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 +23 -12
- package/cli.mjs +93 -22
- package/launcher.mjs +231 -0
- package/lib.mjs +0 -58
- package/package.json +3 -2
- package/server.mjs +12 -15
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.
|
|
@@ -177,25 +178,35 @@ forge-delegate --version Print the installed version
|
|
|
177
178
|
forge-delegate setup Register with Claude Code / Codex / opencode
|
|
178
179
|
forge-delegate serve Start the MCP server (used internally)
|
|
179
180
|
forge-delegate config get | set | path
|
|
180
|
-
forge-delegate status Show version, update
|
|
181
|
+
forge-delegate status Show version, update mode, opencode logins, usage
|
|
181
182
|
forge-delegate stats Show detailed usage statistics
|
|
182
183
|
```
|
|
183
184
|
|
|
184
|
-
`
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
188
191
|
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
191
202
|
|
|
192
203
|
## Publishing (npm)
|
|
193
204
|
|
|
194
205
|
The primary install is `npx -y forge-delegate setup`, so the package must be on npm. Checklist:
|
|
195
206
|
|
|
196
207
|
1. `npm run prepublishOnly` — syntax-checks all entry files (runs automatically on publish).
|
|
197
|
-
2. `npm pack --dry-run` — confirm the tarball contains only `
|
|
198
|
-
`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`.
|
|
199
210
|
3. Bump `version` in `package.json` (and the root `version` in `package-lock.json`).
|
|
200
211
|
4. `npm login && npm publish` — the `bin` field makes `npx forge-delegate …` work.
|
|
201
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,
|
|
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) {
|
|
@@ -210,14 +263,30 @@ function configCmd(argv) {
|
|
|
210
263
|
}
|
|
211
264
|
|
|
212
265
|
async function status() {
|
|
213
|
-
const update = await checkForUpdate();
|
|
214
266
|
const oc = await detectOpencode();
|
|
267
|
+
const kind = installKind();
|
|
215
268
|
let versionNote;
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
}
|
|
221
290
|
console.log(`\nforge-delegate: ${VERSION}${versionNote}`);
|
|
222
291
|
console.log(
|
|
223
292
|
`opencode: ${oc.found ? (oc.version?.raw ?? "unknown version") : "not found on PATH"}` +
|
|
@@ -235,10 +304,9 @@ async function status() {
|
|
|
235
304
|
console.log(`recorded delegations: ${lines} (see 'forge-delegate stats')`);
|
|
236
305
|
}
|
|
237
306
|
} catch {}
|
|
238
|
-
if (
|
|
239
|
-
console.log(
|
|
240
|
-
console.log("
|
|
241
|
-
console.log(" global: npm i -g forge-delegate");
|
|
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");
|
|
242
310
|
}
|
|
243
311
|
}
|
|
244
312
|
|
|
@@ -291,9 +359,12 @@ Usage:
|
|
|
291
359
|
forge-delegate config set --model <m> [--agent <a>] [--variant <v>]
|
|
292
360
|
[--timeout <s>] [--dir <path>] [--autoApprove true|false]
|
|
293
361
|
forge-delegate config path Print config file path
|
|
294
|
-
forge-delegate status Show version, update
|
|
362
|
+
forge-delegate status Show version, update mode, opencode logins, usage
|
|
295
363
|
forge-delegate stats Show detailed usage statistics
|
|
296
364
|
|
|
365
|
+
Automatic updates (launcher installs) are on by default. Disable with:
|
|
366
|
+
FORGE_AUTO_UPDATE=0
|
|
367
|
+
|
|
297
368
|
Models come from your opencode installation:
|
|
298
369
|
opencode auth login <provider> add a provider
|
|
299
370
|
opencode models list available models
|
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
|
@@ -53,8 +53,6 @@ const AUTH_PATH = join(homedir(), ".local/share/opencode/auth.json");
|
|
|
53
53
|
const OPENCODE_CONFIG_PATH = join(homedir(), ".config/opencode/opencode.json");
|
|
54
54
|
const CATALOG_CACHE = join(CONFIG_DIR, "models-dev.json");
|
|
55
55
|
const CATALOG_TTL = 24 * 3600 * 1000;
|
|
56
|
-
const UPDATE_CACHE = join(CONFIG_DIR, "update-check.json");
|
|
57
|
-
const UPDATE_TTL = 24 * 3600 * 1000;
|
|
58
56
|
const OLLAMA_BASE = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
|
59
57
|
|
|
60
58
|
function parseVersion(v) {
|
|
@@ -70,62 +68,6 @@ export function compareVersions(a, b) {
|
|
|
70
68
|
return 0;
|
|
71
69
|
}
|
|
72
70
|
|
|
73
|
-
function readUpdateCache() {
|
|
74
|
-
try {
|
|
75
|
-
const cached = JSON.parse(readFileSync(UPDATE_CACHE, "utf8"));
|
|
76
|
-
if (cached?.latest && Number.isFinite(cached.checkedAt)) return cached;
|
|
77
|
-
} catch {}
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// An installed copy cannot see the registry on its own, and npx happily reuses a
|
|
82
|
-
// cached install forever — which is how this server ran 1.0.7 while 1.0.9 was
|
|
83
|
-
// published. Check once a day, cache the answer, and never let a network failure
|
|
84
|
-
// break the caller. Opt out with FORGE_NO_UPDATE_CHECK=1 (or NO_UPDATE_NOTIFIER=1).
|
|
85
|
-
export async function checkForUpdate({ force = false, timeoutMs = 3000 } = {}) {
|
|
86
|
-
const cached = readUpdateCache();
|
|
87
|
-
if (!force && cached && Date.now() - cached.checkedAt < UPDATE_TTL) {
|
|
88
|
-
return {
|
|
89
|
-
current: VERSION,
|
|
90
|
-
latest: cached.latest,
|
|
91
|
-
updateAvailable: compareVersions(cached.latest, VERSION) > 0,
|
|
92
|
-
checkedAt: cached.checkedAt,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (process.env.FORGE_NO_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) {
|
|
97
|
-
return { current: VERSION, latest: null, updateAvailable: false, checkedAt: null, disabled: true };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
try {
|
|
101
|
-
const res = await fetch("https://registry.npmjs.org/forge-delegate/latest", {
|
|
102
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
103
|
-
});
|
|
104
|
-
if (!res.ok) throw new Error(`registry returned ${res.status}`);
|
|
105
|
-
const { version } = await res.json();
|
|
106
|
-
if (!parseVersion(version)) throw new Error("registry returned no version");
|
|
107
|
-
const checkedAt = Date.now();
|
|
108
|
-
try {
|
|
109
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
110
|
-
writeFileSync(UPDATE_CACHE, JSON.stringify({ latest: version, checkedAt }));
|
|
111
|
-
} catch {}
|
|
112
|
-
return {
|
|
113
|
-
current: VERSION,
|
|
114
|
-
latest: version,
|
|
115
|
-
updateAvailable: compareVersions(version, VERSION) > 0,
|
|
116
|
-
checkedAt,
|
|
117
|
-
};
|
|
118
|
-
} catch (e) {
|
|
119
|
-
return {
|
|
120
|
-
current: VERSION,
|
|
121
|
-
latest: cached?.latest ?? null,
|
|
122
|
-
updateAvailable: cached ? compareVersions(cached.latest, VERSION) > 0 : false,
|
|
123
|
-
checkedAt: cached?.checkedAt ?? null,
|
|
124
|
-
error: e.message,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
71
|
function execFileText(cmd, args, opts = {}) {
|
|
130
72
|
return new Promise((resolve, reject) => {
|
|
131
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,13 +21,22 @@ import {
|
|
|
21
21
|
saveConfig,
|
|
22
22
|
configPath,
|
|
23
23
|
resolveConfig,
|
|
24
|
-
checkForUpdate,
|
|
25
24
|
VERSION,
|
|
26
25
|
} from "./lib.mjs";
|
|
27
26
|
|
|
28
27
|
const execFileAsync = promisify(execFile);
|
|
29
28
|
const RUNS_DIR = join(homedir(), ".forge-delegate", "runs");
|
|
30
29
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const herePosix = here.replace(/\\/g, "/");
|
|
31
|
+
|
|
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
|
+
}
|
|
31
40
|
|
|
32
41
|
// The installed skill is a file on disk that only `setup` rewrites, so a published
|
|
33
42
|
// skill change goes unnoticed. Compare the two and say so, rather than running
|
|
@@ -287,27 +296,19 @@ server.tool(
|
|
|
287
296
|
|
|
288
297
|
server.tool(
|
|
289
298
|
"get_delegate_config",
|
|
290
|
-
"Read the current forge-delegate configuration: defaultModel, defaultAgent, defaultVariant, autoApprove, timeoutMs, defaultDirectory, and named profiles — plus the installed version and whether
|
|
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.",
|
|
291
300
|
{},
|
|
292
301
|
async () => {
|
|
293
302
|
const cfg = loadConfig();
|
|
294
|
-
const update = await checkForUpdate();
|
|
295
303
|
const { profiles, ...rest } = cfg;
|
|
296
304
|
const lines = Object.entries(rest).map(([k, v]) => ` ${k}: ${v ?? "(unset)"}`);
|
|
297
305
|
const prof = Object.entries(profiles).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join("\n") || " (none)";
|
|
298
306
|
const skill = SKILL_STATUS ? `\nskill (delegate-work): ${SKILL_STATUS.detail}${SKILL_STATUS.state === "ok" ? "" : " — run: npx -y forge-delegate setup"}` : "";
|
|
299
|
-
const updateLine = update.disabled
|
|
300
|
-
? "updateAvailable: unknown (check disabled)"
|
|
301
|
-
: update.error && !update.latest
|
|
302
|
-
? `updateAvailable: unknown (${update.error})`
|
|
303
|
-
: update.updateAvailable
|
|
304
|
-
? `updateAvailable: true (latest ${update.latest}, running ${VERSION})`
|
|
305
|
-
: "updateAvailable: false";
|
|
306
307
|
return {
|
|
307
308
|
content: [
|
|
308
309
|
{
|
|
309
310
|
type: "text",
|
|
310
|
-
text: `version: ${VERSION}\
|
|
311
|
+
text: `version: ${VERSION}\ninstall: ${installKind()}\nconfig: ${configPath()}\n${lines.join("\n")}\nprofiles:\n${prof}${skill}`,
|
|
311
312
|
},
|
|
312
313
|
],
|
|
313
314
|
};
|
|
@@ -339,7 +340,3 @@ server.tool(
|
|
|
339
340
|
|
|
340
341
|
const transport = new StdioServerTransport();
|
|
341
342
|
await server.connect(transport);
|
|
342
|
-
|
|
343
|
-
// Warm the update cache after the handshake — a registry call must never delay
|
|
344
|
-
// the MCP connection. get_delegate_config reads whatever this leaves behind.
|
|
345
|
-
checkForUpdate().catch(() => {});
|