vexp-cli 3.0.1 → 3.1.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.
@@ -123,7 +123,9 @@ const AGENT_DETECTORS = [
123
123
  {
124
124
  agent: "Cursor",
125
125
  detectPath: ".cursor",
126
- configFile: ".cursor/rules",
126
+ // Cursor reads a DIRECTORY of rules here (`.cursor/rules/*.mdc`); the
127
+ // file inside is ours. See `cursorRulesTarget` for the one exception.
128
+ configFile: ".cursor/rules/vexp.mdc",
127
129
  templateName: "cursor",
128
130
  mcpConfigFile: ".cursor/mcp.json",
129
131
  },
@@ -341,7 +343,8 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
341
343
  const filter = agentFilter.map((a) => a.toLowerCase());
342
344
  agents = agents.filter((d) => filter.includes(d.agent.toLowerCase()));
343
345
  }
344
- for (const detector of agents) {
346
+ for (const detected of agents) {
347
+ const detector = withCursorTarget(detected, workspaceRoot);
345
348
  const configFilePath = path.join(workspaceRoot, detector.configFile);
346
349
  const alreadyExists = fs.existsSync(configFilePath);
347
350
  // Sweep the location we used to write to. For several releases the
@@ -689,7 +692,8 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
689
692
  const results = [];
690
693
  const mcpConfigs = [];
691
694
  const writtenConfigFiles = new Set();
692
- for (const detector of AGENT_DETECTORS) {
695
+ for (const detected of AGENT_DETECTORS) {
696
+ const detector = withCursorTarget(detected, workspaceRoot);
693
697
  if (!selectedAgentNames.includes(detector.agent))
694
698
  continue;
695
699
  // Create agent directory if it doesn't exist. Skip for file-based
@@ -966,6 +970,70 @@ export function readJsonConfigSafe(filePath) {
966
970
  * {command: {path, args}} Zed
967
971
  * {command: ["node", "…/mcp-server.cjs"]} Kilo Code
968
972
  */
973
+ /**
974
+ * The interpreter to write into an editor's MCP config for `mcp-server.cjs`.
975
+ *
976
+ * A bare `node` is resolved through the PATH of the process that spawns the
977
+ * server — and a VS Code / Cursor / Windsurf launched from the Dock, the Start
978
+ * menu or a desktop shortcut carries the login PATH, which on most developer
979
+ * machines has no nvm, Volta or Homebrew node in it. The server then dies with
980
+ * `spawn node ENOENT`, the editor lists no vexp tools, and nothing on our side
981
+ * logged anything (GitHub Copilot field report, 2026-08). Pin the node that is
982
+ * running us instead; the VS Code extension has done the same
983
+ * (`resolveNodePath`) since 2.x. Same search order, bare `node` as last resort.
984
+ */
985
+ export function resolveNodeCommand() {
986
+ const execPath = process.execPath;
987
+ if (execPath && !/Code|Electron/i.test(execPath)) {
988
+ try {
989
+ fs.accessSync(execPath, fs.constants.X_OK);
990
+ return execPath;
991
+ }
992
+ catch { /* not executable */ }
993
+ }
994
+ if (process.platform !== "win32" && fs.existsSync("/usr/local/bin/node"))
995
+ return "/usr/local/bin/node";
996
+ const home = os.homedir();
997
+ const nvmDir = path.join(home, ".nvm", "versions", "node");
998
+ if (fs.existsSync(nvmDir)) {
999
+ try {
1000
+ const versions = fs.readdirSync(nvmDir).filter((d) => d.startsWith("v")).sort().reverse();
1001
+ for (const v of versions) {
1002
+ const candidate = path.join(nvmDir, v, "bin", "node");
1003
+ if (fs.existsSync(candidate))
1004
+ return candidate;
1005
+ }
1006
+ }
1007
+ catch { /* ignore */ }
1008
+ }
1009
+ return "node";
1010
+ }
1011
+ /**
1012
+ * An entry whose interpreter is a bare name (`node`, `node.exe`) rather than a
1013
+ * path. Such an entry may well work from a terminal and still fail inside a
1014
+ * GUI editor, so it is never adopted as "already working" — the writer replaces
1015
+ * it with the pinned interpreter on the next setup.
1016
+ */
1017
+ export function isBareInterpreter(entry) {
1018
+ if (!entry || typeof entry !== "object")
1019
+ return false;
1020
+ const e = entry;
1021
+ let command = e.command;
1022
+ if (Array.isArray(command))
1023
+ command = command[0];
1024
+ else if (command !== null && typeof command === "object")
1025
+ command = command.path;
1026
+ return typeof command === "string" && command.length > 0 && !/[\\/]/.test(command);
1027
+ }
1028
+ /**
1029
+ * Adopt a foreign entry only when it resolves AND pins its interpreter. Used by
1030
+ * the GUI-editor writers (VS Code/Copilot, Cursor/Windsurf/Kiro/Trae, Zed),
1031
+ * where a bare `node` is exactly the entry that fails; terminal agents
1032
+ * (opencode, Claude Code) keep adopting a bare interpreter — it works there.
1033
+ */
1034
+ function adoptableEntry(entry) {
1035
+ return vexpEntryStillResolves(entry) && !isBareInterpreter(entry);
1036
+ }
969
1037
  function vexpEntryStillResolves(entry) {
970
1038
  if (!entry || typeof entry !== "object")
971
1039
  return false;
@@ -1115,7 +1183,49 @@ export function removeVexpSection(filePath) {
1115
1183
  return "absent";
1116
1184
  }
1117
1185
  }
1118
- function appendOrCreate(filePath, content, version) {
1186
+ /**
1187
+ * Where the Cursor rule goes, after clearing the way.
1188
+ *
1189
+ * Cursor reads `.cursor/rules/` as a directory of `.mdc` rules. vexp wrote a
1190
+ * single FILE at `.cursor/rules` for several releases — read by nothing, and
1191
+ * a file where Cursor wants a folder, so Cursor could not create its own
1192
+ * rules either. On a project where the folder already existed, `vexp setup`
1193
+ * then crashed with EISDIR reading the directory (field report, Windows /
1194
+ * Cursor, 2026-08) after indexing and the MCP start had already succeeded.
1195
+ *
1196
+ * Our stale file is removed when it holds only our section. If a user put
1197
+ * their own text in a `.cursor/rules` FILE we keep it and fall back to the
1198
+ * legacy root `.cursorrules`, which Cursor still reads — the folder cannot
1199
+ * exist while that file does.
1200
+ */
1201
+ export function cursorRulesTarget(workspaceRoot) {
1202
+ const legacy = path.join(workspaceRoot, ".cursor", "rules");
1203
+ let isFile = false;
1204
+ try {
1205
+ isFile = fs.statSync(legacy).isFile();
1206
+ }
1207
+ catch {
1208
+ /* absent, or a directory */
1209
+ }
1210
+ if (isFile) {
1211
+ removeVexpSection(legacy);
1212
+ if (fs.existsSync(legacy))
1213
+ return ".cursorrules";
1214
+ }
1215
+ return path.join(".cursor", "rules", "vexp.mdc");
1216
+ }
1217
+ /** The detector with its Cursor target resolved for THIS workspace. */
1218
+ function withCursorTarget(d, workspaceRoot) {
1219
+ return d.agent === "Cursor" ? { ...d, configFile: cursorRulesTarget(workspaceRoot) } : d;
1220
+ }
1221
+ export function appendOrCreate(filePath, content, version) {
1222
+ // A directory where a file was expected. Reading it is EISDIR, and that
1223
+ // aborted the whole setup once (the `.cursor/rules` folder above). The
1224
+ // detectors no longer point at folders; if one ever does again, write a
1225
+ // file inside it rather than crash on the user's machine.
1226
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
1227
+ filePath = path.join(filePath, "vexp.md");
1228
+ }
1119
1229
  if (!fs.existsSync(filePath)) {
1120
1230
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
1121
1231
  fs.writeFileSync(filePath, content, "utf-8");
@@ -1234,7 +1344,7 @@ approveKey = "alwaysAllow") {
1234
1344
  }
1235
1345
  const existing = read.data;
1236
1346
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1237
- const targetCmd = useNode ? "node" : binaryPath;
1347
+ const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
1238
1348
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
1239
1349
  const targetEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
1240
1350
  const beforeServers = existing.mcpServers;
@@ -1258,7 +1368,7 @@ approveKey = "alwaysAllow") {
1258
1368
  // Another vexp install already left a working entry here — don't fight it.
1259
1369
  // Only the install path may differ: `envMatches` still gates on the
1260
1370
  // VEXP_WORKSPACE pin, so a moved or renamed project is repinned normally.
1261
- if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
1371
+ if (removed.length === 0 && envMatches && adoptableEntry(previousVexp))
1262
1372
  return false;
1263
1373
  const servers = existing.mcpServers ?? {};
1264
1374
  servers["vexp"] = {
@@ -1500,7 +1610,7 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
1500
1610
  }
1501
1611
  const existing = read.data;
1502
1612
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1503
- const targetCmd = useNode ? "node" : binaryPath;
1613
+ const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
1504
1614
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
1505
1615
  const targetEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
1506
1616
  const beforeServers = existing.servers;
@@ -1520,7 +1630,7 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
1520
1630
  // Copilot branch, which is gated only on `.github/` existing. Only the
1521
1631
  // install path may differ — `envMatches` still gates on the VEXP_WORKSPACE
1522
1632
  // pin, so a moved or renamed project is repinned normally.
1523
- if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
1633
+ if (removed.length === 0 && envMatches && adoptableEntry(previousVexp))
1524
1634
  return false;
1525
1635
  const servers = existing.servers ?? {};
1526
1636
  servers["vexp"] = {
@@ -1658,7 +1768,7 @@ export function configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath) {
1658
1768
  }
1659
1769
  const cfg = read.data;
1660
1770
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1661
- const command = useNode ? ["node", mcpServerPath] : [binaryPath, "mcp"];
1771
+ const command = useNode ? [resolveNodeCommand(), mcpServerPath] : [binaryPath, "mcp"];
1662
1772
  const env = { VEXP_WORKSPACE: workspaceRoot };
1663
1773
  const mcp = cfg.mcp ?? {};
1664
1774
  const prev = mcp["vexp"];
@@ -1699,7 +1809,7 @@ export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
1699
1809
  }
1700
1810
  const cfg = read.data;
1701
1811
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1702
- const command = useNode ? ["node", mcpServerPath] : [binaryPath, "mcp"];
1812
+ const command = useNode ? [resolveNodeCommand(), mcpServerPath] : [binaryPath, "mcp"];
1703
1813
  const env = { VEXP_WORKSPACE: workspaceRoot };
1704
1814
  const mcp = cfg.mcp ?? {};
1705
1815
  const prev = mcp["vexp"];
@@ -1733,7 +1843,7 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
1733
1843
  }
1734
1844
  const settings = read.data;
1735
1845
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1736
- const targetCmd = useNode ? "node" : binaryPath;
1846
+ const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
1737
1847
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
1738
1848
  const targetEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
1739
1849
  const beforeCs = settings.context_servers;
@@ -1752,7 +1862,7 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
1752
1862
  // Another vexp install already left a working entry here — don't fight it.
1753
1863
  // Only the install path may differ: `envMatches` still gates on the
1754
1864
  // VEXP_WORKSPACE pin, so a moved or renamed project is repinned normally.
1755
- if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
1865
+ if (removed.length === 0 && envMatches && adoptableEntry(previousVexp))
1756
1866
  return false;
1757
1867
  const cs = settings.context_servers ?? {};
1758
1868
  cs["vexp"] = {
@@ -1795,7 +1905,7 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
1795
1905
  const beforeServers = config.mcpServers;
1796
1906
  const existing = beforeServers?.["vexp"];
1797
1907
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1798
- const desiredCommand = useNode ? "node" : binaryPath;
1908
+ const desiredCommand = useNode ? resolveNodeCommand() : binaryPath;
1799
1909
  const desiredArgs = useNode ? [mcpServerPath] : ["mcp"];
1800
1910
  // Multi-session fix: do NOT pin VEXP_WORKSPACE on this user-scope entry.
1801
1911
  // Claude Code applies ~/.claude.json mcpServers to EVERY project, so a pinned
@@ -2960,7 +3070,17 @@ ${MANDATE_CORE}
2960
3070
  <!-- /vexp -->`;
2961
3071
  }
2962
3072
  function cursorTemplate(vars) {
2963
- return `## vexp rules for Cursor <!-- vexp v${vars.version} -->
3073
+ // Cursor project rules are `.cursor/rules/*.mdc` with front matter;
3074
+ // `alwaysApply: true` puts the rule in every chat without the agent having
3075
+ // to pick it. A rule file that Cursor reads at all is new: for several
3076
+ // releases vexp wrote a single FILE at `.cursor/rules`, a path Cursor never
3077
+ // reads, and one that stopped Cursor from creating its rules folder.
3078
+ return `---
3079
+ description: vexp code-graph orientation for this workspace
3080
+ alwaysApply: true
3081
+ ---
3082
+
3083
+ ## vexp rules for Cursor <!-- vexp v${vars.version} -->
2964
3084
 
2965
3085
  ${MANDATE_CORE}
2966
3086
  <!-- /vexp -->`;
package/dist/cli.js CHANGED
@@ -42,6 +42,11 @@ if (isTraceEnabled()) {
42
42
  const AUTOSTART_SKIP = new Set([
43
43
  "setup", "daemon-cmd", "activate", "deactivate", "license", "version",
44
44
  "serve", "autostart", "use", "doctor",
45
+ // Lifecycle commands: `vexp daemons` LISTS daemons and `vexp stop` ENDS
46
+ // one — spawning a daemon for the current directory first made `stop`
47
+ // followed by `daemons` bring the stopped daemon straight back
48
+ // (3.1 e2e, 2026-08).
49
+ "daemons", "stop",
45
50
  ]);
46
51
  program.hook("preAction", async (_thisCmd, actionCmd) => {
47
52
  // One-shot config migration for the multi-session fix — de-pins a legacy
@@ -456,7 +461,7 @@ program
456
461
  });
457
462
  program
458
463
  .command("search <query>")
459
- .description("Exhaustive index search: every matching node (code and docs), no top-K. Built for rename sweeps and zero-reference audits")
464
+ .description("Exhaustive index search: every symbol matching the query (code and docs) plus every line in indexed symbol bodies that references it, no top-K. Built for rename sweeps and zero-reference audits")
460
465
  .option("--substring", "Substring match (LIKE) instead of token search — partial identifiers, punctuation")
461
466
  .option("--files-only", "Print only the distinct file paths")
462
467
  .option("--json", "Machine-readable JSON with a stable shape")
@@ -828,7 +833,15 @@ program
828
833
  // the daemon already cold-starts on the first `vexp` invocation, so this is
829
834
  // purely a convenience for post-reboot warm-up. Skip entirely when the user
830
835
  // opted out via env or when stdin is not a TTY (non-interactive install).
831
- if (process.env.VEXP_NO_AUTOSTART_INSTALL === "1") {
836
+ //
837
+ // A dry run must not reach this step at all: the prompt was asked even
838
+ // under --dry-run and answering Yes rewrote the Startup-folder .vbs while
839
+ // the summary still claimed nothing was written (field report, Peiyuan,
840
+ // 3.0.1 on Windows). Persistence is a write like any other.
841
+ if (opts.dryRun) {
842
+ console.log(chalk.dim(" --dry-run: would ask about login autostart (writes a Startup entry only if you accept)."));
843
+ }
844
+ else if (process.env.VEXP_NO_AUTOSTART_INSTALL === "1") {
832
845
  // Explicit opt-out — do nothing.
833
846
  }
834
847
  else if (!process.stdin.isTTY) {
@@ -1108,6 +1121,9 @@ program
1108
1121
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
1109
1122
  console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
1110
1123
  console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
1124
+ if (limits.allTools) {
1125
+ console.log(` (the MCP tool list shows 4 by default to keep the catalog small; every tool stays callable — VEXP_ALL_TOOLS=1 lists them all)`);
1126
+ }
1111
1127
  if (limits.renewsAt) {
1112
1128
  console.log(ltd
1113
1129
  ? ` Renewal: none — lifetime licence (local token auto-refreshes, ` +
@@ -1749,6 +1765,9 @@ async function executeCommand(label, rl, outCtl) {
1749
1765
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
1750
1766
  console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
1751
1767
  console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
1768
+ if (limits.allTools) {
1769
+ console.log(` (the MCP tool list shows 4 by default to keep the catalog small; every tool stays callable — VEXP_ALL_TOOLS=1 lists them all)`);
1770
+ }
1752
1771
  if (limits.renewsAt) {
1753
1772
  console.log(ltd
1754
1773
  ? ` Renewal: none — lifetime licence (token auto-refreshes, valid to ${limits.renewsAt.toLocaleDateString()})`
package/dist/doctor.js CHANGED
@@ -5,6 +5,7 @@ import * as net from "net";
5
5
  import { spawnSync } from "child_process";
6
6
  import chalk from "chalk";
7
7
  import { socketPathFor } from "./socket-path.js";
8
+ import { parseJsonc } from "./agent-config.js";
8
9
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
9
10
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
10
11
  // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
@@ -191,6 +192,80 @@ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
191
192
  ` the hooks only make that immediate — if you want them and a tool manages this directory (moon, husky, lefthook), add 'vexp index --finalize || true' through ITS config, not the generated file.`,
192
193
  };
193
194
  }
195
+ /**
196
+ * The size-skip verdict, from `.vexp/coverage.json` (3.1 shape), as data.
197
+ *
198
+ * Files over `max_file_size_kb` are left out of the index by the walk. Until
199
+ * 3.1 the only trace was an INFO line in the daemon log: doctor, the
200
+ * `index_status` tool and coverage.json all reported a healthy index while a
201
+ * tier-4 user was missing five hand-written Dart files — 21% of the bytes of
202
+ * his lib/ — from every impact, search and pipeline answer (field report,
203
+ * 2026-08). Raising the cap only moved the cliff, so the cliff is reported
204
+ * wherever it stands. Null when nothing was skipped for size, or when the
205
+ * file predates 3.1 and has no skip keys.
206
+ */
207
+ export function coverageVerdict(cov) {
208
+ if (!cov || typeof cov !== "object")
209
+ return null;
210
+ const c = cov;
211
+ const oversized = Number(c.skipped_oversized) || 0;
212
+ if (oversized === 0)
213
+ return null;
214
+ const cap = Number(c.max_file_size_kb) || 0;
215
+ const files = Array.isArray(c.skipped_files) ? c.skipped_files : [];
216
+ const examples = files
217
+ .filter((f) => f.reason === "oversized")
218
+ .slice(0, 5)
219
+ .map((f) => `${f.path} (${Number(f.size_kb) || 0} KB)`);
220
+ const more = oversized - examples.length;
221
+ return {
222
+ level: WARN,
223
+ message: `${oversized} file(s) over max_file_size_kb = ${cap} are NOT indexed — their symbols and callers are invisible to impact, search and run_pipeline:\n` +
224
+ examples.map((e) => ` - ${e}`).join("\n") +
225
+ (more > 0 ? `\n … +${more} more (full list: .vexp/coverage.json)` : "") +
226
+ `\n raise max_file_size_kb in .vexp/vexp.toml (0 = no cap) to include them, or exclude them on purpose with exclude_patterns.`,
227
+ };
228
+ }
229
+ /**
230
+ * The `.vscode/mcp.json` verdict for GitHub Copilot, as data.
231
+ *
232
+ * A Copilot user who "set up the vexp agent" and sees neither a vexp server in
233
+ * VS Code nor vexp tools in chat has, in our experience, one of three things:
234
+ * the file is not where VS Code looks (the folder open in VS Code is not the
235
+ * one that was set up), the server entry cannot start (a bare `node` that the
236
+ * GUI-launched editor cannot find on its PATH, or a bundle path an extension
237
+ * upgrade removed), or Chat is not in Agent mode — the only mode that offers
238
+ * MCP tools. doctor can prove the first two; it can only say the third.
239
+ */
240
+ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p)) {
241
+ if (!cfg || typeof cfg !== "object")
242
+ return { level: WARN, message: ".vscode/mcp.json is present but not valid JSON — VS Code will ignore every server in it; fix the syntax and re-run 'vexp setup'" };
243
+ const servers = cfg.servers;
244
+ const vexp = servers?.vexp;
245
+ if (!vexp)
246
+ return { level: WARN, message: ".vscode/mcp.json has no 'vexp' server — run: vexp setup --agents \"GitHub Copilot\"" };
247
+ const command = typeof vexp.command === "string" ? vexp.command : "";
248
+ const args = Array.isArray(vexp.args) ? vexp.args.filter((a) => typeof a === "string") : [];
249
+ const script = args.find((a) => /\.[cm]?js$/.test(a));
250
+ if (!command)
251
+ return { level: WARN, message: ".vscode/mcp.json vexp server has no 'command' — re-run 'vexp setup'" };
252
+ if (!/[\\/]/.test(command)) {
253
+ return {
254
+ level: WARN,
255
+ message: `.vscode/mcp.json starts the vexp server with a bare '${command}' — resolved through the editor's PATH, which a VS Code launched from the Dock/Start menu usually lacks (symptom: 'spawn ${command} ENOENT' in Output › MCP: vexp, no vexp tools in chat).\n` +
256
+ ` re-run 'vexp setup' — 3.1 pins the absolute node path.`,
257
+ };
258
+ }
259
+ if (!exists(command))
260
+ return { level: WARN, message: `.vscode/mcp.json vexp command does not exist: ${command} — re-run 'vexp setup' to repin it` };
261
+ if (script && !exists(script))
262
+ return { level: WARN, message: `.vscode/mcp.json vexp server bundle is missing: ${script} (an editor upgrade removed the old extension folder?) — re-run 'vexp setup'` };
263
+ const pinned = typeof vexp.env?.VEXP_WORKSPACE === "string" ? vexp.env.VEXP_WORKSPACE : undefined;
264
+ if (pinned && path.resolve(pinned).toLowerCase() !== path.resolve(wsRoot).toLowerCase()) {
265
+ return { level: WARN, message: `.vscode/mcp.json vexp server is pinned to ${pinned}, but this workspace is ${wsRoot} — re-run 'vexp setup' here` };
266
+ }
267
+ return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
268
+ }
194
269
  export async function runDoctor() {
195
270
  const home = vexpHome();
196
271
  let warns = 0;
@@ -310,6 +385,16 @@ export async function runDoctor() {
310
385
  }
311
386
  }
312
387
  }
388
+ // 3.1 — coverage gaps only the daemon log used to witness. Read from disk,
389
+ // not from the daemon: the index that skipped the files may have been
390
+ // built by a daemon that is no longer running.
391
+ try {
392
+ const cov = JSON.parse(fs.readFileSync(path.join(ws.root, ".vexp", "coverage.json"), "utf-8"));
393
+ const v = coverageVerdict(cov);
394
+ if (v)
395
+ line(v.level, v.message);
396
+ }
397
+ catch { /* no coverage.json: never indexed here, or an index older than 2.7 */ }
313
398
  // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
314
399
  console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
315
400
  const regPath = path.join(home, ".vexp", "daemons.json");
@@ -434,6 +519,27 @@ export async function runDoctor() {
434
519
  catch {
435
520
  line(OK, "no ~/.claude.json");
436
521
  }
522
+ // 5a) GitHub Copilot — VS Code reads MCP servers from <folder>/.vscode/mcp.json.
523
+ console.log(chalk.bold("\nGitHub Copilot / VS Code (.vscode/mcp.json)"));
524
+ {
525
+ const mcpPath = path.join(ws.root, ".vscode", "mcp.json");
526
+ if (!fs.existsSync(mcpPath)) {
527
+ line(OK, "no .vscode/mcp.json (Copilot MCP not configured in this folder — 'vexp setup --agents \"GitHub Copilot\"' writes it)");
528
+ }
529
+ else {
530
+ let cfg = null;
531
+ try {
532
+ cfg = parseJsonc(fs.readFileSync(mcpPath, "utf-8"));
533
+ }
534
+ catch {
535
+ cfg = null;
536
+ }
537
+ const v = vsCodeMcpVerdict(cfg, ws.root);
538
+ line(v.level, v.message);
539
+ console.log(chalk.dim(" VS Code shows it under Extensions → MCP SERVERS - INSTALLED; vexp tools appear only in Copilot Chat AGENT mode (tools picker → 'MCP Server: vexp')."));
540
+ console.log(chalk.dim(" server log: Command Palette → 'MCP: List Servers' → vexp → Show Output (channel 'MCP: vexp'); daemon log: .vexp/daemon.log"));
541
+ }
542
+ }
437
543
  // 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
438
544
  // just check presence. A shell-form command that word-splits on a project
439
545
  // path containing a space fails non-blocking on every call: the guard never
package/dist/license.js CHANGED
@@ -206,10 +206,10 @@ export async function tryOnlineRefresh(longJwt) {
206
206
  // Only save if the freshToken itself verifies locally
207
207
  if (verifyAndDecode(data.freshToken)) {
208
208
  saveFreshToken(data.freshToken);
209
- // The server re-issued a 30-day long token (entitlement changed:
210
- // AppSumo tier up/downgrade, or a Stripe resub migration). Overwrite
211
- // the on-disk long JWT so the new entitlement survives even fully
212
- // offline and the previous one stops working — without the user
209
+ // The server re-issued the 30-day long token (rolled forward on every
210
+ // refresh since vexp-web 3.1; also on entitlement change). Overwrite
211
+ // the on-disk long JWT so the current entitlement survives even fully
212
+ // offline and a superseded one stops working — without the user
213
213
  // re-pasting a key. Only persist if it verifies locally.
214
214
  if (data.newLongToken && verifyAndDecode(data.newLongToken)) {
215
215
  try {
@@ -119,7 +119,7 @@ To lift the limit now, upgrade to Pro or Team: https://vexp.dev/#pricing`)}var V
119
119
 
120
120
  `)),e.push("```"),e.push("");return e.join(`
121
121
  `)}var aF=S.object({}),Y$={name:"index_status",description:"Get the current status of the vexp index, including indexed repos, node/edge counts, cross-repo edges, daemon uptime, and indexing progress. Use this to verify vexp is working correctly before making queries.",inputSchema:{type:"object",properties:{},required:[]}};async function Q$(t,e){aF.parse(t);let r=Xa(),n=await e.call("index_status",{});return iF(n,r)}function J$(t){return t.replace(/[\\/]+$/,"").toLowerCase()}function iF(t,e){let r=[],n={healthy:"\u2713",indexing:"\u27F3",error:"\u2717",not_initialized:"\u25CB"}[t.status]??"?";if(r.push("# vexp Index Status"),r.push(`> ${n} ${t.status.toUpperCase()} | v${t.daemon_version} | uptime ${oF(t.daemon_uptime_s)}`),r.push(""),e||t.workspace_root){let a=e?.workspaceRoot,i=t.workspace_root;if(r.push("## Targeting"),e&&(r.push(`- This MCP resolved to: \`${a}\` (via ${e.source})`),e.socket&&r.push(`- Socket/pipe: \`${e.socket}\` (${e.socketOrigin})`)),i){let s=t.served_aliases?.length?` \u2014 repos: ${t.served_aliases.join(", ")}`:"";r.push(`- Daemon serving: \`${i}\`${s}`)}a&&i&&J$(a)!==J$(i)&&r.push(`- \u26A0\uFE0F MISMATCH: this session resolved a different workspace than the daemon it reached. Likely a parallel-session mis-target \u2014 set VEXP_WORKSPACE for this session, or launch \`claude\` from \`${a}\`.`);let o=t.other_daemons??[];r.push(o.length>0?`- Other live daemons: ${o.map(s=>`\`${s.root}\``).join(", ")}`:"- Other live daemons: none"),r.push("")}if(t.status==="error"&&t.error&&(r.push(`**Error:** ${t.error}`),r.push("")),t.status==="not_initialized")return r.push('*vexp is not initialized. Run `vexp index` or use the VS Code command "Setup Workspace".*'),r.join(`
122
- `);if(r.push("## Overview"),r.push(""),r.push("| Metric | Value |"),r.push("|--------|-------|"),r.push(`| Repos indexed | ${t.repos.length} |`),r.push(`| Total nodes | ${t.total_nodes.toLocaleString()} |`),r.push(`| Total edges | ${t.total_edges.toLocaleString()} |`),r.push(`| Total files | ${t.total_files.toLocaleString()} |`),r.push(`| Cross-repo edges | ${t.cross_repo_edges.toLocaleString()} |`),r.push(`| Index DB size | ${X$(t.db_size_bytes)} |`),r.push(`| Daemon memory | ${X$(t.memory_rss_bytes)} |`),t.compressor){let a=t.compressor==="llm"?`llm (${t.llm_model??"?"}, ${t.llm_inference??"?"})`:"rule";r.push(`| Compressor | ${a} |`)}if(t.last_query_time_ms!==void 0&&r.push(`| Last query time | ${t.last_query_time_ms}ms |`),r.push(""),t.llm_configured_but_inactive&&(r.push("\u26A0\uFE0F A local LLM model is installed and enabled in config, but this daemon is running the rule compressor. Restart the daemon (`vexp daemon-cmd restart`) or re-run `vexp setup-llm` \u2014 until then, results are NOT LLM-compressed."),r.push("")),t.ledger){let a=t.ledger;r.push(`## Savings Ledger (last ${a.period_days??7} days)`),r.push(""),(a.prompts_analyzed??0)>0&&r.push(`- Activity: ${a.prompts_analyzed} prompt(s) analyzed \u2014 ${a.silences} silence(s) (task already oriented), ${a.hints_served} orientation hint(s) served${(a.held_out??0)>0?`, ${a.held_out} held out (randomized measurement)`:""}`),(a.measured_calls??0)>0&&r.push(`- Measured: ${a.measured_calls} tool call(s), ~${(a.measured_tokens_saved??0).toLocaleString()} tokens saved (full-read baseline minus served)`),(a.prompts_analyzed??0)===0&&(a.measured_calls??0)===0&&r.push("- No classified prompts or tool calls in this window."),r.push("")}if(t.sessions&&t.sessions.length>0){r.push("## Active sessions (last 4h)"),r.push("");for(let a of t.sessions){let i=a.idle_s<60?`${a.idle_s}s`:`${Math.floor(a.idle_s/60)}m`;r.push(`- \`${a.session}\u2026\` \u2014 ${a.pipeline_calls} pipeline, ${a.skeleton_calls} skeleton, ${a.other_calls} other (idle ${i})`)}r.push("")}if(t.repos.length>0){r.push("## Repos"),r.push("");for(let a of t.repos){let i=a.is_indexing?` *(indexing ${a.indexing_progress?.toFixed(0)??"?"}%)*`:"";if(r.push(`### \`${a.alias}\`${i}`),r.push(`*${a.path}*`),r.push(""),r.push(`- Nodes: ${a.node_count.toLocaleString()}`),r.push(`- Edges: ${a.edge_count.toLocaleString()}`),r.push(`- Files: ${a.file_count.toLocaleString()}`),r.push(`- Last indexed: ${a.last_indexed_at}`),Object.keys(a.languages).length>0){let o=Object.entries(a.languages).sort(([,s],[,c])=>c-s).map(([s,c])=>`${s}: ${c}`).join(", ");r.push(`- Languages: ${o}`)}r.push("")}}return r.join(`
122
+ `);if(r.push("## Overview"),r.push(""),r.push("| Metric | Value |"),r.push("|--------|-------|"),r.push(`| Repos indexed | ${t.repos.length} |`),r.push(`| Total nodes | ${t.total_nodes.toLocaleString()} |`),r.push(`| Total edges | ${t.total_edges.toLocaleString()} |`),r.push(`| Total files | ${t.total_files.toLocaleString()} |`),r.push(`| Cross-repo edges | ${t.cross_repo_edges.toLocaleString()} |`),r.push(`| Index DB size | ${X$(t.db_size_bytes)} |`),r.push(`| Daemon memory | ${X$(t.memory_rss_bytes)} |`),t.compressor){let a=t.compressor==="llm"?`llm (${t.llm_model??"?"}, ${t.llm_inference??"?"})`:"rule";r.push(`| Compressor | ${a} |`)}if(t.last_query_time_ms!==void 0&&r.push(`| Last query time | ${t.last_query_time_ms}ms |`),r.push(""),t.llm_configured_but_inactive&&(r.push("\u26A0\uFE0F A local LLM model is installed and enabled in config, but this daemon is running the rule compressor. Restart the daemon (`vexp daemon-cmd restart`) or re-run `vexp setup-llm` \u2014 until then, results are NOT LLM-compressed."),r.push("")),t.coverage){let a=t.coverage,i=[];if((a.skipped_oversized??0)>0){let o=a.skipped_examples?.length?` \u2014 e.g. ${a.skipped_examples.join(", ")}`:"";i.push(`- \u26A0\uFE0F ${a.skipped_oversized} file(s) over max_file_size_kb = ${a.max_file_size_kb??"?"} are NOT indexed${o}. Their symbols and callers are invisible to impact, search and run_pipeline. Full list: .vexp/coverage.json; raise the cap in .vexp/vexp.toml to include them.`)}a.unparsed_code_files?.length&&i.push(`- Unparsed file types (no parser yet): ${a.unparsed_code_files.map(o=>`${o.count} .${o.ext}`).join(", ")}`),i.length>0&&(r.push("## Coverage gaps"),r.push(""),r.push(...i),r.push(""))}if(t.ledger){let a=t.ledger;r.push(`## Savings Ledger (last ${a.period_days??7} days)`),r.push(""),(a.prompts_analyzed??0)>0&&r.push(`- Activity: ${a.prompts_analyzed} prompt(s) analyzed \u2014 ${a.silences} silence(s) (task already oriented), ${a.hints_served} orientation hint(s) served${(a.held_out??0)>0?`, ${a.held_out} held out (randomized measurement)`:""}`),(a.measured_calls??0)>0&&r.push(`- Measured: ${a.measured_calls} tool call(s), ~${(a.measured_tokens_saved??0).toLocaleString()} tokens saved (full-read baseline minus served)`),(a.prompts_analyzed??0)===0&&(a.measured_calls??0)===0&&r.push("- No classified prompts or tool calls in this window."),r.push("")}if(t.sessions&&t.sessions.length>0){r.push("## Active sessions (last 4h)"),r.push("");for(let a of t.sessions){let i=a.idle_s<60?`${a.idle_s}s`:`${Math.floor(a.idle_s/60)}m`;r.push(`- \`${a.session}\u2026\` \u2014 ${a.pipeline_calls} pipeline, ${a.skeleton_calls} skeleton, ${a.other_calls} other (idle ${i})`)}r.push("")}if(t.repos.length>0){r.push("## Repos"),r.push("");for(let a of t.repos){let i=a.is_indexing?` *(indexing ${a.indexing_progress?.toFixed(0)??"?"}%)*`:"";if(r.push(`### \`${a.alias}\`${i}`),r.push(`*${a.path}*`),r.push(""),r.push(`- Nodes: ${a.node_count.toLocaleString()}`),r.push(`- Edges: ${a.edge_count.toLocaleString()}`),r.push(`- Files: ${a.file_count.toLocaleString()}`),r.push(`- Last indexed: ${a.last_indexed_at}`),Object.keys(a.languages).length>0){let o=Object.entries(a.languages).sort(([,s],[,c])=>c-s).map(([s,c])=>`${s}: ${c}`).join(", ");r.push(`- Languages: ${o}`)}r.push("")}}return r.join(`
123
123
  `)}function oF(t){if(t<60)return`${t}s`;if(t<3600)return`${Math.floor(t/60)}m ${t%60}s`;let e=Math.floor(t/3600),r=Math.floor(t%3600/60);return`${e}h ${r}m`}function X$(t){return t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(1)} KB`:t<1024*1024*1024?`${(t/1024/1024).toFixed(1)} MB`:`${(t/1024/1024/1024).toFixed(2)} GB`}var sF=S.object({workspace_root:S.string().optional().describe("Workspace root path (default: CWD)"),detect_agents:S.boolean().optional().default(!0).describe("Auto-detect AI agents")}),e4={name:"workspace_setup",description:"Set up vexp for the current workspace. Generates configuration files, detects AI coding agents (Claude Code, Cursor, Windsurf, Continue), and provides setup instructions. Run this once when starting to work on a new project.",inputSchema:{type:"object",properties:{workspace_root:{type:"string",description:"Workspace root path (default: current directory)"},detect_agents:{type:"boolean",description:"Auto-detect installed AI agents (default: true)"}},required:[]}};async function t4(t,e){let r=sF.parse(t),n=await e.call("workspace_setup",r);return cF(n)}function cF(t){let e=[];if(e.push("# vexp Workspace Setup"),e.push(`> v${t.vexp_version} | \`${t.workspace_root}\``),e.push(""),t.generated_files.length>0){e.push("## Generated Files"),e.push("");for(let r of t.generated_files)e.push(`- \`${r}\``);e.push("")}if(t.workspace_json&&(e.push("## workspace.json"),e.push(""),e.push("```json"),e.push(t.workspace_json),e.push("```"),e.push("")),t.detected_agents.length>0){e.push("## Detected AI Agents"),e.push("");for(let r of t.detected_agents)e.push(`- **${r}**`);e.push("")}if(t.agent_configs.length>0){e.push("## Agent Configuration Files"),e.push("");for(let r of t.agent_configs){let n=r.already_exists?" *(already exists \u2014 merge manually)*":" *(created)*";e.push(`### ${r.agent} \u2014 \`${r.config_file}\`${n}`),e.push(""),e.push("```"),e.push(r.content),e.push("```"),e.push("")}}if(t.git_hooks_installed&&(e.push("## Git Hooks"),e.push(""),e.push("\u2713 Git hooks installed: `pre-commit`, `post-merge`, `post-checkout`"),e.push("\u2713 Merge driver configured for `index.db`"),e.push("")),t.suggested_gitignore_additions.length>0){e.push("## Suggested .gitignore Additions"),e.push(""),e.push("Add these to your `.gitignore`:"),e.push("```gitignore");for(let r of t.suggested_gitignore_additions)e.push(r);e.push("```"),e.push("")}if(t.index_started&&(e.push("## Indexing"),e.push(""),e.push("\u2713 Initial indexing started in background. Use `index_status` to check progress."),e.push("")),t.setup_instructions.length>0){e.push("## Next Steps"),e.push("");for(let r=0;r<t.setup_instructions.length;r++)e.push(`${r+1}. ${t.setup_instructions[r]}`);e.push("")}return e.join(`
124
124
  `)}var uF=S.object({include_previous:S.preprocess(ft,S.boolean().optional().default(!1)).describe("Include observations from previous sessions"),max_results:S.preprocess(pt,S.number().optional().default(20)).describe("Max observations to return"),types:S.preprocess(or,S.array(S.string()).optional()).describe("Filter by observation type")}),r4={name:"get_session_context",description:"Get observations from the current and optionally previous sessions. Observations are automatically captured from every vexp tool call and include what code was explored, decisions made, and insights discovered. Use this to recall what you worked on earlier or in prior sessions. Observations linked to code symbols are automatically marked stale when that code changes.",inputSchema:{type:"object",properties:{include_previous:{type:"boolean",description:"Include observations from previous sessions (default: false)"},max_results:{type:"number",description:"Max observations to return (default: 20)"},types:{type:"array",items:{type:"string",enum:["tool_call","insight","decision","error","manual"]},description:"Filter by observation type"}},required:[]}};async function n4(t,e){let r=uF.parse(t),n=await e.call("get_session_context",r);return pF(n)}function pF(t){let e=[];if(e.push("# Session Context"),e.push(`> Session: ${t.session_id} | ${t.count} observations`),e.push(""),t.observations.length===0)return e.push("*No observations yet in this session.*"),e.join(`
125
125
  `);for(let r of t.observations){let n=new Date(r.created_at*1e3),a=`${String(n.getMonth()+1).padStart(2,"0")}-${String(n.getDate()).padStart(2,"0")} ${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}`,i=r.stale?" ~stale~":"",o=r.current_session===!1?` [prev: ${r.session_id?.slice(0,8)}]`:"";e.push(`- [${a}${i}] (${r.type}) ${r.content}${o}`)}return e.join(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexp-cli",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Local-first context engine for AI coding agents. Pre-indexes your codebase into a dependency graph and feeds any MCP agent only the code that matters — 87% fewer tokens per call. New in 2.5: mechanical work verification and a PII/secret scanner. Works with Claude Code, Cursor, Codex, Copilot, Windsurf, Cline, Aider and 14 agents. Your code never leaves your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -105,10 +105,10 @@
105
105
  },
106
106
  "homepage": "https://vexp.dev",
107
107
  "optionalDependencies": {
108
- "@vexp/core-linux-x64": "3.0.1",
109
- "@vexp/core-linux-arm64": "3.0.1",
110
- "@vexp/core-darwin-x64": "3.0.1",
111
- "@vexp/core-darwin-arm64": "3.0.1",
112
- "@vexp/core-win32-x64": "3.0.1"
108
+ "@vexp/core-linux-x64": "3.1.0",
109
+ "@vexp/core-linux-arm64": "3.1.0",
110
+ "@vexp/core-darwin-x64": "3.1.0",
111
+ "@vexp/core-darwin-arm64": "3.1.0",
112
+ "@vexp/core-win32-x64": "3.1.0"
113
113
  }
114
114
  }