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.
@@ -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
- if (existed) {
1620
- const current = fs.readFileSync(hookPath, "utf-8");
1621
- if (current === VEXP_GUARD_HOOK)
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
- timeout: 3000,
1649
+ command: "${CLAUDE_PROJECT_DIR}/.claude/hooks/vexp-guard.sh",
1650
+ args: [],
1651
+ timeout: 5,
1643
1652
  },
1644
1653
  ],
1645
1654
  });
1646
- settings.hooks = { ...hooks, PreToolUse: filtered };
1647
- if (read.existed)
1648
- backupConfig(settingsPath);
1649
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
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
- line(WARN, `no agent session has called vexp since daemon start — if an agent is working here, it is not using vexp (check its MCP config)`);
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
- console.log(chalk.bold("\nCodex (~/.codex/config.toml)"));
225
- const codexPath = path.join(os.homedir(), ".codex", "config.toml");
226
- if (!fs.existsSync(codexPath)) {
227
- line(OK, "no ~/.codex/config.toml (Codex not configured)");
228
- }
229
- else {
230
- const toml = fs.readFileSync(codexPath, "utf-8");
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, "no [mcp_servers.vexp] stanza");
235
- else {
236
- const hasUrl = /^\s*url\s*=/m.test(section);
237
- const hasCmd = /^\s*command\s*=/m.test(section);
238
- if (hasUrl && hasCmd)
239
- line(BAD, "stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.");
240
- else if (hasUrl)
241
- line(OK, "transport: http (url)");
242
- else if (hasCmd) {
243
- const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
244
- line(OK, `transport: stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
245
- }
246
- else
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.0",
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.0",
104
- "@vexp/core-linux-arm64": "2.3.0",
105
- "@vexp/core-darwin-x64": "2.3.0",
106
- "@vexp/core-darwin-arm64": "2.3.0",
107
- "@vexp/core-win32-x64": "2.3.0"
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
  }