vexp-cli 2.3.1 → 2.5.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 +49 -79
- package/dist/agent-config.js +320 -86
- package/dist/cli.js +15 -0
- package/dist/doctor.js +60 -8
- package/dist/hook-template.js +135 -0
- package/dist/mcp-supervisor.js +15 -3
- package/dist/serve.js +10 -0
- package/dist/update-check.js +18 -6
- package/mcp/mcp-server.cjs +43 -43
- package/package.json +51 -39
package/dist/doctor.js
CHANGED
|
@@ -151,13 +151,43 @@ export async function runDoctor() {
|
|
|
151
151
|
if (st.llm_configured_but_inactive === true) {
|
|
152
152
|
line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed. Run 'vexp daemon-cmd restart' to load the model.`);
|
|
153
153
|
}
|
|
154
|
+
// 2.4.0 upgrade guard (CLI side): a daemon surviving an upgrade keeps
|
|
155
|
+
// serving the OLD feature set and its gaps read as product bugs
|
|
156
|
+
// (field case: pre-ledger daemon after the 2.4.0 install). The VS Code
|
|
157
|
+
// extension restarts automatically; CLI users get told explicitly.
|
|
158
|
+
try {
|
|
159
|
+
const { getBinaryPath } = await import("./binary.js");
|
|
160
|
+
const { execFileSync } = await import("node:child_process");
|
|
161
|
+
const out = execFileSync(getBinaryPath(), ["--version"], { timeout: 5000, encoding: "utf8" });
|
|
162
|
+
const bundled = out.trim().split(/\s+/).pop();
|
|
163
|
+
const running = st.daemon_version;
|
|
164
|
+
if (bundled && running && bundled !== running) {
|
|
165
|
+
line(WARN, `daemon is v${running} but the installed binary is v${bundled} — this workspace is still served by the OLD version. Run 'vexp daemon-cmd restart' to upgrade it now.`);
|
|
166
|
+
}
|
|
167
|
+
else if (st.binary_stale === true) {
|
|
168
|
+
line(WARN, `daemon is running a deleted executable (upgraded on disk) — run 'vexp daemon-cmd restart' to load the new build.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch { /* best-effort */ }
|
|
154
172
|
// 2.3 C1 — which agent sessions actually used vexp (a session with zero
|
|
155
173
|
// calls never shows up here; that absence is the diagnostic).
|
|
156
174
|
const sessions = Array.isArray(st.sessions) ? st.sessions : [];
|
|
175
|
+
// 2.3.3 Savings Ledger — activity counts even when no tool is ever
|
|
176
|
+
// called: silence on oriented prompts is vexp WORKING, and the old
|
|
177
|
+
// "never used" warning on quiet-but-active daemons generated real
|
|
178
|
+
// support tickets ("is it working? it never got called").
|
|
179
|
+
const ledger = (st.ledger ?? {});
|
|
180
|
+
const analyzed = Number(ledger.prompts_analyzed) || 0;
|
|
181
|
+
if (analyzed > 0) {
|
|
182
|
+
line(OK, `savings ledger (7d): ${analyzed} prompt(s) analyzed — ${Number(ledger.silences) || 0} silences (task already oriented), ${Number(ledger.hints_served) || 0} hints served. Details: vexp savings`);
|
|
183
|
+
}
|
|
157
184
|
if (sessions.length > 0) {
|
|
158
185
|
const total = sessions.reduce((n, s) => n + (Number(s.pipeline_calls) || 0), 0);
|
|
159
186
|
line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
|
|
160
187
|
}
|
|
188
|
+
else if (analyzed > 0) {
|
|
189
|
+
line(OK, `no tool calls in the last 4h — but the ledger above shows vexp is analyzing prompts (zero calls is normal on oriented tasks)`);
|
|
190
|
+
}
|
|
161
191
|
else if (Number(st.daemon_uptime_s) > 600) {
|
|
162
192
|
// Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
|
|
163
193
|
// the daemon was unreachable serves from its own in-process index and
|
|
@@ -207,6 +237,12 @@ export async function runDoctor() {
|
|
|
207
237
|
// 3) License (fresh.jwt rolling token vs license.jwt).
|
|
208
238
|
console.log(chalk.bold("\nLicense tokens (~/.vexp)"));
|
|
209
239
|
const now = Math.floor(Date.now() / 1000);
|
|
240
|
+
// Either valid token keeps the plan ACTIVE. An expired license.jwt next to
|
|
241
|
+
// a valid fresh.jwt is normal steady-state (the long token re-rolls on the
|
|
242
|
+
// next online validation) — labeling it a bare WARN read as "my license
|
|
243
|
+
// expired" and generated tickets from perfectly licensed users.
|
|
244
|
+
const freshExp = jwtExp(path.join(home, ".vexp", "fresh.jwt"));
|
|
245
|
+
const freshValid = freshExp != null && freshExp > now;
|
|
210
246
|
for (const name of ["fresh.jwt", "license.jwt"]) {
|
|
211
247
|
const p = path.join(home, ".vexp", name);
|
|
212
248
|
if (!fs.existsSync(p)) {
|
|
@@ -220,7 +256,15 @@ export async function runDoctor() {
|
|
|
220
256
|
}
|
|
221
257
|
const days = Math.round((exp - now) / 86400);
|
|
222
258
|
if (exp < now) {
|
|
223
|
-
|
|
259
|
+
if (name === "fresh.jwt") {
|
|
260
|
+
line(OK, `${name}: expired ${-days}d ago (benign — rolling token, falls back to license.jwt)`);
|
|
261
|
+
}
|
|
262
|
+
else if (freshValid) {
|
|
263
|
+
line(OK, `${name}: long token expired ${-days}d ago — plan still ACTIVE via fresh.jwt; it renews automatically on the next online validation`);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
line(WARN, `${name}: expired ${-days}d ago and no valid fresh token — plan features may be limited; go online or re-activate the license`);
|
|
267
|
+
}
|
|
224
268
|
}
|
|
225
269
|
else {
|
|
226
270
|
line(OK, `${name}: valid, ~${days}d remaining`);
|
|
@@ -310,31 +354,39 @@ export async function runDoctor() {
|
|
|
310
354
|
else if (guardHooks.length === 0) {
|
|
311
355
|
line(OK, "no vexp guard configured (2.3 default — enable with 'vexp setup --guard-strict')");
|
|
312
356
|
}
|
|
313
|
-
else if (process.platform === "win32") {
|
|
314
|
-
line(OK, `guard configured (${guardHooks.length} entry) — live execution check skipped on Windows`);
|
|
315
|
-
}
|
|
316
357
|
else {
|
|
317
358
|
for (const h of guardHooks) {
|
|
318
359
|
const cmd = h.command;
|
|
319
360
|
const execForm = Array.isArray(h.args);
|
|
320
361
|
const timeoutS = typeof h.timeout === "number" ? h.timeout : 600;
|
|
321
362
|
if (!execForm && /\$\{?CLAUDE_PROJECT_DIR\}?\//.test(cmd) && !cmd.includes('"')) {
|
|
322
|
-
line(ws.root.includes(" ") ? BAD : WARN, `shell-form hook command
|
|
363
|
+
line(ws.root.includes(" ") ? BAD : WARN, `shell-form hook command with unquoted $CLAUDE_PROJECT_DIR — word-splits on paths with spaces${ws.root.includes(" ") ? ` and THIS project path has one: the guard never runs` : ""}. Re-run 'vexp setup --guard-strict' to rewrite with a quoted path.`);
|
|
364
|
+
}
|
|
365
|
+
if (execForm && process.platform === "win32") {
|
|
366
|
+
// Exec form spawns the .sh directly, which Windows cannot do at
|
|
367
|
+
// all — the entry LOOKS installed and enforces nothing. This was
|
|
368
|
+
// invisible for days because doctor used to skip the live check
|
|
369
|
+
// on Windows entirely.
|
|
370
|
+
line(BAD, `exec-form hook entry ('args' present) cannot run a .sh on Windows — the guard fails open. Re-run 'vexp setup --guard-strict' to rewrite it (bash-prefixed shell form).`);
|
|
323
371
|
}
|
|
324
372
|
if (timeoutS > 600) {
|
|
325
373
|
line(WARN, `hook timeout ${timeoutS} is in SECONDS (${Math.round(timeoutS / 60)} minutes) — likely meant milliseconds. Re-run 'vexp setup --guard-strict' to fix.`);
|
|
326
374
|
}
|
|
327
375
|
// Run it exactly as Claude Code would: exec form = direct spawn with
|
|
328
|
-
// the placeholder substituted by the host; shell form =
|
|
329
|
-
// CLAUDE_PROJECT_DIR in the environment.
|
|
376
|
+
// the placeholder substituted by the host; shell form = a shell with
|
|
377
|
+
// CLAUDE_PROJECT_DIR in the environment. On Windows Claude Code runs
|
|
378
|
+
// shell-form hooks through Git Bash, so probe via bash there too —
|
|
379
|
+
// if bash is missing, that IS the finding (Claude Code itself
|
|
380
|
+
// requires Git Bash on Windows).
|
|
330
381
|
const substituted = cmd.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root);
|
|
382
|
+
const shell = process.platform === "win32" ? "bash" : "sh";
|
|
331
383
|
const r = execForm
|
|
332
384
|
? spawnSync(substituted, h.args.map((a) => String(a).replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root)), {
|
|
333
385
|
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
334
386
|
timeout: 5000,
|
|
335
387
|
encoding: "utf-8",
|
|
336
388
|
})
|
|
337
|
-
: spawnSync(
|
|
389
|
+
: spawnSync(shell, ["-c", cmd], {
|
|
338
390
|
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
339
391
|
timeout: 5000,
|
|
340
392
|
encoding: "utf-8",
|
package/dist/hook-template.js
CHANGED
|
@@ -374,3 +374,138 @@ process.stdin.on("end", () => {
|
|
|
374
374
|
try { main(input); } catch (e) { allow(); } // any surprise -> fail open
|
|
375
375
|
});
|
|
376
376
|
`;
|
|
377
|
+
/**
|
|
378
|
+
* UserPromptSubmit hint hook (2.3.3 event-driven mandate). The resident
|
|
379
|
+
* CLAUDE.md strategy text is gone; instead, when a prompt names no known
|
|
380
|
+
* symbol or file of the indexed workspace, the daemon returns a one-line
|
|
381
|
+
* hint suggesting run_pipeline. All logic (stdin parsing, classification,
|
|
382
|
+
* hook-JSON output) lives in the Rust binary so the script is OS-neutral;
|
|
383
|
+
* Claude Code runs shell-form hooks through Git Bash on Windows.
|
|
384
|
+
* FAIL-OPEN CONTRACT: every failure path (binary missing, daemon down,
|
|
385
|
+
* timeout) exits 0 with no output = vanilla behavior, never a broken
|
|
386
|
+
* prompt. Keep in lockstep with the VS Code extension copy.
|
|
387
|
+
* The __VEXP_BIN__ placeholder is baked at install time.
|
|
388
|
+
*/
|
|
389
|
+
export const VEXP_HINT_HOOK = `#!/bin/bash
|
|
390
|
+
# vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.
|
|
391
|
+
VEXP_BIN="__VEXP_BIN__"
|
|
392
|
+
[ -x "$VEXP_BIN" ] || exit 0
|
|
393
|
+
"$VEXP_BIN" prompt-hint 2>/dev/null
|
|
394
|
+
exit 0
|
|
395
|
+
`;
|
|
396
|
+
/** Bake the binary path into the hint hook script. */
|
|
397
|
+
export function vexpHintHookScript(binaryPath) {
|
|
398
|
+
return VEXP_HINT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Horizon F2a: Stop-hook verification gate (Claude Code). All logic lives
|
|
402
|
+
* in the Rust binary (stop-gate): mechanical completion check via daemon,
|
|
403
|
+
* blocks at most once per session and ONLY on parse errors / broken
|
|
404
|
+
* imports. FAIL-OPEN by construction: no binary / no daemon / timeout =
|
|
405
|
+
* silent exit 0 = normal stop.
|
|
406
|
+
*/
|
|
407
|
+
export const VEXP_STOP_GATE_HOOK = `#!/bin/bash
|
|
408
|
+
# vexp-verify: mechanical completion gate on Stop (Horizon). Fails open.
|
|
409
|
+
VEXP_BIN="__VEXP_BIN__"
|
|
410
|
+
[ -x "$VEXP_BIN" ] || exit 0
|
|
411
|
+
"$VEXP_BIN" stop-gate 2>/dev/null
|
|
412
|
+
exit 0
|
|
413
|
+
`;
|
|
414
|
+
/** Bake the binary path into the stop-gate hook script. */
|
|
415
|
+
export function vexpStopGateHookScript(binaryPath) {
|
|
416
|
+
return VEXP_STOP_GATE_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* opencode/Kilo per-prompt hint plugin (2.4.0). The plugin API's
|
|
420
|
+
* `chat.message` hook sees the user message before the LLM call and can
|
|
421
|
+
* append parts — the opencode-family equivalent of UserPromptSubmit +
|
|
422
|
+
* additionalContext. All classification lives in the Rust binary
|
|
423
|
+
* (prompt-hint, Claude-shaped JSON envelope; we extract additionalContext
|
|
424
|
+
* here). FAIL-OPEN: any error/timeout/missing binary => no parts appended.
|
|
425
|
+
* __VEXP_BIN__ is baked at install time. Keep in lockstep with the VS Code
|
|
426
|
+
* extension copy.
|
|
427
|
+
*/
|
|
428
|
+
export const VEXP_OPENCODE_HINT = `// vexp-hint: per-prompt orientation + idle verification (fail-open). Managed by vexp.
|
|
429
|
+
const VEXP_BIN = "__VEXP_BIN__";
|
|
430
|
+
export const VexpHint = async ({ directory, client }) => {
|
|
431
|
+
const fs = await import("node:fs");
|
|
432
|
+
const path = await import("node:path");
|
|
433
|
+
const taskFileFor = (sid) =>
|
|
434
|
+
path.join(directory, ".vexp", "task-" + String(sid || "unknown") + ".txt");
|
|
435
|
+
const gateMarker = (sid) =>
|
|
436
|
+
path.join(directory, ".vexp", "idle-gate-" + String(sid || "unknown") + ".done");
|
|
437
|
+
return {
|
|
438
|
+
"chat.message": async (input, output) => {
|
|
439
|
+
try {
|
|
440
|
+
const { execFileSync } = await import("node:child_process");
|
|
441
|
+
const text = (output.parts || [])
|
|
442
|
+
.filter((p) => p && p.type === "text" && typeof p.text === "string")
|
|
443
|
+
.map((p) => p.text)
|
|
444
|
+
.join("\\n");
|
|
445
|
+
if (!text || text.length < 40) return;
|
|
446
|
+
// First prompt of the session = the task spec for the idle gate.
|
|
447
|
+
const sid = (input && input.sessionID) || null;
|
|
448
|
+
try {
|
|
449
|
+
const tf = taskFileFor(sid);
|
|
450
|
+
if (!fs.existsSync(tf)) {
|
|
451
|
+
fs.mkdirSync(path.dirname(tf), { recursive: true });
|
|
452
|
+
fs.writeFileSync(tf, text);
|
|
453
|
+
}
|
|
454
|
+
} catch (e) { /* fail open */ }
|
|
455
|
+
const out = execFileSync(VEXP_BIN, ["prompt-hint"], {
|
|
456
|
+
input: JSON.stringify({ prompt: text, session_id: sid }),
|
|
457
|
+
timeout: 4000,
|
|
458
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: directory },
|
|
459
|
+
encoding: "utf8",
|
|
460
|
+
});
|
|
461
|
+
if (!out || !out.trim()) return;
|
|
462
|
+
const hint = JSON.parse(out).hookSpecificOutput?.additionalContext;
|
|
463
|
+
if (hint) output.parts.push({ type: "text", text: hint });
|
|
464
|
+
} catch (e) { /* fail open */ }
|
|
465
|
+
},
|
|
466
|
+
event: async ({ event }) => {
|
|
467
|
+
// Idle gate: the opencode twin of the Claude Stop hook. Runs the
|
|
468
|
+
// mechanical completion check once per session; on gaps, sends ONE
|
|
469
|
+
// follow-up prompt with the exact list (best effort - any failure
|
|
470
|
+
// is silent and the session simply stays stopped).
|
|
471
|
+
try {
|
|
472
|
+
if (!event || event.type !== "session.idle") return;
|
|
473
|
+
const sid = event.properties && event.properties.sessionID;
|
|
474
|
+
if (!sid) return;
|
|
475
|
+
const marker = gateMarker(sid);
|
|
476
|
+
if (fs.existsSync(marker)) return;
|
|
477
|
+
const tf = taskFileFor(sid);
|
|
478
|
+
if (!fs.existsSync(tf)) return;
|
|
479
|
+
const { execFileSync } = await import("node:child_process");
|
|
480
|
+
const out = execFileSync(
|
|
481
|
+
VEXP_BIN,
|
|
482
|
+
["verify", "--json", "--task-file", tf],
|
|
483
|
+
{ timeout: 15000, cwd: directory, encoding: "utf8" }
|
|
484
|
+
);
|
|
485
|
+
const rep = JSON.parse(out);
|
|
486
|
+
const items = [];
|
|
487
|
+
for (const f of (rep.spec && rep.spec.forbidden_touched) || [])
|
|
488
|
+
items.push("- the task says NOT to modify \`" + f + "\` but it was changed - revert or justify");
|
|
489
|
+
for (const a of (rep.spec && rep.spec.artifacts_missing) || [])
|
|
490
|
+
items.push("- the task asks for \`" + a + "\` and it does not exist yet");
|
|
491
|
+
for (const b of (rep.broken_imports || []).slice(0, 8))
|
|
492
|
+
items.push("- " + b.file + ":" + b.line + " imports \`" + b.imports + "\` which no longer exists in " + b.from_changed_file);
|
|
493
|
+
if (!items.length) return;
|
|
494
|
+
fs.writeFileSync(marker, "1");
|
|
495
|
+
await client.session.prompt({
|
|
496
|
+
path: { id: sid },
|
|
497
|
+
body: {
|
|
498
|
+
parts: [{ type: "text", text:
|
|
499
|
+
"vexp verify found mechanically checkable gaps between the session's work and the task:\\n" +
|
|
500
|
+
items.join("\\n") + "\\nFix each item above. This check will not repeat." }],
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
} catch (e) { /* fail open */ }
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
};
|
|
507
|
+
`;
|
|
508
|
+
/** Bake the binary path into the opencode hint plugin. */
|
|
509
|
+
export function vexpOpencodeHintPlugin(binaryPath) {
|
|
510
|
+
return VEXP_OPENCODE_HINT.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
|
|
511
|
+
}
|
package/dist/mcp-supervisor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
|
+
import { CLI_VERSION } from "./version.js";
|
|
2
3
|
import * as net from "net";
|
|
3
4
|
import * as os from "os";
|
|
4
5
|
import * as path from "path";
|
|
@@ -95,11 +96,22 @@ export async function ensureMcpHttpServer(opts = {}) {
|
|
|
95
96
|
const mcpPath = getMcpServerPath();
|
|
96
97
|
if (!mcpPath)
|
|
97
98
|
return null;
|
|
98
|
-
// Fast path: already alive and tracked.
|
|
99
|
+
// Fast path: already alive and tracked - but only if it is OUR version.
|
|
100
|
+
// Field report (Nathan): a 2.3.0 http server stayed authoritative for
|
|
101
|
+
// 9 days across extension upgrades because reuse never compared
|
|
102
|
+
// versions. On mismatch (or a legacy record without one) we take over:
|
|
103
|
+
// SIGTERM the old listener and spawn the current build.
|
|
99
104
|
{
|
|
100
105
|
const existing = readPidRecord();
|
|
101
106
|
if (existing && existing.port === port && isPidAlive(existing.pid) && (await isPortInUse(port))) {
|
|
102
|
-
|
|
107
|
+
if (existing.version === CLI_VERSION) {
|
|
108
|
+
return { pid: existing.pid, port, started: false };
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
process.kill(existing.pid, "SIGTERM");
|
|
112
|
+
}
|
|
113
|
+
catch { /* already gone */ }
|
|
114
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
103
115
|
}
|
|
104
116
|
}
|
|
105
117
|
// Slow path: we may need to spawn. Serialize across concurrent callers
|
|
@@ -164,7 +176,7 @@ export async function ensureMcpHttpServer(opts = {}) {
|
|
|
164
176
|
const pid = child.pid;
|
|
165
177
|
if (!pid)
|
|
166
178
|
return null;
|
|
167
|
-
writePidRecord({ pid, port, startedAt: Date.now(), owner });
|
|
179
|
+
writePidRecord({ pid, port, startedAt: Date.now(), owner, version: CLI_VERSION });
|
|
168
180
|
// Wait for the child to actually bind the port BEFORE releasing the
|
|
169
181
|
// lock. Without this, a second caller arriving right after release
|
|
170
182
|
// sees pidfile+live-pid but port-not-yet-bound → falsely concludes
|
package/dist/serve.js
CHANGED
|
@@ -140,7 +140,17 @@ async function resurrectAll() {
|
|
|
140
140
|
// Prune rows whose workspace vanished, but keep routing rows alive in the
|
|
141
141
|
// registry — they are data, not spawn instructions.
|
|
142
142
|
const pruned = {};
|
|
143
|
+
// Windows: dedupe case-variant keys for the same workspace (C:\ vs c:\)
|
|
144
|
+
// left behind by pre-2.3.3 daemons — they map to the same pipe and every
|
|
145
|
+
// rewrite here would otherwise immortalize the phantom twin.
|
|
146
|
+
const seenKeys = new Set();
|
|
143
147
|
for (const [ws, sock] of Object.entries(reg)) {
|
|
148
|
+
const canonical = process.platform === "win32" ? ws.toLowerCase() : ws;
|
|
149
|
+
if (seenKeys.has(canonical)) {
|
|
150
|
+
appendLog(`registry prune: ${ws} (case-variant duplicate)`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
seenKeys.add(canonical);
|
|
144
154
|
const manifest = path.join(ws, ".vexp", "manifest.json");
|
|
145
155
|
if (!fs.existsSync(manifest)) {
|
|
146
156
|
appendLog(`registry prune: ${ws} (no manifest)`);
|
package/dist/update-check.js
CHANGED
|
@@ -63,6 +63,19 @@ export async function checkForUpdate() {
|
|
|
63
63
|
return;
|
|
64
64
|
if (process.argv.includes("--skip-update-check"))
|
|
65
65
|
return;
|
|
66
|
+
// Protocol and diagnostic commands are NEVER touched by update
|
|
67
|
+
// machinery. Field report (Nathan, 2.4.0): the old hard gate ran for
|
|
68
|
+
// every subcommand and exit(1)'d on any registry delta - publishing a
|
|
69
|
+
// release remotely disabled every installed CLI at the instant of
|
|
70
|
+
// publish. `vexp mcp` died at spawn (host saw zero tools, silently),
|
|
71
|
+
// and doctor/--help/--version were disabled by the exact condition
|
|
72
|
+
// they existed to diagnose.
|
|
73
|
+
const argv = process.argv.slice(2);
|
|
74
|
+
const cmd = argv.find((a) => !a.startsWith("-"));
|
|
75
|
+
if (cmd === "mcp" || cmd === "doctor" || cmd === "stop-gate" || cmd === "prompt-hint")
|
|
76
|
+
return;
|
|
77
|
+
if (argv.includes("--version") || argv.includes("-V") || argv.includes("--help") || argv.includes("-h"))
|
|
78
|
+
return;
|
|
66
79
|
let latest = null;
|
|
67
80
|
// Try cache first
|
|
68
81
|
const cache = readCache();
|
|
@@ -76,14 +89,13 @@ export async function checkForUpdate() {
|
|
|
76
89
|
writeCache(latest);
|
|
77
90
|
}
|
|
78
91
|
if (isOutdated(CLI_VERSION, latest)) {
|
|
92
|
+
// ADVISORY ONLY - never exit. A newer version on the registry is
|
|
93
|
+
// news, not an incompatibility: the daemon handshake is where real
|
|
94
|
+
// incompatibilities surface, with an actionable error.
|
|
79
95
|
console.error("");
|
|
80
|
-
console.error(chalk.
|
|
81
|
-
console.error(
|
|
82
|
-
console.error(` Installed: ${chalk.dim(CLI_VERSION)}`);
|
|
83
|
-
console.error(` Available: ${chalk.green(latest)}`);
|
|
84
|
-
console.error("");
|
|
96
|
+
console.error(chalk.yellow.bold(" vexp update available"));
|
|
97
|
+
console.error(` Installed: ${chalk.dim(CLI_VERSION)} -> Available: ${chalk.green(latest)}`);
|
|
85
98
|
console.error(` Run: ${chalk.cyan("npm install -g vexp-cli")}`);
|
|
86
99
|
console.error("");
|
|
87
|
-
process.exit(1);
|
|
88
100
|
}
|
|
89
101
|
}
|