forge-delegate 1.0.8 → 1.0.10
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 +10 -1
- package/cli.mjs +19 -4
- package/lib.mjs +96 -1
- package/package.json +1 -1
- package/server.mjs +26 -6
package/README.md
CHANGED
|
@@ -173,13 +173,22 @@ Every delegation tool also accepts per-call `model`, `directory`, `agent`, `auto
|
|
|
173
173
|
## CLI
|
|
174
174
|
|
|
175
175
|
```text
|
|
176
|
+
forge-delegate --version Print the installed version
|
|
176
177
|
forge-delegate setup Register with Claude Code / Codex / opencode
|
|
177
178
|
forge-delegate serve Start the MCP server (used internally)
|
|
178
179
|
forge-delegate config get | set | path
|
|
179
|
-
forge-delegate status Show opencode logins
|
|
180
|
+
forge-delegate status Show version, update status, opencode logins, usage
|
|
180
181
|
forge-delegate stats Show detailed usage statistics
|
|
181
182
|
```
|
|
182
183
|
|
|
184
|
+
`status` also asks npm for the newest release (cached for 24 hours in
|
|
185
|
+
`~/.forge-delegate/update-check.json`) and reports it when a newer version exists. Disable the
|
|
186
|
+
check with `FORGE_NO_UPDATE_CHECK=1`. `get_delegate_config` returns the same version and
|
|
187
|
+
`updateAvailable` fields, so an agent can answer "is this up to date?" without a CLI call.
|
|
188
|
+
|
|
189
|
+
Updating an npx install is not automatic: npx keeps using its cached copy. Clear it with
|
|
190
|
+
`rm -rf ~/.npm/_npx` and restart your agent, or install globally with `npm i -g forge-delegate`.
|
|
191
|
+
|
|
183
192
|
## Publishing (npm)
|
|
184
193
|
|
|
185
194
|
The primary install is `npx -y forge-delegate setup`, so the package must be on npm. Checklist:
|
package/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { execSync } from "node:child_process";
|
|
8
|
-
import { loadConfig, saveConfig, configPath, detectOpencode, loadOpencodeAuth } from "./lib.mjs";
|
|
8
|
+
import { loadConfig, saveConfig, configPath, detectOpencode, loadOpencodeAuth, checkForUpdate, VERSION } from "./lib.mjs";
|
|
9
9
|
|
|
10
10
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
|
|
@@ -210,9 +210,17 @@ function configCmd(argv) {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
async function status() {
|
|
213
|
+
const update = await checkForUpdate();
|
|
213
214
|
const oc = await detectOpencode();
|
|
215
|
+
let versionNote;
|
|
216
|
+
if (update.disabled) versionNote = " (update check disabled)";
|
|
217
|
+
else if (update.updateAvailable) versionNote = ` \u2192 ${update.latest} available`;
|
|
218
|
+
else if (update.error && !update.latest) versionNote = " (update check unavailable)";
|
|
219
|
+
else if (update.error) versionNote = ` (latest known: ${update.latest}; check failed)`;
|
|
220
|
+
else versionNote = " (up to date)";
|
|
221
|
+
console.log(`\nforge-delegate: ${VERSION}${versionNote}`);
|
|
214
222
|
console.log(
|
|
215
|
-
|
|
223
|
+
`opencode: ${oc.found ? (oc.version?.raw ?? "unknown version") : "not found on PATH"}` +
|
|
216
224
|
`${oc.found && !oc.supported ? " (too old — requires v2 or newer)" : ""}`
|
|
217
225
|
);
|
|
218
226
|
let providers = [];
|
|
@@ -227,6 +235,11 @@ async function status() {
|
|
|
227
235
|
console.log(`recorded delegations: ${lines} (see 'forge-delegate stats')`);
|
|
228
236
|
}
|
|
229
237
|
} catch {}
|
|
238
|
+
if (update.updateAvailable) {
|
|
239
|
+
console.log(`\nUpdate to ${update.latest}:`);
|
|
240
|
+
console.log(" npx install: rm -rf ~/.npm/_npx (then restart your agent)");
|
|
241
|
+
console.log(" global: npm i -g forge-delegate");
|
|
242
|
+
}
|
|
230
243
|
}
|
|
231
244
|
|
|
232
245
|
function stats() {
|
|
@@ -268,6 +281,7 @@ function help() {
|
|
|
268
281
|
forge-delegate — delegate coding work from Claude Code/Codex/opencode to other AI models
|
|
269
282
|
|
|
270
283
|
Usage:
|
|
284
|
+
forge-delegate --version Print the installed version
|
|
271
285
|
forge-delegate setup [--model provider/model] [--targets claude,codex,opencode]
|
|
272
286
|
[--scope user|project] [--project-dir <path>] [--no-skill]
|
|
273
287
|
Interactive setup; flags optional (run bare to be prompted)
|
|
@@ -277,7 +291,7 @@ Usage:
|
|
|
277
291
|
forge-delegate config set --model <m> [--agent <a>] [--variant <v>]
|
|
278
292
|
[--timeout <s>] [--dir <path>] [--autoApprove true|false]
|
|
279
293
|
forge-delegate config path Print config file path
|
|
280
|
-
forge-delegate status Show opencode logins
|
|
294
|
+
forge-delegate status Show version, update status, opencode logins, usage
|
|
281
295
|
forge-delegate stats Show detailed usage statistics
|
|
282
296
|
|
|
283
297
|
Models come from your opencode installation:
|
|
@@ -287,7 +301,8 @@ Models come from your opencode installation:
|
|
|
287
301
|
}
|
|
288
302
|
|
|
289
303
|
const cmd = process.argv[2] ?? "help";
|
|
290
|
-
if (cmd === "
|
|
304
|
+
if (cmd === "--version" || cmd === "-v" || cmd === "version") console.log(VERSION);
|
|
305
|
+
else if (cmd === "setup") await setup();
|
|
291
306
|
else if (cmd === "serve") await import("./server.mjs");
|
|
292
307
|
else if (cmd === "config") configCmd(process.argv.slice(3));
|
|
293
308
|
else if (cmd === "status") await status();
|
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
|
|
|
@@ -25,13 +29,103 @@ export function detectOpencode() {
|
|
|
25
29
|
});
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
// The startup warning on stderr is easy for an MCP client to swallow, and a v1
|
|
33
|
+
// binary does not fail loudly: `run` reads the v2 flags as a help request and
|
|
34
|
+
// exits 0. Check once per process, then refuse to spawn instead of returning
|
|
35
|
+
// "(no output)" and letting it look like the model had nothing to say.
|
|
36
|
+
let opencodeGate;
|
|
37
|
+
function requireOpencodeV2() {
|
|
38
|
+
opencodeGate ??= detectOpencode();
|
|
39
|
+
return opencodeGate.then((oc) => {
|
|
40
|
+
if (!oc.found)
|
|
41
|
+
throw new Error("opencode not found on PATH — install it from https://opencode.ai");
|
|
42
|
+
if (!oc.supported)
|
|
43
|
+
throw new Error(
|
|
44
|
+
`opencode ${oc.version?.raw ?? "(unknown version)"} is too old — forge-delegate requires opencode 2 or newer ` +
|
|
45
|
+
`(v2 removed the CLI flags it uses). Run: opencode upgrade`
|
|
46
|
+
);
|
|
47
|
+
return oc;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
const CONFIG_DIR = join(homedir(), ".forge-delegate");
|
|
29
52
|
const AUTH_PATH = join(homedir(), ".local/share/opencode/auth.json");
|
|
30
53
|
const OPENCODE_CONFIG_PATH = join(homedir(), ".config/opencode/opencode.json");
|
|
31
54
|
const CATALOG_CACHE = join(CONFIG_DIR, "models-dev.json");
|
|
32
55
|
const CATALOG_TTL = 24 * 3600 * 1000;
|
|
56
|
+
const UPDATE_CACHE = join(CONFIG_DIR, "update-check.json");
|
|
57
|
+
const UPDATE_TTL = 24 * 3600 * 1000;
|
|
33
58
|
const OLLAMA_BASE = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
|
34
59
|
|
|
60
|
+
function parseVersion(v) {
|
|
61
|
+
const m = String(v ?? "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
62
|
+
return m ? [+m[1], +m[2], +m[3]] : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function compareVersions(a, b) {
|
|
66
|
+
const pa = parseVersion(a);
|
|
67
|
+
const pb = parseVersion(b);
|
|
68
|
+
if (!pa || !pb) return 0;
|
|
69
|
+
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
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
|
+
|
|
35
129
|
function execFileText(cmd, args, opts = {}) {
|
|
36
130
|
return new Promise((resolve, reject) => {
|
|
37
131
|
execFile(cmd, args, { timeout: 15_000, maxBuffer: 4 * 1024 * 1024, ...opts }, (err, stdout) => {
|
|
@@ -191,6 +285,7 @@ export function parseEvents(stdout) {
|
|
|
191
285
|
}
|
|
192
286
|
|
|
193
287
|
export async function opencodeRun(model, prompt, opts = {}) {
|
|
288
|
+
await requireOpencodeV2();
|
|
194
289
|
const started = Date.now();
|
|
195
290
|
const variant = opts.variant ?? (await pickVariant(model));
|
|
196
291
|
const timeoutMs = opts.timeoutMs ?? OPENCODE_TIMEOUT;
|
package/package.json
CHANGED
package/server.mjs
CHANGED
|
@@ -21,16 +21,17 @@ import {
|
|
|
21
21
|
saveConfig,
|
|
22
22
|
configPath,
|
|
23
23
|
resolveConfig,
|
|
24
|
+
checkForUpdate,
|
|
25
|
+
VERSION,
|
|
24
26
|
} from "./lib.mjs";
|
|
25
27
|
|
|
26
28
|
const execFileAsync = promisify(execFile);
|
|
27
29
|
const RUNS_DIR = join(homedir(), ".forge-delegate", "runs");
|
|
28
30
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
-
const VERSION = JSON.parse(readFileSync(join(here, "package.json"), "utf8")).version;
|
|
30
31
|
|
|
31
|
-
// The
|
|
32
|
-
// skill
|
|
33
|
-
//
|
|
32
|
+
// The installed skill is a file on disk that only `setup` rewrites, so a published
|
|
33
|
+
// skill change goes unnoticed. Compare the two and say so, rather than running
|
|
34
|
+
// stale in silence.
|
|
34
35
|
function skillStatus() {
|
|
35
36
|
const src = join(here, "skills", "delegate-work", "SKILL.md");
|
|
36
37
|
if (!existsSync(src)) return null;
|
|
@@ -286,15 +287,30 @@ server.tool(
|
|
|
286
287
|
|
|
287
288
|
server.tool(
|
|
288
289
|
"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.",
|
|
290
|
+
"Read the current forge-delegate configuration: defaultModel, defaultAgent, defaultVariant, autoApprove, timeoutMs, defaultDirectory, and named profiles — plus the installed version and whether a newer release is published. Call this to see what will be used when a model/directory is omitted, or when the user asks whether forge-delegate is up to date.",
|
|
290
291
|
{},
|
|
291
292
|
async () => {
|
|
292
293
|
const cfg = loadConfig();
|
|
294
|
+
const update = await checkForUpdate();
|
|
293
295
|
const { profiles, ...rest } = cfg;
|
|
294
296
|
const lines = Object.entries(rest).map(([k, v]) => ` ${k}: ${v ?? "(unset)"}`);
|
|
295
297
|
const prof = Object.entries(profiles).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join("\n") || " (none)";
|
|
296
298
|
const skill = SKILL_STATUS ? `\nskill (delegate-work): ${SKILL_STATUS.detail}${SKILL_STATUS.state === "ok" ? "" : " — run: npx -y forge-delegate setup"}` : "";
|
|
297
|
-
|
|
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
|
+
return {
|
|
307
|
+
content: [
|
|
308
|
+
{
|
|
309
|
+
type: "text",
|
|
310
|
+
text: `version: ${VERSION}\n${updateLine}\nconfig: ${configPath()}\n${lines.join("\n")}\nprofiles:\n${prof}${skill}`,
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
298
314
|
}
|
|
299
315
|
);
|
|
300
316
|
|
|
@@ -323,3 +339,7 @@ server.tool(
|
|
|
323
339
|
|
|
324
340
|
const transport = new StdioServerTransport();
|
|
325
341
|
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(() => {});
|