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.
- package/dist/agent-config.js +442 -18
- package/dist/cli.js +32 -2
- package/dist/doctor.js +241 -12
- package/dist/license.js +4 -4
- package/dist/serve.js +41 -0
- package/mcp/mcp-server.cjs +29 -29
- 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
|
// ---------------------------------------------------------------------------
|
|
@@ -123,16 +124,26 @@ const AGENT_DETECTORS = [
|
|
|
123
124
|
{
|
|
124
125
|
agent: "Cursor",
|
|
125
126
|
detectPath: ".cursor",
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 &&
|
|
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 ?
|
|
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 &&
|
|
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 ? [
|
|
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 ? [
|
|
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 ?
|
|
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 &&
|
|
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 ?
|
|
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
|
-
|
|
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 -->`;
|