vexp-cli 3.1.0 → 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
  // ---------------------------------------------------------------------------
@@ -132,9 +133,17 @@ const AGENT_DETECTORS = [
132
133
  {
133
134
  agent: "Windsurf",
134
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"],
135
139
  configFile: ".windsurf/rules/vexp.md",
136
140
  templateName: "windsurf",
137
- 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).
138
147
  },
139
148
  {
140
149
  agent: "Continue.dev",
@@ -295,6 +304,12 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
295
304
  case "Antigravity":
296
305
  out.push("~/.gemini/antigravity MCP config (user scope)");
297
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;
298
313
  case "Zed":
299
314
  out.push(".zed/settings.json `context_servers` (MCP server entry)");
300
315
  break;
@@ -416,6 +431,16 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
416
431
  mcpConfigs.push(detector.mcpConfigFile);
417
432
  }
418
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
+ }
419
444
  // Claude Code: configure MCP in ~/.claude.json (user-scope, stdio)
420
445
  if (detector.agent === "Claude Code") {
421
446
  const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
@@ -766,6 +791,16 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
766
791
  if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey))
767
792
  mcpConfigs.push(detector.mcpConfigFile);
768
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
+ }
769
804
  if (detector.agent === "Claude Code") {
770
805
  const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
771
806
  if (wrote)
@@ -863,6 +898,11 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
863
898
  * commas (preserving string contents) before parsing.
864
899
  */
865
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);
866
906
  let out = "";
867
907
  let inString = false;
868
908
  let inLineComment = false;
@@ -940,7 +980,6 @@ export function readJsonConfigSafe(filePath) {
940
980
  catch { /* fall through to ok:false */ }
941
981
  return { data: {}, ok: false, existed: true };
942
982
  }
943
- /** Copy an existing config to <file>.vexp-bak before overwriting it. */
944
983
  /**
945
984
  * True when the vexp entry already in a config still resolves to something
946
985
  * runnable — even if it points at a DIFFERENT vexp install than ours.
@@ -983,30 +1022,101 @@ export function readJsonConfigSafe(filePath) {
983
1022
  * (`resolveNodePath`) since 2.x. Same search order, bare `node` as last resort.
984
1023
  */
985
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.
986
1032
  const execPath = process.execPath;
987
- if (execPath && !/Code|Electron/i.test(execPath)) {
1033
+ const execBase = execPath ? path.basename(execPath).toLowerCase() : "";
1034
+ if (execPath && (execBase === "node" || execBase === "node.exe")) {
988
1035
  try {
989
1036
  fs.accessSync(execPath, fs.constants.X_OK);
990
1037
  return execPath;
991
1038
  }
992
1039
  catch { /* not executable */ }
993
1040
  }
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)) {
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) => {
999
1064
  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
- }
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));
1006
1069
  }
1007
- catch { /* ignore */ }
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"));
1008
1098
  }
1009
- return "node";
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;
1010
1120
  }
1011
1121
  /**
1012
1122
  * An entry whose interpreter is a bare name (`node`, `node.exe`) rather than a
@@ -1014,6 +1124,34 @@ export function resolveNodeCommand() {
1014
1124
  * GUI editor, so it is never adopted as "already working" — the writer replaces
1015
1125
  * it with the pinned interpreter on the next setup.
1016
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
+ }
1017
1155
  export function isBareInterpreter(entry) {
1018
1156
  if (!entry || typeof entry !== "object")
1019
1157
  return false;
@@ -1032,7 +1170,7 @@ export function isBareInterpreter(entry) {
1032
1170
  * (opencode, Claude Code) keep adopting a bare interpreter — it works there.
1033
1171
  */
1034
1172
  function adoptableEntry(entry) {
1035
- return vexpEntryStillResolves(entry) && !isBareInterpreter(entry);
1173
+ return vexpEntryStillResolves(entry) && !isBareInterpreter(entry) && !isEditorExecutable(entry);
1036
1174
  }
1037
1175
  function vexpEntryStillResolves(entry) {
1038
1176
  if (!entry || typeof entry !== "object")
@@ -1064,10 +1202,52 @@ function vexpEntryStillResolves(entry) {
1064
1202
  const script = (Array.isArray(rawArgs) ? rawArgs : []).find((a) => typeof a === "string" && path.isAbsolute(a) && /\.[cm]?js$/.test(a));
1065
1203
  return script === undefined || fs.existsSync(script);
1066
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. */
1067
1238
  function backupConfig(filePath) {
1068
1239
  try {
1069
- if (fs.existsSync(filePath))
1070
- 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);
1071
1251
  }
1072
1252
  catch { /* non-fatal */ }
1073
1253
  }
@@ -1323,6 +1503,130 @@ export function mergeJsonConfig(filePath, mergePayload, version) {
1323
1503
  * one workspace breaks every other project (the 2.0.32 multi-session
1324
1504
  * lesson). `vexp-core mcp` resolves the workspace from its spawn cwd.
1325
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
+ }
1326
1630
  export function configureAntigravityGlobal(binaryPath, mcpServerPath) {
1327
1631
  const cfgDir = path.join(os.homedir(), ".gemini", "antigravity");
1328
1632
  try {
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocke
14
14
  import { checkForUpdate } from "./update-check.js";
15
15
  import { ensureMcpHttpServer, mcpHttpStatus } from "./mcp-supervisor.js";
16
16
  import { installAutostart, uninstallAutostart, autostartStatus, migrateClaudeUnpinIfNeeded } from "./autostart.js";
17
+ import { isWsl, windsurfWslBridgeNote } from "./agent-config.js";
17
18
  import { runServe } from "./serve.js";
18
19
  import { runDoctor } from "./doctor.js";
19
20
  import { socketPathFor } from "./socket-path.js";
@@ -801,6 +802,16 @@ program
801
802
  console.log(chalk.dim(` ${mcpFile}`));
802
803
  }
803
804
  }
805
+ // Windsurf/Devin Desktop on Windows with the project in WSL: Cascade
806
+ // starts its MCP servers on the Windows side and reads the Windows
807
+ // profile, which nothing in WSL can write. Hand over the exact entry
808
+ // instead of letting the user discover the boundary the hard way.
809
+ if (isWsl() && result.agents.some((a) => a.agent === "Windsurf")) {
810
+ console.log("");
811
+ for (const l of windsurfWslBridgeNote(workspaceRoot, binaryPath, mcpServerPath).split("\n")) {
812
+ console.log(chalk.yellow(` ${l}`));
813
+ }
814
+ }
804
815
  // Files we refused to touch because they would not parse. This
805
816
  // used to be a stderr line mid-spinner: it scrolled away, and a
806
817
  // skipped settings.json silently cost the whole hook (field