forge-delegate 1.0.9 → 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 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 and usage summary
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
- `\nopencode: ${oc.found ? (oc.version?.raw ?? "unknown version") : "not found on PATH"}` +
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 and usage summary
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 === "setup") await setup();
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
 
@@ -49,8 +53,79 @@ const AUTH_PATH = join(homedir(), ".local/share/opencode/auth.json");
49
53
  const OPENCODE_CONFIG_PATH = join(homedir(), ".config/opencode/opencode.json");
50
54
  const CATALOG_CACHE = join(CONFIG_DIR, "models-dev.json");
51
55
  const CATALOG_TTL = 24 * 3600 * 1000;
56
+ const UPDATE_CACHE = join(CONFIG_DIR, "update-check.json");
57
+ const UPDATE_TTL = 24 * 3600 * 1000;
52
58
  const OLLAMA_BASE = process.env.OLLAMA_URL ?? "http://localhost:11434";
53
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
+
54
129
  function execFileText(cmd, args, opts = {}) {
55
130
  return new Promise((resolve, reject) => {
56
131
  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.9",
3
+ "version": "1.0.10",
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": {
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 server auto-updates (npx re-resolves latest on each spawn) but the installed
32
- // skill is a file on disk that only `setup` rewrites — so a published skill change
33
- // goes unnoticed. Compare the two and say so, rather than running stale in silence.
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
- return { content: [{ type: "text", text: `config: ${configPath()}\n${lines.join("\n")}\nprofiles:\n${prof}${skill}` }] };
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(() => {});