vexp-cli 3.1.0 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-config.js +328 -20
- package/dist/cli.js +45 -3
- package/dist/doctor.js +192 -24
- package/dist/mcp-supervisor.js +67 -7
- package/dist/serve.js +81 -1
- package/mcp/mcp-server.cjs +37 -35
- package/package.json +6 -6
package/dist/agent-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
@@ -2614,7 +2918,11 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
2614
2918
|
let root = {};
|
|
2615
2919
|
if (fs.existsSync(hooksJsonPath)) {
|
|
2616
2920
|
try {
|
|
2617
|
-
|
|
2921
|
+
// parseJsonc, not JSON.parse: every other config reader here is
|
|
2922
|
+
// BOM-tolerant and this one was missed. Windows PowerShell 5.1 writes
|
|
2923
|
+
// JSON with a UTF-8 BOM, and a user who edits .codex/hooks.json by hand
|
|
2924
|
+
// there would have the hook silently left unregistered.
|
|
2925
|
+
root = parseJsonc(fs.readFileSync(hooksJsonPath, "utf-8"));
|
|
2618
2926
|
}
|
|
2619
2927
|
catch {
|
|
2620
2928
|
warnUnparseable(hooksJsonPath);
|
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";
|
|
@@ -65,13 +66,24 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
|
|
|
65
66
|
// the parent, so we boot (or reuse) the parent daemon instead of spawning
|
|
66
67
|
// a conflicting one for the child.
|
|
67
68
|
const eff = findEffectiveWorkspace();
|
|
68
|
-
|
|
69
|
+
// Say so. Bootstrapping is what replaces a stale daemon and takes over a
|
|
70
|
+
// stale MCP server on :7821, and it is silently skipped outside a
|
|
71
|
+
// configured workspace: a tester ran the command doctor had suggested from
|
|
72
|
+
// a package subdirectory of a monorepo, got no error and no takeover, and
|
|
73
|
+
// reasonably concluded the server had been replaced (tier-4 field report,
|
|
74
|
+
// 2026-09-05). Doing nothing quietly is the one outcome that cannot be
|
|
75
|
+
// told apart from success.
|
|
76
|
+
if (!eff) {
|
|
77
|
+
console.error(chalk.yellow(` ⚠ no vexp workspace found from ${process.cwd()} — the daemon and the MCP server were NOT started or updated.`));
|
|
78
|
+
console.error(chalk.dim(` vexp walks up for .vexp/manifest.json; run 'vexp setup' in the workspace root.`));
|
|
69
79
|
return;
|
|
80
|
+
}
|
|
70
81
|
let binaryPath;
|
|
71
82
|
try {
|
|
72
83
|
binaryPath = getBinaryPath();
|
|
73
84
|
}
|
|
74
|
-
catch {
|
|
85
|
+
catch (err) {
|
|
86
|
+
console.error(chalk.yellow(` ⚠ vexp-core binary not resolved (${err instanceof Error ? err.message : err}) — nothing was started or updated.`));
|
|
75
87
|
return;
|
|
76
88
|
}
|
|
77
89
|
// Full bootstrap: daemon + MCP + autostart-if-needed. All three steps are
|
|
@@ -273,7 +285,12 @@ async function ensureBootstrap(workspaceRoot, binaryPath) {
|
|
|
273
285
|
try {
|
|
274
286
|
await ensureMcpHttpServer({ owner: "cli" });
|
|
275
287
|
}
|
|
276
|
-
catch {
|
|
288
|
+
catch (err) {
|
|
289
|
+
// Non-fatal, but never silent: this is the step that replaces an MCP
|
|
290
|
+
// server left behind by an older install, and skipping it leaves every
|
|
291
|
+
// MCP client on the old build.
|
|
292
|
+
console.error(chalk.dim(` ⚠ MCP HTTP server not started or taken over: ${err instanceof Error ? err.message : err}`));
|
|
293
|
+
}
|
|
277
294
|
}
|
|
278
295
|
/**
|
|
279
296
|
* Start the vexp daemon and MCP HTTP server in the background.
|
|
@@ -724,6 +741,21 @@ program
|
|
|
724
741
|
selectedNames = requested.map((n) => resolveAgentName(n));
|
|
725
742
|
console.log(chalk.dim(` Agents (from flag): ${selectedNames.join(", ")}`));
|
|
726
743
|
}
|
|
744
|
+
else if (!process.stdin.isTTY) {
|
|
745
|
+
// No one is at the keyboard. This command is named as a remediation
|
|
746
|
+
// in doctor's own output, and an agent — or a CI step, or a script
|
|
747
|
+
// reading that line — cannot answer a prompt: it hangs until it is
|
|
748
|
+
// killed, which is worse than failing (field report, 2026-09-07: a
|
|
749
|
+
// tester followed doctor's advice and had to abandon the command).
|
|
750
|
+
// Configure what is detected, and say that is what happened.
|
|
751
|
+
selectedNames = allAgents.filter((a) => detectedNames.has(a.agent)).map((a) => a.agent);
|
|
752
|
+
if (selectedNames.length === 0) {
|
|
753
|
+
console.error(chalk.yellow(" No AI agent detected here and stdin is not a terminal — nothing to configure."));
|
|
754
|
+
console.error(chalk.dim(" Name them explicitly: vexp setup --agents \"Claude Code,Codex\""));
|
|
755
|
+
process.exit(1);
|
|
756
|
+
}
|
|
757
|
+
console.log(chalk.dim(` Agents (detected, non-interactive): ${selectedNames.join(", ")}`));
|
|
758
|
+
}
|
|
727
759
|
else {
|
|
728
760
|
// Interactive selection
|
|
729
761
|
console.log(chalk.bold("\n Select AI agents to configure:\n"));
|
|
@@ -801,6 +833,16 @@ program
|
|
|
801
833
|
console.log(chalk.dim(` ${mcpFile}`));
|
|
802
834
|
}
|
|
803
835
|
}
|
|
836
|
+
// Windsurf/Devin Desktop on Windows with the project in WSL: Cascade
|
|
837
|
+
// starts its MCP servers on the Windows side and reads the Windows
|
|
838
|
+
// profile, which nothing in WSL can write. Hand over the exact entry
|
|
839
|
+
// instead of letting the user discover the boundary the hard way.
|
|
840
|
+
if (isWsl() && result.agents.some((a) => a.agent === "Windsurf")) {
|
|
841
|
+
console.log("");
|
|
842
|
+
for (const l of windsurfWslBridgeNote(workspaceRoot, binaryPath, mcpServerPath).split("\n")) {
|
|
843
|
+
console.log(chalk.yellow(` ${l}`));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
804
846
|
// Files we refused to touch because they would not parse. This
|
|
805
847
|
// used to be a stderr line mid-spinner: it scrolled away, and a
|
|
806
848
|
// skipped settings.json silently cost the whole hook (field
|