vexp-cli 3.0.1 → 3.1.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.
@@ -9,6 +9,7 @@
9
9
  import * as fs from "fs";
10
10
  import * as path from "path";
11
11
  import * as os from "os";
12
+ import { spawnSync } from "child_process";
12
13
  import * as crypto from "crypto";
13
14
  import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSearchHookScript, vexpHintHookCmdScript, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin, vexpOpencodeCompressPlugin, bakeEditHintHook, bakeReadHintHook, bakeBashCapHook } from "./hook-template.js";
14
15
  // ---------------------------------------------------------------------------
@@ -123,16 +124,26 @@ const AGENT_DETECTORS = [
123
124
  {
124
125
  agent: "Cursor",
125
126
  detectPath: ".cursor",
126
- configFile: ".cursor/rules",
127
+ // Cursor reads a DIRECTORY of rules here (`.cursor/rules/*.mdc`); the
128
+ // file inside is ours. See `cursorRulesTarget` for the one exception.
129
+ configFile: ".cursor/rules/vexp.mdc",
127
130
  templateName: "cursor",
128
131
  mcpConfigFile: ".cursor/mcp.json",
129
132
  },
130
133
  {
131
134
  agent: "Windsurf",
132
135
  detectPath: ".windsurf",
136
+ // Windsurf became Devin Desktop (2026-06-02); a project set up for the new
137
+ // agent carries `.devin/` instead of `.windsurf/`.
138
+ detectPaths: [".devin"],
133
139
  configFile: ".windsurf/rules/vexp.md",
134
140
  templateName: "windsurf",
135
- mcpConfigFile: ".windsurf/mcp.json",
141
+ // MCP is NOT project-level here: Cascade reads only the machine-global
142
+ // ~/.codeium/windsurf/mcp_config.json, and Devin Local reads
143
+ // .devin/mcp_config.json. Both are written by the Windsurf block in the
144
+ // writers (configureWindsurfGlobal). The `.windsurf/mcp.json` older
145
+ // versions wrote is read by neither — a WSL user found out by building
146
+ // the Windows-side entry by hand (field report, 2026-09-01).
136
147
  },
137
148
  {
138
149
  agent: "Continue.dev",
@@ -293,6 +304,12 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
293
304
  case "Antigravity":
294
305
  out.push("~/.gemini/antigravity MCP config (user scope)");
295
306
  break;
307
+ case "Windsurf":
308
+ out.push("~/.codeium/windsurf/mcp_config.json (MCP server entry, user scope — Cascade)");
309
+ if (windsurfNextInstalled())
310
+ out.push("~/.codeium/windsurf-next/mcp_config.json (MCP server entry, user scope — Cascade in Devin Desktop Next)");
311
+ out.push(".devin/mcp_config.json (MCP server entry — Devin Local)");
312
+ break;
296
313
  case "Zed":
297
314
  out.push(".zed/settings.json `context_servers` (MCP server entry)");
298
315
  break;
@@ -341,7 +358,8 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
341
358
  const filter = agentFilter.map((a) => a.toLowerCase());
342
359
  agents = agents.filter((d) => filter.includes(d.agent.toLowerCase()));
343
360
  }
344
- for (const detector of agents) {
361
+ for (const detected of agents) {
362
+ const detector = withCursorTarget(detected, workspaceRoot);
345
363
  const configFilePath = path.join(workspaceRoot, detector.configFile);
346
364
  const alreadyExists = fs.existsSync(configFilePath);
347
365
  // Sweep the location we used to write to. For several releases the
@@ -413,6 +431,16 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
413
431
  mcpConfigs.push(detector.mcpConfigFile);
414
432
  }
415
433
  }
434
+ // Windsurf / Devin Desktop: the two files the agent actually reads.
435
+ if (detector.agent === "Windsurf") {
436
+ if (configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot))
437
+ mcpConfigs.push("~/.codeium/windsurf/mcp_config.json");
438
+ if (configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot))
439
+ mcpConfigs.push("~/.codeium/windsurf-next/mcp_config.json");
440
+ if (writeMcpConfig(path.join(workspaceRoot, ".devin", "mcp_config.json"), binaryPath, undefined, mcpServerPath, workspaceRoot))
441
+ mcpConfigs.push(".devin/mcp_config.json");
442
+ removeDeadWindsurfMcpJson(workspaceRoot);
443
+ }
416
444
  // Claude Code: configure MCP in ~/.claude.json (user-scope, stdio)
417
445
  if (detector.agent === "Claude Code") {
418
446
  const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
@@ -689,7 +717,8 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
689
717
  const results = [];
690
718
  const mcpConfigs = [];
691
719
  const writtenConfigFiles = new Set();
692
- for (const detector of AGENT_DETECTORS) {
720
+ for (const detected of AGENT_DETECTORS) {
721
+ const detector = withCursorTarget(detected, workspaceRoot);
693
722
  if (!selectedAgentNames.includes(detector.agent))
694
723
  continue;
695
724
  // Create agent directory if it doesn't exist. Skip for file-based
@@ -762,6 +791,16 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
762
791
  if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey))
763
792
  mcpConfigs.push(detector.mcpConfigFile);
764
793
  }
794
+ // Windsurf / Devin Desktop: see configureAgents().
795
+ if (detector.agent === "Windsurf") {
796
+ if (configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot))
797
+ mcpConfigs.push("~/.codeium/windsurf/mcp_config.json");
798
+ if (configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot))
799
+ mcpConfigs.push("~/.codeium/windsurf-next/mcp_config.json");
800
+ if (writeMcpConfig(path.join(workspaceRoot, ".devin", "mcp_config.json"), binaryPath, undefined, mcpServerPath, workspaceRoot))
801
+ mcpConfigs.push(".devin/mcp_config.json");
802
+ removeDeadWindsurfMcpJson(workspaceRoot);
803
+ }
765
804
  if (detector.agent === "Claude Code") {
766
805
  const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
767
806
  if (wrote)
@@ -859,6 +898,11 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
859
898
  * commas (preserving string contents) before parsing.
860
899
  */
861
900
  export function parseJsonc(text) {
901
+ // A UTF-8 BOM (PowerShell's default when a user hand-edits on Windows)
902
+ // made the file "unparseable" here and unreadable to Cursor itself; our
903
+ // next rewrite drops it, so tolerating it on read self-heals the file.
904
+ if (text.charCodeAt(0) === 0xfeff)
905
+ text = text.slice(1);
862
906
  let out = "";
863
907
  let inString = false;
864
908
  let inLineComment = false;
@@ -936,7 +980,6 @@ export function readJsonConfigSafe(filePath) {
936
980
  catch { /* fall through to ok:false */ }
937
981
  return { data: {}, ok: false, existed: true };
938
982
  }
939
- /** Copy an existing config to <file>.vexp-bak before overwriting it. */
940
983
  /**
941
984
  * True when the vexp entry already in a config still resolves to something
942
985
  * runnable — even if it points at a DIFFERENT vexp install than ours.
@@ -966,6 +1009,169 @@ export function readJsonConfigSafe(filePath) {
966
1009
  * {command: {path, args}} Zed
967
1010
  * {command: ["node", "…/mcp-server.cjs"]} Kilo Code
968
1011
  */
1012
+ /**
1013
+ * The interpreter to write into an editor's MCP config for `mcp-server.cjs`.
1014
+ *
1015
+ * A bare `node` is resolved through the PATH of the process that spawns the
1016
+ * server — and a VS Code / Cursor / Windsurf launched from the Dock, the Start
1017
+ * menu or a desktop shortcut carries the login PATH, which on most developer
1018
+ * machines has no nvm, Volta or Homebrew node in it. The server then dies with
1019
+ * `spawn node ENOENT`, the editor lists no vexp tools, and nothing on our side
1020
+ * logged anything (GitHub Copilot field report, 2026-08). Pin the node that is
1021
+ * running us instead; the VS Code extension has done the same
1022
+ * (`resolveNodePath`) since 2.x. Same search order, bare `node` as last resort.
1023
+ */
1024
+ export function resolveNodeCommand() {
1025
+ // POSITIVE check: accept process.execPath only when it IS a node binary.
1026
+ // The old denylist (/Code|Electron/) let every renamed Electron fork
1027
+ // through — Cursor.exe, Windsurf.exe, Trae.exe — and a Windows setup wrote
1028
+ // `command: ...\Cursor.exe` into mcp.json: the editor opens instead of a
1029
+ // stdio server, "MCP error -32000: Connection closed" (field report,
1030
+ // 2026-09-02). A VS Code remote/WSL extension host runs under a real
1031
+ // `node` binary and still passes.
1032
+ const execPath = process.execPath;
1033
+ const execBase = execPath ? path.basename(execPath).toLowerCase() : "";
1034
+ if (execPath && (execBase === "node" || execBase === "node.exe")) {
1035
+ try {
1036
+ fs.accessSync(execPath, fs.constants.X_OK);
1037
+ return execPath;
1038
+ }
1039
+ catch { /* not executable */ }
1040
+ }
1041
+ for (const candidate of nodeCandidates(process.platform, os.homedir(), process.env)) {
1042
+ if (fs.existsSync(candidate))
1043
+ return candidate;
1044
+ }
1045
+ const fromShell = nodeFromLoginShell();
1046
+ if (fromShell)
1047
+ return fromShell;
1048
+ return "node";
1049
+ }
1050
+ /**
1051
+ * Absolute locations a node binary is commonly installed at, newest version
1052
+ * manager entries first. A GUI-launched editor (Dock, Start menu) has the
1053
+ * login PATH of nothing but the system — `/usr/bin:/bin:/usr/sbin:/sbin` on
1054
+ * macOS — so a bare `node` in an MCP entry spawns only when the editor was
1055
+ * started from a terminal, and "works from `code .`, disconnects from the
1056
+ * Dock" is what a user sees. The chain used to know `/usr/local/bin` and nvm
1057
+ * only: Homebrew on Apple Silicon (`/opt/homebrew`), Volta, fnm and the
1058
+ * Windows installers were all "node" (field report, Mac Silicon + Windows,
1059
+ * VS Code + Copilot). Pure, so the list itself is testable.
1060
+ */
1061
+ export function nodeCandidates(platform, home, env) {
1062
+ const out = [];
1063
+ const newestUnder = (dir, rel) => {
1064
+ try {
1065
+ return fs.readdirSync(dir)
1066
+ .filter((d) => /^v?\d/.test(d))
1067
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
1068
+ .map((v) => path.join(dir, v, ...rel));
1069
+ }
1070
+ catch {
1071
+ return [];
1072
+ }
1073
+ };
1074
+ if (platform === "win32") {
1075
+ for (const base of [env.ProgramFiles, env["ProgramFiles(x86)"]]) {
1076
+ if (base)
1077
+ out.push(path.join(base, "nodejs", "node.exe"));
1078
+ }
1079
+ if (env.LOCALAPPDATA) {
1080
+ out.push(path.join(env.LOCALAPPDATA, "Programs", "nodejs", "node.exe"));
1081
+ out.push(path.join(env.LOCALAPPDATA, "Volta", "bin", "node.exe"));
1082
+ out.push(...newestUnder(path.join(env.LOCALAPPDATA, "fnm_multishells"), ["node.exe"]));
1083
+ }
1084
+ if (env.APPDATA)
1085
+ out.push(...newestUnder(path.join(env.APPDATA, "nvm"), ["node.exe"]));
1086
+ return out;
1087
+ }
1088
+ if (platform === "darwin")
1089
+ out.push("/opt/homebrew/bin/node");
1090
+ out.push("/usr/local/bin/node", "/usr/bin/node");
1091
+ if (home) {
1092
+ out.push(...newestUnder(path.join(home, ".nvm", "versions", "node"), ["bin", "node"]));
1093
+ out.push(path.join(home, ".volta", "bin", "node"));
1094
+ out.push(path.join(home, ".local", "share", "fnm", "aliases", "default", "bin", "node"));
1095
+ if (platform === "darwin")
1096
+ out.push(path.join(home, "Library", "Application Support", "fnm", "aliases", "default", "bin", "node"));
1097
+ out.push(path.join(home, ".asdf", "shims", "node"));
1098
+ }
1099
+ return out;
1100
+ }
1101
+ /**
1102
+ * Last resort before a bare `node`: ask the user's LOGIN shell where node
1103
+ * is. Version managers that live only in shell init (nvm via .zshrc, fnm,
1104
+ * asdf) are invisible to every static candidate above but resolve here.
1105
+ * Bounded (3 s) and validated: the answer must be an absolute path that
1106
+ * exists, or it is discarded.
1107
+ */
1108
+ function nodeFromLoginShell() {
1109
+ if (process.platform === "win32")
1110
+ return undefined;
1111
+ try {
1112
+ const shell = process.env.SHELL && fs.existsSync(process.env.SHELL) ? process.env.SHELL : "/bin/sh";
1113
+ const r = spawnSync(shell, ["-lc", "command -v node"], { encoding: "utf-8", timeout: 2_500 });
1114
+ const line = String(r.stdout ?? "").trim().split(/\r?\n/).filter((l) => l.startsWith("/")).pop();
1115
+ if (line && fs.existsSync(line))
1116
+ return line;
1117
+ }
1118
+ catch { /* no shell, no answer */ }
1119
+ return undefined;
1120
+ }
1121
+ /**
1122
+ * An entry whose interpreter is a bare name (`node`, `node.exe`) rather than a
1123
+ * path. Such an entry may well work from a terminal and still fail inside a
1124
+ * GUI editor, so it is never adopted as "already working" — the writer replaces
1125
+ * it with the pinned interpreter on the next setup.
1126
+ */
1127
+ /** The command string of an MCP entry, wherever the shape hides it. */
1128
+ function entryCommand(entry) {
1129
+ if (!entry || typeof entry !== "object")
1130
+ return undefined;
1131
+ let command = entry.command;
1132
+ if (Array.isArray(command))
1133
+ command = command[0];
1134
+ else if (command !== null && typeof command === "object")
1135
+ command = command.path;
1136
+ return typeof command === "string" && command.length > 0 ? command : undefined;
1137
+ }
1138
+ /**
1139
+ * An MCP entry whose command is the EDITOR's own executable. Written by the
1140
+ * pre-3.1.1 node resolver on Windows (Cursor.exe passed the old denylist):
1141
+ * the path exists, so `vexpEntryStillResolves` adopted it forever while every
1142
+ * connection died with "MCP error -32000". Such an entry is broken by
1143
+ * construction and must be repaired, never adopted.
1144
+ */
1145
+ export function isEditorExecutable(entry) {
1146
+ const command = entryCommand(entry);
1147
+ if (!command)
1148
+ return false;
1149
+ // Both separators, whatever the host: a config written on Windows must be
1150
+ // recognised when read from WSL/CI too (path.basename splits only on the
1151
+ // host's separator).
1152
+ const base = (command.split(/[\\/]/).pop() ?? "").toLowerCase().replace(/\.exe$/, "");
1153
+ return ["code", "code-insiders", "code-oss", "cursor", "windsurf", "windsurf-next", "trae", "kiro", "zed", "antigravity", "electron", "devin"].includes(base);
1154
+ }
1155
+ export function isBareInterpreter(entry) {
1156
+ if (!entry || typeof entry !== "object")
1157
+ return false;
1158
+ const e = entry;
1159
+ let command = e.command;
1160
+ if (Array.isArray(command))
1161
+ command = command[0];
1162
+ else if (command !== null && typeof command === "object")
1163
+ command = command.path;
1164
+ return typeof command === "string" && command.length > 0 && !/[\\/]/.test(command);
1165
+ }
1166
+ /**
1167
+ * Adopt a foreign entry only when it resolves AND pins its interpreter. Used by
1168
+ * the GUI-editor writers (VS Code/Copilot, Cursor/Windsurf/Kiro/Trae, Zed),
1169
+ * where a bare `node` is exactly the entry that fails; terminal agents
1170
+ * (opencode, Claude Code) keep adopting a bare interpreter — it works there.
1171
+ */
1172
+ function adoptableEntry(entry) {
1173
+ return vexpEntryStillResolves(entry) && !isBareInterpreter(entry) && !isEditorExecutable(entry);
1174
+ }
969
1175
  function vexpEntryStillResolves(entry) {
970
1176
  if (!entry || typeof entry !== "object")
971
1177
  return false;
@@ -996,10 +1202,52 @@ function vexpEntryStillResolves(entry) {
996
1202
  const script = (Array.isArray(rawArgs) ? rawArgs : []).find((a) => typeof a === "string" && path.isAbsolute(a) && /\.[cm]?js$/.test(a));
997
1203
  return script === undefined || fs.existsSync(script);
998
1204
  }
1205
+ /**
1206
+ * Where a pre-overwrite backup of `filePath` goes. Inside a vexp workspace —
1207
+ * an ancestor directory holding `.vexp/`, the user's home excepted — the copy
1208
+ * lands in `<root>/.vexp/backups/<path relative to the root, separators →
1209
+ * "__">.vexp-bak`, which the generated `.vexp/.gitignore` covers. It used to
1210
+ * be a `.vexp-bak` sibling of the file, and `.vscode/mcp.json.vexp-bak` sat as
1211
+ * an untracked file in a user's Changes view (field report, 2026-09-01).
1212
+ * Outside a workspace (`~/.claude.json`, `~/.codex/config.toml`, the Windsurf
1213
+ * global file) the sibling stays: nothing there is under version control.
1214
+ */
1215
+ export function backupPathFor(filePath) {
1216
+ const abs = path.resolve(filePath);
1217
+ const home = path.resolve(os.homedir());
1218
+ let dir = path.dirname(abs);
1219
+ for (;;) {
1220
+ const marker = path.join(dir, ".vexp");
1221
+ let isWorkspace = false;
1222
+ try {
1223
+ isWorkspace = dir !== home && fs.statSync(marker).isDirectory();
1224
+ }
1225
+ catch { /* no marker here */ }
1226
+ if (isWorkspace) {
1227
+ const rel = path.relative(dir, abs).split(path.sep).join("__");
1228
+ return path.join(dir, ".vexp", "backups", `${rel}.vexp-bak`);
1229
+ }
1230
+ const parent = path.dirname(dir);
1231
+ if (parent === dir)
1232
+ break;
1233
+ dir = parent;
1234
+ }
1235
+ return `${abs}.vexp-bak`;
1236
+ }
1237
+ /** Copy an existing config to its backup location before overwriting it. */
999
1238
  function backupConfig(filePath) {
1000
1239
  try {
1001
- if (fs.existsSync(filePath))
1002
- fs.copyFileSync(filePath, `${filePath}.vexp-bak`);
1240
+ if (!fs.existsSync(filePath))
1241
+ return;
1242
+ const dest = backupPathFor(filePath);
1243
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1244
+ fs.copyFileSync(filePath, dest);
1245
+ // Older versions left the copy next to the file; inside a workspace that
1246
+ // sibling is exactly what git shows as untracked. Retire it now that the
1247
+ // fresh copy lives in .vexp/backups/.
1248
+ const legacy = path.resolve(`${filePath}.vexp-bak`);
1249
+ if (legacy !== dest && fs.existsSync(legacy))
1250
+ fs.unlinkSync(legacy);
1003
1251
  }
1004
1252
  catch { /* non-fatal */ }
1005
1253
  }
@@ -1115,7 +1363,49 @@ export function removeVexpSection(filePath) {
1115
1363
  return "absent";
1116
1364
  }
1117
1365
  }
1118
- function appendOrCreate(filePath, content, version) {
1366
+ /**
1367
+ * Where the Cursor rule goes, after clearing the way.
1368
+ *
1369
+ * Cursor reads `.cursor/rules/` as a directory of `.mdc` rules. vexp wrote a
1370
+ * single FILE at `.cursor/rules` for several releases — read by nothing, and
1371
+ * a file where Cursor wants a folder, so Cursor could not create its own
1372
+ * rules either. On a project where the folder already existed, `vexp setup`
1373
+ * then crashed with EISDIR reading the directory (field report, Windows /
1374
+ * Cursor, 2026-08) after indexing and the MCP start had already succeeded.
1375
+ *
1376
+ * Our stale file is removed when it holds only our section. If a user put
1377
+ * their own text in a `.cursor/rules` FILE we keep it and fall back to the
1378
+ * legacy root `.cursorrules`, which Cursor still reads — the folder cannot
1379
+ * exist while that file does.
1380
+ */
1381
+ export function cursorRulesTarget(workspaceRoot) {
1382
+ const legacy = path.join(workspaceRoot, ".cursor", "rules");
1383
+ let isFile = false;
1384
+ try {
1385
+ isFile = fs.statSync(legacy).isFile();
1386
+ }
1387
+ catch {
1388
+ /* absent, or a directory */
1389
+ }
1390
+ if (isFile) {
1391
+ removeVexpSection(legacy);
1392
+ if (fs.existsSync(legacy))
1393
+ return ".cursorrules";
1394
+ }
1395
+ return path.join(".cursor", "rules", "vexp.mdc");
1396
+ }
1397
+ /** The detector with its Cursor target resolved for THIS workspace. */
1398
+ function withCursorTarget(d, workspaceRoot) {
1399
+ return d.agent === "Cursor" ? { ...d, configFile: cursorRulesTarget(workspaceRoot) } : d;
1400
+ }
1401
+ export function appendOrCreate(filePath, content, version) {
1402
+ // A directory where a file was expected. Reading it is EISDIR, and that
1403
+ // aborted the whole setup once (the `.cursor/rules` folder above). The
1404
+ // detectors no longer point at folders; if one ever does again, write a
1405
+ // file inside it rather than crash on the user's machine.
1406
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
1407
+ filePath = path.join(filePath, "vexp.md");
1408
+ }
1119
1409
  if (!fs.existsSync(filePath)) {
1120
1410
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
1121
1411
  fs.writeFileSync(filePath, content, "utf-8");
@@ -1213,6 +1503,130 @@ export function mergeJsonConfig(filePath, mergePayload, version) {
1213
1503
  * one workspace breaks every other project (the 2.0.32 multi-session
1214
1504
  * lesson). `vexp-core mcp` resolves the workspace from its spawn cwd.
1215
1505
  */
1506
+ /** Cascade's one and only MCP config, on the side of the boundary vexp runs on. */
1507
+ export function windsurfGlobalMcpPath() {
1508
+ return path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
1509
+ }
1510
+ /**
1511
+ * Devin Desktop Next — the beta channel, formerly Windsurf Next — is a separate
1512
+ * install with its own Cascade config under `~/.codeium/windsurf-next/`
1513
+ * (field report, 2026-09-01: a user ran the stable build on one project and
1514
+ * Next on another; the entry written for stable was invisible to Next). We
1515
+ * write it only when that directory exists: it is the build's own footprint,
1516
+ * never ours to create.
1517
+ */
1518
+ export function windsurfNextGlobalMcpPath() {
1519
+ return path.join(os.homedir(), ".codeium", "windsurf-next", "mcp_config.json");
1520
+ }
1521
+ export function windsurfNextInstalled() {
1522
+ return fs.existsSync(path.dirname(windsurfNextGlobalMcpPath()));
1523
+ }
1524
+ export function configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot) {
1525
+ if (!windsurfNextInstalled())
1526
+ return false;
1527
+ return writeMcpConfig(windsurfNextGlobalMcpPath(), binaryPath, VEXP_TOOLS, mcpServerPath, workspaceRoot);
1528
+ }
1529
+ /**
1530
+ * Windsurf (Cascade) reads MCP servers from ~/.codeium/windsurf/mcp_config.json
1531
+ * and nowhere else — the documentation names no project-level file, and the
1532
+ * `.windsurf/mcp.json` vexp wrote for several releases was read by nothing.
1533
+ * Machine-global, so the entry is pinned to the workspace being set up and the
1534
+ * last setup wins, exactly like the Codex desktop entry: a global file cannot
1535
+ * follow whichever project happens to be open. Under WSL this lands in the WSL
1536
+ * home while Cascade on Windows reads the Windows profile; the setup prints the
1537
+ * Windows-side entry to paste (`windsurfWslBridgeNote`).
1538
+ */
1539
+ export function configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot) {
1540
+ const p = windsurfGlobalMcpPath();
1541
+ try {
1542
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1543
+ }
1544
+ catch {
1545
+ return false;
1546
+ }
1547
+ return writeMcpConfig(p, binaryPath, VEXP_TOOLS, mcpServerPath, workspaceRoot);
1548
+ }
1549
+ /**
1550
+ * Drop our entry from the `.windsurf/mcp.json` older versions wrote (a file
1551
+ * Cascade never read), deleting the file when nothing of the user's is left.
1552
+ */
1553
+ export function removeDeadWindsurfMcpJson(workspaceRoot) {
1554
+ const p = path.join(workspaceRoot, ".windsurf", "mcp.json");
1555
+ const read = readJsonConfigSafe(p);
1556
+ if (!read.existed || !read.ok)
1557
+ return "absent";
1558
+ const servers = read.data.mcpServers;
1559
+ if (!servers || !("vexp" in servers))
1560
+ return "absent";
1561
+ delete servers["vexp"];
1562
+ const otherKeys = Object.keys(read.data).filter((k) => k !== "mcpServers");
1563
+ if (Object.keys(servers).length === 0 && otherKeys.length === 0) {
1564
+ try {
1565
+ fs.unlinkSync(p);
1566
+ }
1567
+ catch { /* best effort */ }
1568
+ return "deleted";
1569
+ }
1570
+ try {
1571
+ fs.writeFileSync(p, JSON.stringify(read.data, null, 2), "utf-8");
1572
+ }
1573
+ catch { /* best effort */ }
1574
+ return "stripped";
1575
+ }
1576
+ /**
1577
+ * True inside WSL. `procVersion` is injectable for tests; when given, it is
1578
+ * the only thing consulted.
1579
+ */
1580
+ export function isWsl(procVersion) {
1581
+ if (procVersion !== undefined)
1582
+ return /microsoft/i.test(procVersion);
1583
+ if (process.platform !== "linux")
1584
+ return false;
1585
+ if (process.env.WSL_DISTRO_NAME)
1586
+ return true;
1587
+ try {
1588
+ return /microsoft/i.test(fs.readFileSync("/proc/version", "utf-8"));
1589
+ }
1590
+ catch {
1591
+ return false;
1592
+ }
1593
+ }
1594
+ function shellQuote(s) {
1595
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1596
+ }
1597
+ /**
1598
+ * The Windows-side Cascade entry for a project that lives in WSL, filled in
1599
+ * with the real paths, ready to paste. Cascade launches MCP servers on the
1600
+ * Windows side even when the workspace is a WSL folder, and reads
1601
+ * %USERPROFILE%\.codeium\windsurf\mcp_config.json there — never the WSL home
1602
+ * `vexp setup` can write to. Environment variables in the entry do not cross
1603
+ * wsl.exe, so VEXP_WORKSPACE is set on the command line inside WSL; `bash -lc`
1604
+ * puts a version-managed node on PATH. We do not write Windows files from WSL
1605
+ * (see the Windows + WSL2 docs) — we hand the user the exact entry instead.
1606
+ */
1607
+ export function windsurfWslBridgeNote(workspaceRoot, binaryPath, mcpServerPath, distro = process.env.WSL_DISTRO_NAME ?? "<your distro>") {
1608
+ const inner = mcpServerPath && fs.existsSync(mcpServerPath)
1609
+ ? `VEXP_WORKSPACE=${shellQuote(workspaceRoot)} exec ${shellQuote(resolveNodeCommand())} ${shellQuote(mcpServerPath)}`
1610
+ : `VEXP_WORKSPACE=${shellQuote(workspaceRoot)} exec ${shellQuote(binaryPath)} mcp`;
1611
+ const entry = {
1612
+ mcpServers: {
1613
+ vexp: {
1614
+ command: "wsl.exe",
1615
+ args: ["-d", distro, "--", "bash", "-lc", inner],
1616
+ alwaysAllow: VEXP_TOOLS,
1617
+ },
1618
+ },
1619
+ };
1620
+ return [
1621
+ "Windsurf on Windows, project in WSL: Cascade starts MCP servers on the WINDOWS side and reads",
1622
+ "%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json there — the entry vexp just wrote in your WSL home is not the one it reads.",
1623
+ "(Devin Desktop Next, the beta build, keeps its own copy: %USERPROFILE%\\.codeium\\windsurf-next\\mcp_config.json — same entry.)",
1624
+ "Paste this on the Windows side (Windsurf: Settings → Cascade → MCP → view raw config), then quit and reopen Windsurf:",
1625
+ JSON.stringify(entry, null, 2),
1626
+ "Devin Local reads .devin/mcp_config.json from the project (written). If it runs on the Windows side as well, the same entry goes in %APPDATA%\\devin\\mcp_config.json.",
1627
+ "Docs: https://vexp.dev/docs#windows-wsl",
1628
+ ].join("\n");
1629
+ }
1216
1630
  export function configureAntigravityGlobal(binaryPath, mcpServerPath) {
1217
1631
  const cfgDir = path.join(os.homedir(), ".gemini", "antigravity");
1218
1632
  try {
@@ -1234,7 +1648,7 @@ approveKey = "alwaysAllow") {
1234
1648
  }
1235
1649
  const existing = read.data;
1236
1650
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1237
- const targetCmd = useNode ? "node" : binaryPath;
1651
+ const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
1238
1652
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
1239
1653
  const targetEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
1240
1654
  const beforeServers = existing.mcpServers;
@@ -1258,7 +1672,7 @@ approveKey = "alwaysAllow") {
1258
1672
  // Another vexp install already left a working entry here — don't fight it.
1259
1673
  // Only the install path may differ: `envMatches` still gates on the
1260
1674
  // VEXP_WORKSPACE pin, so a moved or renamed project is repinned normally.
1261
- if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
1675
+ if (removed.length === 0 && envMatches && adoptableEntry(previousVexp))
1262
1676
  return false;
1263
1677
  const servers = existing.mcpServers ?? {};
1264
1678
  servers["vexp"] = {
@@ -1500,7 +1914,7 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
1500
1914
  }
1501
1915
  const existing = read.data;
1502
1916
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1503
- const targetCmd = useNode ? "node" : binaryPath;
1917
+ const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
1504
1918
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
1505
1919
  const targetEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
1506
1920
  const beforeServers = existing.servers;
@@ -1520,7 +1934,7 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
1520
1934
  // Copilot branch, which is gated only on `.github/` existing. Only the
1521
1935
  // install path may differ — `envMatches` still gates on the VEXP_WORKSPACE
1522
1936
  // pin, so a moved or renamed project is repinned normally.
1523
- if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
1937
+ if (removed.length === 0 && envMatches && adoptableEntry(previousVexp))
1524
1938
  return false;
1525
1939
  const servers = existing.servers ?? {};
1526
1940
  servers["vexp"] = {
@@ -1658,7 +2072,7 @@ export function configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath) {
1658
2072
  }
1659
2073
  const cfg = read.data;
1660
2074
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1661
- const command = useNode ? ["node", mcpServerPath] : [binaryPath, "mcp"];
2075
+ const command = useNode ? [resolveNodeCommand(), mcpServerPath] : [binaryPath, "mcp"];
1662
2076
  const env = { VEXP_WORKSPACE: workspaceRoot };
1663
2077
  const mcp = cfg.mcp ?? {};
1664
2078
  const prev = mcp["vexp"];
@@ -1699,7 +2113,7 @@ export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
1699
2113
  }
1700
2114
  const cfg = read.data;
1701
2115
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1702
- const command = useNode ? ["node", mcpServerPath] : [binaryPath, "mcp"];
2116
+ const command = useNode ? [resolveNodeCommand(), mcpServerPath] : [binaryPath, "mcp"];
1703
2117
  const env = { VEXP_WORKSPACE: workspaceRoot };
1704
2118
  const mcp = cfg.mcp ?? {};
1705
2119
  const prev = mcp["vexp"];
@@ -1733,7 +2147,7 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
1733
2147
  }
1734
2148
  const settings = read.data;
1735
2149
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1736
- const targetCmd = useNode ? "node" : binaryPath;
2150
+ const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
1737
2151
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
1738
2152
  const targetEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
1739
2153
  const beforeCs = settings.context_servers;
@@ -1752,7 +2166,7 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
1752
2166
  // Another vexp install already left a working entry here — don't fight it.
1753
2167
  // Only the install path may differ: `envMatches` still gates on the
1754
2168
  // VEXP_WORKSPACE pin, so a moved or renamed project is repinned normally.
1755
- if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
2169
+ if (removed.length === 0 && envMatches && adoptableEntry(previousVexp))
1756
2170
  return false;
1757
2171
  const cs = settings.context_servers ?? {};
1758
2172
  cs["vexp"] = {
@@ -1795,7 +2209,7 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
1795
2209
  const beforeServers = config.mcpServers;
1796
2210
  const existing = beforeServers?.["vexp"];
1797
2211
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1798
- const desiredCommand = useNode ? "node" : binaryPath;
2212
+ const desiredCommand = useNode ? resolveNodeCommand() : binaryPath;
1799
2213
  const desiredArgs = useNode ? [mcpServerPath] : ["mcp"];
1800
2214
  // Multi-session fix: do NOT pin VEXP_WORKSPACE on this user-scope entry.
1801
2215
  // Claude Code applies ~/.claude.json mcpServers to EVERY project, so a pinned
@@ -2960,7 +3374,17 @@ ${MANDATE_CORE}
2960
3374
  <!-- /vexp -->`;
2961
3375
  }
2962
3376
  function cursorTemplate(vars) {
2963
- return `## vexp rules for Cursor <!-- vexp v${vars.version} -->
3377
+ // Cursor project rules are `.cursor/rules/*.mdc` with front matter;
3378
+ // `alwaysApply: true` puts the rule in every chat without the agent having
3379
+ // to pick it. A rule file that Cursor reads at all is new: for several
3380
+ // releases vexp wrote a single FILE at `.cursor/rules`, a path Cursor never
3381
+ // reads, and one that stopped Cursor from creating its rules folder.
3382
+ return `---
3383
+ description: vexp code-graph orientation for this workspace
3384
+ alwaysApply: true
3385
+ ---
3386
+
3387
+ ## vexp rules for Cursor <!-- vexp v${vars.version} -->
2964
3388
 
2965
3389
  ${MANDATE_CORE}
2966
3390
  <!-- /vexp -->`;