vexp-cli 2.3.0 → 2.3.1
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/dist/agent-config.js +31 -13
- package/dist/doctor.js +180 -24
- package/package.json +6 -6
package/dist/agent-config.js
CHANGED
|
@@ -1613,40 +1613,58 @@ export function installClaudeCodeHook(workspaceRoot) {
|
|
|
1613
1613
|
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
1614
1614
|
const hookPath = path.join(hookDir, "vexp-guard.sh");
|
|
1615
1615
|
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
1616
|
-
// 1. Write hook script
|
|
1616
|
+
// 1. Write hook script. An identical script must NOT short-circuit the
|
|
1617
|
+
// settings merge below: the 2.3.0→2.3.1 fix changed only the settings
|
|
1618
|
+
// ENTRY (shell form → exec form), so skipping on identical script would
|
|
1619
|
+
// leave every existing install on the broken word-splitting entry forever.
|
|
1617
1620
|
fs.mkdirSync(hookDir, { recursive: true });
|
|
1618
1621
|
const existed = fs.existsSync(hookPath);
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
return null; // identical - skip
|
|
1622
|
+
const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === VEXP_GUARD_HOOK;
|
|
1623
|
+
if (!scriptIdentical) {
|
|
1624
|
+
fs.writeFileSync(hookPath, VEXP_GUARD_HOOK, { mode: 0o755 });
|
|
1623
1625
|
}
|
|
1624
|
-
fs.writeFileSync(hookPath, VEXP_GUARD_HOOK, { mode: 0o755 });
|
|
1625
1626
|
// 2. Merge hook config into .claude/settings.json
|
|
1626
1627
|
const read = readJsonConfigSafe(settingsPath);
|
|
1627
1628
|
if (!read.ok) {
|
|
1628
1629
|
warnUnparseable(settingsPath);
|
|
1629
|
-
return existed ? "updated" : "created";
|
|
1630
|
+
return scriptIdentical ? null : existed ? "updated" : "created";
|
|
1630
1631
|
}
|
|
1631
1632
|
const settings = read.data;
|
|
1632
1633
|
const hooks = (settings.hooks ?? {});
|
|
1633
1634
|
const existingPreToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
1634
1635
|
// Aggressively remove ALL vexp-related hook entries (old format, stale matchers, malformed)
|
|
1635
1636
|
const filtered = existingPreToolUse.filter((h) => !isVexpGuardHookEntry(h));
|
|
1637
|
+
// Exec form (`args` present → direct spawn, no `sh -c`): the old shell-form
|
|
1638
|
+
// `$CLAUDE_PROJECT_DIR/...` word-split on project paths containing spaces,
|
|
1639
|
+
// so sh tried to exec the path's first fragment and the guard NEVER ran —
|
|
1640
|
+
// non-blocking failure, every search went through unguarded. In exec form
|
|
1641
|
+
// Claude Code substitutes `${CLAUDE_PROJECT_DIR}` itself (brace form
|
|
1642
|
+
// required) before spawning. `timeout` is SECONDS (default 600), not ms:
|
|
1643
|
+
// 3000 configured a 50-minute hook timeout.
|
|
1636
1644
|
filtered.push({
|
|
1637
1645
|
matcher: "Grep|Glob|Regex",
|
|
1638
1646
|
hooks: [
|
|
1639
1647
|
{
|
|
1640
1648
|
type: "command",
|
|
1641
|
-
command: "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-guard.sh",
|
|
1642
|
-
|
|
1649
|
+
command: "${CLAUDE_PROJECT_DIR}/.claude/hooks/vexp-guard.sh",
|
|
1650
|
+
args: [],
|
|
1651
|
+
timeout: 5,
|
|
1643
1652
|
},
|
|
1644
1653
|
],
|
|
1645
1654
|
});
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1655
|
+
// Idempotence check AFTER the merge is computed: skip the write (and the
|
|
1656
|
+
// backup) only when settings already contain exactly the entry we would
|
|
1657
|
+
// write AND the script was already current.
|
|
1658
|
+
const merged = { ...hooks, PreToolUse: filtered };
|
|
1659
|
+
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
1660
|
+
if (scriptIdentical && settingsIdentical)
|
|
1661
|
+
return null;
|
|
1662
|
+
settings.hooks = merged;
|
|
1663
|
+
if (!settingsIdentical) {
|
|
1664
|
+
if (read.existed)
|
|
1665
|
+
backupConfig(settingsPath);
|
|
1666
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1667
|
+
}
|
|
1650
1668
|
return existed ? "updated" : "created";
|
|
1651
1669
|
}
|
|
1652
1670
|
// ---------------------------------------------------------------------------
|
package/dist/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as net from "net";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import { socketPathFor } from "./socket-path.js";
|
|
7
8
|
// `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
|
|
@@ -158,7 +159,12 @@ export async function runDoctor() {
|
|
|
158
159
|
line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
|
|
159
160
|
}
|
|
160
161
|
else if (Number(st.daemon_uptime_s) > 600) {
|
|
161
|
-
|
|
162
|
+
// Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
|
|
163
|
+
// the daemon was unreachable serves from its own in-process index and
|
|
164
|
+
// never attaches to the daemon later — its calls are real but
|
|
165
|
+
// invisible to these counters. Don't tell that user "the agent is not
|
|
166
|
+
// using vexp"; tell them how to converge on the daemon.
|
|
167
|
+
line(WARN, `no agent session has called vexp through this daemon since it started — either no agent is using vexp here (check its MCP config), or the agent's vexp MCP server started BEFORE the daemon and is running embedded (in-process). If calls do succeed in the agent, restart the agent (with the daemon already up) so its MCP server attaches to the daemon.`);
|
|
162
168
|
}
|
|
163
169
|
else {
|
|
164
170
|
line(OK, `no session calls yet (daemon just started)`);
|
|
@@ -220,32 +226,44 @@ export async function runDoctor() {
|
|
|
220
226
|
line(OK, `${name}: valid, ~${days}d remaining`);
|
|
221
227
|
}
|
|
222
228
|
}
|
|
223
|
-
// 4) Codex MCP transport stanza.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
// 4) Codex MCP transport stanza — global AND project-level. Codex supports
|
|
230
|
+
// per-project `.codex/config.toml`; checking only the global file made
|
|
231
|
+
// doctor report "[OK] no stanza" to users whose (working) config lives in
|
|
232
|
+
// the workspace.
|
|
233
|
+
console.log(chalk.bold("\nCodex (config.toml)"));
|
|
234
|
+
const codexConfigs = [
|
|
235
|
+
{ label: "~/.codex/config.toml", file: path.join(os.homedir(), ".codex", "config.toml") },
|
|
236
|
+
{ label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml") },
|
|
237
|
+
];
|
|
238
|
+
let codexStanzaSeen = false;
|
|
239
|
+
for (const { label, file } of codexConfigs) {
|
|
240
|
+
if (!fs.existsSync(file)) {
|
|
241
|
+
line(OK, `${label}: absent`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const toml = fs.readFileSync(file, "utf-8");
|
|
231
245
|
const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[[A-Za-z_]|$)/);
|
|
232
246
|
const section = m ? m[0] : "";
|
|
233
|
-
if (!section)
|
|
234
|
-
line(OK,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
line(WARN, "stanza present but neither url nor command found");
|
|
247
|
+
if (!section) {
|
|
248
|
+
line(OK, `${label}: no [mcp_servers.vexp] stanza`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
codexStanzaSeen = true;
|
|
252
|
+
const hasUrl = /^\s*url\s*=/m.test(section);
|
|
253
|
+
const hasCmd = /^\s*command\s*=/m.test(section);
|
|
254
|
+
if (hasUrl && hasCmd)
|
|
255
|
+
line(BAD, `${label}: stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.`);
|
|
256
|
+
else if (hasUrl)
|
|
257
|
+
line(OK, `${label}: transport http (url)`);
|
|
258
|
+
else if (hasCmd) {
|
|
259
|
+
const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
|
|
260
|
+
line(OK, `${label}: transport stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
|
|
248
261
|
}
|
|
262
|
+
else
|
|
263
|
+
line(WARN, `${label}: stanza present but neither url nor command found`);
|
|
264
|
+
}
|
|
265
|
+
if (!codexStanzaSeen) {
|
|
266
|
+
console.log(chalk.dim(" → no vexp stanza in either file (Codex not configured for vexp)"));
|
|
249
267
|
}
|
|
250
268
|
// 5) Claude Code entry (should be UNPINNED after the multi-session fix).
|
|
251
269
|
console.log(chalk.bold("\nClaude Code (~/.claude.json)"));
|
|
@@ -263,6 +281,144 @@ export async function runDoctor() {
|
|
|
263
281
|
catch {
|
|
264
282
|
line(OK, "no ~/.claude.json");
|
|
265
283
|
}
|
|
284
|
+
// 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
|
|
285
|
+
// just check presence. A shell-form command that word-splits on a project
|
|
286
|
+
// path containing a space fails non-blocking on every call: the guard never
|
|
287
|
+
// denies anything while the config "looks correct" and presence-only checks
|
|
288
|
+
// report healthy (Nathan, 2026-07).
|
|
289
|
+
console.log(chalk.bold("\nClaude Code guard hook (.claude/settings.json)"));
|
|
290
|
+
{
|
|
291
|
+
const sPath = path.join(ws.root, ".claude", "settings.json");
|
|
292
|
+
let guardHooks = [];
|
|
293
|
+
let settingsReadable = false;
|
|
294
|
+
try {
|
|
295
|
+
const settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
|
|
296
|
+
settingsReadable = true;
|
|
297
|
+
const pre = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
298
|
+
for (const m of pre) {
|
|
299
|
+
const hks = Array.isArray(m?.hooks) ? m.hooks : [];
|
|
300
|
+
for (const h of hks) {
|
|
301
|
+
if (typeof h?.command === "string" && h.command.includes("vexp-guard"))
|
|
302
|
+
guardHooks.push(h);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch { /* absent or unparseable */ }
|
|
307
|
+
if (!settingsReadable) {
|
|
308
|
+
line(OK, "no .claude/settings.json (guard not installed)");
|
|
309
|
+
}
|
|
310
|
+
else if (guardHooks.length === 0) {
|
|
311
|
+
line(OK, "no vexp guard configured (2.3 default — enable with 'vexp setup --guard-strict')");
|
|
312
|
+
}
|
|
313
|
+
else if (process.platform === "win32") {
|
|
314
|
+
line(OK, `guard configured (${guardHooks.length} entry) — live execution check skipped on Windows`);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
for (const h of guardHooks) {
|
|
318
|
+
const cmd = h.command;
|
|
319
|
+
const execForm = Array.isArray(h.args);
|
|
320
|
+
const timeoutS = typeof h.timeout === "number" ? h.timeout : 600;
|
|
321
|
+
if (!execForm && /\$\{?CLAUDE_PROJECT_DIR\}?\//.test(cmd) && !cmd.includes('"')) {
|
|
322
|
+
line(ws.root.includes(" ") ? BAD : WARN, `shell-form hook command ('args' missing) — 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 in exec form.`);
|
|
323
|
+
}
|
|
324
|
+
if (timeoutS > 600) {
|
|
325
|
+
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
|
+
}
|
|
327
|
+
// Run it exactly as Claude Code would: exec form = direct spawn with
|
|
328
|
+
// the placeholder substituted by the host; shell form = sh -c with
|
|
329
|
+
// CLAUDE_PROJECT_DIR in the environment.
|
|
330
|
+
const substituted = cmd.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root);
|
|
331
|
+
const r = execForm
|
|
332
|
+
? spawnSync(substituted, h.args.map((a) => String(a).replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root)), {
|
|
333
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
334
|
+
timeout: 5000,
|
|
335
|
+
encoding: "utf-8",
|
|
336
|
+
})
|
|
337
|
+
: spawnSync("sh", ["-c", cmd], {
|
|
338
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
339
|
+
timeout: 5000,
|
|
340
|
+
encoding: "utf-8",
|
|
341
|
+
});
|
|
342
|
+
if (r.error) {
|
|
343
|
+
line(BAD, `guard hook DID NOT RUN: ${r.error.code ?? r.error.message} spawning '${substituted}' — the guard is enforcing nothing. Re-run 'vexp setup --guard-strict'.`);
|
|
344
|
+
}
|
|
345
|
+
else if (r.status !== 0) {
|
|
346
|
+
line(BAD, `guard hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 200)}` : ""} — Claude Code treats this as a non-blocking failure, so searches proceed unguarded.`);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const decision = /"permissionDecision"\s*:\s*"(\w+)"/.exec(String(r.stdout ?? ""))?.[1];
|
|
350
|
+
if (decision)
|
|
351
|
+
line(OK, `guard hook runs (live decision here: ${decision})`);
|
|
352
|
+
else
|
|
353
|
+
line(WARN, `guard hook ran (exit 0) but produced no permissionDecision output — check ${path.join(".claude", "hooks", "vexp-guard.sh")}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
|
|
359
|
+
// hooks fail OPEN too (`failClosed` defaults to false), so a guard that
|
|
360
|
+
// cannot spawn silently enforces nothing there as well. The guard's stdin
|
|
361
|
+
// protocol: JSON {tool_name, tool_input, workspace_roots, cwd}; a healthy
|
|
362
|
+
// run answers {"permission":"allow"} with exit 0 OR a deny verdict with
|
|
363
|
+
// exit 2 — BOTH mean "the hook works", anything else is a failure.
|
|
364
|
+
console.log(chalk.bold("\nCursor guard hook (.cursor/hooks.json)"));
|
|
365
|
+
{
|
|
366
|
+
const cursorCfgPath = path.join(ws.root, ".cursor", "hooks.json");
|
|
367
|
+
let cursorCmds = [];
|
|
368
|
+
let cursorReadable = false;
|
|
369
|
+
try {
|
|
370
|
+
const cfg = JSON.parse(fs.readFileSync(cursorCfgPath, "utf-8"));
|
|
371
|
+
cursorReadable = true;
|
|
372
|
+
const pre = Array.isArray(cfg?.hooks?.preToolUse) ? cfg.hooks.preToolUse : [];
|
|
373
|
+
for (const h of pre) {
|
|
374
|
+
if (typeof h?.command === "string" && h.command.includes("vexp-guard"))
|
|
375
|
+
cursorCmds.push(h.command);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch { /* absent or unparseable */ }
|
|
379
|
+
if (!cursorReadable) {
|
|
380
|
+
line(OK, "no .cursor/hooks.json (guard not installed)");
|
|
381
|
+
}
|
|
382
|
+
else if (cursorCmds.length === 0) {
|
|
383
|
+
line(OK, "no vexp guard configured (enable with 'vexp setup --guard-strict')");
|
|
384
|
+
}
|
|
385
|
+
else if (process.platform === "win32") {
|
|
386
|
+
line(OK, `guard configured (${cursorCmds.length} entry) — live execution check skipped on Windows`);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
// A benign Grep probe: with a healthy daemon the guard denies (exit 2),
|
|
390
|
+
// without one it allows (exit 0) — either proves the hook executes.
|
|
391
|
+
const probe = JSON.stringify({
|
|
392
|
+
tool_name: "Grep",
|
|
393
|
+
tool_input: {},
|
|
394
|
+
workspace_roots: [ws.root],
|
|
395
|
+
cwd: ws.root,
|
|
396
|
+
});
|
|
397
|
+
for (const cmd of cursorCmds) {
|
|
398
|
+
// Cursor runs project hooks from the project root; emulate that.
|
|
399
|
+
const r = spawnSync("sh", ["-c", cmd], {
|
|
400
|
+
cwd: ws.root,
|
|
401
|
+
input: probe,
|
|
402
|
+
timeout: 5000,
|
|
403
|
+
encoding: "utf-8",
|
|
404
|
+
});
|
|
405
|
+
if (r.error) {
|
|
406
|
+
line(BAD, `guard hook DID NOT RUN: ${r.error.code ?? r.error.message} spawning '${cmd}' — Cursor hooks fail open, so the guard is enforcing nothing. Re-run 'vexp setup --guard-strict'.`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const decision = /"permission"\s*:\s*"(\w+)"/.exec(String(r.stdout ?? ""))?.[1];
|
|
410
|
+
if ((r.status === 0 || r.status === 2) && decision) {
|
|
411
|
+
line(OK, `guard hook runs (live decision here: ${decision})`);
|
|
412
|
+
}
|
|
413
|
+
else if (r.status !== 0 && r.status !== 2) {
|
|
414
|
+
line(BAD, `guard hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 200)}` : ""} — Cursor treats hook failures as allow, so searches proceed unguarded.`);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
line(WARN, `guard hook ran (exit ${r.status}) but produced no permission verdict — check ${path.join(".cursor", "hooks", "vexp-guard.js")}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
266
422
|
// 6) HTTP MCP supervisor.
|
|
267
423
|
console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
|
|
268
424
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vexp-cli",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Vexp — Context Engine for AI Coding Agents. Pre-indexes your codebase into a dependency graph and delivers ranked context to any MCP-compatible agent. 58% lower cost per task, 90% fewer tool calls (SWE-bench Verified). Works with Claude Code, Cursor, Copilot, Windsurf, Codex, Cline, Aider, and 12+ agents. Local-first. Your code never leaves your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -100,10 +100,10 @@
|
|
|
100
100
|
"node": ">=20.0.0"
|
|
101
101
|
},
|
|
102
102
|
"optionalDependencies": {
|
|
103
|
-
"@vexp/core-linux-x64": "2.3.
|
|
104
|
-
"@vexp/core-linux-arm64": "2.3.
|
|
105
|
-
"@vexp/core-darwin-x64": "2.3.
|
|
106
|
-
"@vexp/core-darwin-arm64": "2.3.
|
|
107
|
-
"@vexp/core-win32-x64": "2.3.
|
|
103
|
+
"@vexp/core-linux-x64": "2.3.1",
|
|
104
|
+
"@vexp/core-linux-arm64": "2.3.1",
|
|
105
|
+
"@vexp/core-darwin-x64": "2.3.1",
|
|
106
|
+
"@vexp/core-darwin-arm64": "2.3.1",
|
|
107
|
+
"@vexp/core-win32-x64": "2.3.1"
|
|
108
108
|
}
|
|
109
109
|
}
|