vexp-cli 2.2.4 → 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/cli.js CHANGED
@@ -8,7 +8,7 @@ import * as fs from "fs";
8
8
  import * as net from "net";
9
9
  import { checkbox, confirm } from "@inquirer/prompts";
10
10
  import { getBinaryPath, getInstalledVersion, getMcpServerPath, binaryEnv } from "./binary.js";
11
- import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName } from "./agent-config.js";
11
+ import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode } from "./agent-config.js";
12
12
  import { CLI_VERSION } from "./version.js";
13
13
  import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, } from "./license.js";
14
14
  import { checkForUpdate } from "./update-check.js";
@@ -533,8 +533,10 @@ program
533
533
  .option("--agents [list]", "Filter agents to configure (comma-separated). Without value: auto-detect all")
534
534
  .option("--dry-run", "Show what would be configured without writing files")
535
535
  .option("--personal", "Personal mode: index locally without writing agent configs or git hooks to the shared repo")
536
+ .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default setup removes them)")
536
537
  .action(async (dir, opts) => {
537
538
  const workspaceRoot = path.resolve(dir ?? process.cwd());
539
+ setGuardMode(opts.guardStrict ? "strict" : "off");
538
540
  console.log(chalk.bold(`\nvexp setup — ${workspaceRoot}\n`));
539
541
  // Step 1: Ensure binary
540
542
  const spinner1 = ora("Checking vexp binary...").start();
@@ -737,7 +739,9 @@ program
737
739
  program
738
740
  .command("setup-agents [dir]")
739
741
  .description("Configure AI coding agents to use vexp MCP (interactive multi-select)")
740
- .action(async (dir) => {
742
+ .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default removes them)")
743
+ .action(async (dir, opts) => {
744
+ setGuardMode(opts.guardStrict ? "strict" : "off");
741
745
  await runSetupAgents(dir);
742
746
  });
743
747
  async function runSetupAgents(dir) {
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.
@@ -48,6 +49,32 @@ function resolveWorkspace() {
48
49
  // it — so doctor probed a pipe name the daemon never binds for ~6% of Windows
49
50
  // workspaces and declared a healthy daemon down.
50
51
  const socketForWorkspace = socketPathFor;
52
+ /** One-shot daemon query over the newline-JSON socket protocol. Returns the
53
+ * `result` payload or null on any failure — doctor must never hang or throw. */
54
+ function queryDaemon(sock, tool, timeoutMs = 2500) {
55
+ return new Promise((resolve) => {
56
+ const c = process.platform === "win32" ? net.createConnection(sock) : net.createConnection({ path: sock });
57
+ let buf = "";
58
+ const t = setTimeout(() => { c.destroy(); resolve(null); }, timeoutMs);
59
+ c.on("connect", () => c.write(JSON.stringify({ id: 1, tool, params: {} }) + "\n"));
60
+ c.on("data", (d) => {
61
+ buf += d.toString();
62
+ const i = buf.indexOf("\n");
63
+ if (i !== -1) {
64
+ clearTimeout(t);
65
+ c.destroy();
66
+ try {
67
+ const parsed = JSON.parse(buf.slice(0, i));
68
+ resolve(parsed?.result ?? null);
69
+ }
70
+ catch {
71
+ resolve(null);
72
+ }
73
+ }
74
+ });
75
+ c.on("error", () => { clearTimeout(t); resolve(null); });
76
+ });
77
+ }
51
78
  function reachable(sock, timeoutMs = 600) {
52
79
  return new Promise((resolve) => {
53
80
  const c = process.platform === "win32" ? net.createConnection(sock) : net.createConnection({ path: sock });
@@ -111,6 +138,39 @@ export async function runDoctor() {
111
138
  line(live ? OK : WARN, `daemon socket ${live ? "reachable" : "NOT reachable"}: ${sock}`);
112
139
  if (!live)
113
140
  console.log(chalk.dim(` → run: vexp daemon-cmd start (or 'vexp daemon-cmd restart')`));
141
+ // 2.3 B1/B2 — live daemon truth: coverage + active compressor. The config can
142
+ // say "LLM enabled" while the daemon serves rule-compressed output (config
143
+ // race / stale daemon / non-LLM build); only the daemon knows what's active.
144
+ if (live) {
145
+ const st = await queryDaemon(sock, "index_status");
146
+ if (st) {
147
+ const comp = st.compressor === "llm"
148
+ ? `llm (${st.llm_model ?? "?"}, ${st.llm_inference ?? "?"})`
149
+ : st.compressor ?? "unknown (older daemon)";
150
+ line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
151
+ if (st.llm_configured_but_inactive === true) {
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
+ }
154
+ // 2.3 C1 — which agent sessions actually used vexp (a session with zero
155
+ // calls never shows up here; that absence is the diagnostic).
156
+ const sessions = Array.isArray(st.sessions) ? st.sessions : [];
157
+ if (sessions.length > 0) {
158
+ const total = sessions.reduce((n, s) => n + (Number(s.pipeline_calls) || 0), 0);
159
+ line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
160
+ }
161
+ else if (Number(st.daemon_uptime_s) > 600) {
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.`);
168
+ }
169
+ else {
170
+ line(OK, `no session calls yet (daemon just started)`);
171
+ }
172
+ }
173
+ }
114
174
  // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
115
175
  console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
116
176
  const regPath = path.join(home, ".vexp", "daemons.json");
@@ -166,32 +226,44 @@ export async function runDoctor() {
166
226
  line(OK, `${name}: valid, ~${days}d remaining`);
167
227
  }
168
228
  }
169
- // 4) Codex MCP transport stanza.
170
- console.log(chalk.bold("\nCodex (~/.codex/config.toml)"));
171
- const codexPath = path.join(os.homedir(), ".codex", "config.toml");
172
- if (!fs.existsSync(codexPath)) {
173
- line(OK, "no ~/.codex/config.toml (Codex not configured)");
174
- }
175
- else {
176
- 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");
177
245
  const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[[A-Za-z_]|$)/);
178
246
  const section = m ? m[0] : "";
179
- if (!section)
180
- line(OK, "no [mcp_servers.vexp] stanza");
181
- else {
182
- const hasUrl = /^\s*url\s*=/m.test(section);
183
- const hasCmd = /^\s*command\s*=/m.test(section);
184
- if (hasUrl && hasCmd)
185
- line(BAD, "stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.");
186
- else if (hasUrl)
187
- line(OK, "transport: http (url)");
188
- else if (hasCmd) {
189
- const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
190
- line(OK, `transport: stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
191
- }
192
- else
193
- line(WARN, "stanza present but neither url nor command found");
247
+ if (!section) {
248
+ line(OK, `${label}: no [mcp_servers.vexp] stanza`);
249
+ continue;
194
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]}` : ""}`);
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)"));
195
267
  }
196
268
  // 5) Claude Code entry (should be UNPINNED after the multi-session fix).
197
269
  console.log(chalk.bold("\nClaude Code (~/.claude.json)"));
@@ -209,6 +281,144 @@ export async function runDoctor() {
209
281
  catch {
210
282
  line(OK, "no ~/.claude.json");
211
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
+ }
212
422
  // 6) HTTP MCP supervisor.
213
423
  console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
214
424
  try {
package/dist/serve.js CHANGED
@@ -89,17 +89,71 @@ async function resurrectDaemon(workspaceRoot, socketPath) {
89
89
  return false;
90
90
  }
91
91
  }
92
+ /** True when `sock` is the canonical socket for `ws` itself (unix path or the
93
+ * win32 named pipe derived from `ws`). A registry row that FAILS this is a
94
+ * ROUTING entry: a connected secondary repo mapped to its parent workspace's
95
+ * socket so lookups route correctly. */
96
+ export function isOwnerEntry(ws, sock) {
97
+ if (process.platform === "win32") {
98
+ // Named pipes carry no path; treat the row as owner unless another row
99
+ // with the same pipe proves otherwise (handled by the grouping below).
100
+ return true;
101
+ }
102
+ return path.resolve(sock) === path.resolve(path.join(ws, ".vexp", "daemon.sock"));
103
+ }
104
+ /** One (workspace, socket) spawn per SOCKET, always the socket's OWNER root.
105
+ * Field incident (2026-07-23): the registry legitimately maps a connected
106
+ * repo to its PARENT workspace's socket for routing; resurrecting that row
107
+ * verbatim spawned `daemon --workspace <child> --socket <parent sock>` — a
108
+ * cross-wired daemon serving the child's index on the parent's socket, which
109
+ * then blocked every legitimate restart of the parent (it held the bind) and
110
+ * never matched the parent's config. Routing rows must never be spawned. */
111
+ export function resurrectionPlan(reg) {
112
+ const bySock = new Map();
113
+ for (const [ws, sock] of Object.entries(reg)) {
114
+ const list = bySock.get(sock) ?? [];
115
+ list.push(ws);
116
+ bySock.set(sock, list);
117
+ }
118
+ const plan = [];
119
+ for (const [sock, roots] of bySock) {
120
+ const owner = roots.find((ws) => isOwnerEntry(ws, sock));
121
+ if (owner) {
122
+ plan.push({ ws: owner, sock });
123
+ }
124
+ else if (roots.length === 1 && !sock.endsWith(path.join(".vexp", "daemon.sock"))) {
125
+ // Long-path /tmp-fallback socket (lives OUTSIDE any repo): a single
126
+ // registrant is unambiguous, keep prior behavior. A canonical
127
+ // in-repo socket that is NOT under the registrant is a routing row
128
+ // whose owner vanished from the registry — never cross-spawn it.
129
+ plan.push({ ws: roots[0], sock });
130
+ }
131
+ else {
132
+ appendLog(`registry: no owner for socket ${sock} (roots: ${roots.join(", ")}) — skipping resurrect`);
133
+ }
134
+ }
135
+ return plan;
136
+ }
92
137
  async function resurrectAll() {
93
138
  const reg = readRegistry();
94
139
  const live = {};
140
+ // Prune rows whose workspace vanished, but keep routing rows alive in the
141
+ // registry — they are data, not spawn instructions.
142
+ const pruned = {};
95
143
  for (const [ws, sock] of Object.entries(reg)) {
96
144
  const manifest = path.join(ws, ".vexp", "manifest.json");
97
145
  if (!fs.existsSync(manifest)) {
98
146
  appendLog(`registry prune: ${ws} (no manifest)`);
99
147
  continue;
100
148
  }
101
- const ok = await resurrectDaemon(ws, sock);
102
- if (ok)
149
+ pruned[ws] = sock;
150
+ }
151
+ const resurrected = new Map();
152
+ for (const { ws, sock } of resurrectionPlan(pruned)) {
153
+ resurrected.set(sock, await resurrectDaemon(ws, sock));
154
+ }
155
+ for (const [ws, sock] of Object.entries(pruned)) {
156
+ if (resurrected.get(sock))
103
157
  live[ws] = sock;
104
158
  }
105
159
  writeRegistry(live);