vexp-cli 3.3.9 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-config.js +3454 -602
- package/dist/agent-consent.js +111 -1
- package/dist/cli.js +99 -62
- package/dist/devmind.js +269 -0
- package/dist/doctor.js +1068 -89
- package/dist/git-worktree.js +136 -0
- package/dist/hook-scripts.js +271 -0
- package/dist/license-activate.js +33 -0
- package/dist/serve.js +7 -2
- package/mcp/mcp-server.cjs +52 -51
- package/package.json +7 -7
package/dist/agent-config.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Agent auto-detection and configuration for the vexp CLI.
|
|
3
3
|
*
|
|
4
4
|
* Ported from packages/vexp-vscode/src/providers/agent-auto-config.ts
|
|
5
|
-
* with VS Code dependencies removed.
|
|
6
|
-
* (
|
|
7
|
-
* `node <mcp-server.cjs
|
|
5
|
+
* with VS Code dependencies removed. The MCP command is the npm package's
|
|
6
|
+
* own binary (`<npm>/@vexp/core-<platform>/bin/vexp-core mcp`, the
|
|
7
|
+
* version-free path getBinaryPath returns) or `node <mcp-server.cjs>` when
|
|
8
|
+
* the bundle ships; nothing creates a `~/.vexp/bin` launcher.
|
|
8
9
|
*/
|
|
9
10
|
import * as fs from "fs";
|
|
10
11
|
import * as path from "path";
|
|
@@ -12,8 +13,10 @@ import * as os from "os";
|
|
|
12
13
|
import { spawnSync } from "child_process";
|
|
13
14
|
import * as crypto from "crypto";
|
|
14
15
|
import { canonicalWorkspaceRoot } from "./socket-path.js";
|
|
15
|
-
import { readAgentConsent,
|
|
16
|
+
import { readAgentConsent, decisionsFor } from "./agent-consent.js";
|
|
17
|
+
import { linkedWorktreeMain, sameRepositoryCheckout } from "./git-worktree.js";
|
|
16
18
|
import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSubagentHookScript, vexpSearchHookScript, vexpHintHookCmdScript, codexWindowsHookCommand, opencodeFamilyAgent, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin, vexpOpencodeCompressPlugin, bakeEditHintHook, bakeReadHintHook, bakeBashCapHook, upperDriveLetter } from "./hook-template.js";
|
|
19
|
+
import { committedHookScriptLine, committedHookScriptStays, readHookScript } from "./hook-scripts.js";
|
|
17
20
|
// ---------------------------------------------------------------------------
|
|
18
21
|
// Constants
|
|
19
22
|
// ---------------------------------------------------------------------------
|
|
@@ -149,6 +152,36 @@ function windsurfOnThisMachine() {
|
|
|
149
152
|
}
|
|
150
153
|
});
|
|
151
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Does this machine show Antigravity? What Antigravity itself leaves under
|
|
157
|
+
* ~/.gemini (measured with agy 1.5.3, 2026-11-07): anything in
|
|
158
|
+
* ~/.gemini/antigravity beside mcp_config.json and its backup (vexp up to
|
|
159
|
+
* 3.3.9 created that folder and that file itself, on machines that never
|
|
160
|
+
* had Antigravity, so they prove nothing); the agy CLI's app data,
|
|
161
|
+
* ~/.gemini/antigravity-cli; the marker every Antigravity surface writes at
|
|
162
|
+
* its first start, ~/.gemini/config/.migrated; or the agy binary the VS Code
|
|
163
|
+
* extension downloads to ~/.gemini/bin. The same test as
|
|
164
|
+
* windsurfOnThisMachine: vexp's own leftover folder made every project on
|
|
165
|
+
* the machine offer Antigravity, and GEMINI.md set it up unasked. Lockstep
|
|
166
|
+
* with the extension, which can also ask the editor it runs in; a terminal
|
|
167
|
+
* has no editor to ask, and `--agents Antigravity` is there for that case.
|
|
168
|
+
*/
|
|
169
|
+
export function antigravityOnThisMachine() {
|
|
170
|
+
const gemini = path.join(os.homedir(), ".gemini");
|
|
171
|
+
try {
|
|
172
|
+
if (fs.readdirSync(path.join(gemini, "antigravity")).some((e) => e !== "mcp_config.json" && e !== "mcp_config.json.vexp-bak")) {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* no such folder */
|
|
178
|
+
}
|
|
179
|
+
if (isDirectory(path.join(gemini, "antigravity-cli")))
|
|
180
|
+
return true;
|
|
181
|
+
if (fs.existsSync(path.join(gemini, "config", ".migrated")))
|
|
182
|
+
return true;
|
|
183
|
+
return ["agy", "agy.exe"].some((bin) => fs.existsSync(path.join(gemini, "bin", bin)));
|
|
184
|
+
}
|
|
152
185
|
const AGENT_DETECTORS = [
|
|
153
186
|
{
|
|
154
187
|
agent: "Claude Code",
|
|
@@ -311,14 +344,26 @@ const AGENT_DETECTORS = [
|
|
|
311
344
|
agent: "Antigravity",
|
|
312
345
|
// Antigravity keeps NO workspace marker dir of its own: it reads the
|
|
313
346
|
// cross-tool AGENTS.md (v1.20.3+), GEMINI.md, and .agent/rules/ from
|
|
314
|
-
// the workspace, and
|
|
347
|
+
// the workspace, and keeps its app data under ~/.gemini/antigravity.
|
|
348
|
+
// `.agents` is no marker: ZCode's .agents/mcp.json, Agent Skills and
|
|
349
|
+
// vexp's own .agents/mcp_config.json live there too.
|
|
350
|
+
//
|
|
351
|
+
// GEMINI.md and .agent are committed for the teammates who use
|
|
352
|
+
// Antigravity, so, as for Windsurf, they set it up without a question
|
|
353
|
+
// only where the machine shows Antigravity (antigravityOnThisMachine);
|
|
354
|
+
// elsewhere they are a hint and the user is asked. The machine-only
|
|
355
|
+
// marker is the same evidence, not the bare ~/.gemini/antigravity
|
|
356
|
+
// folder, which vexp itself created up to 3.3.9. Lockstep with the
|
|
357
|
+
// extension.
|
|
315
358
|
detectPath: "GEMINI.md",
|
|
316
359
|
detectPaths: [".agent"],
|
|
317
|
-
|
|
360
|
+
onThisMachine: antigravityOnThisMachine,
|
|
361
|
+
detectMachine: antigravityOnThisMachine,
|
|
318
362
|
configFile: "AGENTS.md",
|
|
319
363
|
templateName: "agents-md",
|
|
320
|
-
// MCP
|
|
321
|
-
// by
|
|
364
|
+
// MCP: the project file <root>/.agents/mcp_config.json, pinned to this
|
|
365
|
+
// project - written by configureAntigravityProject(), not via
|
|
366
|
+
// mcpConfigFile (the entry is the binary, never node).
|
|
322
367
|
},
|
|
323
368
|
{
|
|
324
369
|
// ZCode (zcode.z.ai), Z.ai's desktop agentic IDE. It reads the
|
|
@@ -445,6 +490,8 @@ export function classifyAgent(d, workspaceRoot) {
|
|
|
445
490
|
return "project";
|
|
446
491
|
if (d.detectHome && isDirectory(path.join(os.homedir(), d.detectHome)))
|
|
447
492
|
return "machine";
|
|
493
|
+
if (d.detectMachine && d.detectMachine())
|
|
494
|
+
return "machine";
|
|
448
495
|
if (d.detectAbsPaths && d.detectAbsPaths().some((p) => fs.existsSync(p)))
|
|
449
496
|
return "machine";
|
|
450
497
|
if (projectHint(d, workspaceRoot))
|
|
@@ -464,7 +511,7 @@ export function classifyAgents(workspaceRoot) {
|
|
|
464
511
|
/**
|
|
465
512
|
* What a setup does without asking. Project markers and earlier "yes"
|
|
466
513
|
* answers are configured as they always were; traces on the machine alone
|
|
467
|
-
* (Codex's login, Antigravity's
|
|
514
|
+
* (Codex's login, Antigravity's own files, Cline's storage, ~/.claude) and a
|
|
468
515
|
* hint in the project (a bare `.github`, a `.windsurf` folder) wait for the
|
|
469
516
|
* user. Nothing vexp wrote counts as an answer: a 3.2.5 user whose Codex
|
|
470
517
|
* setup came from AGENTS.md is asked once, like everyone else, and so is a
|
|
@@ -472,7 +519,13 @@ export function classifyAgents(workspaceRoot) {
|
|
|
472
519
|
*/
|
|
473
520
|
export function planAgentSetup(workspaceRoot, consent = readAgentConsent()) {
|
|
474
521
|
const plan = { configure: [], offer: [], declined: [], kinds: {} };
|
|
475
|
-
|
|
522
|
+
// A linked git worktree with no answer of its own for an agent takes the
|
|
523
|
+
// main checkout's (decisionsFor): it is the same project, and every new
|
|
524
|
+
// worktree asked every question again (field report, 2026-11-07). The
|
|
525
|
+
// writers that then run there leave the files committed to the
|
|
526
|
+
// repository as they are where only this checkout's path would change
|
|
527
|
+
// (committedPin, committedCleanupBlocked). Lockstep with the extension.
|
|
528
|
+
const decisionOf = decisionsFor(consent, workspaceRoot);
|
|
476
529
|
// The extension asks one question after another, and one left open keeps
|
|
477
530
|
// the rest from being seen: a tool the machine shows goes before one the
|
|
478
531
|
// project only hints at. The same order here, so the two plans compare.
|
|
@@ -481,7 +534,7 @@ export function planAgentSetup(workspaceRoot, consent = readAgentConsent()) {
|
|
|
481
534
|
const kind = classifyAgent(d, workspaceRoot);
|
|
482
535
|
if (kind)
|
|
483
536
|
plan.kinds[d.agent] = kind;
|
|
484
|
-
const decision =
|
|
537
|
+
const decision = decisionOf(d.agent);
|
|
485
538
|
if (kind === "project" || decision === "yes")
|
|
486
539
|
plan.configure.push(d.agent);
|
|
487
540
|
else if (decision === "no" || decision === "never") {
|
|
@@ -592,7 +645,11 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
|
|
|
592
645
|
out.push(".kilo/plugins/vexp-guard.js (guard, opt-in)");
|
|
593
646
|
break;
|
|
594
647
|
case "Antigravity":
|
|
595
|
-
out.push("
|
|
648
|
+
out.push(".agents/mcp_config.json (MCP server entry, pinned to this project; kept out of git in .git/info/exclude)");
|
|
649
|
+
// Setup also takes vexp's old unpinned entry out of a machine-wide file
|
|
650
|
+
// (removeDeadAntigravityEntries): named only where there is one.
|
|
651
|
+
for (const file of deadAntigravityEntryFiles())
|
|
652
|
+
out.push(`${file} (vexp's old unpinned entry removed)`);
|
|
596
653
|
break;
|
|
597
654
|
case "Windsurf":
|
|
598
655
|
out.push("~/.codeium/windsurf/mcp_config.json (MCP server entry, user scope — Cascade)");
|
|
@@ -646,311 +703,338 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
|
|
|
646
703
|
}
|
|
647
704
|
for (const detected of agents) {
|
|
648
705
|
const detector = withCursorTarget(detected, workspaceRoot);
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
}
|
|
659
|
-
const content = generateAgentConfig(detector.templateName, {
|
|
660
|
-
workspaceRoot,
|
|
661
|
-
binaryPath,
|
|
662
|
-
configFile: detector.configFile,
|
|
663
|
-
version,
|
|
664
|
-
});
|
|
665
|
-
// Write instruction file (deduplicated by configFile path)
|
|
666
|
-
let action = "skipped";
|
|
667
|
-
if (!writtenConfigFiles.has(detector.configFile)) {
|
|
668
|
-
if (detector.templateName === "continue") {
|
|
669
|
-
const continuePayload = {
|
|
670
|
-
customCommands: [
|
|
671
|
-
{
|
|
672
|
-
name: "vexp-context",
|
|
673
|
-
description: "Get vexp context for current task",
|
|
674
|
-
prompt: "{{{ input }}}\n\nFirst, call run_pipeline with the above task description.",
|
|
675
|
-
},
|
|
676
|
-
],
|
|
677
|
-
contextProviders: [
|
|
678
|
-
{
|
|
679
|
-
name: "vexp",
|
|
680
|
-
params: {},
|
|
681
|
-
},
|
|
682
|
-
],
|
|
683
|
-
mcpServers: [
|
|
684
|
-
{
|
|
685
|
-
name: "vexp",
|
|
686
|
-
command: binaryPath,
|
|
687
|
-
args: ["mcp"],
|
|
688
|
-
type: "stdio",
|
|
689
|
-
},
|
|
690
|
-
],
|
|
691
|
-
};
|
|
692
|
-
const mergeResult = mergeJsonConfig(configFilePath, continuePayload, version);
|
|
693
|
-
action = mergeResult === "merged" ? "updated" : "skipped";
|
|
694
|
-
}
|
|
695
|
-
else {
|
|
696
|
-
action = appendOrCreate(configFilePath, content, version);
|
|
697
|
-
}
|
|
698
|
-
writtenConfigFiles.add(detector.configFile);
|
|
706
|
+
// A folder of the agent's that is a link to nothing (danglingAgentFolder):
|
|
707
|
+
// mkdirSync and writeFileSync both throw ENOENT there, and the exception
|
|
708
|
+
// left this loop, so the setup failed for every agent, not only for the
|
|
709
|
+
// one whose folder it was (bug at 3.3.9). That agent alone is skipped,
|
|
710
|
+
// and the end of setup says why (takeUnreachableTargets).
|
|
711
|
+
const dangling = danglingAgentFolder(detector, workspaceRoot);
|
|
712
|
+
if (dangling) {
|
|
713
|
+
noteDanglingAgentFolder(dangling);
|
|
714
|
+
continue;
|
|
699
715
|
}
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
//
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
// reads VEXP_WORKSPACE from env) by withholding mcpServerPath — no `node`
|
|
714
|
-
// needed, an absolute path that always resolves.
|
|
715
|
-
const mcpSrv = isKiro ? undefined : mcpServerPath;
|
|
716
|
-
if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey)) {
|
|
717
|
-
mcpConfigs.push(detector.mcpConfigFile);
|
|
716
|
+
// Anything else that throws for one agent stops that agent only, and
|
|
717
|
+
// the end of setup says so (noteAgentSetupStopped): the agents after
|
|
718
|
+
// it are set up all the same.
|
|
719
|
+
try {
|
|
720
|
+
const configFilePath = path.join(workspaceRoot, detector.configFile);
|
|
721
|
+
const alreadyExists = fs.existsSync(configFilePath);
|
|
722
|
+
// Sweep the location we used to write to. For several releases the
|
|
723
|
+
// Windsurf rules went to `.windsurf/rules.md`, which Cascade does not
|
|
724
|
+
// read: its rules are `.windsurf/rules/*.md`, `.devin/rules/*.md`, or the
|
|
725
|
+
// legacy root `.windsurfrules`. Upgrading users would otherwise keep a
|
|
726
|
+
// stale, unread copy of our instructions beside the live one.
|
|
727
|
+
if (detector.agent === "Windsurf") {
|
|
728
|
+
removeVexpSection(path.join(workspaceRoot, ".windsurf", "rules.md"), "Windsurf");
|
|
718
729
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
730
|
+
const content = generateAgentConfig(detector.templateName, {
|
|
731
|
+
workspaceRoot,
|
|
732
|
+
binaryPath,
|
|
733
|
+
configFile: detector.configFile,
|
|
734
|
+
version,
|
|
735
|
+
});
|
|
736
|
+
// Write instruction file (deduplicated by configFile path)
|
|
737
|
+
let action = "skipped";
|
|
738
|
+
if (!writtenConfigFiles.has(detector.configFile)) {
|
|
739
|
+
if (detector.templateName === "continue") {
|
|
740
|
+
const continuePayload = {
|
|
741
|
+
customCommands: [
|
|
742
|
+
{
|
|
743
|
+
name: "vexp-context",
|
|
744
|
+
description: "Get vexp context for current task",
|
|
745
|
+
prompt: "{{{ input }}}\n\nFirst, call run_pipeline with the above task description.",
|
|
746
|
+
},
|
|
747
|
+
],
|
|
748
|
+
contextProviders: [
|
|
749
|
+
{
|
|
750
|
+
name: "vexp",
|
|
751
|
+
params: {},
|
|
752
|
+
},
|
|
753
|
+
],
|
|
754
|
+
mcpServers: [
|
|
755
|
+
{
|
|
756
|
+
name: "vexp",
|
|
757
|
+
command: binaryPath,
|
|
758
|
+
args: ["mcp"],
|
|
759
|
+
type: "stdio",
|
|
760
|
+
},
|
|
761
|
+
],
|
|
762
|
+
};
|
|
763
|
+
const mergeResult = mergeJsonConfig(configFilePath, continuePayload, version);
|
|
764
|
+
action = mergeResult === "merged" ? "updated" : "skipped";
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
action = appendOrCreate(configFilePath, content, version);
|
|
768
|
+
}
|
|
769
|
+
writtenConfigFiles.add(detector.configFile);
|
|
745
770
|
}
|
|
746
|
-
|
|
747
|
-
|
|
771
|
+
// Write MCP config for agents with mcpConfigFile (project-level JSON)
|
|
772
|
+
if (detector.mcpConfigFile) {
|
|
773
|
+
const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
|
|
774
|
+
const isKiro = detector.agent === "Kiro";
|
|
775
|
+
// Windsurf and Kiro auto-approve vexp's tools so the agent isn't prompted
|
|
776
|
+
// on every run_pipeline call. Kiro's field is `autoApprove`; others use
|
|
777
|
+
// `alwaysAllow`.
|
|
778
|
+
const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
|
|
779
|
+
const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
|
|
780
|
+
// Kiro resolves the MCP `command` via PATH, and a macOS GUI launch has no
|
|
781
|
+
// `node` on PATH — so the `node <mcp-server.cjs>` transport silently fails
|
|
782
|
+
// to start and run_pipeline never appears (the agent then falls back to
|
|
783
|
+
// grep/sed). Point Kiro straight at the vexp binary (`vexp-core mcp`, which
|
|
784
|
+
// reads VEXP_WORKSPACE from env) by withholding mcpServerPath — no `node`
|
|
785
|
+
// needed, an absolute path that always resolves.
|
|
786
|
+
const mcpSrv = isKiro ? undefined : mcpServerPath;
|
|
787
|
+
if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey)) {
|
|
788
|
+
mcpConfigs.push(detector.mcpConfigFile);
|
|
789
|
+
}
|
|
748
790
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
});
|
|
791
|
+
// Windsurf / Devin Desktop: the two files the agent actually reads.
|
|
792
|
+
if (detector.agent === "Windsurf") {
|
|
793
|
+
if (configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot))
|
|
794
|
+
mcpConfigs.push("~/.codeium/windsurf/mcp_config.json");
|
|
795
|
+
if (configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot))
|
|
796
|
+
mcpConfigs.push("~/.codeium/windsurf-next/mcp_config.json");
|
|
797
|
+
if (writeMcpConfig(path.join(workspaceRoot, ".devin", "mcp_config.json"), binaryPath, undefined, mcpServerPath, workspaceRoot))
|
|
798
|
+
mcpConfigs.push(".devin/mcp_config.json");
|
|
799
|
+
removeDeadWindsurfMcpJson(workspaceRoot);
|
|
759
800
|
}
|
|
760
|
-
//
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
801
|
+
// Claude Code: configure MCP in ~/.claude.json (user-scope, stdio)
|
|
802
|
+
if (detector.agent === "Claude Code") {
|
|
803
|
+
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
|
|
804
|
+
if (wrote)
|
|
805
|
+
mcpConfigs.push("~/.claude.json");
|
|
806
|
+
// Event-driven hint hook: default-on (non-blocking, fail-open — the
|
|
807
|
+
// 2.3 A2 opt-in applies to DENY hooks, this one cannot block).
|
|
808
|
+
const hintResult = installClaudeCodeHintHook(workspaceRoot, binaryPath);
|
|
809
|
+
installClaudeCodeSubagentHook(workspaceRoot, binaryPath);
|
|
810
|
+
installClaudeCodeSearchHook(workspaceRoot, binaryPath);
|
|
811
|
+
// Both doors. Bounding either alone measured as no change at all: the
|
|
812
|
+
// work simply moves to the other one.
|
|
813
|
+
if (interventionMode() === "on") {
|
|
814
|
+
installClaudeCodeReadHint(workspaceRoot, binaryPath);
|
|
815
|
+
installClaudeCodeBashCap(workspaceRoot, binaryPath);
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
removeClaudeCodePreToolHooks(workspaceRoot);
|
|
819
|
+
}
|
|
820
|
+
installClaudeCodeStopGate(workspaceRoot, binaryPath);
|
|
821
|
+
installClaudeCodeSessionContext(workspaceRoot, binaryPath);
|
|
822
|
+
if (hintResult) {
|
|
823
|
+
results.push({
|
|
824
|
+
agent: "Claude Code Hint",
|
|
825
|
+
// A committed script left as it is (K1) was not written: what
|
|
826
|
+
// changed is its registration.
|
|
827
|
+
configFile: hookScriptLeft(path.join(workspaceRoot, ".claude", "hooks", "vexp-hint.sh"))
|
|
828
|
+
? ".claude/settings.json"
|
|
829
|
+
: ".claude/hooks/vexp-hint.sh",
|
|
830
|
+
content: vexpHintHookScript(binaryPath),
|
|
831
|
+
alreadyExists: false,
|
|
832
|
+
action: hintResult,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
// Guard is OPT-IN from 2.3 (see GuardMode): strict installs the
|
|
836
|
+
// PreToolUse deny hook; default removes any previously installed one.
|
|
837
|
+
if (guardMode() === "strict") {
|
|
838
|
+
const hookResult = installClaudeCodeHook(workspaceRoot);
|
|
839
|
+
if (hookResult) {
|
|
840
|
+
results.push({
|
|
841
|
+
agent: "Claude Code Hook",
|
|
842
|
+
configFile: ".claude/hooks/vexp-guard.sh",
|
|
843
|
+
content: VEXP_GUARD_HOOK,
|
|
844
|
+
alreadyExists: false,
|
|
845
|
+
action: hookResult,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
else if (uninstallClaudeCodeHook(workspaceRoot)) {
|
|
765
850
|
results.push({
|
|
766
851
|
agent: "Claude Code Hook",
|
|
767
852
|
configFile: ".claude/hooks/vexp-guard.sh",
|
|
768
|
-
content:
|
|
853
|
+
content: "",
|
|
769
854
|
alreadyExists: false,
|
|
770
|
-
action:
|
|
855
|
+
action: "removed",
|
|
771
856
|
});
|
|
772
857
|
}
|
|
773
858
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
action: "removed",
|
|
781
|
-
});
|
|
859
|
+
// Codex: configure MCP in ~/.codex/config.toml (global, HTTP + bearer token)
|
|
860
|
+
if (detector.agent === "Codex") {
|
|
861
|
+
const wrote = configureCodexGlobal(binaryPath, mcpServerPath, workspaceRoot);
|
|
862
|
+
if (wrote)
|
|
863
|
+
mcpConfigs.push("~/.codex/config.toml");
|
|
864
|
+
installCodexHintHook(workspaceRoot, binaryPath);
|
|
782
865
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
installCodexHintHook(workspaceRoot, binaryPath);
|
|
790
|
-
}
|
|
791
|
-
// Antigravity: machine-global MCP config (~/.gemini/antigravity/mcp_config.json)
|
|
792
|
-
if (detector.agent === "Antigravity") {
|
|
793
|
-
const wrote = configureAntigravityGlobal(binaryPath, mcpServerPath);
|
|
794
|
-
if (wrote)
|
|
795
|
-
mcpConfigs.push("~/.gemini/antigravity/mcp_config.json");
|
|
796
|
-
}
|
|
797
|
-
// ZCode: `mcp.servers` in .zcode/config.json (or the .agents/mcp.json
|
|
798
|
-
// compatibility file when that is what the project uses) + the
|
|
799
|
-
// orientation hook in ZCode's USER config — see configureZcodeMcp.
|
|
800
|
-
if (detector.agent === "ZCode") {
|
|
801
|
-
const wrote = configureZcodeMcp(workspaceRoot, binaryPath);
|
|
802
|
-
if (wrote)
|
|
803
|
-
mcpConfigs.push(wrote);
|
|
804
|
-
const hint = installZcodeHintHook(binaryPath);
|
|
805
|
-
if (hint) {
|
|
806
|
-
results.push({
|
|
807
|
-
agent: "ZCode Hint",
|
|
808
|
-
configFile: "~/.zcode/cli/config.json",
|
|
809
|
-
content: "",
|
|
810
|
-
alreadyExists: hint === "updated",
|
|
811
|
-
action: hint,
|
|
812
|
-
});
|
|
866
|
+
// Antigravity: the project's .agents/mcp_config.json, pinned to this
|
|
867
|
+
// project (configureAntigravityProject). The dead machine-wide entries
|
|
868
|
+
// older setups left go at the end, whichever agents are set up.
|
|
869
|
+
if (detector.agent === "Antigravity") {
|
|
870
|
+
if (configureAntigravityProject(workspaceRoot, binaryPath))
|
|
871
|
+
mcpConfigs.push(ANTIGRAVITY_PROJECT_MCP);
|
|
813
872
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
873
|
+
// ZCode: `mcp.servers` in .zcode/config.json (or the .agents/mcp.json
|
|
874
|
+
// compatibility file when that is what the project uses) + the
|
|
875
|
+
// orientation hook in ZCode's USER config — see configureZcodeMcp.
|
|
876
|
+
if (detector.agent === "ZCode") {
|
|
877
|
+
const wrote = configureZcodeMcp(workspaceRoot, binaryPath);
|
|
878
|
+
if (wrote)
|
|
879
|
+
mcpConfigs.push(wrote);
|
|
880
|
+
const hint = installZcodeHintHook(binaryPath);
|
|
881
|
+
if (hint) {
|
|
882
|
+
results.push({
|
|
883
|
+
agent: "ZCode Hint",
|
|
884
|
+
configFile: "~/.zcode/cli/config.json",
|
|
885
|
+
content: "",
|
|
886
|
+
alreadyExists: hint === "updated",
|
|
887
|
+
action: hint,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
820
890
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
mcpConfigs.push(".zed/settings.json");
|
|
891
|
+
// GitHub Copilot: VS Code format (.vscode/mcp.json with "servers" key)
|
|
892
|
+
// `.mcp.json` at the root is kept runnable, and when it defines vexp
|
|
893
|
+
// `.vscode/mcp.json` gets no entry of its own (configureCopilotMcp).
|
|
894
|
+
if (detector.agent === "GitHub Copilot") {
|
|
895
|
+
mcpConfigs.push(...configureCopilotMcp(workspaceRoot, binaryPath, mcpServerPath));
|
|
827
896
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
897
|
+
// Zed: context_servers in .zed/settings.json
|
|
898
|
+
if (detector.agent === "Zed") {
|
|
899
|
+
const zedPath = path.join(workspaceRoot, ".zed/settings.json");
|
|
900
|
+
if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot)) {
|
|
901
|
+
mcpConfigs.push(".zed/settings.json");
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// Kilo Code: MCP lives under the `mcp` key in kilo.jsonc (root or .kilo/),
|
|
905
|
+
// not a separate mcp.json (that file is no longer read).
|
|
906
|
+
if (detector.agent === "Kilo Code") {
|
|
907
|
+
const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
908
|
+
if (wrote)
|
|
909
|
+
mcpConfigs.push(wrote);
|
|
910
|
+
installOpencodeHintPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
|
|
911
|
+
installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
|
|
912
|
+
// Kilo v7 vendors opencode, so it takes the same guard plugin — the rules
|
|
913
|
+
// markdown alone was demonstrably not enough (a reported session loaded
|
|
914
|
+
// vexp.md, quoted it back, and still read five files by hand).
|
|
915
|
+
if (guardMode() === "strict") {
|
|
916
|
+
const kilo = installKiloPlugin(workspaceRoot);
|
|
917
|
+
if (kilo.plugin) {
|
|
918
|
+
results.push({
|
|
919
|
+
agent: "Kilo Code Guard",
|
|
920
|
+
configFile: path.join(".kilo", "plugins", "vexp-guard.js"),
|
|
921
|
+
content: VEXP_OPENCODE_GUARD,
|
|
922
|
+
alreadyExists: kilo.plugin === "updated",
|
|
923
|
+
action: kilo.plugin,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
if (kilo.registered)
|
|
927
|
+
mcpConfigs.push(path.relative(workspaceRoot, kiloConfigTarget(workspaceRoot)));
|
|
928
|
+
}
|
|
929
|
+
else if (uninstallKiloPlugin(workspaceRoot)) {
|
|
843
930
|
results.push({
|
|
844
931
|
agent: "Kilo Code Guard",
|
|
845
932
|
configFile: path.join(".kilo", "plugins", "vexp-guard.js"),
|
|
846
|
-
content:
|
|
847
|
-
alreadyExists:
|
|
848
|
-
action:
|
|
933
|
+
content: "",
|
|
934
|
+
alreadyExists: false,
|
|
935
|
+
action: "removed",
|
|
849
936
|
});
|
|
850
937
|
}
|
|
851
|
-
if (kilo.registered)
|
|
852
|
-
mcpConfigs.push(path.relative(workspaceRoot, kiloConfigTarget(workspaceRoot)));
|
|
853
|
-
}
|
|
854
|
-
else if (uninstallKiloPlugin(workspaceRoot)) {
|
|
855
|
-
results.push({
|
|
856
|
-
agent: "Kilo Code Guard",
|
|
857
|
-
configFile: path.join(".kilo", "plugins", "vexp-guard.js"),
|
|
858
|
-
content: "",
|
|
859
|
-
alreadyExists: false,
|
|
860
|
-
action: "removed",
|
|
861
|
-
});
|
|
862
938
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
939
|
+
// Cursor: preToolUse hook matching Grep. Text search only - Cursor's native
|
|
940
|
+
// semantic search is not hookable.
|
|
941
|
+
if (detector.agent === "Cursor") {
|
|
942
|
+
if (guardMode() === "strict") {
|
|
943
|
+
const cur = installCursorHook(workspaceRoot);
|
|
944
|
+
if (cur.hook) {
|
|
945
|
+
results.push({
|
|
946
|
+
agent: "Cursor Guard",
|
|
947
|
+
configFile: path.join(".cursor", "hooks", "vexp-guard.js"),
|
|
948
|
+
content: VEXP_CURSOR_GUARD,
|
|
949
|
+
alreadyExists: cur.hook === "updated",
|
|
950
|
+
action: cur.hook,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
if (cur.registered)
|
|
954
|
+
mcpConfigs.push(path.join(".cursor", "hooks.json"));
|
|
955
|
+
}
|
|
956
|
+
else if (uninstallCursorHook(workspaceRoot)) {
|
|
870
957
|
results.push({
|
|
871
958
|
agent: "Cursor Guard",
|
|
872
959
|
configFile: path.join(".cursor", "hooks", "vexp-guard.js"),
|
|
873
|
-
content:
|
|
874
|
-
alreadyExists:
|
|
875
|
-
action:
|
|
960
|
+
content: "",
|
|
961
|
+
alreadyExists: false,
|
|
962
|
+
action: "removed",
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
// v5: the coupling on edit. Default-ON and independent of the guard —
|
|
966
|
+
// it blocks nothing, so there is no reason to make it opt-in.
|
|
967
|
+
const edit = installCursorEditHint(workspaceRoot, binaryPath);
|
|
968
|
+
if (edit) {
|
|
969
|
+
results.push({
|
|
970
|
+
agent: "Cursor Edit Hint",
|
|
971
|
+
// A committed script left as it is (K1) was not written: what
|
|
972
|
+
// changed is its registration.
|
|
973
|
+
configFile: hookScriptLeft(path.join(workspaceRoot, ".cursor", "hooks", "vexp-edit-hint.sh"))
|
|
974
|
+
? path.join(".cursor", "hooks.json")
|
|
975
|
+
: path.join(".cursor", "hooks", "vexp-edit-hint.sh"),
|
|
976
|
+
content: "",
|
|
977
|
+
alreadyExists: edit === "updated",
|
|
978
|
+
action: edit,
|
|
876
979
|
});
|
|
877
980
|
}
|
|
878
|
-
if (cur.registered)
|
|
879
|
-
mcpConfigs.push(path.join(".cursor", "hooks.json"));
|
|
880
|
-
}
|
|
881
|
-
else if (uninstallCursorHook(workspaceRoot)) {
|
|
882
|
-
results.push({
|
|
883
|
-
agent: "Cursor Guard",
|
|
884
|
-
configFile: path.join(".cursor", "hooks", "vexp-guard.js"),
|
|
885
|
-
content: "",
|
|
886
|
-
alreadyExists: false,
|
|
887
|
-
action: "removed",
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
// v5: the coupling on edit. Default-ON and independent of the guard —
|
|
891
|
-
// it blocks nothing, so there is no reason to make it opt-in.
|
|
892
|
-
const edit = installCursorEditHint(workspaceRoot, binaryPath);
|
|
893
|
-
if (edit) {
|
|
894
|
-
results.push({
|
|
895
|
-
agent: "Cursor Edit Hint",
|
|
896
|
-
configFile: path.join(".cursor", "hooks", "vexp-edit-hint.sh"),
|
|
897
|
-
content: "",
|
|
898
|
-
alreadyExists: edit === "updated",
|
|
899
|
-
action: edit,
|
|
900
|
-
});
|
|
901
981
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
982
|
+
// Cline: rules in .clinerules (generic template, handled by the writer
|
|
983
|
+
// above); MCP registry is machine-global VS Code storage.
|
|
984
|
+
if (detector.agent === "Cline") {
|
|
985
|
+
const wrote = configureClineMcp(workspaceRoot, binaryPath);
|
|
986
|
+
for (const w of wrote) {
|
|
987
|
+
mcpConfigs.push(w);
|
|
988
|
+
}
|
|
989
|
+
if (wrote.length === 0 && !clineInstalled())
|
|
990
|
+
noteClineMcpUnreachable();
|
|
909
991
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
992
|
+
// Opencode: MCP lives under the `mcp` key in opencode.json(c) — opencode
|
|
993
|
+
// carries no `mcpConfigFile`, so without this call the generic writer above
|
|
994
|
+
// skips it and setup registers no tool at all. Plus the guard plugin that
|
|
995
|
+
// blocks grep/glob while the daemon is up (opencode's analogue of the
|
|
996
|
+
// Claude Code PreToolUse hook) — which is only safe once MCP is registered.
|
|
997
|
+
if (detector.agent === "Opencode") {
|
|
998
|
+
const wroteMcp = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
999
|
+
installOpencodeHintPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
|
|
1000
|
+
installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
|
|
1001
|
+
if (wroteMcp)
|
|
1002
|
+
mcpConfigs.push(wroteMcp);
|
|
1003
|
+
if (guardMode() === "strict") {
|
|
1004
|
+
const pluginResult = installOpencodePlugin(workspaceRoot);
|
|
1005
|
+
if (pluginResult) {
|
|
1006
|
+
results.push({
|
|
1007
|
+
agent: "Opencode Guard",
|
|
1008
|
+
configFile: ".opencode/plugins/vexp-guard.js",
|
|
1009
|
+
content: VEXP_OPENCODE_GUARD,
|
|
1010
|
+
alreadyExists: pluginResult === "updated",
|
|
1011
|
+
action: pluginResult,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
else if (uninstallOpencodePlugin(workspaceRoot)) {
|
|
927
1016
|
results.push({
|
|
928
1017
|
agent: "Opencode Guard",
|
|
929
1018
|
configFile: ".opencode/plugins/vexp-guard.js",
|
|
930
|
-
content:
|
|
931
|
-
alreadyExists:
|
|
932
|
-
action:
|
|
1019
|
+
content: "",
|
|
1020
|
+
alreadyExists: false,
|
|
1021
|
+
action: "removed",
|
|
933
1022
|
});
|
|
934
1023
|
}
|
|
935
1024
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1025
|
+
results.push({
|
|
1026
|
+
agent: detector.agent,
|
|
1027
|
+
configFile: detector.configFile,
|
|
1028
|
+
content,
|
|
1029
|
+
alreadyExists,
|
|
1030
|
+
action,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
catch (err) {
|
|
1034
|
+
noteAgentSetupStopped(detector.agent, err);
|
|
945
1035
|
}
|
|
946
|
-
results.push({
|
|
947
|
-
agent: detector.agent,
|
|
948
|
-
configFile: detector.configFile,
|
|
949
|
-
content,
|
|
950
|
-
alreadyExists,
|
|
951
|
-
action,
|
|
952
|
-
});
|
|
953
1036
|
}
|
|
1037
|
+
removeDeadAntigravityEntriesAtSetup();
|
|
954
1038
|
return { agents: results, mcpConfigs };
|
|
955
1039
|
}
|
|
956
1040
|
/**
|
|
@@ -1026,178 +1110,197 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
|
|
|
1026
1110
|
const detector = withCursorTarget(detected, workspaceRoot);
|
|
1027
1111
|
if (!selectedAgentNames.includes(detector.agent))
|
|
1028
1112
|
continue;
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
}
|
|
1039
|
-
// Create parent directory for configFile (handles nested paths like .kiro/steering/vexp.md)
|
|
1040
|
-
const configFilePath = path.join(workspaceRoot, detector.configFile);
|
|
1041
|
-
const configDir = path.dirname(configFilePath);
|
|
1042
|
-
if (!fs.existsSync(configDir)) {
|
|
1043
|
-
fs.mkdirSync(configDir, { recursive: true });
|
|
1113
|
+
// A folder of the agent's that is a link to nothing (danglingAgentFolder):
|
|
1114
|
+
// mkdirSync and writeFileSync both throw ENOENT there, and the exception
|
|
1115
|
+
// left this loop, so the setup failed for every agent, not only for the
|
|
1116
|
+
// one whose folder it was (bug at 3.3.9). That agent alone is skipped,
|
|
1117
|
+
// and the end of setup says why (takeUnreachableTargets).
|
|
1118
|
+
const dangling = danglingAgentFolder(detector, workspaceRoot);
|
|
1119
|
+
if (dangling) {
|
|
1120
|
+
noteDanglingAgentFolder(dangling);
|
|
1121
|
+
continue;
|
|
1044
1122
|
}
|
|
1045
|
-
//
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1123
|
+
// Anything else that throws for one agent stops that agent only, and
|
|
1124
|
+
// the end of setup says so (noteAgentSetupStopped): the agents after
|
|
1125
|
+
// it are set up all the same.
|
|
1126
|
+
try {
|
|
1127
|
+
// Create agent directory if it doesn't exist. Skip for file-based
|
|
1128
|
+
// detectPaths: extension-bearing markers (AGENTS.md) and extensionless
|
|
1129
|
+
// file markers where the marker IS the config file (.clinerules) —
|
|
1130
|
+
// mkdir-ing those creates a directory where a file must go (EISDIR).
|
|
1131
|
+
const detectAbsPath = path.join(workspaceRoot, detector.detectPath);
|
|
1132
|
+
if (!fs.existsSync(detectAbsPath) &&
|
|
1133
|
+
!path.extname(detector.detectPath) &&
|
|
1134
|
+
detector.detectPath !== detector.configFile) {
|
|
1135
|
+
fs.mkdirSync(detectAbsPath, { recursive: true });
|
|
1050
1136
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
}
|
|
1057
|
-
const alreadyExists = fs.existsSync(configFilePath);
|
|
1058
|
-
const content = generateAgentConfig(detector.templateName, {
|
|
1059
|
-
workspaceRoot,
|
|
1060
|
-
binaryPath,
|
|
1061
|
-
configFile: detector.configFile,
|
|
1062
|
-
version,
|
|
1063
|
-
});
|
|
1064
|
-
let action = "skipped";
|
|
1065
|
-
if (!writtenConfigFiles.has(detector.configFile)) {
|
|
1066
|
-
if (detector.templateName === "continue") {
|
|
1067
|
-
const continuePayload = {
|
|
1068
|
-
customCommands: [
|
|
1069
|
-
{
|
|
1070
|
-
name: "vexp-context",
|
|
1071
|
-
description: "Get vexp context for current task",
|
|
1072
|
-
prompt: "{{{ input }}}\n\nFirst, call run_pipeline with the above task description.",
|
|
1073
|
-
},
|
|
1074
|
-
],
|
|
1075
|
-
mcpServers: [
|
|
1076
|
-
{ name: "vexp", command: binaryPath, args: ["mcp"], type: "stdio" },
|
|
1077
|
-
],
|
|
1078
|
-
};
|
|
1079
|
-
const mergeResult = mergeJsonConfig(configFilePath, continuePayload, version);
|
|
1080
|
-
action = mergeResult === "merged" ? "updated" : "skipped";
|
|
1137
|
+
// Create parent directory for configFile (handles nested paths like .kiro/steering/vexp.md)
|
|
1138
|
+
const configFilePath = path.join(workspaceRoot, detector.configFile);
|
|
1139
|
+
const configDir = path.dirname(configFilePath);
|
|
1140
|
+
if (!fs.existsSync(configDir)) {
|
|
1141
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
1081
1142
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1143
|
+
// Create parent directory for MCP config (handles .codex/, .kiro/settings/, etc.)
|
|
1144
|
+
// Not through a link out of the project: the writer leaves that file
|
|
1145
|
+
// alone and says so (projectWriteLanding), and creates nothing there.
|
|
1146
|
+
if (detector.mcpConfigFile) {
|
|
1147
|
+
const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
|
|
1148
|
+
const mcpDir = path.dirname(mcpConfigPath);
|
|
1149
|
+
if (!fs.existsSync(mcpDir) && projectWriteLanding(mcpConfigPath, workspaceRoot).ok) {
|
|
1150
|
+
fs.mkdirSync(mcpDir, { recursive: true });
|
|
1151
|
+
}
|
|
1084
1152
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
const isKiro = detector.agent === "Kiro";
|
|
1090
|
-
// See configureAgents(): Kiro auto-approves under `autoApprove` and is
|
|
1091
|
-
// pointed at the vexp binary (no `node` on macOS GUI PATH) by withholding
|
|
1092
|
-
// mcpServerPath.
|
|
1093
|
-
const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
|
|
1094
|
-
const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
|
|
1095
|
-
const mcpSrv = isKiro ? undefined : mcpServerPath;
|
|
1096
|
-
if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey))
|
|
1097
|
-
mcpConfigs.push(detector.mcpConfigFile);
|
|
1098
|
-
}
|
|
1099
|
-
// Windsurf / Devin Desktop: see configureAgents().
|
|
1100
|
-
if (detector.agent === "Windsurf") {
|
|
1101
|
-
if (configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot))
|
|
1102
|
-
mcpConfigs.push("~/.codeium/windsurf/mcp_config.json");
|
|
1103
|
-
if (configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot))
|
|
1104
|
-
mcpConfigs.push("~/.codeium/windsurf-next/mcp_config.json");
|
|
1105
|
-
if (writeMcpConfig(path.join(workspaceRoot, ".devin", "mcp_config.json"), binaryPath, undefined, mcpServerPath, workspaceRoot))
|
|
1106
|
-
mcpConfigs.push(".devin/mcp_config.json");
|
|
1107
|
-
removeDeadWindsurfMcpJson(workspaceRoot);
|
|
1108
|
-
}
|
|
1109
|
-
if (detector.agent === "Claude Code") {
|
|
1110
|
-
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
|
|
1111
|
-
if (wrote)
|
|
1112
|
-
mcpConfigs.push("~/.claude.json");
|
|
1113
|
-
installClaudeCodeHintHook(workspaceRoot, binaryPath);
|
|
1114
|
-
installClaudeCodeSubagentHook(workspaceRoot, binaryPath);
|
|
1115
|
-
if (interventionMode() === "on") {
|
|
1116
|
-
installClaudeCodeReadHint(workspaceRoot, binaryPath);
|
|
1117
|
-
installClaudeCodeBashCap(workspaceRoot, binaryPath);
|
|
1153
|
+
// Same sweep as the other writer: the Windsurf rules moved out of
|
|
1154
|
+
// `.windsurf/rules.md`, which Cascade never read.
|
|
1155
|
+
if (detector.agent === "Windsurf") {
|
|
1156
|
+
removeVexpSection(path.join(workspaceRoot, ".windsurf", "rules.md"), "Windsurf");
|
|
1118
1157
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1158
|
+
const alreadyExists = fs.existsSync(configFilePath);
|
|
1159
|
+
const content = generateAgentConfig(detector.templateName, {
|
|
1160
|
+
workspaceRoot,
|
|
1161
|
+
binaryPath,
|
|
1162
|
+
configFile: detector.configFile,
|
|
1163
|
+
version,
|
|
1164
|
+
});
|
|
1165
|
+
let action = "skipped";
|
|
1166
|
+
if (!writtenConfigFiles.has(detector.configFile)) {
|
|
1167
|
+
if (detector.templateName === "continue") {
|
|
1168
|
+
const continuePayload = {
|
|
1169
|
+
customCommands: [
|
|
1170
|
+
{
|
|
1171
|
+
name: "vexp-context",
|
|
1172
|
+
description: "Get vexp context for current task",
|
|
1173
|
+
prompt: "{{{ input }}}\n\nFirst, call run_pipeline with the above task description.",
|
|
1174
|
+
},
|
|
1175
|
+
],
|
|
1176
|
+
mcpServers: [
|
|
1177
|
+
{ name: "vexp", command: binaryPath, args: ["mcp"], type: "stdio" },
|
|
1178
|
+
],
|
|
1179
|
+
};
|
|
1180
|
+
const mergeResult = mergeJsonConfig(configFilePath, continuePayload, version);
|
|
1181
|
+
action = mergeResult === "merged" ? "updated" : "skipped";
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
action = appendOrCreate(configFilePath, content, version);
|
|
1185
|
+
}
|
|
1186
|
+
writtenConfigFiles.add(detector.configFile);
|
|
1121
1187
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
installCodexHintHook(workspaceRoot, binaryPath);
|
|
1134
|
-
}
|
|
1135
|
-
if (detector.agent === "Antigravity") {
|
|
1136
|
-
const wrote = configureAntigravityGlobal(binaryPath, mcpServerPath);
|
|
1137
|
-
if (wrote)
|
|
1138
|
-
mcpConfigs.push("~/.gemini/antigravity/mcp_config.json");
|
|
1139
|
-
}
|
|
1140
|
-
if (detector.agent === "ZCode") {
|
|
1141
|
-
const wrote = configureZcodeMcp(workspaceRoot, binaryPath);
|
|
1142
|
-
if (wrote)
|
|
1143
|
-
mcpConfigs.push(wrote);
|
|
1144
|
-
installZcodeHintHook(binaryPath);
|
|
1145
|
-
}
|
|
1146
|
-
if (detector.agent === "GitHub Copilot") {
|
|
1147
|
-
const mcpPath = path.join(workspaceRoot, ".vscode/mcp.json");
|
|
1148
|
-
if (writeVsCodeMcpConfig(mcpPath, binaryPath, mcpServerPath, workspaceRoot))
|
|
1149
|
-
mcpConfigs.push(".vscode/mcp.json");
|
|
1150
|
-
}
|
|
1151
|
-
if (detector.agent === "Zed") {
|
|
1152
|
-
const zedPath = path.join(workspaceRoot, ".zed/settings.json");
|
|
1153
|
-
if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot))
|
|
1154
|
-
mcpConfigs.push(".zed/settings.json");
|
|
1155
|
-
}
|
|
1156
|
-
if (detector.agent === "Cline") {
|
|
1157
|
-
const wrote = configureClineMcp(workspaceRoot, binaryPath);
|
|
1158
|
-
for (const w of wrote)
|
|
1159
|
-
mcpConfigs.push(w);
|
|
1160
|
-
if (wrote.length === 0)
|
|
1161
|
-
noteClineMcpUnreachable();
|
|
1162
|
-
}
|
|
1163
|
-
if (detector.agent === "Opencode") {
|
|
1164
|
-
const wrote = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
1165
|
-
if (wrote)
|
|
1166
|
-
mcpConfigs.push(wrote);
|
|
1167
|
-
installOpencodeHintPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
|
|
1168
|
-
installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
|
|
1169
|
-
if (guardMode() === "strict")
|
|
1170
|
-
installOpencodePlugin(workspaceRoot);
|
|
1171
|
-
else
|
|
1172
|
-
uninstallOpencodePlugin(workspaceRoot);
|
|
1173
|
-
}
|
|
1174
|
-
if (detector.agent === "Cursor") {
|
|
1175
|
-
if (interventionMode() === "on") {
|
|
1176
|
-
installCursorReadHint(workspaceRoot, binaryPath);
|
|
1177
|
-
installCursorBashCap(workspaceRoot, binaryPath);
|
|
1188
|
+
if (detector.mcpConfigFile) {
|
|
1189
|
+
const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
|
|
1190
|
+
const isKiro = detector.agent === "Kiro";
|
|
1191
|
+
// See configureAgents(): Kiro auto-approves under `autoApprove` and is
|
|
1192
|
+
// pointed at the vexp binary (no `node` on macOS GUI PATH) by withholding
|
|
1193
|
+
// mcpServerPath.
|
|
1194
|
+
const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
|
|
1195
|
+
const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
|
|
1196
|
+
const mcpSrv = isKiro ? undefined : mcpServerPath;
|
|
1197
|
+
if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey))
|
|
1198
|
+
mcpConfigs.push(detector.mcpConfigFile);
|
|
1178
1199
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1200
|
+
// Windsurf / Devin Desktop: see configureAgents().
|
|
1201
|
+
if (detector.agent === "Windsurf") {
|
|
1202
|
+
if (configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot))
|
|
1203
|
+
mcpConfigs.push("~/.codeium/windsurf/mcp_config.json");
|
|
1204
|
+
if (configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot))
|
|
1205
|
+
mcpConfigs.push("~/.codeium/windsurf-next/mcp_config.json");
|
|
1206
|
+
if (writeMcpConfig(path.join(workspaceRoot, ".devin", "mcp_config.json"), binaryPath, undefined, mcpServerPath, workspaceRoot))
|
|
1207
|
+
mcpConfigs.push(".devin/mcp_config.json");
|
|
1208
|
+
removeDeadWindsurfMcpJson(workspaceRoot);
|
|
1209
|
+
}
|
|
1210
|
+
if (detector.agent === "Claude Code") {
|
|
1211
|
+
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
|
|
1212
|
+
if (wrote)
|
|
1213
|
+
mcpConfigs.push("~/.claude.json");
|
|
1214
|
+
installClaudeCodeHintHook(workspaceRoot, binaryPath);
|
|
1215
|
+
installClaudeCodeSubagentHook(workspaceRoot, binaryPath);
|
|
1216
|
+
if (interventionMode() === "on") {
|
|
1217
|
+
installClaudeCodeReadHint(workspaceRoot, binaryPath);
|
|
1218
|
+
installClaudeCodeBashCap(workspaceRoot, binaryPath);
|
|
1219
|
+
}
|
|
1220
|
+
else {
|
|
1221
|
+
removeClaudeCodePreToolHooks(workspaceRoot);
|
|
1222
|
+
}
|
|
1223
|
+
installClaudeCodeStopGate(workspaceRoot, binaryPath);
|
|
1224
|
+
installClaudeCodeSessionContext(workspaceRoot, binaryPath);
|
|
1225
|
+
if (guardMode() === "strict")
|
|
1226
|
+
installClaudeCodeHook(workspaceRoot);
|
|
1227
|
+
else
|
|
1228
|
+
uninstallClaudeCodeHook(workspaceRoot);
|
|
1229
|
+
}
|
|
1230
|
+
if (detector.agent === "Codex") {
|
|
1231
|
+
const wrote = configureCodexGlobal(binaryPath, mcpServerPath, workspaceRoot);
|
|
1232
|
+
if (wrote)
|
|
1233
|
+
mcpConfigs.push("~/.codex/config.toml");
|
|
1234
|
+
installCodexHintHook(workspaceRoot, binaryPath);
|
|
1235
|
+
}
|
|
1236
|
+
if (detector.agent === "Antigravity") {
|
|
1237
|
+
if (configureAntigravityProject(workspaceRoot, binaryPath))
|
|
1238
|
+
mcpConfigs.push(ANTIGRAVITY_PROJECT_MCP);
|
|
1239
|
+
}
|
|
1240
|
+
if (detector.agent === "ZCode") {
|
|
1241
|
+
const wrote = configureZcodeMcp(workspaceRoot, binaryPath);
|
|
1242
|
+
if (wrote)
|
|
1243
|
+
mcpConfigs.push(wrote);
|
|
1244
|
+
installZcodeHintHook(binaryPath);
|
|
1245
|
+
}
|
|
1246
|
+
if (detector.agent === "GitHub Copilot") {
|
|
1247
|
+
mcpConfigs.push(...configureCopilotMcp(workspaceRoot, binaryPath, mcpServerPath));
|
|
1248
|
+
}
|
|
1249
|
+
if (detector.agent === "Zed") {
|
|
1250
|
+
const zedPath = path.join(workspaceRoot, ".zed/settings.json");
|
|
1251
|
+
if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot))
|
|
1252
|
+
mcpConfigs.push(".zed/settings.json");
|
|
1253
|
+
}
|
|
1254
|
+
if (detector.agent === "Cline") {
|
|
1255
|
+
const wrote = configureClineMcp(workspaceRoot, binaryPath);
|
|
1256
|
+
for (const w of wrote)
|
|
1257
|
+
mcpConfigs.push(w);
|
|
1258
|
+
if (wrote.length === 0 && !clineInstalled())
|
|
1259
|
+
noteClineMcpUnreachable();
|
|
1260
|
+
}
|
|
1261
|
+
if (detector.agent === "Opencode") {
|
|
1262
|
+
const wrote = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
1263
|
+
if (wrote)
|
|
1264
|
+
mcpConfigs.push(wrote);
|
|
1265
|
+
installOpencodeHintPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
|
|
1266
|
+
installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
|
|
1267
|
+
if (guardMode() === "strict")
|
|
1268
|
+
installOpencodePlugin(workspaceRoot);
|
|
1269
|
+
else
|
|
1270
|
+
uninstallOpencodePlugin(workspaceRoot);
|
|
1271
|
+
}
|
|
1272
|
+
if (detector.agent === "Cursor") {
|
|
1273
|
+
if (interventionMode() === "on") {
|
|
1274
|
+
installCursorReadHint(workspaceRoot, binaryPath);
|
|
1275
|
+
installCursorBashCap(workspaceRoot, binaryPath);
|
|
1276
|
+
}
|
|
1277
|
+
if (guardMode() === "strict")
|
|
1278
|
+
installCursorHook(workspaceRoot);
|
|
1279
|
+
else
|
|
1280
|
+
uninstallCursorHook(workspaceRoot);
|
|
1281
|
+
}
|
|
1282
|
+
// Kilo carries no `mcpConfigFile` (its MCP lives under the `mcp` key inside
|
|
1283
|
+
// kilo.jsonc), so without this branch the generic writer above skips it and
|
|
1284
|
+
// picking Kilo explicitly used to write the rules file and NOTHING else — an
|
|
1285
|
+
// agent told "always call run_pipeline" while the tool was never registered.
|
|
1286
|
+
if (detector.agent === "Kilo Code") {
|
|
1287
|
+
const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
1288
|
+
if (wrote)
|
|
1289
|
+
mcpConfigs.push(wrote);
|
|
1290
|
+
installOpencodeHintPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
|
|
1291
|
+
installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
|
|
1292
|
+
if (guardMode() === "strict")
|
|
1293
|
+
installKiloPlugin(workspaceRoot);
|
|
1294
|
+
else
|
|
1295
|
+
uninstallKiloPlugin(workspaceRoot);
|
|
1296
|
+
}
|
|
1297
|
+
results.push({ agent: detector.agent, configFile: detector.configFile, content, alreadyExists, action });
|
|
1183
1298
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
// picking Kilo explicitly used to write the rules file and NOTHING else — an
|
|
1187
|
-
// agent told "always call run_pipeline" while the tool was never registered.
|
|
1188
|
-
if (detector.agent === "Kilo Code") {
|
|
1189
|
-
const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
1190
|
-
if (wrote)
|
|
1191
|
-
mcpConfigs.push(wrote);
|
|
1192
|
-
installOpencodeHintPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
|
|
1193
|
-
installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
|
|
1194
|
-
if (guardMode() === "strict")
|
|
1195
|
-
installKiloPlugin(workspaceRoot);
|
|
1196
|
-
else
|
|
1197
|
-
uninstallKiloPlugin(workspaceRoot);
|
|
1299
|
+
catch (err) {
|
|
1300
|
+
noteAgentSetupStopped(detector.agent, err);
|
|
1198
1301
|
}
|
|
1199
|
-
results.push({ agent: detector.agent, configFile: detector.configFile, content, alreadyExists, action });
|
|
1200
1302
|
}
|
|
1303
|
+
removeDeadAntigravityEntriesAtSetup();
|
|
1201
1304
|
return { agents: results, mcpConfigs };
|
|
1202
1305
|
}
|
|
1203
1306
|
// ---------------------------------------------------------------------------
|
|
@@ -1263,8 +1366,42 @@ export function parseJsonc(text) {
|
|
|
1263
1366
|
}
|
|
1264
1367
|
out += c;
|
|
1265
1368
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1369
|
+
return JSON.parse(stripTrailingCommas(out));
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Drop each `,` that only whitespace separates from a closing `}` or `]`,
|
|
1373
|
+
* outside string literals: a `,]` inside another server's argument is the
|
|
1374
|
+
* user's text and stays (a regex over the whole text took it out, and the
|
|
1375
|
+
* rewrite saved the changed string). Comments are gone by now. Lockstep
|
|
1376
|
+
* with the CLI/extension twin and with vexp-core's strip_trailing_commas.
|
|
1377
|
+
*/
|
|
1378
|
+
function stripTrailingCommas(text) {
|
|
1379
|
+
let out = "";
|
|
1380
|
+
let inString = false;
|
|
1381
|
+
for (let i = 0; i < text.length; i++) {
|
|
1382
|
+
const c = text[i];
|
|
1383
|
+
if (inString) {
|
|
1384
|
+
out += c;
|
|
1385
|
+
if (c === "\\") {
|
|
1386
|
+
out += text[i + 1] ?? "";
|
|
1387
|
+
i++;
|
|
1388
|
+
}
|
|
1389
|
+
else if (c === '"')
|
|
1390
|
+
inString = false;
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
if (c === '"')
|
|
1394
|
+
inString = true;
|
|
1395
|
+
else if (c === ",") {
|
|
1396
|
+
let j = i + 1;
|
|
1397
|
+
while (j < text.length && /\s/.test(text[j]))
|
|
1398
|
+
j++;
|
|
1399
|
+
if (text[j] === "}" || text[j] === "]")
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
out += c;
|
|
1403
|
+
}
|
|
1404
|
+
return out;
|
|
1268
1405
|
}
|
|
1269
1406
|
/**
|
|
1270
1407
|
* Read a JSON/JSONC config file without ever risking user data. When the file
|
|
@@ -1283,13 +1420,24 @@ export function readJsonConfigSafe(filePath) {
|
|
|
1283
1420
|
}
|
|
1284
1421
|
if (raw.trim() === "")
|
|
1285
1422
|
return { data: {}, ok: true, existed: true };
|
|
1423
|
+
// Strict JSON first, and the JSONC walk only for a file that needs it:
|
|
1424
|
+
// what JSON.parse reads is what the file says, and a JSONC pass may only
|
|
1425
|
+
// ever widen that. Lockstep with the extension's readJsonConfigSafe.
|
|
1426
|
+
let parsed;
|
|
1286
1427
|
try {
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1428
|
+
parsed = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
try {
|
|
1432
|
+
parsed = parseJsonc(raw);
|
|
1433
|
+
}
|
|
1434
|
+
catch {
|
|
1435
|
+
return { data: {}, ok: false, existed: true };
|
|
1290
1436
|
}
|
|
1291
1437
|
}
|
|
1292
|
-
|
|
1438
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1439
|
+
return { data: parsed, ok: true, existed: true };
|
|
1440
|
+
}
|
|
1293
1441
|
return { data: {}, ok: false, existed: true };
|
|
1294
1442
|
}
|
|
1295
1443
|
/**
|
|
@@ -1665,8 +1813,10 @@ function backupConfig(filePath) {
|
|
|
1665
1813
|
// Older versions left the copy next to the file; inside a workspace that
|
|
1666
1814
|
// sibling is exactly what git shows as untracked. Retire it now that the
|
|
1667
1815
|
// fresh copy lives in .vexp/backups/.
|
|
1816
|
+
// One committed to the repository stays (committedCleanupBlocked's rule;
|
|
1817
|
+
// no agent to name for a backup).
|
|
1668
1818
|
const legacy = path.resolve(`${filePath}.vexp-bak`);
|
|
1669
|
-
if (legacy !== dest && fs.existsSync(legacy))
|
|
1819
|
+
if (legacy !== dest && fs.existsSync(legacy) && !isGitTracked(legacy))
|
|
1670
1820
|
fs.unlinkSync(legacy);
|
|
1671
1821
|
}
|
|
1672
1822
|
catch { /* non-fatal */ }
|
|
@@ -1679,10 +1829,47 @@ function backupConfig(filePath) {
|
|
|
1679
1829
|
* global config files where cross-process races are possible.
|
|
1680
1830
|
*/
|
|
1681
1831
|
function atomicWriteFileSync(filePath, content) {
|
|
1682
|
-
|
|
1832
|
+
// A path that is a link to a file is written where the link leads: the
|
|
1833
|
+
// temp file goes next to the real target and is renamed onto it, so the
|
|
1834
|
+
// link stays and its target gets the new content, with the permissions it
|
|
1835
|
+
// had. Renamed over the path itself, the link was replaced by a regular
|
|
1836
|
+
// file, and a dotfile manager's repository (stow, chezmoi and home-manager
|
|
1837
|
+
// link ~/.claude.json, ~/.codex/config.toml and the like into it) silently
|
|
1838
|
+
// stopped receiving the user's updates. A link to nothing is replaced by
|
|
1839
|
+
// the new regular file, as before: there is no target to keep, and
|
|
1840
|
+
// creating one wherever the link points is not vexp's to do.
|
|
1841
|
+
//
|
|
1842
|
+
// A link whose target cannot be written is replaced too, as up to 3.3.9:
|
|
1843
|
+
// home-manager links ~/.codex/config.toml into the read-only Nix store,
|
|
1844
|
+
// so the write there fails (EACCES, EROFS) where the rename over the link
|
|
1845
|
+
// worked, and the agent lost its setup (review, 2026-11-07, round 5). The
|
|
1846
|
+
// new file takes the target's permissions with the owner's write bit, so
|
|
1847
|
+
// it is never more open than the target was. Lockstep with the
|
|
1848
|
+
// extension's atomicWriteFileSync.
|
|
1849
|
+
const linked = linkedFileTarget(filePath);
|
|
1850
|
+
let mode;
|
|
1851
|
+
if (linked) {
|
|
1852
|
+
try {
|
|
1853
|
+
// The temp file is created with the default mode; the link's target
|
|
1854
|
+
// keeps the one it had (a 0600 file stays private).
|
|
1855
|
+
mode = fs.statSync(linked).mode & 0o7777;
|
|
1856
|
+
writeThroughTemp(linked, content, mode);
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
catch {
|
|
1860
|
+
/* the target cannot be written: the link is replaced, below */
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
writeThroughTemp(filePath, content, mode === undefined ? undefined : mode | 0o200);
|
|
1864
|
+
}
|
|
1865
|
+
/** Temp file beside `target`, renamed onto it; a direct write when the rename cannot be done. */
|
|
1866
|
+
function writeThroughTemp(target, content, mode) {
|
|
1867
|
+
const tmp = `${target}.vexp-tmp-${process.pid}`;
|
|
1683
1868
|
try {
|
|
1684
1869
|
fs.writeFileSync(tmp, content, "utf-8");
|
|
1685
|
-
|
|
1870
|
+
if (mode !== undefined)
|
|
1871
|
+
fs.chmodSync(tmp, mode);
|
|
1872
|
+
fs.renameSync(tmp, target);
|
|
1686
1873
|
}
|
|
1687
1874
|
catch {
|
|
1688
1875
|
// Cross-device rename or a Windows edge — fall back to a direct write.
|
|
@@ -1690,7 +1877,24 @@ function atomicWriteFileSync(filePath, content) {
|
|
|
1690
1877
|
fs.unlinkSync(tmp);
|
|
1691
1878
|
}
|
|
1692
1879
|
catch { /* ignore */ }
|
|
1693
|
-
fs.writeFileSync(
|
|
1880
|
+
fs.writeFileSync(target, content, "utf-8");
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* The real path of the file `p` leads to when `p` is a link (or a chain of
|
|
1885
|
+
* links) to a file; undefined when `p` is no link, a link to nothing, or a
|
|
1886
|
+
* link to something other than a file, which atomicWriteFileSync replaces
|
|
1887
|
+
* as it always did. Lockstep with the extension's linkedFileTarget.
|
|
1888
|
+
*/
|
|
1889
|
+
function linkedFileTarget(p) {
|
|
1890
|
+
try {
|
|
1891
|
+
if (!fs.lstatSync(p).isSymbolicLink())
|
|
1892
|
+
return undefined;
|
|
1893
|
+
const real = fs.realpathSync(p);
|
|
1894
|
+
return fs.statSync(real).isFile() ? real : undefined;
|
|
1895
|
+
}
|
|
1896
|
+
catch {
|
|
1897
|
+
return undefined;
|
|
1694
1898
|
}
|
|
1695
1899
|
}
|
|
1696
1900
|
/** Warn that we refused to touch an unparseable config file. */
|
|
@@ -1769,6 +1973,223 @@ function warnUnparseable(filePath) {
|
|
|
1769
1973
|
}
|
|
1770
1974
|
process.stderr.write(` [!] ${filePath} could not be parsed - leaving it untouched. Fix the file or add vexp manually, then re-run setup.\n`);
|
|
1771
1975
|
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Is the key an MCP writer puts vexp's entry under there, and not an object
|
|
1978
|
+
* (an array, a string, a number, null)? vexp's entry cannot go in without
|
|
1979
|
+
* replacing the user's value: an array took it as a property that
|
|
1980
|
+
* JSON.stringify drops, so the file was rewritten without it and setup
|
|
1981
|
+
* reported an entry it had not written; a string threw; null was replaced.
|
|
1982
|
+
* Every JSON MCP writer leaves such a file byte for byte and says so
|
|
1983
|
+
* (leftUntouched). Lockstep with the extension and vexp-core.
|
|
1984
|
+
*/
|
|
1985
|
+
function containerMisshapen(data, key) {
|
|
1986
|
+
if (!Object.prototype.hasOwnProperty.call(data, key))
|
|
1987
|
+
return false;
|
|
1988
|
+
return !asPlainObject(data[key]);
|
|
1989
|
+
}
|
|
1990
|
+
/** A JSON object, not an array or null. */
|
|
1991
|
+
function asPlainObject(v) {
|
|
1992
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
1993
|
+
}
|
|
1994
|
+
/**
|
|
1995
|
+
* Report a file a writer left untouched because the key vexp's entry goes
|
|
1996
|
+
* under is not an object (containerMisshapen), at the end of setup
|
|
1997
|
+
* (takeUnreachableTargets), in the words the extension's setup report
|
|
1998
|
+
* shows. `key` is the path of the key in the file (`mcp.servers`). Returns
|
|
1999
|
+
* false: nothing was written.
|
|
2000
|
+
*/
|
|
2001
|
+
function leftUntouched(filePath, key, shape = "an object") {
|
|
2002
|
+
const msg = `${filePath} has an ${key} that is not ${shape} - left untouched`;
|
|
2003
|
+
if (!unreachableTargets.includes(msg))
|
|
2004
|
+
unreachableTargets.push(msg);
|
|
2005
|
+
return false;
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Where a path really is: its real path when it is there, else the real
|
|
2009
|
+
* path of its nearest ancestor that is there, joined with the rest. A write
|
|
2010
|
+
* lands there, since writeFileSync and mkdirSync follow every link on the
|
|
2011
|
+
* way. undefined when the way goes through a link to nothing (or a loop):
|
|
2012
|
+
* a write there would create the link's target, wherever that is.
|
|
2013
|
+
* Lockstep with the extension's landingOf.
|
|
2014
|
+
*/
|
|
2015
|
+
function landingOf(p) {
|
|
2016
|
+
// On macOS the system's realpath(3) (fs.realpathSync.native): its file
|
|
2017
|
+
// systems are case-insensitive and normalization-insensitive by default,
|
|
2018
|
+
// and realpath(3) returns each name as it is on disk, while the JS walk
|
|
2019
|
+
// keeps a link's target as written. Compared byte for byte, a link that
|
|
2020
|
+
// stays in the project but spells its folder in another letter case (or
|
|
2021
|
+
// the other Unicode form) read as leading out of it (review, 2026-11-07,
|
|
2022
|
+
// round 4); vexp-core's canonicalize is realpath(3) too. Elsewhere the JS
|
|
2023
|
+
// walk, as before: Linux file systems are case-sensitive, and on Windows
|
|
2024
|
+
// path.relative folds case. A native call that fails leaves it to the JS
|
|
2025
|
+
// walk, which throws the same for a link to nothing.
|
|
2026
|
+
const realpath = (q) => {
|
|
2027
|
+
if (process.platform === "darwin") {
|
|
2028
|
+
try {
|
|
2029
|
+
return fs.realpathSync.native(q);
|
|
2030
|
+
}
|
|
2031
|
+
catch {
|
|
2032
|
+
/* the JS walk decides */
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return fs.realpathSync(q);
|
|
2036
|
+
};
|
|
2037
|
+
const rest = [];
|
|
2038
|
+
let at = path.resolve(p);
|
|
2039
|
+
for (;;) {
|
|
2040
|
+
let st;
|
|
2041
|
+
try {
|
|
2042
|
+
st = fs.lstatSync(at);
|
|
2043
|
+
}
|
|
2044
|
+
catch {
|
|
2045
|
+
st = undefined; // not there: its parent decides
|
|
2046
|
+
}
|
|
2047
|
+
if (st) {
|
|
2048
|
+
try {
|
|
2049
|
+
return path.join(realpath(at), ...rest);
|
|
2050
|
+
}
|
|
2051
|
+
catch {
|
|
2052
|
+
// lstat found it, so only a link can fail to resolve; anything
|
|
2053
|
+
// else (a folder that cannot be read) is compared as written.
|
|
2054
|
+
return st.isSymbolicLink() ? undefined : path.join(at, ...rest);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
const parent = path.dirname(at);
|
|
2058
|
+
if (parent === at)
|
|
2059
|
+
return path.join(at, ...rest);
|
|
2060
|
+
rest.unshift(path.basename(at));
|
|
2061
|
+
at = parent;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Where a write to `file`, a file inside `workspaceRoot`, would land
|
|
2066
|
+
* (landingOf), checked against the root's own real path. Inside: ok, and
|
|
2067
|
+
* the writer writes as before, through an inner link, which stays a link
|
|
2068
|
+
* (a repository that keeps one MCP file and links it where each tool reads
|
|
2069
|
+
* it). Outside the project, through a link of the file or of a folder on
|
|
2070
|
+
* its way: refused. Through a link to nothing: refused, since a write
|
|
2071
|
+
* would create its target. Reads nothing but the file system's links.
|
|
2072
|
+
* ASCII texts, word for word the extension's (projectWriteLanding) and
|
|
2073
|
+
* vexp-core's.
|
|
2074
|
+
*/
|
|
2075
|
+
export function projectWriteLanding(file, workspaceRoot) {
|
|
2076
|
+
const landing = landingOf(file);
|
|
2077
|
+
if (landing === undefined)
|
|
2078
|
+
return { ok: false, refuse: `${file} is a link to a file that does not exist - left untouched` };
|
|
2079
|
+
const root = landingOf(workspaceRoot) ?? path.resolve(workspaceRoot);
|
|
2080
|
+
// path.relative folds letter case on Windows, as the file system does.
|
|
2081
|
+
const rel = path.relative(root, landing);
|
|
2082
|
+
if (rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel))
|
|
2083
|
+
return { ok: true, landing };
|
|
2084
|
+
return { ok: false, refuse: `${file} leads outside this project through a link (${landing}) - left untouched` };
|
|
2085
|
+
}
|
|
2086
|
+
export function projectFileLink(file, workspaceRoot) {
|
|
2087
|
+
if (projectWriteLanding(file, workspaceRoot).ok)
|
|
2088
|
+
return undefined;
|
|
2089
|
+
const landing = landingOf(file);
|
|
2090
|
+
return landing === undefined ? { dangling: true } : { outside: landing };
|
|
2091
|
+
}
|
|
2092
|
+
/**
|
|
2093
|
+
* Is `file` a project file a pinned writer must check first: a root was
|
|
2094
|
+
* given (the entry is pinned to it) and the file is inside it as spelled?
|
|
2095
|
+
* A writer handed a file elsewhere writes it as it did before.
|
|
2096
|
+
*/
|
|
2097
|
+
function pinnedProjectFile(file, workspaceRoot) {
|
|
2098
|
+
if (!workspaceRoot)
|
|
2099
|
+
return false;
|
|
2100
|
+
const rel = path.relative(path.resolve(workspaceRoot), path.resolve(file));
|
|
2101
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* True when the write to `file` must not happen (projectWriteLanding); the
|
|
2105
|
+
* reason goes to the end of setup (takeUnreachableTargets). Called at the
|
|
2106
|
+
* top of each pinned project writer, before it reads the file.
|
|
2107
|
+
*/
|
|
2108
|
+
function projectWriteRefused(file, workspaceRoot) {
|
|
2109
|
+
const l = projectWriteLanding(file, workspaceRoot);
|
|
2110
|
+
if (l.ok)
|
|
2111
|
+
return false;
|
|
2112
|
+
if (!unreachableTargets.includes(l.refuse))
|
|
2113
|
+
unreachableTargets.push(l.refuse);
|
|
2114
|
+
return true;
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* The first link to nothing among the folders of `rel` under `root`,
|
|
2118
|
+
* walking down from the root, as an absolute path; undefined when there is
|
|
2119
|
+
* none. A folder that is not there ends the walk: nothing under it is
|
|
2120
|
+
* there either. A loop counts as a link to nothing, as in landingOf.
|
|
2121
|
+
* Lockstep with the extension.
|
|
2122
|
+
*/
|
|
2123
|
+
function danglingFolderOnWay(root, rel) {
|
|
2124
|
+
let at = path.resolve(root);
|
|
2125
|
+
for (const part of rel.split(/[\\/]/)) {
|
|
2126
|
+
if (part === "" || part === ".")
|
|
2127
|
+
continue;
|
|
2128
|
+
at = path.join(at, part);
|
|
2129
|
+
let st;
|
|
2130
|
+
try {
|
|
2131
|
+
st = fs.lstatSync(at);
|
|
2132
|
+
}
|
|
2133
|
+
catch {
|
|
2134
|
+
return undefined;
|
|
2135
|
+
}
|
|
2136
|
+
if (!st.isSymbolicLink())
|
|
2137
|
+
continue;
|
|
2138
|
+
try {
|
|
2139
|
+
fs.statSync(at);
|
|
2140
|
+
}
|
|
2141
|
+
catch {
|
|
2142
|
+
return at;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
return undefined;
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* The agent's folder in the workspace (`.cursor`), or a folder on the way
|
|
2149
|
+
* to its instruction file or its project MCP file, that is a link to
|
|
2150
|
+
* nothing: a dotfile manager's link whose checkout is not on this machine,
|
|
2151
|
+
* a folder moved away. mkdirSync throws ENOENT there even with
|
|
2152
|
+
* `recursive`, and so does a write under it, and the exception left the
|
|
2153
|
+
* setup loop: `vexp setup` failed for every agent selected, not only for
|
|
2154
|
+
* the one whose folder it was (bug at 3.3.9). Setup skips that agent,
|
|
2155
|
+
* writes nothing for it and creates nothing, the link's target included:
|
|
2156
|
+
* where it leads is the user's. Lockstep with the extension's
|
|
2157
|
+
* danglingAgentFolder and vexp-core's dangling_folder_on_way.
|
|
2158
|
+
*/
|
|
2159
|
+
export function danglingAgentFolder(d, workspaceRoot) {
|
|
2160
|
+
// A folder marker is the agent's folder; a file marker's folder is the
|
|
2161
|
+
// one it sits in (.codex for .codex/config.toml, the root for AGENTS.md).
|
|
2162
|
+
const folderMarker = !path.extname(d.detectPath) && d.detectPath !== d.configFile;
|
|
2163
|
+
const rels = [folderMarker ? d.detectPath : path.dirname(d.detectPath), path.dirname(d.configFile)];
|
|
2164
|
+
if (d.mcpConfigFile)
|
|
2165
|
+
rels.push(path.dirname(d.mcpConfigFile));
|
|
2166
|
+
for (const rel of rels) {
|
|
2167
|
+
const dangling = danglingFolderOnWay(workspaceRoot, rel);
|
|
2168
|
+
if (dangling)
|
|
2169
|
+
return dangling;
|
|
2170
|
+
}
|
|
2171
|
+
return undefined;
|
|
2172
|
+
}
|
|
2173
|
+
/** The words for a folder danglingAgentFolder found: the extension's setup report's, word for word. ASCII only. */
|
|
2174
|
+
export function danglingFolderLine(folder) {
|
|
2175
|
+
return `${folder} is a link to a folder that does not exist - left untouched`;
|
|
2176
|
+
}
|
|
2177
|
+
/** Say at the end of setup (takeUnreachableTargets) that an agent was skipped over a link to nothing. */
|
|
2178
|
+
function noteDanglingAgentFolder(folder) {
|
|
2179
|
+
const msg = danglingFolderLine(folder);
|
|
2180
|
+
if (!unreachableTargets.includes(msg))
|
|
2181
|
+
unreachableTargets.push(msg);
|
|
2182
|
+
}
|
|
2183
|
+
/** The words for an agent whose setup threw part way: the extension's, word for word. */
|
|
2184
|
+
export function agentSetupStopped(agent, err) {
|
|
2185
|
+
return `${agent} setup stopped - ${err instanceof Error ? err.message : String(err)}`;
|
|
2186
|
+
}
|
|
2187
|
+
/** Say at the end of setup (takeUnreachableTargets) that one agent's setup threw part way; the others went on. */
|
|
2188
|
+
function noteAgentSetupStopped(agent, err) {
|
|
2189
|
+
const msg = agentSetupStopped(agent, err);
|
|
2190
|
+
if (!unreachableTargets.includes(msg))
|
|
2191
|
+
unreachableTargets.push(msg);
|
|
2192
|
+
}
|
|
1772
2193
|
/**
|
|
1773
2194
|
* Remove a vexp section from a file we no longer write to, and delete the file
|
|
1774
2195
|
* when nothing of the user's is left in it.
|
|
@@ -1779,7 +2200,7 @@ function warnUnparseable(filePath) {
|
|
|
1779
2200
|
* Leaving the old file behind would put a stale, unread copy of our
|
|
1780
2201
|
* instructions next to the live one.
|
|
1781
2202
|
*/
|
|
1782
|
-
export function removeVexpSection(filePath) {
|
|
2203
|
+
export function removeVexpSection(filePath, agent) {
|
|
1783
2204
|
if (!fs.existsSync(filePath))
|
|
1784
2205
|
return "absent";
|
|
1785
2206
|
let existing;
|
|
@@ -1793,6 +2214,10 @@ export function removeVexpSection(filePath) {
|
|
|
1793
2214
|
const endIdx = existing.indexOf(VEXP_MARKER_END);
|
|
1794
2215
|
if (!marker || endIdx === -1)
|
|
1795
2216
|
return "absent";
|
|
2217
|
+
// A cleanup for `agent` leaves a file committed to the repository alone
|
|
2218
|
+
// (committedCleanupBlocked): nothing was removed.
|
|
2219
|
+
if (agent !== undefined && committedCleanupBlocked([filePath], agent))
|
|
2220
|
+
return "absent";
|
|
1796
2221
|
const markerIdx = existing.indexOf(marker[0]);
|
|
1797
2222
|
let startIdx = markerIdx;
|
|
1798
2223
|
while (startIdx > 0 && existing[startIdx - 1] !== "\n")
|
|
@@ -1840,7 +2265,7 @@ export function cursorRulesTarget(workspaceRoot) {
|
|
|
1840
2265
|
/* absent, or a directory */
|
|
1841
2266
|
}
|
|
1842
2267
|
if (isFile) {
|
|
1843
|
-
removeVexpSection(legacy);
|
|
2268
|
+
removeVexpSection(legacy, "Cursor");
|
|
1844
2269
|
if (fs.existsSync(legacy))
|
|
1845
2270
|
return ".cursorrules";
|
|
1846
2271
|
}
|
|
@@ -1863,12 +2288,26 @@ export function appendOrCreate(filePath, content, version) {
|
|
|
1863
2288
|
fs.writeFileSync(filePath, content, "utf-8");
|
|
1864
2289
|
return "created";
|
|
1865
2290
|
}
|
|
1866
|
-
|
|
2291
|
+
const original = fs.readFileSync(filePath, "utf-8");
|
|
2292
|
+
let existing = original;
|
|
2293
|
+
// A BOM the file starts with stays in front of it (Visual Studio with
|
|
2294
|
+
// charset = utf-8-bom, Windows PowerShell 5.1's Set-Content -Encoding
|
|
2295
|
+
// UTF8). A section on the first line, or the front matter above it, took
|
|
2296
|
+
// the BOM along in the splice, so a rewrite dropped the user's encoding
|
|
2297
|
+
// mark and a committed file showed one more changed line (review,
|
|
2298
|
+
// 2026-11-08). vexp-core's wizard splices after it and always kept it.
|
|
2299
|
+
// Lockstep with the extension.
|
|
2300
|
+
const bom = original.startsWith("") ? "" : "";
|
|
2301
|
+
const keepBom = (text) => (bom && !text.startsWith(bom) ? bom + text : text);
|
|
1867
2302
|
// Front matter has to stay the FIRST thing in the file, and the splice below
|
|
1868
2303
|
// preserves whatever sits above our marker — so a rewrite would leave the
|
|
1869
2304
|
// old block above and insert a second one. One rule file, one front matter.
|
|
2305
|
+
// A BOM or CRLF line ends (a Windows checkout with core.autocrlf, an
|
|
2306
|
+
// editor that saves CRLF) hid the block from an LF-only match, so every
|
|
2307
|
+
// run added one more (review, 2026-11-08). A BOM in front of the block
|
|
2308
|
+
// goes back in front of the file (keepBom). The template itself is LF.
|
|
1870
2309
|
if (content.startsWith("---\n")) {
|
|
1871
|
-
const lead = existing.match(
|
|
2310
|
+
const lead = existing.match(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---\r?\n/);
|
|
1872
2311
|
if (lead) {
|
|
1873
2312
|
existing = existing.slice(lead[0].length).replace(/^\s*\n/, "");
|
|
1874
2313
|
}
|
|
@@ -1888,19 +2327,79 @@ export function appendOrCreate(filePath, content, version) {
|
|
|
1888
2327
|
}
|
|
1889
2328
|
const endIdx = existing.indexOf(VEXP_MARKER_END);
|
|
1890
2329
|
if (endIdx !== -1) {
|
|
1891
|
-
const existingSection = existing.substring(startIdx, endIdx + VEXP_MARKER_END.length);
|
|
1892
|
-
if (existingSection === content)
|
|
1893
|
-
return "skipped"; // truly identical -> no write
|
|
1894
2330
|
const before = existing.substring(0, startIdx);
|
|
1895
2331
|
const after = existing.substring(endIdx + VEXP_MARKER_END.length);
|
|
1896
|
-
|
|
2332
|
+
const next = keepBom(before + content + after);
|
|
2333
|
+
// The same bytes: nothing to write, and not "updated". A rule file with
|
|
2334
|
+
// front matter (Cursor .mdc, Windsurf) never matched a section check,
|
|
2335
|
+
// since the template starts with the front matter and the section with
|
|
2336
|
+
// the marker line, so every run at the same version rewrote it and said
|
|
2337
|
+
// "(updated)", while the same committed file at another version (M1)
|
|
2338
|
+
// said "(up to date)" (review, 2026-11-08). Lockstep with the extension.
|
|
2339
|
+
if (next === original)
|
|
2340
|
+
return "skipped";
|
|
2341
|
+
if (committedMarkerOnly(filePath, original, next))
|
|
2342
|
+
return "skipped";
|
|
2343
|
+
fs.writeFileSync(filePath, next, "utf-8");
|
|
1897
2344
|
}
|
|
1898
2345
|
else {
|
|
1899
|
-
const
|
|
1900
|
-
|
|
2346
|
+
const next = keepBom(existing.substring(0, startIdx) + content);
|
|
2347
|
+
if (committedMarkerOnly(filePath, original, next))
|
|
2348
|
+
return "skipped";
|
|
2349
|
+
fs.writeFileSync(filePath, next, "utf-8");
|
|
1901
2350
|
}
|
|
1902
2351
|
return "updated";
|
|
1903
2352
|
}
|
|
2353
|
+
/**
|
|
2354
|
+
* `text` as git compares it in a checkout with core.autocrlf or eol=crlf:
|
|
2355
|
+
* no leading BOM, LF line ends. The section vexp writes is LF, so in such a
|
|
2356
|
+
* checkout a committed file read as CRLF differed from it in every line,
|
|
2357
|
+
* M1 never applied, and each update moved the marker all the same
|
|
2358
|
+
* (review, 2026-11-08). Only compared, never written. Lockstep with the
|
|
2359
|
+
* extension and vexp-core (as_committed).
|
|
2360
|
+
*/
|
|
2361
|
+
function asCommitted(text) {
|
|
2362
|
+
return text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
2363
|
+
}
|
|
2364
|
+
/** `text` with the version taken out of every vexp marker, as committed (asCommitted): two texts equal under it differ in nothing but the versions their markers name. */
|
|
2365
|
+
function withoutMarkerVersions(text) {
|
|
2366
|
+
return asCommitted(text).replace(/<!-- vexp(?:\s+v[\d.]+)? -->/g, "<!-- vexp -->");
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* M1: is rewriting the instruction file `file` from `original` to `next`
|
|
2370
|
+
* a change of the version in vexp's marker and nothing else, in a file git
|
|
2371
|
+
* tracks? Then the file is left as it is and said once (committedMarkerOnlyLine).
|
|
2372
|
+
* Every update moved `<!-- vexp v... -->` in the committed CLAUDE.md,
|
|
2373
|
+
* AGENTS.md and copilot-instructions.md, so every checkout showed them
|
|
2374
|
+
* modified after an update, and teammates on different versions flipped
|
|
2375
|
+
* them back and forth in their commits (field report, 2026-11-07). Any
|
|
2376
|
+
* other version, older or newer, counts: the text the agents read is the
|
|
2377
|
+
* same. git runs last, only for such a file, so a normal project runs none;
|
|
2378
|
+
* an untracked file is rewritten as before. Lockstep with the extension.
|
|
2379
|
+
*/
|
|
2380
|
+
function committedMarkerOnly(file, original, next) {
|
|
2381
|
+
if (next === original || withoutMarkerVersions(next) !== withoutMarkerVersions(original))
|
|
2382
|
+
return false;
|
|
2383
|
+
if (!isGitTracked(file))
|
|
2384
|
+
return false;
|
|
2385
|
+
// The same version, only a BOM or CRLF line ends differ: this vexp's
|
|
2386
|
+
// section is there already, so the file is left with no word ("differs
|
|
2387
|
+
// only in the version marker" would be untrue).
|
|
2388
|
+
if (asCommitted(next) !== asCommitted(original))
|
|
2389
|
+
noteSetup(committedMarkerOnlyLine(file));
|
|
2390
|
+
return true;
|
|
2391
|
+
}
|
|
2392
|
+
/**
|
|
2393
|
+
* Merge vexp's keys into a JSON config (Continue.dev's config.json): an
|
|
2394
|
+
* object key by key, an array by name. A key the payload fills that is
|
|
2395
|
+
* there in another shape (Continue's mcpServers as an object, a string,
|
|
2396
|
+
* null) leaves the whole file byte for byte and says so (leftUntouched):
|
|
2397
|
+
* vexp's array replaced it, the user's servers were gone, and setup said
|
|
2398
|
+
* "updated". An element whose name is one of vexp's own is replaced where
|
|
2399
|
+
* it stands, and copies of it after the first go: every run appended
|
|
2400
|
+
* another vexp server and another vexp-context command, so the file never
|
|
2401
|
+
* settled (review, 2026-11-07). Lockstep with the extension.
|
|
2402
|
+
*/
|
|
1904
2403
|
export function mergeJsonConfig(filePath, mergePayload, version) {
|
|
1905
2404
|
const read = readJsonConfigSafe(filePath);
|
|
1906
2405
|
if (!read.ok) {
|
|
@@ -1908,6 +2407,11 @@ export function mergeJsonConfig(filePath, mergePayload, version) {
|
|
|
1908
2407
|
return "skipped";
|
|
1909
2408
|
}
|
|
1910
2409
|
const existing = read.data;
|
|
2410
|
+
const misfit = mergeMisfit(existing, mergePayload);
|
|
2411
|
+
if (misfit) {
|
|
2412
|
+
leftUntouched(filePath, misfit.key, misfit.shape);
|
|
2413
|
+
return "skipped";
|
|
2414
|
+
}
|
|
1911
2415
|
// Build the merged result first, then compare to avoid unnecessary writes.
|
|
1912
2416
|
const merged = { ...existing };
|
|
1913
2417
|
for (const [key, value] of Object.entries(mergePayload)) {
|
|
@@ -1918,7 +2422,7 @@ export function mergeJsonConfig(filePath, mergePayload, version) {
|
|
|
1918
2422
|
};
|
|
1919
2423
|
}
|
|
1920
2424
|
else if (Array.isArray(value) && Array.isArray(merged[key])) {
|
|
1921
|
-
merged[key] =
|
|
2425
|
+
merged[key] = mergeNamed(merged[key], value);
|
|
1922
2426
|
}
|
|
1923
2427
|
else {
|
|
1924
2428
|
merged[key] = value;
|
|
@@ -1931,37 +2435,79 @@ export function mergeJsonConfig(filePath, mergePayload, version) {
|
|
|
1931
2435
|
const existingStr = JSON.stringify(existing, null, 2);
|
|
1932
2436
|
if (mergedStr === existingStr)
|
|
1933
2437
|
return "skipped";
|
|
2438
|
+
if (committedSentinelOnly(filePath, existing, merged))
|
|
2439
|
+
return "skipped";
|
|
1934
2440
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1935
2441
|
if (read.existed)
|
|
1936
2442
|
backupConfig(filePath);
|
|
1937
2443
|
fs.writeFileSync(filePath, mergedStr, "utf-8");
|
|
1938
2444
|
return "merged";
|
|
1939
2445
|
}
|
|
1940
|
-
// ---------------------------------------------------------------------------
|
|
1941
|
-
// MCP config writers - adapted for Rust binary path
|
|
1942
|
-
// ---------------------------------------------------------------------------
|
|
1943
|
-
/**
|
|
1944
|
-
* Write MCP config JSON for agents that use mcpServers format (Cursor, Windsurf).
|
|
1945
|
-
* Returns true if a new entry was written.
|
|
1946
|
-
*/
|
|
1947
2446
|
/**
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
*
|
|
1952
|
-
*
|
|
1953
|
-
*
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
*
|
|
2447
|
+
* M1 for Continue's config.json: is the merge a change of `__vexp_version`
|
|
2448
|
+
* and nothing else, in a file git tracks? Then the file is left as it is,
|
|
2449
|
+
* said once in M1's words (committedMarkerOnlyLine): the sentinel is the
|
|
2450
|
+
* marker of a JSON file, and a team that commits .continue/config.json saw
|
|
2451
|
+
* it modified after every update, as with the Markdown files (review,
|
|
2452
|
+
* 2026-11-08). The spread keeps the key where it stands (merged starts as
|
|
2453
|
+
* a copy of existing). git runs last, only for such a merge, so a normal
|
|
2454
|
+
* project runs none; an untracked file is written as before. Lockstep
|
|
2455
|
+
* with the extension.
|
|
1957
2456
|
*/
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
2457
|
+
function committedSentinelOnly(file, existing, merged) {
|
|
2458
|
+
if (typeof existing.__vexp_version !== "string")
|
|
2459
|
+
return false;
|
|
2460
|
+
if (JSON.stringify({ ...merged, __vexp_version: existing.__vexp_version }, null, 2) !== JSON.stringify(existing, null, 2))
|
|
2461
|
+
return false;
|
|
2462
|
+
if (!isGitTracked(file))
|
|
2463
|
+
return false;
|
|
2464
|
+
noteSetup(committedMarkerOnlyLine(file));
|
|
2465
|
+
return true;
|
|
1961
2466
|
}
|
|
1962
|
-
/**
|
|
1963
|
-
|
|
1964
|
-
|
|
2467
|
+
/** The first key of `payload` that `existing` holds in another shape: an array where vexp merges an array, a non-object where an object. */
|
|
2468
|
+
function mergeMisfit(existing, payload) {
|
|
2469
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
2470
|
+
if (!Object.prototype.hasOwnProperty.call(existing, key))
|
|
2471
|
+
continue;
|
|
2472
|
+
if (Array.isArray(value) && !Array.isArray(existing[key]))
|
|
2473
|
+
return { key, shape: "an array" };
|
|
2474
|
+
if (asPlainObject(value) && !asPlainObject(existing[key]))
|
|
2475
|
+
return { key, shape: "an object" };
|
|
2476
|
+
}
|
|
2477
|
+
return undefined;
|
|
2478
|
+
}
|
|
2479
|
+
/** `ours` merged into `theirs` by name: each of ours replaces the first element of that name, later ones of it go, and the rest are appended. */
|
|
2480
|
+
function mergeNamed(theirs, ours) {
|
|
2481
|
+
const nameOf = (v) => {
|
|
2482
|
+
const n = asPlainObject(v) ? v.name : undefined;
|
|
2483
|
+
return typeof n === "string" ? n : undefined;
|
|
2484
|
+
};
|
|
2485
|
+
const out = [...theirs];
|
|
2486
|
+
const tail = [];
|
|
2487
|
+
for (const el of ours) {
|
|
2488
|
+
const name = nameOf(el);
|
|
2489
|
+
const at = name === undefined ? -1 : out.findIndex((x) => nameOf(x) === name);
|
|
2490
|
+
if (at < 0) {
|
|
2491
|
+
tail.push(el);
|
|
2492
|
+
continue;
|
|
2493
|
+
}
|
|
2494
|
+
out[at] = el;
|
|
2495
|
+
for (let k = out.length - 1; k > at; k--)
|
|
2496
|
+
if (nameOf(out[k]) === name)
|
|
2497
|
+
out.splice(k, 1);
|
|
2498
|
+
}
|
|
2499
|
+
return [...out, ...tail];
|
|
2500
|
+
}
|
|
2501
|
+
// ---------------------------------------------------------------------------
|
|
2502
|
+
// MCP config writers - adapted for Rust binary path
|
|
2503
|
+
// ---------------------------------------------------------------------------
|
|
2504
|
+
/** Cascade's one and only MCP config, on the side of the boundary vexp runs on. */
|
|
2505
|
+
export function windsurfGlobalMcpPath() {
|
|
2506
|
+
return path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* Devin Desktop Next — the beta channel, formerly Windsurf Next — is a separate
|
|
2510
|
+
* install with its own Cascade config under `~/.codeium/windsurf-next/`
|
|
1965
2511
|
* (field report, 2026-09-01: a user ran the stable build on one project and
|
|
1966
2512
|
* Next on another; the entry written for stable was invisible to Next). We
|
|
1967
2513
|
* write it only when that directory exists: it is the build's own footprint,
|
|
@@ -1976,7 +2522,8 @@ export function windsurfNextInstalled() {
|
|
|
1976
2522
|
export function configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot) {
|
|
1977
2523
|
if (!windsurfNextInstalled())
|
|
1978
2524
|
return false;
|
|
1979
|
-
|
|
2525
|
+
// Machine-wide: written through a dotfile manager's link, never checked against the project.
|
|
2526
|
+
return writeMcpServersFile(windsurfNextGlobalMcpPath(), binaryPath, VEXP_TOOLS, mcpServerPath, workspaceRoot, "alwaysAllow");
|
|
1980
2527
|
}
|
|
1981
2528
|
/**
|
|
1982
2529
|
* Windsurf (Cascade) reads MCP servers from ~/.codeium/windsurf/mcp_config.json
|
|
@@ -1996,109 +2543,1881 @@ export function configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot
|
|
|
1996
2543
|
catch {
|
|
1997
2544
|
return false;
|
|
1998
2545
|
}
|
|
1999
|
-
|
|
2546
|
+
// Machine-wide: written through a dotfile manager's link, never checked against the project.
|
|
2547
|
+
return writeMcpServersFile(p, binaryPath, VEXP_TOOLS, mcpServerPath, workspaceRoot, "alwaysAllow");
|
|
2000
2548
|
}
|
|
2001
2549
|
/**
|
|
2002
2550
|
* Drop our entry from the `.windsurf/mcp.json` older versions wrote (a file
|
|
2003
2551
|
* Cascade never read), deleting the file when nothing of the user's is left.
|
|
2552
|
+
* A file that is a link is never deleted nor written over: vexp's member
|
|
2553
|
+
* goes from its target when that is inside the project, and nothing is
|
|
2554
|
+
* touched when it is not (removeProjectMcpEntryThroughLink).
|
|
2004
2555
|
*/
|
|
2005
2556
|
export function removeDeadWindsurfMcpJson(workspaceRoot) {
|
|
2006
2557
|
const p = path.join(workspaceRoot, ".windsurf", "mcp.json");
|
|
2558
|
+
let link = false;
|
|
2559
|
+
try {
|
|
2560
|
+
link = fs.lstatSync(p).isSymbolicLink();
|
|
2561
|
+
}
|
|
2562
|
+
catch {
|
|
2563
|
+
return "absent";
|
|
2564
|
+
}
|
|
2565
|
+
if (link)
|
|
2566
|
+
return removeProjectMcpEntryThroughLink(p, "mcpServers", workspaceRoot, "Windsurf") === "edited" ? "stripped" : "absent";
|
|
2007
2567
|
const read = readJsonConfigSafe(p);
|
|
2008
2568
|
if (!read.existed || !read.ok)
|
|
2009
2569
|
return "absent";
|
|
2010
2570
|
const servers = read.data.mcpServers;
|
|
2011
2571
|
if (!servers || !("vexp" in servers))
|
|
2012
2572
|
return "absent";
|
|
2573
|
+
if (committedCleanupBlocked([p], "Windsurf"))
|
|
2574
|
+
return "absent";
|
|
2013
2575
|
delete servers["vexp"];
|
|
2014
2576
|
const otherKeys = Object.keys(read.data).filter((k) => k !== "mcpServers");
|
|
2015
2577
|
if (Object.keys(servers).length === 0 && otherKeys.length === 0) {
|
|
2016
2578
|
try {
|
|
2017
2579
|
fs.unlinkSync(p);
|
|
2018
2580
|
}
|
|
2019
|
-
catch { /* best effort */ }
|
|
2020
|
-
return "deleted";
|
|
2581
|
+
catch { /* best effort */ }
|
|
2582
|
+
return "deleted";
|
|
2583
|
+
}
|
|
2584
|
+
try {
|
|
2585
|
+
fs.writeFileSync(p, JSON.stringify(read.data, null, 2), "utf-8");
|
|
2586
|
+
}
|
|
2587
|
+
catch { /* best effort */ }
|
|
2588
|
+
return "stripped";
|
|
2589
|
+
}
|
|
2590
|
+
/**
|
|
2591
|
+
* Take vexp's server out of a project MCP file that is a symlink (a
|
|
2592
|
+
* repository that keeps one MCP file and links it where each tool reads
|
|
2593
|
+
* it): such a file is not vexp's to delete. A removal unlinked the link, or
|
|
2594
|
+
* wrote a regular file over it, and a link to a file outside the project
|
|
2595
|
+
* edited a file of the user's elsewhere. So a link whose real path is
|
|
2596
|
+
* inside `workspaceRoot` loses vexp's member in its target, cut out of the
|
|
2597
|
+
* text when that is not strict JSON (removeJsoncMember), and the link and
|
|
2598
|
+
* the target both stay, even with nothing left in it; a link out of the
|
|
2599
|
+
* project, or to nothing, is left as it is, with one line said. Never a
|
|
2600
|
+
* link removed. Lockstep with the extension's twin.
|
|
2601
|
+
*/
|
|
2602
|
+
export function removeProjectMcpEntryThroughLink(file, key, workspaceRoot, agent) {
|
|
2603
|
+
try {
|
|
2604
|
+
let real;
|
|
2605
|
+
try {
|
|
2606
|
+
real = fs.realpathSync(file);
|
|
2607
|
+
}
|
|
2608
|
+
catch {
|
|
2609
|
+
process.stdout.write(` - ${file} is a link to a file that does not exist: left as it is\n`);
|
|
2610
|
+
return undefined;
|
|
2611
|
+
}
|
|
2612
|
+
let rootReal = path.resolve(workspaceRoot);
|
|
2613
|
+
try {
|
|
2614
|
+
rootReal = fs.realpathSync(workspaceRoot);
|
|
2615
|
+
}
|
|
2616
|
+
catch {
|
|
2617
|
+
/* compared as written */
|
|
2618
|
+
}
|
|
2619
|
+
const rel = path.relative(rootReal, real);
|
|
2620
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
2621
|
+
process.stdout.write(` - ${file} is a link to ${real}, outside this project: vexp's entry there is left as it is\n`);
|
|
2622
|
+
return undefined;
|
|
2623
|
+
}
|
|
2624
|
+
const read = readJsonConfigSafe(real);
|
|
2625
|
+
if (!read.ok)
|
|
2626
|
+
return undefined;
|
|
2627
|
+
const servers = read.data[key];
|
|
2628
|
+
if (!asPlainObject(servers) || !isVexpWrittenEntry(servers["vexp"]))
|
|
2629
|
+
return undefined;
|
|
2630
|
+
// The link and its target are both the repository's when git tracks either.
|
|
2631
|
+
if (agent !== undefined && committedCleanupBlocked([file, real], agent))
|
|
2632
|
+
return undefined;
|
|
2633
|
+
const raw = fs.readFileSync(real, "utf-8");
|
|
2634
|
+
let next;
|
|
2635
|
+
try {
|
|
2636
|
+
const data = JSON.parse(raw);
|
|
2637
|
+
delete data[key]["vexp"];
|
|
2638
|
+
if (Object.keys(data[key]).length === 0)
|
|
2639
|
+
delete data[key];
|
|
2640
|
+
next = JSON.stringify(data, null, 2) + "\n";
|
|
2641
|
+
}
|
|
2642
|
+
catch {
|
|
2643
|
+
next = removeJsoncMember(raw, key, "vexp");
|
|
2644
|
+
}
|
|
2645
|
+
if (next === undefined) {
|
|
2646
|
+
process.stdout.write(` - vexp's entry in ${real} (linked from ${file}) could not be cut out of the text: left as it is\n`);
|
|
2647
|
+
return undefined;
|
|
2648
|
+
}
|
|
2649
|
+
backupConfig(file);
|
|
2650
|
+
fs.writeFileSync(real, next, "utf-8");
|
|
2651
|
+
return "edited";
|
|
2652
|
+
}
|
|
2653
|
+
catch {
|
|
2654
|
+
/* best-effort: what could not be removed stays, and is harmless */
|
|
2655
|
+
return undefined;
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* True inside WSL. `procVersion` is injectable for tests; when given, it is
|
|
2660
|
+
* the only thing consulted.
|
|
2661
|
+
*/
|
|
2662
|
+
export function isWsl(procVersion) {
|
|
2663
|
+
if (procVersion !== undefined)
|
|
2664
|
+
return /microsoft/i.test(procVersion);
|
|
2665
|
+
if (process.platform !== "linux")
|
|
2666
|
+
return false;
|
|
2667
|
+
if (process.env.WSL_DISTRO_NAME)
|
|
2668
|
+
return true;
|
|
2669
|
+
try {
|
|
2670
|
+
return /microsoft/i.test(fs.readFileSync("/proc/version", "utf-8"));
|
|
2671
|
+
}
|
|
2672
|
+
catch {
|
|
2673
|
+
return false;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
function shellQuote(s) {
|
|
2677
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* The Windows-side Cascade entry for a project that lives in WSL, filled in
|
|
2681
|
+
* with the real paths, ready to paste. Cascade launches MCP servers on the
|
|
2682
|
+
* Windows side even when the workspace is a WSL folder, and reads
|
|
2683
|
+
* %USERPROFILE%\.codeium\windsurf\mcp_config.json there — never the WSL home
|
|
2684
|
+
* `vexp setup` can write to. Environment variables in the entry do not cross
|
|
2685
|
+
* wsl.exe, so VEXP_WORKSPACE is set on the command line inside WSL; `bash -lc`
|
|
2686
|
+
* puts a version-managed node on PATH. We do not write Windows files from WSL
|
|
2687
|
+
* (see the Windows + WSL2 docs) — we hand the user the exact entry instead.
|
|
2688
|
+
*/
|
|
2689
|
+
export function windsurfWslBridgeNote(workspaceRoot, binaryPath, mcpServerPath, distro = process.env.WSL_DISTRO_NAME ?? "<your distro>") {
|
|
2690
|
+
const inner = mcpServerPath && fs.existsSync(mcpServerPath)
|
|
2691
|
+
? `VEXP_WORKSPACE=${shellQuote(workspaceRoot)} exec ${shellQuote(resolveNodeCommand())} ${shellQuote(mcpServerPath)}`
|
|
2692
|
+
: `VEXP_WORKSPACE=${shellQuote(workspaceRoot)} exec ${shellQuote(binaryPath)} mcp`;
|
|
2693
|
+
const entry = {
|
|
2694
|
+
mcpServers: {
|
|
2695
|
+
vexp: {
|
|
2696
|
+
command: "wsl.exe",
|
|
2697
|
+
args: ["-d", distro, "--", "bash", "-lc", inner],
|
|
2698
|
+
alwaysAllow: VEXP_TOOLS,
|
|
2699
|
+
},
|
|
2700
|
+
},
|
|
2701
|
+
};
|
|
2702
|
+
return [
|
|
2703
|
+
"Windsurf on Windows, project in WSL: Cascade starts MCP servers on the WINDOWS side and reads",
|
|
2704
|
+
"%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json there — the entry vexp just wrote in your WSL home is not the one it reads.",
|
|
2705
|
+
"(Devin Desktop Next, the beta build, keeps its own copy: %USERPROFILE%\\.codeium\\windsurf-next\\mcp_config.json — same entry.)",
|
|
2706
|
+
"Paste this on the Windows side (Windsurf: Settings → Cascade → MCP → view raw config), then quit and reopen Windsurf:",
|
|
2707
|
+
JSON.stringify(entry, null, 2),
|
|
2708
|
+
"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.",
|
|
2709
|
+
"Docs: https://vexp.dev/docs#windows-wsl",
|
|
2710
|
+
].join("\n");
|
|
2711
|
+
}
|
|
2712
|
+
// ---------------------------------------------------------------------------
|
|
2713
|
+
// Antigravity (Google). Lockstep with packages/vexp-vscode/src/providers/
|
|
2714
|
+
// agent-auto-config.ts (configureAntigravityProject,
|
|
2715
|
+
// removeDeadAntigravityEntries, isDeadAntigravityEntry).
|
|
2716
|
+
//
|
|
2717
|
+
// Every current Antigravity build (App 2.x, IDE 2.6, the agy CLI and the
|
|
2718
|
+
// VS Code extension) reads MCP servers from ~/.gemini/config/mcp_config.json
|
|
2719
|
+
// and from <workspace>/.agents/mcp_config.json. The 1.x file vexp used to
|
|
2720
|
+
// write, ~/.gemini/antigravity/mcp_config.json, is read by none of them, and
|
|
2721
|
+
// its entry named no project. Measured with agy 1.5.3 (2026-11-07): an MCP
|
|
2722
|
+
// server starts in the agy process's own folder (the window's first folder
|
|
2723
|
+
// in VS Code, often the home folder in the Antigravity app) and gets no
|
|
2724
|
+
// workspace variable, so `vexp-core mcp` with no pin serves whatever folder
|
|
2725
|
+
// that is, and from the home folder an empty tool list (field report,
|
|
2726
|
+
// 2026-11-07).
|
|
2727
|
+
//
|
|
2728
|
+
// So the entry goes in the project file, pinned with VEXP_WORKSPACE. The
|
|
2729
|
+
// files take no ${VAR} expansion, so the pin is the literal path. Nothing
|
|
2730
|
+
// is written machine-wide: a pinned global entry serves one project (the
|
|
2731
|
+
// 2.0.32 multi-session rule), an unpinned one serves the wrong folder, and
|
|
2732
|
+
// a global `vexp` starts with Antigravity, before any conversation, so it
|
|
2733
|
+
// keeps the name and the project's entry, loaded per conversation, is not
|
|
2734
|
+
// used. A file that holds only vexp's entry is kept out of git
|
|
2735
|
+
// (excludeFromGit).
|
|
2736
|
+
// ---------------------------------------------------------------------------
|
|
2737
|
+
/** Antigravity's project MCP file, relative to the project root. */
|
|
2738
|
+
export const ANTIGRAVITY_PROJECT_MCP = ".agents/mcp_config.json";
|
|
2739
|
+
/** Keys of vexp's entry the user may set and Antigravity reads: a rewrite keeps them. */
|
|
2740
|
+
export const ANTIGRAVITY_USER_KEYS = ["disabled", "disabledTools", "enabledTools", "tools", "timeoutSeconds"];
|
|
2741
|
+
export function antigravityProjectMcpPath(workspaceRoot) {
|
|
2742
|
+
return path.join(workspaceRoot, ".agents", "mcp_config.json");
|
|
2743
|
+
}
|
|
2744
|
+
/**
|
|
2745
|
+
* Register vexp with Antigravity for this project: `mcpServers.vexp` in
|
|
2746
|
+
* <root>/.agents/mcp_config.json = the binary, `mcp`, and
|
|
2747
|
+
* env.VEXP_WORKSPACE = the root. The binary and never `node`: Antigravity
|
|
2748
|
+
* is a GUI app, and a dock-launched app has no shell PATH (the Kiro lesson).
|
|
2749
|
+
* The file is read as JSONC (comments, trailing commas, a BOM); one that
|
|
2750
|
+
* will not parse is left untouched and reported (takeSkippedConfigs), and
|
|
2751
|
+
* so is one that is not UTF-8 text (takeUnreachableTargets). Only
|
|
2752
|
+
* `mcpServers.vexp` is vexp's: the project's other servers stay, a
|
|
2753
|
+
* `vexp-dev` of the user's among them (no vexp build ever wrote a legacy
|
|
2754
|
+
* `vexp-*` key here), and so do the keys of ANTIGRAVITY_USER_KEYS the user
|
|
2755
|
+
* set inside vexp's entry, and the pin's spelling when it names this
|
|
2756
|
+
* folder already (antigravityPin). An entry of another vexp install that
|
|
2757
|
+
* works and is pinned here is kept (adoptableEntry). A file whose
|
|
2758
|
+
* mcpServers is not an object, or that cannot be written, is left alone
|
|
2759
|
+
* and reported (takeUnreachableTargets), never thrown: `vexp setup` goes
|
|
2760
|
+
* on with the other agents, as the extension and vexp-core do. A file that
|
|
2761
|
+
* leads out of the project through a link, or to nothing, is left as it is
|
|
2762
|
+
* and reported (projectWriteLanding); one that lands on Antigravity's
|
|
2763
|
+
* machine-wide files is refused in its own words first
|
|
2764
|
+
* (antigravityProjectFileBlocked). ZCode's .agents/mcp.json is another file
|
|
2765
|
+
* and is never touched here. Same bytes as the extension's writer. Returns
|
|
2766
|
+
* true when the file was written.
|
|
2767
|
+
*/
|
|
2768
|
+
export function configureAntigravityProject(workspaceRoot, binaryPath) {
|
|
2769
|
+
const target = antigravityProjectMcpPath(workspaceRoot);
|
|
2770
|
+
const refuse = (reason) => {
|
|
2771
|
+
const msg = `${target} NOT written: it ${reason}`;
|
|
2772
|
+
if (!unreachableTargets.includes(msg))
|
|
2773
|
+
unreachableTargets.push(msg);
|
|
2774
|
+
return false;
|
|
2775
|
+
};
|
|
2776
|
+
const blocked = antigravityProjectFileBlocked(workspaceRoot);
|
|
2777
|
+
if (blocked)
|
|
2778
|
+
return refuse(blocked);
|
|
2779
|
+
if (projectWriteRefused(target, workspaceRoot))
|
|
2780
|
+
return false;
|
|
2781
|
+
const read = readAntigravityProjectFile(target);
|
|
2782
|
+
if (read.notUtf8)
|
|
2783
|
+
return refuse(ANTIGRAVITY_NOT_UTF8);
|
|
2784
|
+
if (!read.ok) {
|
|
2785
|
+
warnUnparseable(target);
|
|
2786
|
+
return false;
|
|
2787
|
+
}
|
|
2788
|
+
const cfg = read.data;
|
|
2789
|
+
if (antigravityServersMisshapen(cfg))
|
|
2790
|
+
return refuse(ANTIGRAVITY_SERVERS_NOT_AN_OBJECT);
|
|
2791
|
+
// The file holds this machine's paths (the binary, this folder), and each
|
|
2792
|
+
// machine's vexp rewrites it with its own. A file that holds only vexp's
|
|
2793
|
+
// entry is vexp's and is never meant for git: it goes into the
|
|
2794
|
+
// repository's own exclude file once the file is settled (written,
|
|
2795
|
+
// already identical, adopted), whether this write created it or found it.
|
|
2796
|
+
// Found matters: the doctor tells a user whose file is committed to run
|
|
2797
|
+
// `git rm --cached`, which leaves it untracked and not ignored, and the
|
|
2798
|
+
// next `git add -A` committed it again. One with other servers or keys is
|
|
2799
|
+
// the project's business (it may be committed on purpose: the doctor says
|
|
2800
|
+
// what that costs), and no git command runs for it, so a start stays
|
|
2801
|
+
// cheap. excludeFromGit's own guards (no repository, no git, ignored
|
|
2802
|
+
// already, tracked, the line there) still decide.
|
|
2803
|
+
const settled = (wrote) => {
|
|
2804
|
+
if (!read.existed || holdsOnlyVexp(cfg))
|
|
2805
|
+
excludeFromGit(workspaceRoot, ANTIGRAVITY_PROJECT_MCP);
|
|
2806
|
+
return wrote;
|
|
2807
|
+
};
|
|
2808
|
+
const targetArgs = ["mcp"];
|
|
2809
|
+
const prev = cfg.mcpServers?.["vexp"];
|
|
2810
|
+
// Committed and pinned by another checkout: see committedPin. A file that
|
|
2811
|
+
// holds this machine's paths is not meant for git (the doctor says how to
|
|
2812
|
+
// take it out), but while it is committed a setup in a worktree must not
|
|
2813
|
+
// repin it either.
|
|
2814
|
+
const committed = committedPin(target, workspaceRoot, envPin(prev?.env));
|
|
2815
|
+
if (committed) {
|
|
2816
|
+
if (!staleVexpInstall(prev))
|
|
2817
|
+
return settled(noteCommittedPin(target, committed));
|
|
2818
|
+
cfg.mcpServers["vexp"] = { ...prev, command: binaryPath, args: targetArgs };
|
|
2819
|
+
try {
|
|
2820
|
+
backupConfig(target);
|
|
2821
|
+
fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
|
|
2822
|
+
}
|
|
2823
|
+
catch (e) {
|
|
2824
|
+
return refuse(`could not be written (${e instanceof Error ? e.message : String(e)})`);
|
|
2825
|
+
}
|
|
2826
|
+
return settled(true);
|
|
2827
|
+
}
|
|
2828
|
+
const targetEnv = { VEXP_WORKSPACE: antigravityPin(prev, workspaceRoot) };
|
|
2829
|
+
const envMatches = JSON.stringify(prev?.env) === JSON.stringify(targetEnv);
|
|
2830
|
+
const identical = prev?.command === binaryPath &&
|
|
2831
|
+
Array.isArray(prev?.args) &&
|
|
2832
|
+
JSON.stringify(prev?.args) === JSON.stringify(targetArgs) &&
|
|
2833
|
+
envMatches;
|
|
2834
|
+
if (identical)
|
|
2835
|
+
return settled(false);
|
|
2836
|
+
// Another vexp install already left a working entry here: don't fight it.
|
|
2837
|
+
// The VEXP_WORKSPACE pin still gates, so a moved project is repinned.
|
|
2838
|
+
if (envMatches && adoptableEntry(prev))
|
|
2839
|
+
return settled(false);
|
|
2840
|
+
const servers = cfg.mcpServers ?? {};
|
|
2841
|
+
const kept = {};
|
|
2842
|
+
if (prev && typeof prev === "object") {
|
|
2843
|
+
for (const k of ANTIGRAVITY_USER_KEYS)
|
|
2844
|
+
if (k in prev)
|
|
2845
|
+
kept[k] = prev[k];
|
|
2846
|
+
}
|
|
2847
|
+
servers["vexp"] = { command: binaryPath, args: targetArgs, env: targetEnv, ...kept };
|
|
2848
|
+
cfg.mcpServers = servers;
|
|
2849
|
+
try {
|
|
2850
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
2851
|
+
// An empty file, or one of only a BOM and whitespace, holds nothing of
|
|
2852
|
+
// the user's: no backup of it, as in vexp-core.
|
|
2853
|
+
if (read.existed && !read.blank)
|
|
2854
|
+
backupConfig(target);
|
|
2855
|
+
fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
|
|
2856
|
+
}
|
|
2857
|
+
catch (e) {
|
|
2858
|
+
return refuse(`could not be written (${e instanceof Error ? e.message : String(e)})`);
|
|
2859
|
+
}
|
|
2860
|
+
return settled(true);
|
|
2861
|
+
}
|
|
2862
|
+
/**
|
|
2863
|
+
* Does the project file hold nothing but vexp's server: no top-level key
|
|
2864
|
+
* but `mcpServers`, and no server in it but `vexp`? Such a file is vexp's
|
|
2865
|
+
* (settled in configureAntigravityProject) and is kept out of git; the
|
|
2866
|
+
* doctor advises `git rm --cached` only for such a file (doctor.ts,
|
|
2867
|
+
* agOnlyVexp). Lockstep with the extension and vexp-core.
|
|
2868
|
+
*/
|
|
2869
|
+
export function holdsOnlyVexp(cfg) {
|
|
2870
|
+
if (!Object.keys(cfg).every((k) => k === "mcpServers"))
|
|
2871
|
+
return false;
|
|
2872
|
+
const servers = cfg.mcpServers;
|
|
2873
|
+
return asPlainObject(servers) && Object.keys(servers).every((k) => k === "vexp");
|
|
2874
|
+
}
|
|
2875
|
+
/** Why a project file whose mcpServers is not an object is left alone. Lockstep with the extension and vexp-core. */
|
|
2876
|
+
export const ANTIGRAVITY_SERVERS_NOT_AN_OBJECT = "has an mcpServers that is not an object - left untouched";
|
|
2877
|
+
/** Why a project file that is not UTF-8 text is left alone. Lockstep with the extension and vexp-core. */
|
|
2878
|
+
export const ANTIGRAVITY_NOT_UTF8 = "is not UTF-8 text - left untouched";
|
|
2879
|
+
/**
|
|
2880
|
+
* The project file as the Antigravity writers read it: readJsonConfigSafe's
|
|
2881
|
+
* verdict, but bytes that are not UTF-8 (an ANSI file PowerShell 5.1's
|
|
2882
|
+
* Set-Content writes) are `notUtf8` and never rewritten, as in vexp-core.
|
|
2883
|
+
* Decoded with replacement characters, such a file parsed, and the rewrite
|
|
2884
|
+
* saved another server's `jos\xe9` as `jos\ufffd` (review, 2026-11-07).
|
|
2885
|
+
* `blank` is a file with nothing of the user's in it: absent, empty, or a
|
|
2886
|
+
* BOM and whitespace. Lockstep with the extension.
|
|
2887
|
+
*/
|
|
2888
|
+
export function readAntigravityProjectFile(target) {
|
|
2889
|
+
if (!fs.existsSync(target))
|
|
2890
|
+
return { data: {}, ok: true, existed: false, blank: true };
|
|
2891
|
+
let raw;
|
|
2892
|
+
try {
|
|
2893
|
+
const bytes = fs.readFileSync(target);
|
|
2894
|
+
try {
|
|
2895
|
+
raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
2896
|
+
}
|
|
2897
|
+
catch {
|
|
2898
|
+
return { data: {}, ok: false, existed: true, blank: false, notUtf8: true };
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
catch {
|
|
2902
|
+
return { data: {}, ok: false, existed: true, blank: false };
|
|
2903
|
+
}
|
|
2904
|
+
if (raw.replace(/^\uFEFF/, "").trim() === "")
|
|
2905
|
+
return { data: {}, ok: true, existed: true, blank: true };
|
|
2906
|
+
let parsed;
|
|
2907
|
+
try {
|
|
2908
|
+
parsed = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
2909
|
+
}
|
|
2910
|
+
catch {
|
|
2911
|
+
try {
|
|
2912
|
+
parsed = parseJsonc(raw);
|
|
2913
|
+
}
|
|
2914
|
+
catch {
|
|
2915
|
+
return { data: {}, ok: false, existed: true, blank: false };
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
2919
|
+
return { data: parsed, ok: true, existed: true, blank: false };
|
|
2920
|
+
}
|
|
2921
|
+
return { data: {}, ok: false, existed: true, blank: false };
|
|
2922
|
+
}
|
|
2923
|
+
/** An mcpServers that is there and is not an object (containerMisshapen). vexp-core's writer skips the same files. */
|
|
2924
|
+
function antigravityServersMisshapen(data) {
|
|
2925
|
+
return containerMisshapen(data, "mcpServers");
|
|
2926
|
+
}
|
|
2927
|
+
/**
|
|
2928
|
+
* Do two absolute spellings name one folder: the same path, the same real
|
|
2929
|
+
* path behind a link, or the same Windows path in another letter case? A
|
|
2930
|
+
* relative pin, or one that does not resolve, is only itself.
|
|
2931
|
+
*/
|
|
2932
|
+
function sameFolder(a, b) {
|
|
2933
|
+
if (a === b)
|
|
2934
|
+
return true;
|
|
2935
|
+
if (!path.isAbsolute(a) || !path.isAbsolute(b))
|
|
2936
|
+
return false;
|
|
2937
|
+
if (process.platform === "win32" && path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase())
|
|
2938
|
+
return true;
|
|
2939
|
+
try {
|
|
2940
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
2941
|
+
}
|
|
2942
|
+
catch {
|
|
2943
|
+
return false;
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
/**
|
|
2947
|
+
* The VEXP_WORKSPACE to write: the root, or the pin vexp's entry already
|
|
2948
|
+
* has when that names the same folder in another spelling. The CLI pins
|
|
2949
|
+
* the shell's path, the extension VS Code's (a lower-case drive letter on
|
|
2950
|
+
* Windows), vexp-core its canonical one, and a project opened through a
|
|
2951
|
+
* link has two; compared as text, each writer rewrote the others' entry,
|
|
2952
|
+
* with a backup, at its next run. Lockstep with the extension and vexp-core.
|
|
2953
|
+
*/
|
|
2954
|
+
export function antigravityPin(prev, workspaceRoot) {
|
|
2955
|
+
const env = prev && typeof prev === "object" ? prev.env : undefined;
|
|
2956
|
+
const pin = env && typeof env === "object" ? env.VEXP_WORKSPACE : undefined;
|
|
2957
|
+
return typeof pin === "string" && sameFolder(pin, workspaceRoot) ? pin : workspaceRoot;
|
|
2958
|
+
}
|
|
2959
|
+
/**
|
|
2960
|
+
* Why the project file must not be written, when it must not: `.agents` is
|
|
2961
|
+
* a file, or a link to nothing; the file is a link to nothing (a write
|
|
2962
|
+
* would create its target, wherever that is); or the file, by a link of its
|
|
2963
|
+
* own or through a linked `.agents` folder, is one of Antigravity's
|
|
2964
|
+
* machine-wide files, and writing it would put a pinned entry there for
|
|
2965
|
+
* every project. Lockstep with the extension (and with vexp-core's
|
|
2966
|
+
* register_antigravity_project).
|
|
2967
|
+
*/
|
|
2968
|
+
export function antigravityProjectFileBlocked(workspaceRoot) {
|
|
2969
|
+
const dir = path.join(workspaceRoot, ".agents");
|
|
2970
|
+
let dirReal;
|
|
2971
|
+
try {
|
|
2972
|
+
if (!fs.statSync(dir).isDirectory())
|
|
2973
|
+
return `cannot be created - ${dir} is a file, not a folder`;
|
|
2974
|
+
dirReal = fs.realpathSync(dir);
|
|
2975
|
+
}
|
|
2976
|
+
catch {
|
|
2977
|
+
try {
|
|
2978
|
+
if (fs.lstatSync(dir).isSymbolicLink())
|
|
2979
|
+
return `cannot be created - ${dir} is a link to a folder that does not exist`;
|
|
2980
|
+
}
|
|
2981
|
+
catch {
|
|
2982
|
+
/* no folder yet: created on write */
|
|
2983
|
+
}
|
|
2984
|
+
return undefined;
|
|
2985
|
+
}
|
|
2986
|
+
const target = antigravityProjectMcpPath(workspaceRoot);
|
|
2987
|
+
let link = false;
|
|
2988
|
+
try {
|
|
2989
|
+
link = fs.lstatSync(target).isSymbolicLink();
|
|
2990
|
+
}
|
|
2991
|
+
catch {
|
|
2992
|
+
/* absent: written in the folder's real place */
|
|
2993
|
+
}
|
|
2994
|
+
let lands = path.join(dirReal, path.basename(target));
|
|
2995
|
+
if (link) {
|
|
2996
|
+
try {
|
|
2997
|
+
lands = fs.realpathSync(target);
|
|
2998
|
+
}
|
|
2999
|
+
catch {
|
|
3000
|
+
return "is a link to a file that does not exist";
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
if (!antigravityMachineMcpLocations().includes(lands))
|
|
3004
|
+
return undefined;
|
|
3005
|
+
return link
|
|
3006
|
+
? "is a link to Antigravity's machine-wide mcp_config.json - vexp registers per project only"
|
|
3007
|
+
: "is in a folder linked to Antigravity's machine-wide config folder - vexp registers per project only";
|
|
3008
|
+
}
|
|
3009
|
+
/**
|
|
3010
|
+
* An MCP entry vexp wrote: the core binary or the bundled mcp-server.cjs,
|
|
3011
|
+
* named by its path. A bare `vexp-core` looked up on PATH is an entry
|
|
3012
|
+
* someone wrote by hand. Lockstep with the extension's isVexpWrittenEntry.
|
|
3013
|
+
*/
|
|
3014
|
+
function isVexpWrittenEntry(entry) {
|
|
3015
|
+
const cmd = entryCommand(entry);
|
|
3016
|
+
if (!cmd)
|
|
3017
|
+
return false;
|
|
3018
|
+
const base = (cmd.split(/[\\/]/).pop() ?? "").toLowerCase();
|
|
3019
|
+
if (base.startsWith("vexp-core"))
|
|
3020
|
+
return /[\\/]/.test(cmd);
|
|
3021
|
+
const args = entry.args;
|
|
3022
|
+
return Array.isArray(args) && args.some((a) => typeof a === "string" && /vexp/i.test(a) && /mcp-server\.cjs$/.test(a));
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Does an entry run what a vexp install put on disk: a file in an extension
|
|
3026
|
+
* folder (vexp.vexp-vscode-<ver>), the npm package's binary
|
|
3027
|
+
* (@vexp/core-<platform>), or a bundled mcp-server.cjs? A vexp-core built
|
|
3028
|
+
* or copied anywhere else is the user's own. Lockstep with the extension.
|
|
3029
|
+
*/
|
|
3030
|
+
function namesVexpInstall(entry) {
|
|
3031
|
+
if (vexpInstallVersion(entry) !== undefined)
|
|
3032
|
+
return true;
|
|
3033
|
+
if (/[\\/]@vexp[\\/]core-[^\\/]+[\\/]/.test(entryCommand(entry) ?? ""))
|
|
3034
|
+
return true;
|
|
3035
|
+
const args = entry.args;
|
|
3036
|
+
return Array.isArray(args) && args.some((a) => typeof a === "string" && /vexp/i.test(a) && /mcp-server\.cjs$/.test(a));
|
|
3037
|
+
}
|
|
3038
|
+
/**
|
|
3039
|
+
* A machine-wide Antigravity entry that vexp wrote and that names no
|
|
3040
|
+
* project: none of what vexp-core takes a project from (`--workspace`, in
|
|
3041
|
+
* either spelling clap accepts, env VEXP_WORKSPACE or CLAUDE_PROJECT_DIR,
|
|
3042
|
+
* a `cwd`). Such an entry serves whatever folder Antigravity starts it from
|
|
3043
|
+
* (the agy process's own: the window's first folder in VS Code, often the
|
|
3044
|
+
* home folder in the Antigravity app, where it lists no tools), and as it
|
|
3045
|
+
* starts with Antigravity, before any conversation, it keeps the name and
|
|
3046
|
+
* the project's pinned entry is not used (measured with agy 1.5.3,
|
|
3047
|
+
* 2026-11-07). A pinned entry or one the user made is never this. Lockstep
|
|
3048
|
+
* with the extension.
|
|
3049
|
+
*/
|
|
3050
|
+
export function isDeadAntigravityEntry(entry) {
|
|
3051
|
+
if (!isVexpWrittenEntry(entry) || !namesVexpInstall(entry))
|
|
3052
|
+
return false;
|
|
3053
|
+
const e = entry;
|
|
3054
|
+
const env = e.env;
|
|
3055
|
+
if (env && typeof env === "object" && (typeof env.VEXP_WORKSPACE === "string" || typeof env.CLAUDE_PROJECT_DIR === "string"))
|
|
3056
|
+
return false;
|
|
3057
|
+
if (Array.isArray(e.args) && e.args.some((a) => a === "--workspace" || (typeof a === "string" && a.startsWith("--workspace="))))
|
|
3058
|
+
return false;
|
|
3059
|
+
return !(typeof e.cwd === "string" && e.cwd.trim() !== "");
|
|
3060
|
+
}
|
|
3061
|
+
/** The 1.x file and today's global one, as named under the home folder. */
|
|
3062
|
+
function antigravityMachineMcpNames() {
|
|
3063
|
+
return [antigravityLegacyMcpPath(os.homedir()), antigravityGlobalMcpPath(os.homedir())];
|
|
3064
|
+
}
|
|
3065
|
+
export function antigravityLegacyMcpPath(home) {
|
|
3066
|
+
return path.join(home, ".gemini", "antigravity", "mcp_config.json");
|
|
3067
|
+
}
|
|
3068
|
+
export function antigravityGlobalMcpPath(home) {
|
|
3069
|
+
return path.join(home, ".gemini", "config", "mcp_config.json");
|
|
3070
|
+
}
|
|
3071
|
+
/**
|
|
3072
|
+
* The machine-wide files vexp may take its own dead entry out of: the 1.x
|
|
3073
|
+
* file when it is a regular file, or the file the 1.x name links to (agy's
|
|
3074
|
+
* migration link). Nothing else, and nothing that does not exist. Lockstep
|
|
3075
|
+
* with the extension.
|
|
3076
|
+
*/
|
|
3077
|
+
export function antigravityFilesVexpCleans(home) {
|
|
3078
|
+
const legacy = antigravityLegacyMcpPath(home);
|
|
3079
|
+
let st;
|
|
3080
|
+
try {
|
|
3081
|
+
st = fs.lstatSync(legacy);
|
|
3082
|
+
}
|
|
3083
|
+
catch {
|
|
3084
|
+
return [];
|
|
3085
|
+
}
|
|
3086
|
+
try {
|
|
3087
|
+
if (st.isFile())
|
|
3088
|
+
return [{ file: fs.realpathSync(legacy), viaLink: false }];
|
|
3089
|
+
if (!st.isSymbolicLink())
|
|
3090
|
+
return [];
|
|
3091
|
+
const real = fs.realpathSync(legacy);
|
|
3092
|
+
return fs.statSync(real).isFile() ? [{ file: real, viaLink: true }] : [];
|
|
3093
|
+
}
|
|
3094
|
+
catch {
|
|
3095
|
+
return []; // a link to nothing
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
/** Does `file` name the same file as one vexp may clean (antigravityFilesVexpCleans)? */
|
|
3099
|
+
export function vexpCleansAntigravityFile(home, file) {
|
|
3100
|
+
let real;
|
|
3101
|
+
try {
|
|
3102
|
+
real = fs.realpathSync(file);
|
|
3103
|
+
}
|
|
3104
|
+
catch {
|
|
3105
|
+
return false;
|
|
3106
|
+
}
|
|
3107
|
+
return antigravityFilesVexpCleans(home).some((c) => c.file === real);
|
|
3108
|
+
}
|
|
3109
|
+
// ---------------------------------------------------------------------------
|
|
3110
|
+
// Keeping the project file out of git. <root>/.agents/mcp_config.json holds
|
|
3111
|
+
// the absolute path of this machine's vexp binary and of this folder, and
|
|
3112
|
+
// every machine's vexp rewrites it with its own: committed, it moves between
|
|
3113
|
+
// machines as a diff nobody wants. When a setup creates it, or settles one
|
|
3114
|
+
// that holds only vexp's entry, its anchored path goes into the
|
|
3115
|
+
// repository's own exclude file, never into .gitignore.
|
|
3116
|
+
// Lockstep with the extension (antigravity-files.ts) and vexp-core
|
|
3117
|
+
// (agent_mcp.rs): the same two lines.
|
|
3118
|
+
// ---------------------------------------------------------------------------
|
|
3119
|
+
/** The comment vexp writes above the project file's line in the repository's exclude file. Lockstep with the extension and vexp-core. */
|
|
3120
|
+
export const ANTIGRAVITY_EXCLUDE_COMMENT = "# vexp: Antigravity MCP entry with this machine's paths";
|
|
3121
|
+
/** How long one git command may take: a setup or a doctor never waits on git for longer. */
|
|
3122
|
+
const GIT_TIMEOUT_MS = 3000;
|
|
3123
|
+
/**
|
|
3124
|
+
* The variables a git hook, `git --git-dir=... <alias>` or a dotfile
|
|
3125
|
+
* manager's shell hands down, which would make git answer for another
|
|
3126
|
+
* repository than the folder's. Lockstep with the extension and vexp-core
|
|
3127
|
+
* (agent_mcp.rs, GIT_REPO_VARS).
|
|
3128
|
+
*/
|
|
3129
|
+
const GIT_REPO_VARS = [
|
|
3130
|
+
"GIT_DIR",
|
|
3131
|
+
"GIT_WORK_TREE",
|
|
3132
|
+
"GIT_COMMON_DIR",
|
|
3133
|
+
"GIT_INDEX_FILE",
|
|
3134
|
+
"GIT_OBJECT_DIRECTORY",
|
|
3135
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
3136
|
+
"GIT_NAMESPACE",
|
|
3137
|
+
"GIT_PREFIX",
|
|
3138
|
+
];
|
|
3139
|
+
/** git run in `cwd`, no shell, bounded; undefined when it could not run at all (not on PATH, timed out). */
|
|
3140
|
+
export function gitIn(cwd, args) {
|
|
3141
|
+
// The folder decides the repository, not a GIT_DIR the shell was started with.
|
|
3142
|
+
const env = { ...process.env };
|
|
3143
|
+
for (const k of GIT_REPO_VARS)
|
|
3144
|
+
delete env[k];
|
|
3145
|
+
try {
|
|
3146
|
+
const r = spawnSync("git", args, {
|
|
3147
|
+
cwd,
|
|
3148
|
+
env,
|
|
3149
|
+
encoding: "utf-8",
|
|
3150
|
+
timeout: GIT_TIMEOUT_MS,
|
|
3151
|
+
windowsHide: true,
|
|
3152
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3153
|
+
});
|
|
3154
|
+
if (r.error || r.status === null)
|
|
3155
|
+
return undefined;
|
|
3156
|
+
return { status: r.status, stdout: r.stdout ?? "" };
|
|
3157
|
+
}
|
|
3158
|
+
catch {
|
|
3159
|
+
return undefined;
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
/** Where `root` sits in its repository: the exclude file git reads for it, and `root` relative to the top level. */
|
|
3163
|
+
function repoOf(root) {
|
|
3164
|
+
const r = gitIn(root, ["rev-parse", "--is-inside-work-tree", "--show-prefix", "--git-path", "info/exclude"]);
|
|
3165
|
+
if (!r || r.status !== 0)
|
|
3166
|
+
return undefined;
|
|
3167
|
+
const lines = r.stdout.split(/\r?\n/);
|
|
3168
|
+
if (lines[0] !== "true" || lines.length < 3 || !lines[2])
|
|
3169
|
+
return undefined;
|
|
3170
|
+
return { exclude: path.resolve(root, lines[2]), prefix: lines[1] };
|
|
3171
|
+
}
|
|
3172
|
+
/** A path as a gitignore pattern matches it literally: the wildcard characters escaped, anchored at the top level. */
|
|
3173
|
+
function anchoredPattern(prefix, rel) {
|
|
3174
|
+
return "/" + `${prefix}${rel}`.replace(/[\\*?[]/g, "\\$&");
|
|
3175
|
+
}
|
|
3176
|
+
function readLines(file) {
|
|
3177
|
+
try {
|
|
3178
|
+
return fs.readFileSync(file, "utf-8").split("\n");
|
|
3179
|
+
}
|
|
3180
|
+
catch (e) {
|
|
3181
|
+
return e.code === "ENOENT" ? [] : undefined;
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
/**
|
|
3185
|
+
* Put the project file (`rel`, relative to `root`) into the repository's
|
|
3186
|
+
* local exclude file (`git rev-parse --git-path info/exclude`: right for a
|
|
3187
|
+
* worktree and a submodule too), as two lines: ANTIGRAVITY_EXCLUDE_COMMENT
|
|
3188
|
+
* and the file's path anchored at the top level. Nothing when `root` is not
|
|
3189
|
+
* in a git work tree, git cannot be run, the file is ignored already or
|
|
3190
|
+
* tracked, or the line is there. Never .gitignore, never thrown: a setup
|
|
3191
|
+
* never fails on it. Returns true when the lines were added. Lockstep with
|
|
3192
|
+
* the extension.
|
|
3193
|
+
*/
|
|
3194
|
+
export function excludeFromGit(root, rel = ANTIGRAVITY_PROJECT_MCP) {
|
|
3195
|
+
try {
|
|
3196
|
+
const repo = repoOf(root);
|
|
3197
|
+
if (!repo)
|
|
3198
|
+
return false;
|
|
3199
|
+
// Exit 1 is "not ignored" and "not tracked"; 0 is the opposite, and
|
|
3200
|
+
// anything else (a path beyond a symlink, a broken repository) is no
|
|
3201
|
+
// answer: nothing is written then either.
|
|
3202
|
+
const ignored = gitIn(root, ["check-ignore", "-q", "--", rel]);
|
|
3203
|
+
if (!ignored || ignored.status !== 1)
|
|
3204
|
+
return false;
|
|
3205
|
+
const tracked = gitIn(root, ["ls-files", "--error-unmatch", "--", rel]);
|
|
3206
|
+
if (!tracked || tracked.status !== 1)
|
|
3207
|
+
return false;
|
|
3208
|
+
const line = anchoredPattern(repo.prefix, rel);
|
|
3209
|
+
const lines = readLines(repo.exclude);
|
|
3210
|
+
if (!lines || lines.some((l) => l.replace(/\r$/, "") === line))
|
|
3211
|
+
return false;
|
|
3212
|
+
const text = lines.join("\n");
|
|
3213
|
+
const lead = text === "" || text.endsWith("\n") ? "" : "\n";
|
|
3214
|
+
fs.mkdirSync(path.dirname(repo.exclude), { recursive: true });
|
|
3215
|
+
fs.appendFileSync(repo.exclude, `${lead}${ANTIGRAVITY_EXCLUDE_COMMENT}\n${line}\n`);
|
|
3216
|
+
return true;
|
|
3217
|
+
}
|
|
3218
|
+
catch (err) {
|
|
3219
|
+
process.stderr.write(` [!] could not keep ${rel} out of git: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
3220
|
+
return false;
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
/**
|
|
3224
|
+
* The project file's path relative to the repository's top level when git
|
|
3225
|
+
* tracks it (committed or staged); undefined when it does not, when it is
|
|
3226
|
+
* not on disk, or when git cannot say. The name is git's, in git's letter
|
|
3227
|
+
* case (a committed agents.md asked as AGENTS.md is "agents.md" where
|
|
3228
|
+
* core.ignorecase is true). What the doctor names in its `git rm --cached`
|
|
3229
|
+
* line. Lockstep with the extension.
|
|
3230
|
+
*/
|
|
3231
|
+
export function gitTrackedPath(root, rel = ANTIGRAVITY_PROJECT_MCP) {
|
|
3232
|
+
try {
|
|
3233
|
+
if (!fs.existsSync(path.join(root, rel)))
|
|
3234
|
+
return undefined;
|
|
3235
|
+
// git matches a pathspec by case even with core.ignorecase: on a
|
|
3236
|
+
// case-insensitive filesystem a committed agents.md, read here as
|
|
3237
|
+
// vexp's AGENTS.md, counted as untracked, and "Not here" edited or
|
|
3238
|
+
// deleted it (review, 2026-11-07). So git is asked case-blind
|
|
3239
|
+
// (`literal` also keeps glob characters in a name from being read as a
|
|
3240
|
+
// pattern), and a name in another case counts only where git itself
|
|
3241
|
+
// folds case, core.ignorecase=true, the rule git status follows. Never
|
|
3242
|
+
// decided by the OS: a Windows folder mounted in a Linux container is
|
|
3243
|
+
// case-insensitive too. A git too old for the magic exits 128: not
|
|
3244
|
+
// tracked, as before. -z: the name as it is, not C-quoted (core.quotePath).
|
|
3245
|
+
const want = rel.split(path.sep).join("/");
|
|
3246
|
+
const r = gitIn(root, ["ls-files", "-z", "--full-name", "--", `:(literal,icase)${want}`]);
|
|
3247
|
+
if (!r || r.status !== 0)
|
|
3248
|
+
return undefined;
|
|
3249
|
+
const names = r.stdout.split("\0").filter((n) => n !== "");
|
|
3250
|
+
const exact = names.find((n) => n === want || n.endsWith(`/${want}`));
|
|
3251
|
+
if (exact !== undefined)
|
|
3252
|
+
return exact;
|
|
3253
|
+
const lower = want.toLowerCase();
|
|
3254
|
+
const folded = names.find((n) => n.toLowerCase() === lower || n.toLowerCase().endsWith(`/${lower}`));
|
|
3255
|
+
if (folded === undefined)
|
|
3256
|
+
return undefined;
|
|
3257
|
+
const ic = gitIn(root, ["config", "--bool", "core.ignorecase"]);
|
|
3258
|
+
return ic?.status === 0 && ic.stdout.trim() === "true" ? folded : undefined;
|
|
3259
|
+
}
|
|
3260
|
+
catch {
|
|
3261
|
+
return undefined;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
// ---------------------------------------------------------------------------
|
|
3265
|
+
// Files committed to the repository (field report, 2026-11-07). A file git
|
|
3266
|
+
// tracks is checked out unchanged in every clone and every linked worktree:
|
|
3267
|
+
// a setup run in a worktree that rewrote it with its own path, or a cleanup
|
|
3268
|
+
// that deleted it, showed up as a change on that branch, and an agent's
|
|
3269
|
+
// `git add -A` carried it back to the main checkout on merge. So a writer
|
|
3270
|
+
// never rewrites a tracked project file only to point it at this checkout
|
|
3271
|
+
// (committedPin), and a cleanup never deletes or edits a tracked file
|
|
3272
|
+
// (committedCleanupBlocked). Both say so at the end of setup
|
|
3273
|
+
// (takeSetupNotes), in the words the extension logs. Lockstep with the
|
|
3274
|
+
// extension (agent-auto-config.ts).
|
|
3275
|
+
// ---------------------------------------------------------------------------
|
|
3276
|
+
/**
|
|
3277
|
+
* Is `file` tracked by git (`git ls-files --error-unmatch`, through
|
|
3278
|
+
* gitTrackedPath: no shell, the file's own folder as cwd, 3 s, the
|
|
3279
|
+
* GIT_DIR family removed)? Not in a repository, git missing, or the file
|
|
3280
|
+
* not on disk: not tracked.
|
|
3281
|
+
*/
|
|
3282
|
+
export function isGitTracked(file) {
|
|
3283
|
+
return gitTrackedPath(path.dirname(file), path.basename(file)) !== undefined;
|
|
3284
|
+
}
|
|
3285
|
+
/** What a setup left as it is on purpose, said at its end. ASCII only. */
|
|
3286
|
+
const setupNotes = [];
|
|
3287
|
+
/** Drain the notes (call after a setup run to print them). */
|
|
3288
|
+
export function takeSetupNotes() {
|
|
3289
|
+
return setupNotes.splice(0, setupNotes.length);
|
|
3290
|
+
}
|
|
3291
|
+
/** Note a line once per run. */
|
|
3292
|
+
function noteSetup(line) {
|
|
3293
|
+
if (!setupNotes.includes(line))
|
|
3294
|
+
setupNotes.push(line);
|
|
3295
|
+
}
|
|
3296
|
+
/** Why a cleanup left a committed file alone. Word for word the extension's. */
|
|
3297
|
+
export function committedCleanupLine(file, agent) {
|
|
3298
|
+
return `${file} is committed to this repository, so vexp left it as it is - remove vexp's part from it in a commit if this project no longer uses ${agent}`;
|
|
3299
|
+
}
|
|
3300
|
+
/** Why a writer left a committed file pinned to another checkout alone. Word for word the extension's. */
|
|
3301
|
+
export function committedPinLine(file, other) {
|
|
3302
|
+
return `${file} is committed to this repository and names ${other}; vexp left it as it is`;
|
|
3303
|
+
}
|
|
3304
|
+
/**
|
|
3305
|
+
* Why a committed instruction file was left with the marker of another
|
|
3306
|
+
* vexp version (M1): its vexp section is, but for the version inside
|
|
3307
|
+
* `<!-- vexp v... -->`, what this vexp writes. Word for word the extension's.
|
|
3308
|
+
*/
|
|
3309
|
+
export function committedMarkerOnlyLine(file) {
|
|
3310
|
+
return `${file} is committed to this repository and its vexp section differs only in the version marker; vexp left it as it is`;
|
|
3311
|
+
}
|
|
3312
|
+
/** Why .vscode/mcp.json gets no vexp entry (W4). Word for word the extension's. */
|
|
3313
|
+
export const MCP_JSON_DEFINES_VEXP = ".mcp.json already defines vexp, so vexp left it out of .vscode/mcp.json (VS Code would use that one and turn the .mcp.json entry off)";
|
|
3314
|
+
/**
|
|
3315
|
+
* The folder an MCP entry pins vexp to by name, read as the server the
|
|
3316
|
+
* entry runs reads it, or undefined for an entry that pins nothing.
|
|
3317
|
+
* vexp-core (`mcp`) lets `--workspace <dir>` / `--workspace=<dir>` win over
|
|
3318
|
+
* env VEXP_WORKSPACE (cmd_mcp reads the env only with no flag); the Node
|
|
3319
|
+
* server, an entry that runs a bundled mcp-server.cjs, parses no
|
|
3320
|
+
* --workspace and reads env only. Here, not in the doctor, because the
|
|
3321
|
+
* Copilot writer applies the same .mcp.json condition (dotMcpJsonMiss);
|
|
3322
|
+
* doctor.ts re-exports it. Lockstep with the extension's vexpPinOf.
|
|
3323
|
+
*/
|
|
3324
|
+
export function vexpPinOf(entry) {
|
|
3325
|
+
if (!asPlainObject(entry))
|
|
3326
|
+
return undefined;
|
|
3327
|
+
const e = entry;
|
|
3328
|
+
const env = asPlainObject(e.env) ? e.env : undefined;
|
|
3329
|
+
const fromEnv = typeof env?.VEXP_WORKSPACE === "string" && env.VEXP_WORKSPACE.trim() ? { pin: env.VEXP_WORKSPACE, from: "env" } : undefined;
|
|
3330
|
+
const args = Array.isArray(e.args) ? e.args : [];
|
|
3331
|
+
if (args.some((a) => typeof a === "string" && /mcp-server\.cjs$/i.test(a)))
|
|
3332
|
+
return fromEnv;
|
|
3333
|
+
const i = args.indexOf("--workspace");
|
|
3334
|
+
if (i >= 0 && typeof args[i + 1] === "string" && args[i + 1].trim())
|
|
3335
|
+
return { pin: args[i + 1], from: "args" };
|
|
3336
|
+
const eq = args.find((a) => typeof a === "string" && a.startsWith("--workspace=") && a.length > 12);
|
|
3337
|
+
return eq ? { pin: eq.slice(12), from: "args" } : fromEnv;
|
|
3338
|
+
}
|
|
3339
|
+
/**
|
|
3340
|
+
* Do two spellings name one folder, by the extension's rule (mcp-folder.ts
|
|
3341
|
+
* samePath): the same spelling once resolved and without a trailing
|
|
3342
|
+
* separator, case-folded on Windows only, else the same folder once links
|
|
3343
|
+
* are resolved. The CLI's samePath folds case on every OS and resolves
|
|
3344
|
+
* links only on Windows, so these findings warned for a root spelled
|
|
3345
|
+
* through a link (/home -> /var/home, /tmp -> /private/tmp on macOS) that
|
|
3346
|
+
* the extension read as this workspace, and read /app/WT as /app/wt on
|
|
3347
|
+
* Linux (review, 2026-11-07). Not on darwin either: macOS realpath already
|
|
3348
|
+
* returns the case on disk, and folding would be wrong on a case-sensitive
|
|
3349
|
+
* APFS volume. The doctors' rule, used by the writer's .mcp.json condition
|
|
3350
|
+
* too (dotMcpJsonMiss). Lockstep with the extension.
|
|
3351
|
+
*/
|
|
3352
|
+
export function sameFolderAsExtension(a, b) {
|
|
3353
|
+
const key = (p) => {
|
|
3354
|
+
const r = path.resolve(p).replace(/[\\/]+$/, "");
|
|
3355
|
+
const k = r.length > 0 ? r : path.resolve(p);
|
|
3356
|
+
return process.platform === "win32" ? k.toLowerCase() : k;
|
|
3357
|
+
};
|
|
3358
|
+
if (key(a) === key(b))
|
|
3359
|
+
return true;
|
|
3360
|
+
try {
|
|
3361
|
+
return key(fs.realpathSync.native(a)) === key(fs.realpathSync.native(b));
|
|
3362
|
+
}
|
|
3363
|
+
catch {
|
|
3364
|
+
return false; // one of them is not there: only the spelling can match
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
/**
|
|
3368
|
+
* M2: would `entry`, the vexp server of `<root>/.mcp.json`, serve this
|
|
3369
|
+
* root when VS Code starts it? undefined when it would, else why not. Only
|
|
3370
|
+
* then may vexp leave its own entry out of .vscode/mcp.json (W4): VS Code
|
|
3371
|
+
* turns the .mcp.json server off as soon as .vscode/mcp.json defines one by
|
|
3372
|
+
* the same name, so leaving ours out hands Copilot the .mcp.json one, and a
|
|
3373
|
+
* committed .mcp.json pinned to the main checkout, or running a `vexp-core`
|
|
3374
|
+
* that is not on PATH (the npm CLI puts only `vexp` there), gave a worktree
|
|
3375
|
+
* window the main checkout's index or no vexp at all (field report,
|
|
3376
|
+
* 2026-11-07).
|
|
3377
|
+
* (a) the pin, as VS Code expands it in .mcp.json (dotMcpJsonPin): none,
|
|
3378
|
+
* this root (sameFolderAsExtension; a relative pin is read against
|
|
3379
|
+
* the root, the folder VS Code starts the server in), or an env
|
|
3380
|
+
* VEXP_WORKSPACE that still holds `${` or is blank (VS Code expands no
|
|
3381
|
+
* ${workspaceFolder} in .mcp.json, and both servers then fall back to
|
|
3382
|
+
* that folder, E3). A --workspace in args that names no folder fails:
|
|
3383
|
+
* vexp-core exits on it;
|
|
3384
|
+
* (b) the command: a stdio entry (an http or sse one fails) whose command,
|
|
3385
|
+
* expanded as VS Code does, is an absolute path to an executable file
|
|
3386
|
+
* or a bare name found on this process's PATH (with PATHEXT on
|
|
3387
|
+
* Windows), and, when it runs node or vexp's bundle, whose absolute
|
|
3388
|
+
* script paths in args exist.
|
|
3389
|
+
* The pin is asked first, so a line names the pin when both fail. Read only
|
|
3390
|
+
* the file system, never git. Lockstep with the extension's dotMcpJsonMiss.
|
|
3391
|
+
*/
|
|
3392
|
+
export function dotMcpJsonMiss(entry, root, env = process.env, platform = process.platform) {
|
|
3393
|
+
// A --workspace that vexp-core cannot take (no value after it, or
|
|
3394
|
+
// `--workspace=` alone) stops it before it reads anything else.
|
|
3395
|
+
if (workspaceFlagWithoutValue(entry))
|
|
3396
|
+
return { pin: NO_WORKSPACE_VALUE };
|
|
3397
|
+
const p = vexpPinOf(entry);
|
|
3398
|
+
if (p !== undefined) {
|
|
3399
|
+
// The pin as VS Code hands it over from .mcp.json (dotMcpJsonPin), so
|
|
3400
|
+
// this line and D2 name the same folder for one entry.
|
|
3401
|
+
const folder = dotMcpJsonPin(p.pin, root, env, platform);
|
|
3402
|
+
if (folder !== undefined && !sameFolderAsExtension(folder, root))
|
|
3403
|
+
return { pin: folder };
|
|
3404
|
+
// Only an env pin that names no folder falls back to the folder: both
|
|
3405
|
+
// servers drop such a VEXP_WORKSPACE (utils::workspace_env_value,
|
|
3406
|
+
// workspaceEnv), while vexp-core canonicalizes a --workspace value and
|
|
3407
|
+
// exits on one that names no folder ("Cannot resolve workspace path").
|
|
3408
|
+
// A `--workspace ${workspaceFolder}` that VS Code moved verbatim from
|
|
3409
|
+
// .vscode/mcp.json into .mcp.json passed here, and vexp's working entry
|
|
3410
|
+
// was removed for a server that dies at start (review, 2026-11-08).
|
|
3411
|
+
if (folder === undefined && p.from === "args") {
|
|
3412
|
+
const text = dotMcpJsonExpand(p.pin, env, platform);
|
|
3413
|
+
return { pin: text.trim() === "" ? NO_WORKSPACE_VALUE : text };
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
const command = dotMcpJsonCommandMissing(entry, env, platform);
|
|
3417
|
+
return command === undefined ? undefined : { command };
|
|
3418
|
+
}
|
|
3419
|
+
/** What the (a) line names for a --workspace with no usable value. ASCII, the extension's words. */
|
|
3420
|
+
export const NO_WORKSPACE_VALUE = "(none: --workspace has no value)";
|
|
3421
|
+
/**
|
|
3422
|
+
* Does `entry` (not one that runs vexp's Node bundle, which parses no
|
|
3423
|
+
* --workspace) hand vexp-core a --workspace it cannot take: the last arg,
|
|
3424
|
+
* one followed by a blank or a non-string, or `--workspace=` alone?
|
|
3425
|
+
* vexpPinOf reads no pin there and falls back to env, but vexp-core exits
|
|
3426
|
+
* on it first. Lockstep with the extension.
|
|
3427
|
+
*/
|
|
3428
|
+
function workspaceFlagWithoutValue(entry) {
|
|
3429
|
+
if (!asPlainObject(entry) || !Array.isArray(entry.args))
|
|
3430
|
+
return false;
|
|
3431
|
+
const args = entry.args;
|
|
3432
|
+
if (args.some((a) => typeof a === "string" && /mcp-server\.cjs$/i.test(a)))
|
|
3433
|
+
return false;
|
|
3434
|
+
const i = args.indexOf("--workspace");
|
|
3435
|
+
if (i >= 0)
|
|
3436
|
+
return !(typeof args[i + 1] === "string" && args[i + 1].trim() !== "");
|
|
3437
|
+
return args.includes("--workspace=");
|
|
3438
|
+
}
|
|
3439
|
+
/** dotMcpJsonMiss (b): undefined when the entry would start here, else the command text its line names. */
|
|
3440
|
+
function dotMcpJsonCommandMissing(entry, env, platform) {
|
|
3441
|
+
const e = asPlainObject(entry) ? entry : {};
|
|
3442
|
+
if (typeof e.type === "string" && e.type.toLowerCase() !== "stdio")
|
|
3443
|
+
return `(none: a ${e.type} server)`;
|
|
3444
|
+
if (typeof e.command !== "string" || e.command.trim() === "")
|
|
3445
|
+
return "(none)";
|
|
3446
|
+
// VS Code expands the command and every arg of a .mcp.json entry from its
|
|
3447
|
+
// environment before it starts the server (dotMcpJsonExpand): what is
|
|
3448
|
+
// looked for here is what it runs.
|
|
3449
|
+
const command = dotMcpJsonExpand(e.command, env, platform);
|
|
3450
|
+
if (!commandFoundHere(command, env, platform))
|
|
3451
|
+
return command;
|
|
3452
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
3453
|
+
const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string").map((a) => dotMcpJsonExpand(a, env, platform)) : [];
|
|
3454
|
+
const base = p.basename(command).toLowerCase().replace(/\.exe$/, "");
|
|
3455
|
+
if (base === "node" || base === "nodejs" || args.some((a) => /mcp-server\.cjs$/i.test(a))) {
|
|
3456
|
+
// A script needs to be there, not executable: node reads it.
|
|
3457
|
+
const missing = args.find((a) => p.isAbsolute(a) && /\.[cm]?js$/i.test(a) && !isPlainFile(a));
|
|
3458
|
+
if (missing !== undefined)
|
|
3459
|
+
return `${command} ${missing}`;
|
|
3460
|
+
}
|
|
3461
|
+
return undefined;
|
|
3462
|
+
}
|
|
3463
|
+
/** A command VS Code could start: an absolute path to an executable file, or a bare name on PATH (PATHEXT on Windows). A relative path names neither. */
|
|
3464
|
+
function commandFoundHere(command, env, platform) {
|
|
3465
|
+
const win = platform === "win32";
|
|
3466
|
+
if ((win ? path.win32 : path.posix).isAbsolute(command))
|
|
3467
|
+
return runnableFile(command, platform);
|
|
3468
|
+
if (/[\\/]/.test(command))
|
|
3469
|
+
return false;
|
|
3470
|
+
// Windows keeps the variable as Path; the lookup is case-blind there.
|
|
3471
|
+
const pathVar = win ? Object.entries(env).find(([k]) => k.toUpperCase() === "PATH")?.[1] : env.PATH;
|
|
3472
|
+
const pathExt = win ? Object.entries(env).find(([k]) => k.toUpperCase() === "PATHEXT")?.[1] : undefined;
|
|
3473
|
+
const exts = (pathExt ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((x) => x !== "");
|
|
3474
|
+
const names = !win ? [command] : exts.some((x) => command.toLowerCase().endsWith(x.toLowerCase())) ? [command] : exts.map((x) => command + x);
|
|
3475
|
+
for (const dir of (pathVar ?? "").split(win ? ";" : ":")) {
|
|
3476
|
+
if (dir === "")
|
|
3477
|
+
continue;
|
|
3478
|
+
if (names.some((n) => runnableFile(path.join(dir, n), platform)))
|
|
3479
|
+
return true;
|
|
3480
|
+
}
|
|
3481
|
+
return false;
|
|
3482
|
+
}
|
|
3483
|
+
function isPlainFile(p) {
|
|
3484
|
+
try {
|
|
3485
|
+
return fs.statSync(p).isFile();
|
|
3486
|
+
}
|
|
3487
|
+
catch {
|
|
3488
|
+
return false;
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
/** A file, executable for this user except on Windows (where a file is enough). */
|
|
3492
|
+
function runnableFile(p, platform) {
|
|
3493
|
+
try {
|
|
3494
|
+
if (!fs.statSync(p).isFile())
|
|
3495
|
+
return false;
|
|
3496
|
+
if (platform !== "win32")
|
|
3497
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
3498
|
+
return true;
|
|
3499
|
+
}
|
|
3500
|
+
catch {
|
|
3501
|
+
return false;
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
/**
|
|
3505
|
+
* Why vexp writes its own entry into .vscode/mcp.json although
|
|
3506
|
+
* `<root>/.mcp.json` defines vexp (M2): a setup note and the doctor's INFO
|
|
3507
|
+
* line, word for word the extension's. ASCII.
|
|
3508
|
+
*/
|
|
3509
|
+
export function dotMcpJsonKeptLine(root, miss) {
|
|
3510
|
+
const file = path.join(root, ".mcp.json");
|
|
3511
|
+
return miss.pin !== undefined
|
|
3512
|
+
? `${file} has a vexp server pinned to ${miss.pin}, so vexp keeps its own entry in .vscode/mcp.json, which VS Code uses here instead`
|
|
3513
|
+
: `${file} has a vexp server whose command ${miss.command} is not found here, so vexp keeps its own entry in .vscode/mcp.json, which VS Code uses here instead`;
|
|
3514
|
+
}
|
|
3515
|
+
/**
|
|
3516
|
+
* A linked worktree's .vscode/mcp.json that holds vexp alone, without the
|
|
3517
|
+
* servers of the main checkout's copy, which git does not track and so `git
|
|
3518
|
+
* worktree add` did not check out (W5). Word for word the extension's
|
|
3519
|
+
* worktreeMcpJsonLine. ASCII.
|
|
3520
|
+
*/
|
|
3521
|
+
export function worktreeMcpJsonLine(file, mainFile, names) {
|
|
3522
|
+
return `${file} was created with vexp only: ${mainFile} is not tracked by git, so its other servers (${names.join(", ")}) are not in this worktree - copy them here, or move them to your user mcp.json so every window has them`;
|
|
3523
|
+
}
|
|
3524
|
+
/**
|
|
3525
|
+
* The W5 line for `file`, a .vscode/mcp.json inside the linked worktree
|
|
3526
|
+
* `workspaceRoot` that holds vexp alone, while the main checkout's copy at
|
|
3527
|
+
* the same place is untracked and holds other servers; undefined anywhere
|
|
3528
|
+
* else. What
|
|
3529
|
+
* `vexp doctor` says (D4): `git worktree add` checks out only what git
|
|
3530
|
+
* tracks, so a repository whose .gitignore leaves .vscode/* out gave the
|
|
3531
|
+
* worktree no copy, and a setup made one with vexp alone (field report,
|
|
3532
|
+
* 2026-11-07: "dropped teamdocs"). vexp never copies another server. Lockstep
|
|
3533
|
+
* with the extension's worktreeMcpJsonNote.
|
|
3534
|
+
*/
|
|
3535
|
+
export function worktreeMcpJsonNote(workspaceRoot, file) {
|
|
3536
|
+
const main = linkedWorktreeMain(workspaceRoot);
|
|
3537
|
+
if (!main)
|
|
3538
|
+
return undefined;
|
|
3539
|
+
const rel = path.relative(workspaceRoot, file);
|
|
3540
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
|
|
3541
|
+
return undefined;
|
|
3542
|
+
const mainFile = path.join(main, rel);
|
|
3543
|
+
if (!fs.existsSync(mainFile) || !fs.existsSync(file) || isGitTracked(mainFile))
|
|
3544
|
+
return undefined;
|
|
3545
|
+
const theirs = readJsonConfigSafe(mainFile);
|
|
3546
|
+
const servers = theirs.ok ? theirs.data.servers : undefined;
|
|
3547
|
+
if (!asPlainObject(servers))
|
|
3548
|
+
return undefined;
|
|
3549
|
+
const here = readJsonConfigSafe(file);
|
|
3550
|
+
const ours = here.ok && asPlainObject(here.data.servers) ? Object.keys(here.data.servers) : [];
|
|
3551
|
+
// The line says the file "was created with vexp only" (W5): true only
|
|
3552
|
+
// while it holds vexp alone. A file with a server of the user's, or none,
|
|
3553
|
+
// or one that does not parse, was made or changed by hand, and the doctor,
|
|
3554
|
+
// which asks at every run, misstated it (review, 2026-11-07).
|
|
3555
|
+
if (ours.length === 0 || ours.some((n) => n.toLowerCase() !== "vexp"))
|
|
3556
|
+
return undefined;
|
|
3557
|
+
const names = Object.keys(servers).filter((n) => n.toLowerCase() !== "vexp");
|
|
3558
|
+
return names.length > 0 ? worktreeMcpJsonLine(file, mainFile, names) : undefined;
|
|
3559
|
+
}
|
|
3560
|
+
/**
|
|
3561
|
+
* True when a cleanup for `agent` must not run: one of `files` (those that
|
|
3562
|
+
* exist) is committed to this repository. Each such file gets
|
|
3563
|
+
* committedCleanupLine. The caller then changes none of them, so a hook
|
|
3564
|
+
* script and the entry that runs it stay together.
|
|
3565
|
+
*/
|
|
3566
|
+
function committedCleanupBlocked(files, agent) {
|
|
3567
|
+
let blocked = false;
|
|
3568
|
+
for (const f of files) {
|
|
3569
|
+
if (!f || !fs.existsSync(f) || !isGitTracked(f))
|
|
3570
|
+
continue;
|
|
3571
|
+
noteSetup(committedCleanupLine(f, agent));
|
|
3572
|
+
blocked = true;
|
|
3573
|
+
}
|
|
3574
|
+
return blocked;
|
|
3575
|
+
}
|
|
3576
|
+
/** The hook scripts the latest hookScriptCurrent call for each left as committed (K1): what the setup report must not call written. */
|
|
3577
|
+
const hookScriptsLeft = new Set();
|
|
3578
|
+
/** Did the latest setup leave the committed hook script `file` as it was (hookScriptCurrent)? */
|
|
3579
|
+
export function hookScriptLeft(file) {
|
|
3580
|
+
return hookScriptsLeft.has(file);
|
|
3581
|
+
}
|
|
3582
|
+
/**
|
|
3583
|
+
* Does the hook script at `file` need no write: it holds `wanted` already,
|
|
3584
|
+
* or it is committed to the repository, holds exactly what this vexp writes
|
|
3585
|
+
* for the engine path it bakes (`plainFor`, in this CLI's form or with the
|
|
3586
|
+
* extension's upgrade fallback) and that path still finds a working engine
|
|
3587
|
+
* here (K1, hook-scripts.ts committedHookScriptStays)? The second case is
|
|
3588
|
+
* said at the end of setup (committedHookScriptLine). Every writer of a
|
|
3589
|
+
* script with a baked engine path asks here first: another machine, an
|
|
3590
|
+
* extension update or the extension's own copy rewrote the committed
|
|
3591
|
+
* .claude/hooks and .codex scripts in every checkout, and `git add -A`
|
|
3592
|
+
* committed the churn (field report, 2026-11-07). git runs only for a
|
|
3593
|
+
* script that differs from `wanted` and is this vexp's text for another
|
|
3594
|
+
* engine path. Lockstep with the extension's hookScriptCurrent.
|
|
3595
|
+
*/
|
|
3596
|
+
function hookScriptCurrent(file, wanted, plainFor) {
|
|
3597
|
+
hookScriptsLeft.delete(file);
|
|
3598
|
+
const text = readHookScript(file);
|
|
3599
|
+
if (text === undefined)
|
|
3600
|
+
return false;
|
|
3601
|
+
if (text === wanted)
|
|
3602
|
+
return true;
|
|
3603
|
+
if (!committedHookScriptStays(text, plainFor, () => isGitTracked(file)))
|
|
3604
|
+
return false;
|
|
3605
|
+
noteSetup(committedHookScriptLine(file));
|
|
3606
|
+
hookScriptsLeft.add(file);
|
|
3607
|
+
return true;
|
|
3608
|
+
}
|
|
3609
|
+
/**
|
|
3610
|
+
* The folder a vexp pin names once VS Code has expanded it: `root` for
|
|
3611
|
+
* "${workspaceFolder}" or "${workspaceFolder:<name>}" alone (optionally
|
|
3612
|
+
* followed by one separator); ${userHome} becomes the home folder and
|
|
3613
|
+
* ${env:NAME} the variable's value. undefined when a variable is left that
|
|
3614
|
+
* cannot be expanded here (${input:...}, ${config:...},
|
|
3615
|
+
* ${workspaceFolder}/sub, an unset ${env:NAME}): such a pin is not
|
|
3616
|
+
* compared. "Any pin with ${ is this root" let a committed
|
|
3617
|
+
* "${userHome}/src/app", which VS Code expands to one folder in every
|
|
3618
|
+
* checkout, serve that folder's index to each worktree with no word from
|
|
3619
|
+
* either doctor (review, 2026-11-07). Lockstep with the extension's.
|
|
3620
|
+
*/
|
|
3621
|
+
export function expandedPin(pin, root, env = process.env, home = os.homedir()) {
|
|
3622
|
+
if (/^\$\{workspaceFolder(?::[^}]*)?\}[\\/]?$/.test(pin))
|
|
3623
|
+
return root;
|
|
3624
|
+
let unresolved = false;
|
|
3625
|
+
const out = pin.replace(/\$\{(userHome|env:([^}]+))\}/g, (_m, v, name) => {
|
|
3626
|
+
if (v === "userHome")
|
|
3627
|
+
return home;
|
|
3628
|
+
const val = name === undefined ? undefined : env[name];
|
|
3629
|
+
if (!val)
|
|
3630
|
+
unresolved = true;
|
|
3631
|
+
return val ?? "";
|
|
3632
|
+
});
|
|
3633
|
+
return unresolved || out.includes("${") ? undefined : out;
|
|
3634
|
+
}
|
|
3635
|
+
/**
|
|
3636
|
+
* A value of <root>/.mcp.json as VS Code (1.142+) hands it to the server,
|
|
3637
|
+
* by the Copilot CLI's rules it follows there (mcpEnvironmentVariableExpansion,
|
|
3638
|
+
* envVarResolution): `${NAME}` and `${NAME:-default}` take NAME
|
|
3639
|
+
* (`[A-Za-z_][A-Za-z0-9_]*`) from `env`, case-blind on Windows; a variable
|
|
3640
|
+
* set to "" gives "", and a default is put in as it is, never expanded
|
|
3641
|
+
* again. An unset name with no default, a bare `$NAME`, and the variables
|
|
3642
|
+
* of .vscode/mcp.json (`${userHome}`, `${env:X}`, `${workspaceFolder}`,
|
|
3643
|
+
* `${input:x}`) stay verbatim; a value over 1000 characters is left whole.
|
|
3644
|
+
* A default ends at its first `}` here: VS Code also balances `${...}` and
|
|
3645
|
+
* `$(...)` inside one, which a vexp pin or command has no use for. Read
|
|
3646
|
+
* with expandedPin, .vscode/mcp.json's rules, "${userHome}/elsewhere" got
|
|
3647
|
+
* a D2 warning beside D4's [OK] for one entry, and "${HOME}", which VS Code
|
|
3648
|
+
* does expand, passed M2 (review, 2026-11-08). Lockstep with the
|
|
3649
|
+
* extension's.
|
|
3650
|
+
*/
|
|
3651
|
+
export function dotMcpJsonExpand(value, env = process.env, platform = process.platform) {
|
|
3652
|
+
if (value.length > 1000)
|
|
3653
|
+
return value;
|
|
3654
|
+
const lookup = (name) => {
|
|
3655
|
+
if (env[name] !== undefined || platform !== "win32")
|
|
3656
|
+
return env[name];
|
|
3657
|
+
const key = Object.keys(env).find((k) => k.toUpperCase() === name.toUpperCase());
|
|
3658
|
+
return key === undefined ? undefined : env[key];
|
|
3659
|
+
};
|
|
3660
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (whole, name, fallback) => {
|
|
3661
|
+
const v = lookup(name);
|
|
3662
|
+
if (v !== undefined)
|
|
3663
|
+
return v;
|
|
3664
|
+
return fallback ?? whole;
|
|
3665
|
+
});
|
|
3666
|
+
}
|
|
3667
|
+
/**
|
|
3668
|
+
* The folder a vexp pin of <root>/.mcp.json names once VS Code has
|
|
3669
|
+
* expanded it (dotMcpJsonExpand), read against `root`, the folder VS Code
|
|
3670
|
+
* starts the server in; undefined when it names none: what is left still
|
|
3671
|
+
* holds `${`, or is blank. Both servers drop such a VEXP_WORKSPACE and
|
|
3672
|
+
* serve that folder (E3). M2 (a) and D2 read .mcp.json through it, so the
|
|
3673
|
+
* two lines agree on one entry; .vscode/mcp.json is read with expandedPin.
|
|
3674
|
+
* Lockstep with the extension's.
|
|
3675
|
+
*/
|
|
3676
|
+
export function dotMcpJsonPin(pin, root, env = process.env, platform = process.platform) {
|
|
3677
|
+
const value = dotMcpJsonExpand(pin, env, platform);
|
|
3678
|
+
if (value.trim() === "" || value.includes("${"))
|
|
3679
|
+
return undefined;
|
|
3680
|
+
return path.resolve(root, value);
|
|
3681
|
+
}
|
|
3682
|
+
/** The VEXP_WORKSPACE of an entry's env, if it has one. */
|
|
3683
|
+
function envPin(env) {
|
|
3684
|
+
return asPlainObject(env) ? env.VEXP_WORKSPACE : undefined;
|
|
3685
|
+
}
|
|
3686
|
+
/**
|
|
3687
|
+
* The pin a writer keeps in a committed project file: `file` is inside
|
|
3688
|
+
* `workspaceRoot` and tracked by git, and vexp's entry there is pinned
|
|
3689
|
+
* (`prevPin`) to a folder other than this one (another checkout's absolute
|
|
3690
|
+
* path, `other`), or by a variable the agent expands (`${workspaceFolder}`,
|
|
3691
|
+
* the same text in every checkout). The writer then leaves the pin as it
|
|
3692
|
+
* is, and touches the entry only when the vexp install it runs is gone
|
|
3693
|
+
* (staleVexpInstall). undefined when the writer pins as before: no pin, a
|
|
3694
|
+
* relative one, this folder in any spelling, a machine-wide file, an
|
|
3695
|
+
* untracked one. git runs only for an entry pinned so. Lockstep with the
|
|
3696
|
+
* extension's committedPinElsewhere.
|
|
3697
|
+
*/
|
|
3698
|
+
function committedPin(file, workspaceRoot, prevPin) {
|
|
3699
|
+
if (!pinnedProjectFile(file, workspaceRoot))
|
|
3700
|
+
return undefined;
|
|
3701
|
+
if (typeof prevPin !== "string" || prevPin === "")
|
|
3702
|
+
return undefined;
|
|
3703
|
+
const variable = prevPin.includes("${");
|
|
3704
|
+
if (!variable && (!path.isAbsolute(prevPin) || sameFolder(prevPin, workspaceRoot)))
|
|
3705
|
+
return undefined;
|
|
3706
|
+
// Another checkout of this repository on this machine only: a pin a
|
|
3707
|
+
// teammate's clone, a moved folder or a removed worktree left names a
|
|
3708
|
+
// folder that is not a checkout of this repository here, and the writer
|
|
3709
|
+
// pins as before (sameRepositoryCheckout).
|
|
3710
|
+
if (!variable && !sameRepositoryCheckout(prevPin, workspaceRoot))
|
|
3711
|
+
return undefined;
|
|
3712
|
+
if (!isGitTracked(file))
|
|
3713
|
+
return undefined;
|
|
3714
|
+
if (!variable)
|
|
3715
|
+
return { pin: prevPin, other: prevPin };
|
|
3716
|
+
// A variable that VS Code expands to one folder in every checkout
|
|
3717
|
+
// (${userHome}/..., ${env:NAME}) is kept as before, and named when that
|
|
3718
|
+
// folder is not this one (expandedPin).
|
|
3719
|
+
const folder = expandedPin(prevPin, workspaceRoot);
|
|
3720
|
+
return folder !== undefined && path.isAbsolute(folder) && !sameFolder(folder, workspaceRoot)
|
|
3721
|
+
? { pin: prevPin, other: folder }
|
|
3722
|
+
: { pin: prevPin };
|
|
3723
|
+
}
|
|
3724
|
+
/** Say why a committed file pinned elsewhere was left alone; false, for the writers' return. */
|
|
3725
|
+
function noteCommittedPin(file, kept) {
|
|
3726
|
+
if (kept.other)
|
|
3727
|
+
noteSetup(committedPinLine(file, kept.other));
|
|
3728
|
+
return false;
|
|
3729
|
+
}
|
|
3730
|
+
/**
|
|
3731
|
+
* Does `entry` run a vexp install that is gone? Its command or a script it
|
|
3732
|
+
* names lies in a vexp install (namesVexpInstall: an extension folder, the
|
|
3733
|
+
* npm package's binary, a bundled mcp-server.cjs) and does not resolve any
|
|
3734
|
+
* more. In every shape the writers read (Kilo's command array, Zed's nested
|
|
3735
|
+
* command). The only reason to touch an entry in a committed file another
|
|
3736
|
+
* checkout pinned, or a `.mcp.json` the user keeps: a bare `node` or another
|
|
3737
|
+
* install's working path is the same on every checkout. The CLI's own entry
|
|
3738
|
+
* names no versioned folder, so "older than this install" never applies
|
|
3739
|
+
* here; the extension's twin also moves one into an older extension folder.
|
|
3740
|
+
*/
|
|
3741
|
+
function staleVexpInstall(entry) {
|
|
3742
|
+
if (!asPlainObject(entry))
|
|
3743
|
+
return false;
|
|
3744
|
+
let command = entry.command;
|
|
3745
|
+
let args = entry.args;
|
|
3746
|
+
if (Array.isArray(command)) {
|
|
3747
|
+
args = command.slice(1);
|
|
3748
|
+
command = command[0];
|
|
3749
|
+
}
|
|
3750
|
+
else if (asPlainObject(command)) {
|
|
3751
|
+
args = command.args;
|
|
3752
|
+
command = command.path;
|
|
3753
|
+
}
|
|
3754
|
+
const flat = { command, args };
|
|
3755
|
+
return namesVexpInstall(flat) && !vexpEntryStillResolves(entry);
|
|
3756
|
+
}
|
|
3757
|
+
/**
|
|
3758
|
+
* The layout of a JSON(C) text whose top level is an object: comments,
|
|
3759
|
+
* trailing commas and a BOM allowed, by the rules removeJsoncMember reads
|
|
3760
|
+
* them. undefined when the text is not shaped so.
|
|
3761
|
+
*/
|
|
3762
|
+
function jsoncLayout(text) {
|
|
3763
|
+
const n = text.length;
|
|
3764
|
+
const skip = (i) => {
|
|
3765
|
+
for (;;) {
|
|
3766
|
+
while (i < n && /\s/.test(text[i]))
|
|
3767
|
+
i++; // \s takes a BOM too
|
|
3768
|
+
if (text[i] === "/" && text[i + 1] === "/") {
|
|
3769
|
+
while (i < n && text[i] !== "\n")
|
|
3770
|
+
i++;
|
|
3771
|
+
}
|
|
3772
|
+
else if (text[i] === "/" && text[i + 1] === "*") {
|
|
3773
|
+
const close = text.indexOf("*/", i + 2);
|
|
3774
|
+
if (close < 0)
|
|
3775
|
+
throw new Error("open comment");
|
|
3776
|
+
i = close + 2;
|
|
3777
|
+
}
|
|
3778
|
+
else {
|
|
3779
|
+
return i;
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
};
|
|
3783
|
+
const str = (i) => {
|
|
3784
|
+
for (i++; i < n; i++) {
|
|
3785
|
+
if (text[i] === "\\")
|
|
3786
|
+
i++;
|
|
3787
|
+
else if (text[i] === '"')
|
|
3788
|
+
return i + 1;
|
|
3789
|
+
}
|
|
3790
|
+
throw new Error("open string");
|
|
3791
|
+
};
|
|
3792
|
+
const value = (i) => {
|
|
3793
|
+
if (text[i] === '"')
|
|
3794
|
+
return { start: i, end: str(i), kind: "other", members: [] };
|
|
3795
|
+
if (text[i] === "{" || text[i] === "[") {
|
|
3796
|
+
const close = text[i] === "{" ? "}" : "]";
|
|
3797
|
+
const members = [];
|
|
3798
|
+
let j = skip(i + 1);
|
|
3799
|
+
while (text[j] !== close) {
|
|
3800
|
+
if (j >= n)
|
|
3801
|
+
throw new Error("open container");
|
|
3802
|
+
const start = j;
|
|
3803
|
+
let key = "";
|
|
3804
|
+
if (close === "}") {
|
|
3805
|
+
if (text[j] !== '"')
|
|
3806
|
+
throw new Error("key expected");
|
|
3807
|
+
const keyEnd = str(j);
|
|
3808
|
+
key = JSON.parse(text.slice(j, keyEnd));
|
|
3809
|
+
j = skip(keyEnd);
|
|
3810
|
+
if (text[j] !== ":")
|
|
3811
|
+
throw new Error(": expected");
|
|
3812
|
+
j = skip(j + 1);
|
|
3813
|
+
}
|
|
3814
|
+
const v = value(j);
|
|
3815
|
+
const m = { key, start, end: v.end, value: v };
|
|
3816
|
+
j = skip(v.end);
|
|
3817
|
+
if (text[j] === ",") {
|
|
3818
|
+
m.comma = j;
|
|
3819
|
+
j = skip(j + 1);
|
|
3820
|
+
}
|
|
3821
|
+
else if (text[j] !== close) {
|
|
3822
|
+
throw new Error(", expected");
|
|
3823
|
+
}
|
|
3824
|
+
members.push(m);
|
|
3825
|
+
}
|
|
3826
|
+
return { start: i, end: j + 1, kind: close === "}" ? "object" : "array", members };
|
|
3827
|
+
}
|
|
3828
|
+
let end = i;
|
|
3829
|
+
while (end < n && !/[\s,}\]\/]/.test(text[end]))
|
|
3830
|
+
end++;
|
|
3831
|
+
if (end === i)
|
|
3832
|
+
throw new Error("value expected");
|
|
3833
|
+
return { start: i, end, kind: "other", members: [] };
|
|
3834
|
+
};
|
|
3835
|
+
try {
|
|
3836
|
+
const at = skip(0);
|
|
3837
|
+
if (text[at] !== "{")
|
|
3838
|
+
return undefined;
|
|
3839
|
+
const top = value(at);
|
|
3840
|
+
if (skip(top.end) !== n)
|
|
3841
|
+
return undefined;
|
|
3842
|
+
return top;
|
|
3843
|
+
}
|
|
3844
|
+
catch {
|
|
3845
|
+
return undefined;
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
/** The blanks that open the line `pos` is on. */
|
|
3849
|
+
function lineIndent(text, pos) {
|
|
3850
|
+
let s = pos;
|
|
3851
|
+
while (s > 0 && text[s - 1] !== "\n")
|
|
3852
|
+
s--;
|
|
3853
|
+
let e = s;
|
|
3854
|
+
while (e < pos && (text[e] === " " || text[e] === "\t"))
|
|
3855
|
+
e++;
|
|
3856
|
+
return text.slice(s, e);
|
|
3857
|
+
}
|
|
3858
|
+
/** Is `pos` the first thing on its line? */
|
|
3859
|
+
function startsLine(text, pos) {
|
|
3860
|
+
let s = pos;
|
|
3861
|
+
while (s > 0 && (text[s - 1] === " " || text[s - 1] === "\t"))
|
|
3862
|
+
s--;
|
|
3863
|
+
return s === 0 || text[s - 1] === "\n";
|
|
3864
|
+
}
|
|
3865
|
+
/**
|
|
3866
|
+
* `value` set at `keys` (a path of object keys from the top level) in a
|
|
3867
|
+
* JSON(C) text, and nothing else changed: the user's comments, BOM, key
|
|
3868
|
+
* order, layout and last newline stay. A member that is there gets its
|
|
3869
|
+
* value replaced where it stands; one that is not is added after the last
|
|
3870
|
+
* member of its object (an object on the way that is missing is added the
|
|
3871
|
+
* same way, holding the rest of the path). The new text is indented as the
|
|
3872
|
+
* file is. undefined when the text is not shaped so, a key on the path is
|
|
3873
|
+
* there twice or is not an object, or the result would not parse to
|
|
3874
|
+
* exactly the same data with that value set: the caller then writes the
|
|
3875
|
+
* file as it did before. The counterpart of removeJsoncMember. Lockstep
|
|
3876
|
+
* with the extension.
|
|
3877
|
+
*/
|
|
3878
|
+
export function upsertJsoncMember(text, keys, value) {
|
|
3879
|
+
if (keys.length === 0)
|
|
3880
|
+
return undefined;
|
|
3881
|
+
const top = jsoncLayout(text);
|
|
3882
|
+
if (!top)
|
|
3883
|
+
return undefined;
|
|
3884
|
+
const nl = text.includes("\r\n") ? "\r\n" : "\n";
|
|
3885
|
+
let unit = " ";
|
|
3886
|
+
const base = lineIndent(text, top.start);
|
|
3887
|
+
for (const m of top.members) {
|
|
3888
|
+
if (!startsLine(text, m.start))
|
|
3889
|
+
continue;
|
|
3890
|
+
const ind = lineIndent(text, m.start);
|
|
3891
|
+
if (ind.startsWith(base) && ind.length > base.length)
|
|
3892
|
+
unit = ind.slice(base.length);
|
|
3893
|
+
break;
|
|
3894
|
+
}
|
|
3895
|
+
const render = (v, indent) => JSON.stringify(v, null, unit).split("\n").join(nl + indent);
|
|
3896
|
+
let node = top;
|
|
3897
|
+
let depth = 0;
|
|
3898
|
+
for (; depth < keys.length - 1; depth++) {
|
|
3899
|
+
const hits = node.members.filter((m) => m.key === keys[depth]);
|
|
3900
|
+
if (hits.length > 1)
|
|
3901
|
+
return undefined;
|
|
3902
|
+
if (hits.length === 0)
|
|
3903
|
+
break;
|
|
3904
|
+
if (hits[0].value.kind !== "object")
|
|
3905
|
+
return undefined;
|
|
3906
|
+
node = hits[0].value;
|
|
3907
|
+
}
|
|
3908
|
+
const hits = node.members.filter((m) => m.key === keys[depth]);
|
|
3909
|
+
if (hits.length > 1)
|
|
3910
|
+
return undefined;
|
|
3911
|
+
let out;
|
|
3912
|
+
if (depth === keys.length - 1 && hits.length === 1) {
|
|
3913
|
+
const m = hits[0];
|
|
3914
|
+
// A list or a value that sat on one line stays on one line (the args of
|
|
3915
|
+
// a one-line entry); an object is laid out as the file is, unless it
|
|
3916
|
+
// shares its line with other members (a one-line file), where it stays
|
|
3917
|
+
// on that line: laid out, it broke the line in two. The extension's
|
|
3918
|
+
// twin keeps the same rule, so both write the same bytes.
|
|
3919
|
+
const oneLine = !text.slice(m.value.start, m.value.end).includes("\n") && (!asPlainObject(value) || !startsLine(text, m.start));
|
|
3920
|
+
out = text.slice(0, m.value.start) + (oneLine ? JSON.stringify(value) : render(value, lineIndent(text, m.start))) + text.slice(m.value.end);
|
|
3921
|
+
}
|
|
3922
|
+
else {
|
|
3923
|
+
let v = value;
|
|
3924
|
+
for (let k = keys.length - 1; k > depth; k--)
|
|
3925
|
+
v = { [keys[k]]: v };
|
|
3926
|
+
const key = JSON.stringify(keys[depth]);
|
|
3927
|
+
const parentIndent = lineIndent(text, node.start);
|
|
3928
|
+
if (node.members.length === 0) {
|
|
3929
|
+
const indent = parentIndent + unit;
|
|
3930
|
+
const open = node.start + 1;
|
|
3931
|
+
const inner = text.slice(open, node.end - 1);
|
|
3932
|
+
out =
|
|
3933
|
+
inner.trim() === ""
|
|
3934
|
+
? text.slice(0, open) + nl + indent + key + ": " + render(v, indent) + nl + parentIndent + text.slice(node.end - 1)
|
|
3935
|
+
: text.slice(0, open) + nl + indent + key + ": " + render(v, indent) + text.slice(open);
|
|
3936
|
+
}
|
|
3937
|
+
else {
|
|
3938
|
+
const last = node.members[node.members.length - 1];
|
|
3939
|
+
const after = last.comma !== undefined ? last.comma + 1 : last.end;
|
|
3940
|
+
const close = node.end - 1;
|
|
3941
|
+
const comma = last.comma === undefined ? "," : "";
|
|
3942
|
+
// The rest of the last member's line: blanks and a // comment, which
|
|
3943
|
+
// stay with it; the new member goes on the line after.
|
|
3944
|
+
let q = after;
|
|
3945
|
+
while (q < close && (text[q] === " " || text[q] === "\t"))
|
|
3946
|
+
q++;
|
|
3947
|
+
if (text[q] === "/" && text[q + 1] === "/")
|
|
3948
|
+
while (q < close && text[q] !== "\n")
|
|
3949
|
+
q++;
|
|
3950
|
+
if (text[q] === "\r" && text[q + 1] === "\n")
|
|
3951
|
+
q++;
|
|
3952
|
+
if (text[q] === "\n" && q < close) {
|
|
3953
|
+
const at = q + 1;
|
|
3954
|
+
const indent = startsLine(text, last.start) ? lineIndent(text, last.start) : parentIndent + unit;
|
|
3955
|
+
out = text.slice(0, last.end) + comma + text.slice(last.end, at) + indent + key + ": " + render(v, indent) + nl + text.slice(at);
|
|
3956
|
+
}
|
|
3957
|
+
else {
|
|
3958
|
+
// All on one line: the member goes right after the last one.
|
|
3959
|
+
out = text.slice(0, after) + (comma ? ", " : " ") + key + ": " + JSON.stringify(v) + text.slice(after);
|
|
3960
|
+
}
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
try {
|
|
3964
|
+
const want = parseJsonc(text);
|
|
3965
|
+
let o = want;
|
|
3966
|
+
for (let k = 0; k < keys.length - 1; k++) {
|
|
3967
|
+
if (!asPlainObject(o[keys[k]]))
|
|
3968
|
+
o[keys[k]] = {};
|
|
3969
|
+
o = o[keys[k]];
|
|
3970
|
+
}
|
|
3971
|
+
o[keys[keys.length - 1]] = value;
|
|
3972
|
+
return JSON.stringify(parseJsonc(out)) === JSON.stringify(want) ? out : undefined;
|
|
3973
|
+
}
|
|
3974
|
+
catch {
|
|
3975
|
+
return undefined;
|
|
3976
|
+
}
|
|
3977
|
+
}
|
|
3978
|
+
/**
|
|
3979
|
+
* The text a JSON MCP writer saves, and the encoding to save it in: `want`,
|
|
3980
|
+
* the data it means to write, which differs from the file's data only by
|
|
3981
|
+
* vexp's member `key`.`inner` (set to `entry`) and the legacy members it
|
|
3982
|
+
* took out (`removed`). A file that is absent or blank is written whole,
|
|
3983
|
+
* as before; one in exactly the layout JSON.stringify(data, null, 2) gives
|
|
3984
|
+
* (what vexp itself writes) gets the bytes the full rewrite gave before,
|
|
3985
|
+
* its BOM and last newline kept; any other file gets only those members
|
|
3986
|
+
* edited in its text (upsertJsoncMember, removeJsoncMember), so its
|
|
3987
|
+
* comments, BOM, layout and last newline stay. Bytes that are not UTF-8
|
|
3988
|
+
* are edited one latin1 character per byte when vexp's own value is ASCII,
|
|
3989
|
+
* so a teammate's `jos\xe9` stays one byte: the full rewrite saved it as
|
|
3990
|
+
* U+FFFD and dropped every comment. If the edit cannot be made safely, the
|
|
3991
|
+
* whole file, as before. The same algorithm as the extension's
|
|
3992
|
+
* memberEditedText, so both twins write the same bytes (worktree-lockstep).
|
|
3993
|
+
*/
|
|
3994
|
+
function memberEditedText(file, want, key, inner, entry, removed = []) {
|
|
3995
|
+
const whole = { text: JSON.stringify(want, null, 2), encoding: "utf-8" };
|
|
3996
|
+
let bytes;
|
|
3997
|
+
try {
|
|
3998
|
+
bytes = fs.readFileSync(file);
|
|
3999
|
+
}
|
|
4000
|
+
catch {
|
|
4001
|
+
return whole;
|
|
4002
|
+
}
|
|
4003
|
+
let raw;
|
|
4004
|
+
let encoding = "utf-8";
|
|
4005
|
+
try {
|
|
4006
|
+
raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
4007
|
+
}
|
|
4008
|
+
catch {
|
|
4009
|
+
if (!/^[\x00-\x7f]*$/.test(JSON.stringify(entry)))
|
|
4010
|
+
return whole;
|
|
4011
|
+
raw = bytes.toString("latin1");
|
|
4012
|
+
encoding = "latin1";
|
|
4013
|
+
}
|
|
4014
|
+
const bom = raw.charCodeAt(0) === 0xfeff ? "" : "";
|
|
4015
|
+
const body = raw.slice(bom.length);
|
|
4016
|
+
if (body.trim() === "")
|
|
4017
|
+
return whole;
|
|
4018
|
+
try {
|
|
4019
|
+
const canon = JSON.stringify(JSON.parse(body), null, 2);
|
|
4020
|
+
if (body.startsWith(canon) && body.slice(canon.length).trim() === "") {
|
|
4021
|
+
return { text: bom + JSON.stringify(want, null, 2) + body.slice(canon.length), encoding };
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
catch {
|
|
4025
|
+
/* JSONC: edited in its text */
|
|
4026
|
+
}
|
|
4027
|
+
let text = raw;
|
|
4028
|
+
for (const k of removed) {
|
|
4029
|
+
text = removeJsoncMember(text, key, k);
|
|
4030
|
+
if (text === undefined)
|
|
4031
|
+
return whole;
|
|
4032
|
+
}
|
|
4033
|
+
text = upsertJsoncMember(text, [key, inner], entry);
|
|
4034
|
+
if (text === undefined)
|
|
4035
|
+
return whole;
|
|
4036
|
+
try {
|
|
4037
|
+
return JSON.stringify(parseJsonc(text)) === JSON.stringify(want) ? { text, encoding } : whole;
|
|
4038
|
+
}
|
|
4039
|
+
catch {
|
|
4040
|
+
return whole;
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
/**
|
|
4044
|
+
* Every real path a write to one of Antigravity's machine-wide files lands
|
|
4045
|
+
* on: each file's own, and its folder's real path joined with its name, so
|
|
4046
|
+
* a link into a folder that holds no such file yet counts as well.
|
|
4047
|
+
*/
|
|
4048
|
+
function antigravityMachineMcpLocations() {
|
|
4049
|
+
const out = [];
|
|
4050
|
+
for (const p of antigravityMachineMcpNames()) {
|
|
4051
|
+
for (const real of [() => fs.realpathSync(p), () => path.join(fs.realpathSync(path.dirname(p)), path.basename(p))]) {
|
|
4052
|
+
try {
|
|
4053
|
+
const r = real();
|
|
4054
|
+
if (!out.includes(r))
|
|
4055
|
+
out.push(r);
|
|
4056
|
+
}
|
|
4057
|
+
catch {
|
|
4058
|
+
/* absent */
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
return out;
|
|
4063
|
+
}
|
|
4064
|
+
/**
|
|
4065
|
+
* `text` with the member `inner` of the top-level object's `outer` object
|
|
4066
|
+
* cut out, and nothing else changed: the user's comments, trailing commas,
|
|
4067
|
+
* BOM and layout stay. Undefined when the text is not shaped so, a key is
|
|
4068
|
+
* there twice, or the cut would not leave exactly the same data less that
|
|
4069
|
+
* member: the caller then leaves the file as it is. Lockstep with the
|
|
4070
|
+
* extension.
|
|
4071
|
+
*/
|
|
4072
|
+
export function removeJsoncMember(text, outer, inner) {
|
|
4073
|
+
const n = text.length;
|
|
4074
|
+
const skip = (i) => {
|
|
4075
|
+
for (;;) {
|
|
4076
|
+
while (i < n && /\s/.test(text[i]))
|
|
4077
|
+
i++; // \s takes a BOM too
|
|
4078
|
+
if (text[i] === "/" && text[i + 1] === "/") {
|
|
4079
|
+
while (i < n && text[i] !== "\n")
|
|
4080
|
+
i++;
|
|
4081
|
+
}
|
|
4082
|
+
else if (text[i] === "/" && text[i + 1] === "*") {
|
|
4083
|
+
const close = text.indexOf("*/", i + 2);
|
|
4084
|
+
if (close < 0)
|
|
4085
|
+
throw new Error("open comment");
|
|
4086
|
+
i = close + 2;
|
|
4087
|
+
}
|
|
4088
|
+
else {
|
|
4089
|
+
return i;
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
};
|
|
4093
|
+
const str = (i) => {
|
|
4094
|
+
for (i++; i < n; i++) {
|
|
4095
|
+
if (text[i] === "\\")
|
|
4096
|
+
i++;
|
|
4097
|
+
else if (text[i] === '"')
|
|
4098
|
+
return i + 1;
|
|
4099
|
+
}
|
|
4100
|
+
throw new Error("open string");
|
|
4101
|
+
};
|
|
4102
|
+
const container = (i) => {
|
|
4103
|
+
const close = text[i] === "{" ? "}" : "]";
|
|
4104
|
+
const members = [];
|
|
4105
|
+
i = skip(i + 1);
|
|
4106
|
+
while (text[i] !== close) {
|
|
4107
|
+
if (i >= n)
|
|
4108
|
+
throw new Error("open container");
|
|
4109
|
+
const start = i;
|
|
4110
|
+
let key = "";
|
|
4111
|
+
if (close === "}") {
|
|
4112
|
+
if (text[i] !== '"')
|
|
4113
|
+
throw new Error("key expected");
|
|
4114
|
+
const keyEnd = str(i);
|
|
4115
|
+
key = JSON.parse(text.slice(i, keyEnd));
|
|
4116
|
+
i = skip(keyEnd);
|
|
4117
|
+
if (text[i] !== ":")
|
|
4118
|
+
throw new Error(": expected");
|
|
4119
|
+
i = skip(i + 1);
|
|
4120
|
+
}
|
|
4121
|
+
const valueStart = i;
|
|
4122
|
+
let end;
|
|
4123
|
+
if (text[i] === '"')
|
|
4124
|
+
end = str(i);
|
|
4125
|
+
else if (text[i] === "{" || text[i] === "[")
|
|
4126
|
+
end = container(i).end;
|
|
4127
|
+
else {
|
|
4128
|
+
end = i;
|
|
4129
|
+
while (end < n && !/[\s,}\]\/]/.test(text[end]))
|
|
4130
|
+
end++;
|
|
4131
|
+
if (end === i)
|
|
4132
|
+
throw new Error("value expected");
|
|
4133
|
+
}
|
|
4134
|
+
const m = { key, start, valueStart, end };
|
|
4135
|
+
i = skip(end);
|
|
4136
|
+
if (text[i] === ",") {
|
|
4137
|
+
m.comma = i;
|
|
4138
|
+
i = skip(i + 1);
|
|
4139
|
+
}
|
|
4140
|
+
else if (text[i] !== close) {
|
|
4141
|
+
throw new Error(", expected");
|
|
4142
|
+
}
|
|
4143
|
+
members.push(m);
|
|
4144
|
+
}
|
|
4145
|
+
return { end: i + 1, members };
|
|
4146
|
+
};
|
|
4147
|
+
try {
|
|
4148
|
+
const at = skip(0);
|
|
4149
|
+
if (text[at] !== "{")
|
|
4150
|
+
return undefined;
|
|
4151
|
+
const top = container(at);
|
|
4152
|
+
if (skip(top.end) !== n)
|
|
4153
|
+
return undefined;
|
|
4154
|
+
const outers = top.members.filter((m) => m.key === outer);
|
|
4155
|
+
if (outers.length !== 1 || text[outers[0].valueStart] !== "{")
|
|
4156
|
+
return undefined;
|
|
4157
|
+
const members = container(outers[0].valueStart).members;
|
|
4158
|
+
const hits = members.filter((m) => m.key === inner);
|
|
4159
|
+
if (hits.length !== 1)
|
|
4160
|
+
return undefined;
|
|
4161
|
+
const hit = hits[0];
|
|
4162
|
+
const k = members.indexOf(hit);
|
|
4163
|
+
// The member with its own comma; the last one without, and then the
|
|
4164
|
+
// comma before it (only that byte: a comment after it is the user's).
|
|
4165
|
+
const cuts = [];
|
|
4166
|
+
let from = hit.start;
|
|
4167
|
+
let to = hit.comma !== undefined ? hit.comma + 1 : hit.end;
|
|
4168
|
+
let a = from;
|
|
4169
|
+
while (a > 0 && (text[a - 1] === " " || text[a - 1] === "\t"))
|
|
4170
|
+
a--;
|
|
4171
|
+
let b = to;
|
|
4172
|
+
while (b < n && (text[b] === " " || text[b] === "\t"))
|
|
4173
|
+
b++;
|
|
4174
|
+
if ((a === 0 || text[a - 1] === "\n") && (text[b] === "\n" || (text[b] === "\r" && text[b + 1] === "\n"))) {
|
|
4175
|
+
// The member's own line: the whole line goes.
|
|
4176
|
+
from = a;
|
|
4177
|
+
to = b + (text[b] === "\r" ? 2 : 1);
|
|
4178
|
+
}
|
|
4179
|
+
else if (hit.comma !== undefined) {
|
|
4180
|
+
to = b; // and the blanks after its comma
|
|
4181
|
+
}
|
|
4182
|
+
else {
|
|
4183
|
+
from = a; // and the blanks before it
|
|
4184
|
+
}
|
|
4185
|
+
cuts.push([from, to]);
|
|
4186
|
+
if (hit.comma === undefined && k > 0) {
|
|
4187
|
+
const before = members[k - 1].comma;
|
|
4188
|
+
cuts.unshift([before, before + 1]);
|
|
4189
|
+
}
|
|
4190
|
+
let out = text;
|
|
4191
|
+
for (const [s, e] of [...cuts].reverse())
|
|
4192
|
+
out = out.slice(0, s) + out.slice(e);
|
|
4193
|
+
// Same data less that member, or nothing.
|
|
4194
|
+
const want = parseJsonc(text);
|
|
4195
|
+
delete want[outer][inner];
|
|
4196
|
+
return JSON.stringify(parseJsonc(out)) === JSON.stringify(want) ? out : undefined;
|
|
4197
|
+
}
|
|
4198
|
+
catch {
|
|
4199
|
+
return undefined;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
/**
|
|
4203
|
+
* The machine-wide files vexp may take its dead entry out of
|
|
4204
|
+
* (antigravityFilesVexpCleans: the 1.x file while it is a regular file, or
|
|
4205
|
+
* the file the 1.x name links to) that hold one now, by real path.
|
|
4206
|
+
*/
|
|
4207
|
+
function deadAntigravityEntryCleanables() {
|
|
4208
|
+
return antigravityFilesVexpCleans(os.homedir()).filter(({ file }) => {
|
|
4209
|
+
const read = readJsonConfigSafe(file);
|
|
4210
|
+
const servers = read.ok ? read.data.mcpServers : undefined;
|
|
4211
|
+
return asPlainObject(servers) && isDeadAntigravityEntry(servers["vexp"]);
|
|
4212
|
+
});
|
|
4213
|
+
}
|
|
4214
|
+
/** The files deadAntigravityEntryCleanables names, for the dry run. */
|
|
4215
|
+
export function deadAntigravityEntryFiles() {
|
|
4216
|
+
return deadAntigravityEntryCleanables().map((c) => c.file);
|
|
4217
|
+
}
|
|
4218
|
+
/**
|
|
4219
|
+
* Take out the machine-wide Antigravity entries earlier setups wrote
|
|
4220
|
+
* (isDeadAntigravityEntry): up to 3.3.9 every start rewrote an unpinned one
|
|
4221
|
+
* into the 1.x file, ~/.gemini/antigravity/mcp_config.json. A pinned entry
|
|
4222
|
+
* and one the user made stay; so does the rest of each file: one that is
|
|
4223
|
+
* not strict JSON (the user's comments, trailing commas, a BOM) loses only
|
|
4224
|
+
* vexp's member (removeJsoncMember) and keeps its text, and one that cannot
|
|
4225
|
+
* be cut so, or does not parse, is left as found.
|
|
4226
|
+
*
|
|
4227
|
+
* Only where vexp's entry can be (antigravityFilesVexpCleans): the 1.x file
|
|
4228
|
+
* while it is a regular file, and the file the 1.x name links to. The first
|
|
4229
|
+
* start of the Antigravity IDE or of its VS Code extension MOVES the 1.x
|
|
4230
|
+
* file onto ~/.gemini/config/mcp_config.json, replacing the user's global
|
|
4231
|
+
* servers, and leaves the 1.x name a link to it, through which 3.3.9 kept
|
|
4232
|
+
* writing (agy 1.5.3, measured 2026-11-07). The global file reached any
|
|
4233
|
+
* other way is the user's or Antigravity's: never edited, the doctor names
|
|
4234
|
+
* what is in it.
|
|
4235
|
+
*
|
|
4236
|
+
* A regular 1.x file left with no server and no other key is deleted, not
|
|
4237
|
+
* written empty, with no backup (it held only vexp's own entry), with an
|
|
4238
|
+
* old mcp_config.json.vexp-bak beside it, and then ~/.gemini/antigravity
|
|
4239
|
+
* when that is empty: a leftover file there is what Antigravity's first
|
|
4240
|
+
* start moves over the user's global servers, and the empty folder vexp
|
|
4241
|
+
* made looked like Antigravity on the machine (antigravityOnThisMachine).
|
|
4242
|
+
* Through the link the target is never deleted, even when it is left with
|
|
4243
|
+
* no servers: it is Antigravity's global file. Same bytes as the extension.
|
|
4244
|
+
* Returns the files changed or deleted.
|
|
4245
|
+
*/
|
|
4246
|
+
export function removeDeadAntigravityEntries() {
|
|
4247
|
+
const cleaned = [];
|
|
4248
|
+
const deleted = [];
|
|
4249
|
+
for (const { file, viaLink } of deadAntigravityEntryCleanables()) {
|
|
4250
|
+
try {
|
|
4251
|
+
const cut = withoutVexpMember(fs.readFileSync(file), "mcpServers");
|
|
4252
|
+
if (cut === undefined) {
|
|
4253
|
+
process.stdout.write(` - vexp's old Antigravity entry (no VEXP_WORKSPACE) is still in ${file}: remove "vexp" from its mcpServers by hand\n`);
|
|
4254
|
+
continue;
|
|
4255
|
+
}
|
|
4256
|
+
if (!viaLink && antigravityHoldsNothing(cut.text) && !globalFileIs(file)) {
|
|
4257
|
+
fs.unlinkSync(file);
|
|
4258
|
+
// The backup goes only when it is vexp's too: up to 3.3.9 the CLI
|
|
4259
|
+
// backed the file up before each write that also dropped any server
|
|
4260
|
+
// named vexp-*, so the copy can be the only one left of the user's
|
|
4261
|
+
// own server (review, 2026-11-07, round 5).
|
|
4262
|
+
const backup = `${file}.vexp-bak`;
|
|
4263
|
+
if (holdsNothingButVexp(backup))
|
|
4264
|
+
fs.rmSync(backup, { force: true });
|
|
4265
|
+
removeDirIfEmpty(path.dirname(file));
|
|
4266
|
+
deleted.push(file);
|
|
4267
|
+
continue;
|
|
4268
|
+
}
|
|
4269
|
+
backupConfig(file);
|
|
4270
|
+
fs.writeFileSync(file, cut.text, cut.encoding);
|
|
4271
|
+
cleaned.push(file);
|
|
4272
|
+
}
|
|
4273
|
+
catch {
|
|
4274
|
+
/* best-effort: what is left there, the doctor names */
|
|
4275
|
+
}
|
|
2021
4276
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
4277
|
+
for (const file of cleaned) {
|
|
4278
|
+
process.stdout.write(` - Removed vexp's old Antigravity entry (no VEXP_WORKSPACE): ${file}\n`);
|
|
2024
4279
|
}
|
|
2025
|
-
|
|
2026
|
-
|
|
4280
|
+
for (const file of deleted) {
|
|
4281
|
+
process.stdout.write(` - Deleted ${file}: it held only vexp's old Antigravity entry, ${antigravityLegacyHarm(os.homedir())}\n`);
|
|
4282
|
+
}
|
|
4283
|
+
return [...cleaned, ...deleted];
|
|
2027
4284
|
}
|
|
2028
4285
|
/**
|
|
2029
|
-
*
|
|
2030
|
-
*
|
|
4286
|
+
* Why a 1.x file that held only vexp's entry is deleted, as the doctor's
|
|
4287
|
+
* note on it says (agLegacyNote): once ~/.gemini/config/.migrated is there,
|
|
4288
|
+
* Antigravity never moves the file again, and "its first start moves that
|
|
4289
|
+
* file over the global one" contradicted the doctor run just before
|
|
4290
|
+
* (review, 2026-11-07, round 5). Lockstep with the extension.
|
|
2031
4291
|
*/
|
|
2032
|
-
export function
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
4292
|
+
export function antigravityLegacyHarm(home) {
|
|
4293
|
+
return fs.existsSync(path.join(home, ".gemini", "config", ".migrated"))
|
|
4294
|
+
? "in a file current Antigravity builds no longer read"
|
|
4295
|
+
: "and Antigravity's first start moves that file over ~/.gemini/config/mcp_config.json, replacing the servers there";
|
|
4296
|
+
}
|
|
4297
|
+
/**
|
|
4298
|
+
* The text of a JSON(C) file with vexp's member of `key` cut out, and the
|
|
4299
|
+
* encoding to write it back in; undefined when it cannot be cut so.
|
|
4300
|
+
* Strict UTF-8 JSON is rewritten as JSON; anything else loses only vexp's
|
|
4301
|
+
* member (removeJsoncMember), so comments stay. A file that is not UTF-8
|
|
4302
|
+
* (an ANSI file PowerShell 5.1's Set-Content writes) is read one latin1
|
|
4303
|
+
* character per byte and written back the same way: decoded as UTF-8, the
|
|
4304
|
+
* rewrite saved every non-ASCII byte of the user's other servers as U+FFFD
|
|
4305
|
+
* (review, 2026-11-07, round 5). JSON's own syntax is ASCII, so the cut
|
|
4306
|
+
* finds the same members, and every byte outside vexp's goes back as it
|
|
4307
|
+
* was. `dropEmpty`: the key goes too when vexp was all it held (strict
|
|
4308
|
+
* JSON only). Lockstep with the extension.
|
|
4309
|
+
*/
|
|
4310
|
+
export function withoutVexpMember(bytes, key, dropEmpty = false) {
|
|
4311
|
+
let raw;
|
|
2039
4312
|
try {
|
|
2040
|
-
|
|
4313
|
+
raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
2041
4314
|
}
|
|
2042
4315
|
catch {
|
|
2043
|
-
|
|
4316
|
+
const text = removeJsoncMember(bytes.toString("latin1"), key, "vexp");
|
|
4317
|
+
return text === undefined ? undefined : { text, encoding: "latin1", strict: false };
|
|
4318
|
+
}
|
|
4319
|
+
try {
|
|
4320
|
+
const data = JSON.parse(raw);
|
|
4321
|
+
if (!asPlainObject(data) || !asPlainObject(data[key]))
|
|
4322
|
+
return undefined;
|
|
4323
|
+
delete data[key]["vexp"];
|
|
4324
|
+
if (dropEmpty && Object.keys(data[key]).length === 0)
|
|
4325
|
+
delete data[key];
|
|
4326
|
+
return { text: JSON.stringify(data, null, 2) + "\n", encoding: "utf-8", strict: true };
|
|
4327
|
+
}
|
|
4328
|
+
catch {
|
|
4329
|
+
const text = removeJsoncMember(raw, key, "vexp");
|
|
4330
|
+
return text === undefined ? undefined : { text, encoding: "utf-8", strict: false };
|
|
2044
4331
|
}
|
|
2045
4332
|
}
|
|
2046
|
-
|
|
2047
|
-
|
|
4333
|
+
/** An MCP config that holds vexp's server and nothing else, or nothing at all. Lockstep with the extension's holdsNothingButVexp. */
|
|
4334
|
+
function holdsNothingButVexp(file) {
|
|
4335
|
+
if (!fs.existsSync(file))
|
|
4336
|
+
return false;
|
|
4337
|
+
const read = readJsonConfigSafe(file);
|
|
4338
|
+
if (!read.ok)
|
|
4339
|
+
return false;
|
|
4340
|
+
const servers = read.data.mcpServers;
|
|
4341
|
+
if (Object.keys(read.data).some((k) => k !== "mcpServers"))
|
|
4342
|
+
return false;
|
|
4343
|
+
if (servers === undefined)
|
|
4344
|
+
return true;
|
|
4345
|
+
if (!asPlainObject(servers))
|
|
4346
|
+
return false;
|
|
4347
|
+
return Object.entries(servers).every(([name, entry]) => name === "vexp" && isVexpWrittenEntry(entry));
|
|
2048
4348
|
}
|
|
2049
4349
|
/**
|
|
2050
|
-
*
|
|
2051
|
-
*
|
|
2052
|
-
*
|
|
2053
|
-
*
|
|
2054
|
-
*
|
|
2055
|
-
*
|
|
2056
|
-
*
|
|
2057
|
-
* (see the Windows + WSL2 docs) — we hand the user the exact entry instead.
|
|
4350
|
+
* removeDeadAntigravityEntries at the end of every setup, whichever agents
|
|
4351
|
+
* it sets up, as the extension does at every start (repairMachineEntries).
|
|
4352
|
+
* Run only with Antigravity chosen, it left a CLI-only machine the 1.x file
|
|
4353
|
+
* 3.3.9 wrote, which the first start of the Antigravity IDE or of its VS
|
|
4354
|
+
* Code extension moves over the user's global servers, while the doctor
|
|
4355
|
+
* called it harmless (review, 2026-11-07). The entries are vexp's own and
|
|
4356
|
+
* named no project. Never thrown: setup never fails on it.
|
|
2058
4357
|
*/
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
"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.",
|
|
2079
|
-
"Docs: https://vexp.dev/docs#windows-wsl",
|
|
2080
|
-
].join("\n");
|
|
4358
|
+
function removeDeadAntigravityEntriesAtSetup() {
|
|
4359
|
+
try {
|
|
4360
|
+
removeDeadAntigravityEntries();
|
|
4361
|
+
}
|
|
4362
|
+
catch {
|
|
4363
|
+
/* best-effort: what is left there, the doctor names */
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
/** A JSON(C) text whose data is nothing: no key, or only an empty mcpServers. */
|
|
4367
|
+
function antigravityHoldsNothing(text) {
|
|
4368
|
+
try {
|
|
4369
|
+
const data = parseJsonc(text);
|
|
4370
|
+
if (!asPlainObject(data))
|
|
4371
|
+
return false;
|
|
4372
|
+
return Object.keys(data).every((k) => k === "mcpServers") && (data.mcpServers === undefined || (asPlainObject(data.mcpServers) && Object.keys(data.mcpServers).length === 0));
|
|
4373
|
+
}
|
|
4374
|
+
catch {
|
|
4375
|
+
return false;
|
|
4376
|
+
}
|
|
2081
4377
|
}
|
|
2082
|
-
|
|
2083
|
-
|
|
4378
|
+
/** Is ~/.gemini/config/mcp_config.json this file, by a link of its own? Then the file is not deleted from under it. */
|
|
4379
|
+
function globalFileIs(file) {
|
|
2084
4380
|
try {
|
|
2085
|
-
fs.
|
|
4381
|
+
return fs.realpathSync(antigravityGlobalMcpPath(os.homedir())) === file;
|
|
2086
4382
|
}
|
|
2087
4383
|
catch {
|
|
2088
4384
|
return false;
|
|
2089
4385
|
}
|
|
2090
|
-
return writeMcpConfig(path.join(cfgDir, "mcp_config.json"), binaryPath, undefined, mcpServerPath, undefined);
|
|
2091
4386
|
}
|
|
4387
|
+
/** Remove `dir` when it is empty; anything else is left as it is. */
|
|
4388
|
+
function removeDirIfEmpty(dir) {
|
|
4389
|
+
try {
|
|
4390
|
+
if (fs.readdirSync(dir).length === 0)
|
|
4391
|
+
fs.rmdirSync(dir);
|
|
4392
|
+
}
|
|
4393
|
+
catch {
|
|
4394
|
+
/* absent, or not ours to remove */
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
/**
|
|
4398
|
+
* Write vexp's entry into an `mcpServers` file. A file inside the workspace
|
|
4399
|
+
* root, pinned to it (Cursor, Kiro, Trae, Firebase Studio, .devin, ZCode's
|
|
4400
|
+
* compatibility file), is checked first: through a link out of the project,
|
|
4401
|
+
* or to nothing, it is left as it is (projectWriteLanding). The machine-wide
|
|
4402
|
+
* Windsurf files go through writeMcpServersFile, unchecked.
|
|
4403
|
+
*/
|
|
2092
4404
|
export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot,
|
|
2093
4405
|
// Key under which the auto-approve tool list is written. Cursor/Windsurf use
|
|
2094
4406
|
// `alwaysAllow`; Kiro uses `autoApprove` (a `.kiro/settings/mcp.json` field).
|
|
2095
4407
|
approveKey = "alwaysAllow") {
|
|
4408
|
+
if (pinnedProjectFile(mcpConfigPath, workspaceRoot) && projectWriteRefused(mcpConfigPath, workspaceRoot))
|
|
4409
|
+
return false;
|
|
4410
|
+
return writeMcpServersFile(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot, approveKey);
|
|
4411
|
+
}
|
|
4412
|
+
function writeMcpServersFile(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot, approveKey) {
|
|
2096
4413
|
const read = readJsonConfigSafe(mcpConfigPath);
|
|
2097
4414
|
if (!read.ok) {
|
|
2098
4415
|
warnUnparseable(mcpConfigPath);
|
|
2099
4416
|
return false;
|
|
2100
4417
|
}
|
|
2101
4418
|
const existing = read.data;
|
|
4419
|
+
if (containerMisshapen(existing, "mcpServers"))
|
|
4420
|
+
return leftUntouched(mcpConfigPath, "mcpServers");
|
|
2102
4421
|
const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
|
|
2103
4422
|
const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
|
|
2104
4423
|
const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
|
|
@@ -2108,6 +4427,18 @@ approveKey = "alwaysAllow") {
|
|
|
2108
4427
|
const currentCmd = previousVexp?.["command"];
|
|
2109
4428
|
const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
|
|
2110
4429
|
const prevEnv = previousVexp?.env;
|
|
4430
|
+
// A project file committed to the repository, pinned by another checkout:
|
|
4431
|
+
// the pin stays, and the command and args change only when the vexp
|
|
4432
|
+
// install they name is gone (committedPin). Machine-wide files are never this.
|
|
4433
|
+
const kept = committedPin(mcpConfigPath, workspaceRoot, envPin(prevEnv));
|
|
4434
|
+
if (kept) {
|
|
4435
|
+
if (!staleVexpInstall(previousVexp))
|
|
4436
|
+
return noteCommittedPin(mcpConfigPath, kept);
|
|
4437
|
+
beforeServers["vexp"] = { ...previousVexp, command: targetCmd, args: targetArgs };
|
|
4438
|
+
backupConfig(mcpConfigPath);
|
|
4439
|
+
fs.writeFileSync(mcpConfigPath, JSON.stringify(existing, null, 2), "utf-8");
|
|
4440
|
+
return true;
|
|
4441
|
+
}
|
|
2111
4442
|
const envMatches = JSON.stringify(prevEnv) === JSON.stringify(targetEnv);
|
|
2112
4443
|
const identical = currentCmd === targetCmd &&
|
|
2113
4444
|
prevArgs !== undefined &&
|
|
@@ -2176,6 +4507,12 @@ export function zcodeMcpTarget(workspaceRoot) {
|
|
|
2176
4507
|
if (fs.existsSync(native))
|
|
2177
4508
|
return native;
|
|
2178
4509
|
const compat = path.join(workspaceRoot, ".agents", "mcp.json");
|
|
4510
|
+
// A compatibility file reached through a link out of the project is not
|
|
4511
|
+
// read: nothing is read through such a link. It may hold the project's
|
|
4512
|
+
// servers, so it is the target, which the writer leaves as it is and
|
|
4513
|
+
// names, rather than a native file that would unplug them.
|
|
4514
|
+
if (fs.existsSync(compat) && !projectWriteLanding(compat, workspaceRoot).ok)
|
|
4515
|
+
return compat;
|
|
2179
4516
|
const read = readJsonConfigSafe(compat);
|
|
2180
4517
|
if (read.existed && read.ok) {
|
|
2181
4518
|
const servers = read.data.mcpServers;
|
|
@@ -2195,20 +4532,43 @@ export function configureZcodeMcp(workspaceRoot, binaryPath) {
|
|
|
2195
4532
|
const rel = path.relative(workspaceRoot, target).replace(/\\/g, "/");
|
|
2196
4533
|
if (rel === ".agents/mcp.json") {
|
|
2197
4534
|
// The compatibility file has the mcpServers shape the shared writer
|
|
2198
|
-
// knows; withholding mcpServerPath selects the binary transport.
|
|
4535
|
+
// knows; withholding mcpServerPath selects the binary transport. The
|
|
4536
|
+
// shared writer checks where the write lands.
|
|
2199
4537
|
return writeMcpConfig(target, binaryPath, undefined, undefined, workspaceRoot) ? rel : null;
|
|
2200
4538
|
}
|
|
4539
|
+
if (projectWriteRefused(target, workspaceRoot))
|
|
4540
|
+
return null;
|
|
2201
4541
|
const read = readJsonConfigSafe(target);
|
|
2202
4542
|
if (!read.ok) {
|
|
2203
4543
|
warnUnparseable(target);
|
|
2204
4544
|
return null;
|
|
2205
4545
|
}
|
|
2206
4546
|
const cfg = read.data;
|
|
4547
|
+
// The key the entry goes under is not an object: left untouched and
|
|
4548
|
+
// reported (leftUntouched), as the shared writer does for the
|
|
4549
|
+
// compatibility file above.
|
|
4550
|
+
const misshapen = containerMisshapen(cfg, "mcp")
|
|
4551
|
+
? "mcp"
|
|
4552
|
+
: asPlainObject(cfg.mcp) && containerMisshapen(cfg.mcp, "servers") && "mcp.servers";
|
|
4553
|
+
if (misshapen) {
|
|
4554
|
+
leftUntouched(target, misshapen);
|
|
4555
|
+
return null;
|
|
4556
|
+
}
|
|
2207
4557
|
const mcp = (cfg.mcp && typeof cfg.mcp === "object" ? cfg.mcp : {});
|
|
2208
4558
|
const servers = (mcp.servers && typeof mcp.servers === "object" ? mcp.servers : {});
|
|
2209
4559
|
const targetArgs = ["mcp"];
|
|
2210
4560
|
const targetEnv = { VEXP_WORKSPACE: workspaceRoot };
|
|
2211
4561
|
const prev = servers["vexp"];
|
|
4562
|
+
// Committed and pinned by another checkout: see committedPin.
|
|
4563
|
+
const kept = committedPin(target, workspaceRoot, envPin(prev?.env));
|
|
4564
|
+
if (kept) {
|
|
4565
|
+
if (!staleVexpInstall(prev))
|
|
4566
|
+
return noteCommittedPin(target, kept) || null;
|
|
4567
|
+
servers["vexp"] = { ...prev, command: binaryPath, args: targetArgs };
|
|
4568
|
+
backupConfig(target);
|
|
4569
|
+
fs.writeFileSync(target, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
4570
|
+
return rel;
|
|
4571
|
+
}
|
|
2212
4572
|
const envMatches = JSON.stringify(prev?.env) === JSON.stringify(targetEnv);
|
|
2213
4573
|
const identical = prev?.command === binaryPath &&
|
|
2214
4574
|
JSON.stringify(prev?.args) === JSON.stringify(targetArgs) &&
|
|
@@ -2600,23 +4960,269 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot,
|
|
|
2600
4960
|
atomicWriteFileSync(configPath, content);
|
|
2601
4961
|
return true;
|
|
2602
4962
|
}
|
|
4963
|
+
/**
|
|
4964
|
+
* The VEXP_WORKSPACE vexp's .vscode/mcp.json entry gets. When the folder that
|
|
4965
|
+
* holds `.vscode/mcp.json` is the workspace root, "${workspaceFolder}": every
|
|
4966
|
+
* VS Code that reads the file (1.99+) expands it to that folder before it
|
|
4967
|
+
* starts the server, or does not start it; where the literal reaches a
|
|
4968
|
+
* server anyway (a .mcp.json entry, a client that expands nothing), both MCP
|
|
4969
|
+
* servers take a value still holding "${" for no pin, and the session's
|
|
4970
|
+
* folder decides. No "cwd" is written: VS Code 1.101.1+ starts the server
|
|
4971
|
+
* in that folder already, 1.99-1.100 ignore the key, and 1.139+ refuses to
|
|
4972
|
+
* migrate an entry that has one to .mcp.json. The absolute root went into a file teams commit, so
|
|
4973
|
+
* every checkout and worktree that ran a setup repinned it to itself, and
|
|
4974
|
+
* main repinned it back after the merge (field report, 2026-11-07). Only a
|
|
4975
|
+
* file in a subfolder of the root (a multi-root window with the root not
|
|
4976
|
+
* listed: the extension's case; the CLI writes at the root) keeps the
|
|
4977
|
+
* absolute root, since the index lives there. Lockstep with the extension.
|
|
4978
|
+
*/
|
|
4979
|
+
export function vsCodeMcpPin(p, workspaceRoot) {
|
|
4980
|
+
const holder = path.dirname(path.dirname(path.resolve(p)));
|
|
4981
|
+
const atRoot = path.basename(path.dirname(path.resolve(p))) === ".vscode" && sameFolder(holder, path.resolve(workspaceRoot));
|
|
4982
|
+
return atRoot ? "${workspaceFolder}" : workspaceRoot;
|
|
4983
|
+
}
|
|
4984
|
+
/**
|
|
4985
|
+
* The server named vexp, in any letter case, in `<root>/.mcp.json`: the
|
|
4986
|
+
* portable file VS Code 1.139+ migrates .vscode/mcp.json into and Claude
|
|
4987
|
+
* Code reads as its project scope. Read as JSON, JSONC or with a BOM, and
|
|
4988
|
+
* through a link, as VS Code and Claude Code read it: a .mcp.json linked
|
|
4989
|
+
* from a shared folder still defines vexp for them, and a vexp entry added
|
|
4990
|
+
* to .vscode/mcp.json would turn that one off. Writing through such a link
|
|
4991
|
+
* is refused all the same (maintainProjectMcpJson). Nothing from a file
|
|
4992
|
+
* whose mcpServers is not an object. undefined when there is no such
|
|
4993
|
+
* server. Lockstep with the extension's dotMcpJsonVexp.
|
|
4994
|
+
*/
|
|
4995
|
+
export function projectMcpJsonVexp(workspaceRoot) {
|
|
4996
|
+
const file = path.join(workspaceRoot, ".mcp.json");
|
|
4997
|
+
if (!fs.existsSync(file))
|
|
4998
|
+
return undefined;
|
|
4999
|
+
const read = readJsonConfigSafe(file);
|
|
5000
|
+
if (!read.ok)
|
|
5001
|
+
return undefined;
|
|
5002
|
+
const servers = read.data.mcpServers;
|
|
5003
|
+
if (!asPlainObject(servers))
|
|
5004
|
+
return undefined;
|
|
5005
|
+
const name = Object.keys(servers).find((k) => k.toLowerCase() === "vexp");
|
|
5006
|
+
return name === undefined ? undefined : { file, name, entry: servers[name] };
|
|
5007
|
+
}
|
|
5008
|
+
/**
|
|
5009
|
+
* The command and args a vexp entry gets when the install it names is gone
|
|
5010
|
+
* (staleVexpInstall), in the shape it has: an entry that runs a bundled
|
|
5011
|
+
* mcp-server.cjs gets this node and this bundle in place of the old one,
|
|
5012
|
+
* its other arguments kept; one that runs vexp-core gets this binary, its
|
|
5013
|
+
* arguments (`mcp`, a `--workspace`) kept. undefined for any other shape.
|
|
5014
|
+
*/
|
|
5015
|
+
function repairedCommand(entry, binaryPath, mcpServerPath) {
|
|
5016
|
+
if (!asPlainObject(entry) || typeof entry.command !== "string")
|
|
5017
|
+
return undefined;
|
|
5018
|
+
const args = Array.isArray(entry.args) ? entry.args : [];
|
|
5019
|
+
const isBundle = (a) => typeof a === "string" && /mcp-server\.cjs$/.test(a);
|
|
5020
|
+
if (args.some(isBundle)) {
|
|
5021
|
+
if (!mcpServerPath || !fs.existsSync(mcpServerPath))
|
|
5022
|
+
return { command: binaryPath, args: ["mcp"] };
|
|
5023
|
+
return { command: resolveNodeCommand(), args: args.map((a) => (isBundle(a) ? mcpServerPath : a)) };
|
|
5024
|
+
}
|
|
5025
|
+
const base = (entry.command.split(/[\\/]/).pop() ?? "").toLowerCase();
|
|
5026
|
+
if (base.startsWith("vexp-core"))
|
|
5027
|
+
return { command: binaryPath, args };
|
|
5028
|
+
return undefined;
|
|
5029
|
+
}
|
|
5030
|
+
/**
|
|
5031
|
+
* W4: keep the vexp server of `<root>/.mcp.json` runnable. When its command
|
|
5032
|
+
* or args point into a vexp install that is gone (staleVexpInstall), only
|
|
5033
|
+
* those two are changed, in the text (upsertJsoncMember): its env, type,
|
|
5034
|
+
* tools and everything else in the file stay as they are, and a pin is
|
|
5035
|
+
* never touched. Never creates the file; a link out of the project or to
|
|
5036
|
+
* nothing is left alone (projectWriteLanding), and so is a file that is
|
|
5037
|
+
* not UTF-8. Returns true when the file was written.
|
|
5038
|
+
*/
|
|
5039
|
+
export function maintainProjectMcpJson(workspaceRoot, binaryPath, mcpServerPath) {
|
|
5040
|
+
const found = projectMcpJsonVexp(workspaceRoot);
|
|
5041
|
+
if (!found || !staleVexpInstall(found.entry))
|
|
5042
|
+
return false;
|
|
5043
|
+
const repaired = repairedCommand(found.entry, binaryPath, mcpServerPath);
|
|
5044
|
+
if (!repaired)
|
|
5045
|
+
return false;
|
|
5046
|
+
const entry = found.entry;
|
|
5047
|
+
if (entry.command === repaired.command && JSON.stringify(entry.args) === JSON.stringify(repaired.args))
|
|
5048
|
+
return false;
|
|
5049
|
+
if (projectWriteRefused(found.file, workspaceRoot))
|
|
5050
|
+
return false;
|
|
5051
|
+
let raw;
|
|
5052
|
+
try {
|
|
5053
|
+
raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(found.file));
|
|
5054
|
+
}
|
|
5055
|
+
catch {
|
|
5056
|
+
return false;
|
|
5057
|
+
}
|
|
5058
|
+
let text = upsertJsoncMember(raw, ["mcpServers", found.name, "command"], repaired.command);
|
|
5059
|
+
if (text !== undefined)
|
|
5060
|
+
text = upsertJsoncMember(text, ["mcpServers", found.name, "args"], repaired.args);
|
|
5061
|
+
if (text === undefined) {
|
|
5062
|
+
// The text could not be edited safely: the whole file, as the writers
|
|
5063
|
+
// here always did.
|
|
5064
|
+
const data = parseJsonc(raw);
|
|
5065
|
+
data.mcpServers[found.name] = { ...data.mcpServers[found.name], command: repaired.command, args: repaired.args };
|
|
5066
|
+
text = JSON.stringify(data, null, 2) + "\n";
|
|
5067
|
+
}
|
|
5068
|
+
backupConfig(found.file);
|
|
5069
|
+
fs.writeFileSync(found.file, text, "utf-8");
|
|
5070
|
+
return true;
|
|
5071
|
+
}
|
|
5072
|
+
/**
|
|
5073
|
+
* W4: `<root>/.mcp.json` defines vexp, so .vscode/mcp.json gets no vexp
|
|
5074
|
+
* entry: VS Code would start that one and turn the .mcp.json entry off (a
|
|
5075
|
+
* name in .vscode/mcp.json wins), and that is the one the user kept. vexp's
|
|
5076
|
+
* own entry (isVexpWrittenEntry; never one the user wrote, never one pinned
|
|
5077
|
+
* to another folder) goes from .vscode/mcp.json, cut out of its text
|
|
5078
|
+
* (removeJsoncMember), and the file goes only when nothing at all is left
|
|
5079
|
+
* in it, no comment either, and then its folder when that is empty. A file
|
|
5080
|
+
* committed to the repository is left as it is (committedCleanupBlocked),
|
|
5081
|
+
* and so is one that leads out of the project through a link
|
|
5082
|
+
* (projectWriteLanding). Says MCP_JSON_DEFINES_VEXP once. Lockstep with
|
|
5083
|
+
* the extension's leaveVexpOutOfVsCodeMcp.
|
|
5084
|
+
*/
|
|
5085
|
+
function leaveVexpOutOfVsCodeMcp(p, workspaceRoot) {
|
|
5086
|
+
try {
|
|
5087
|
+
// A .vscode/mcp.json that is itself a link loses vexp's member in its
|
|
5088
|
+
// target, and the link stays (removeProjectMcpEntryThroughLink, the
|
|
5089
|
+
// round-5 rule): unlinked when nothing was left, the repository's link
|
|
5090
|
+
// was gone. The extension's removeProjectMcpEntry does the same.
|
|
5091
|
+
let link = false;
|
|
5092
|
+
try {
|
|
5093
|
+
link = fs.lstatSync(p).isSymbolicLink();
|
|
5094
|
+
}
|
|
5095
|
+
catch {
|
|
5096
|
+
return; // absent
|
|
5097
|
+
}
|
|
5098
|
+
if (link) {
|
|
5099
|
+
removeProjectMcpEntryThroughLink(p, "servers", workspaceRoot, "GitHub Copilot");
|
|
5100
|
+
return;
|
|
5101
|
+
}
|
|
5102
|
+
// Nothing is read through a folder that leads out of the project.
|
|
5103
|
+
if (!fs.existsSync(p) || !projectWriteLanding(p, workspaceRoot).ok)
|
|
5104
|
+
return;
|
|
5105
|
+
const read = readJsonConfigSafe(p);
|
|
5106
|
+
const servers = read.ok ? read.data.servers : undefined;
|
|
5107
|
+
if (!asPlainObject(servers) || !isVexpWrittenEntry(servers["vexp"]))
|
|
5108
|
+
return;
|
|
5109
|
+
const pin = envPin(servers["vexp"].env);
|
|
5110
|
+
// "${workspaceFolder}" names whichever checkout reads the file: this one.
|
|
5111
|
+
if (typeof pin === "string" && !pin.includes("${") && !sameFolder(pin, workspaceRoot)) {
|
|
5112
|
+
noteSetup(`the vexp entry in ${p} is pinned to ${pin}, not to this workspace: left as it is`);
|
|
5113
|
+
return;
|
|
5114
|
+
}
|
|
5115
|
+
if (committedCleanupBlocked([p], "GitHub Copilot"))
|
|
5116
|
+
return;
|
|
5117
|
+
const bytes = fs.readFileSync(p);
|
|
5118
|
+
let raw;
|
|
5119
|
+
let encoding = "utf-8";
|
|
5120
|
+
try {
|
|
5121
|
+
raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
5122
|
+
}
|
|
5123
|
+
catch {
|
|
5124
|
+
raw = bytes.toString("latin1");
|
|
5125
|
+
encoding = "latin1";
|
|
5126
|
+
}
|
|
5127
|
+
const cut = removeJsoncMember(raw, "servers", "vexp");
|
|
5128
|
+
if (cut === undefined) {
|
|
5129
|
+
noteSetup(`vexp's entry in ${p} could not be cut out of the text: left as it is`);
|
|
5130
|
+
return;
|
|
5131
|
+
}
|
|
5132
|
+
// Deleted with no backup when nothing is left, not even a comment: it
|
|
5133
|
+
// held vexp's entry and nothing else.
|
|
5134
|
+
const bare = cut.replace(/^\uFEFF/, "").replace(/\s+/g, "");
|
|
5135
|
+
if (bare === "{}" || bare === '{"servers":{}}') {
|
|
5136
|
+
fs.unlinkSync(p);
|
|
5137
|
+
removeDirIfEmpty(path.dirname(p));
|
|
5138
|
+
}
|
|
5139
|
+
else {
|
|
5140
|
+
backupConfig(p);
|
|
5141
|
+
fs.writeFileSync(p, cut, encoding);
|
|
5142
|
+
}
|
|
5143
|
+
}
|
|
5144
|
+
catch {
|
|
5145
|
+
/* best-effort: what is left there, the doctor names */
|
|
5146
|
+
}
|
|
5147
|
+
finally {
|
|
5148
|
+
noteSetup(MCP_JSON_DEFINES_VEXP);
|
|
5149
|
+
}
|
|
5150
|
+
}
|
|
5151
|
+
/**
|
|
5152
|
+
* GitHub Copilot's MCP setup, as `vexp setup` does it: `<root>/.mcp.json`'s
|
|
5153
|
+
* vexp server kept runnable (maintainProjectMcpJson), and
|
|
5154
|
+
* `<root>/.vscode/mcp.json` written (writeVsCodeMcpConfig), which gets no
|
|
5155
|
+
* vexp entry when .mcp.json has one that would serve this root (M2). Returns
|
|
5156
|
+
* the files written, relative to the root.
|
|
5157
|
+
*/
|
|
5158
|
+
export function configureCopilotMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
5159
|
+
const out = [];
|
|
5160
|
+
if (maintainProjectMcpJson(workspaceRoot, binaryPath, mcpServerPath))
|
|
5161
|
+
out.push(".mcp.json");
|
|
5162
|
+
if (writeVsCodeMcpConfig(path.join(workspaceRoot, ".vscode", "mcp.json"), binaryPath, mcpServerPath, workspaceRoot))
|
|
5163
|
+
out.push(".vscode/mcp.json");
|
|
5164
|
+
return out;
|
|
5165
|
+
}
|
|
2603
5166
|
/**
|
|
2604
5167
|
* Write .vscode/mcp.json for GitHub Copilot.
|
|
2605
5168
|
* VS Code uses "servers" (not "mcpServers") + "type": "stdio".
|
|
5169
|
+
*
|
|
5170
|
+
* Only vexp's member is edited (memberEditedText): the user's comments, BOM,
|
|
5171
|
+
* key order, layout and last newline stay, where every write used to
|
|
5172
|
+
* re-serialise the whole file. Pinned with vsCodeMcpPin. A file committed to
|
|
5173
|
+
* the repository whose entry another checkout pinned keeps that pin
|
|
5174
|
+
* (committedPin); its command and args change only when the vexp install
|
|
5175
|
+
* they name is gone. Nothing is added when `<root>/.mcp.json` defines vexp
|
|
5176
|
+
* and that entry would serve this root from VS Code (leaveVexpOutOfVsCodeMcp,
|
|
5177
|
+
* dotMcpJsonMiss); otherwise vexp's entry is written as with no .mcp.json,
|
|
5178
|
+
* and the reason is a setup note (dotMcpJsonKeptLine).
|
|
2606
5179
|
*/
|
|
2607
5180
|
export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
|
|
5181
|
+
// .mcp.json at the root already defines vexp: nothing is added here, and
|
|
5182
|
+
// vexp's own entry goes (W4). Before the link check: the removal makes its own.
|
|
5183
|
+
// Only when that entry would serve this root from VS Code (M2): one pinned
|
|
5184
|
+
// to another folder, or whose command does not start here, would leave
|
|
5185
|
+
// Copilot with another checkout's index or none, so vexp's entry is
|
|
5186
|
+
// written as with no .mcp.json at all, and VS Code uses it instead.
|
|
5187
|
+
const dot = workspaceRoot && path.resolve(path.dirname(path.dirname(p))) === path.resolve(workspaceRoot) ? projectMcpJsonVexp(workspaceRoot) : undefined;
|
|
5188
|
+
if (dot && workspaceRoot) {
|
|
5189
|
+
const miss = dotMcpJsonMiss(dot.entry, workspaceRoot);
|
|
5190
|
+
if (!miss) {
|
|
5191
|
+
leaveVexpOutOfVsCodeMcp(p, workspaceRoot);
|
|
5192
|
+
return false;
|
|
5193
|
+
}
|
|
5194
|
+
noteSetup(dotMcpJsonKeptLine(workspaceRoot, miss));
|
|
5195
|
+
}
|
|
5196
|
+
// Pinned to the project: a link out of it, or to nothing, is left as it is.
|
|
5197
|
+
if (pinnedProjectFile(p, workspaceRoot) && projectWriteRefused(p, workspaceRoot))
|
|
5198
|
+
return false;
|
|
2608
5199
|
const read = readJsonConfigSafe(p);
|
|
2609
5200
|
if (!read.ok) {
|
|
2610
5201
|
warnUnparseable(p);
|
|
2611
5202
|
return false;
|
|
2612
5203
|
}
|
|
2613
5204
|
const existing = read.data;
|
|
5205
|
+
if (containerMisshapen(existing, "servers"))
|
|
5206
|
+
return leftUntouched(p, "servers");
|
|
2614
5207
|
const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
|
|
2615
5208
|
const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
|
|
2616
5209
|
const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
|
|
2617
|
-
const
|
|
5210
|
+
const pin = workspaceRoot ? vsCodeMcpPin(p, workspaceRoot) : undefined;
|
|
5211
|
+
const targetEnv = pin ? { VEXP_WORKSPACE: pin } : undefined;
|
|
2618
5212
|
const beforeServers = existing.servers;
|
|
2619
5213
|
const previousVexp = beforeServers?.["vexp"];
|
|
5214
|
+
// Committed, and another checkout's pin: that pin stays (W3).
|
|
5215
|
+
const kept = committedPin(p, workspaceRoot, envPin(previousVexp?.env));
|
|
5216
|
+
if (kept) {
|
|
5217
|
+
if (!staleVexpInstall(previousVexp))
|
|
5218
|
+
return noteCommittedPin(p, kept);
|
|
5219
|
+
const entry = { ...previousVexp, command: targetCmd, args: targetArgs };
|
|
5220
|
+
beforeServers["vexp"] = entry;
|
|
5221
|
+
backupConfig(p);
|
|
5222
|
+
const edited = memberEditedText(p, existing, "servers", "vexp", entry);
|
|
5223
|
+
fs.writeFileSync(p, edited.text, edited.encoding);
|
|
5224
|
+
return true;
|
|
5225
|
+
}
|
|
2620
5226
|
const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
|
|
2621
5227
|
const envMatches = JSON.stringify(previousVexp?.env) === JSON.stringify(targetEnv);
|
|
2622
5228
|
const identical = previousVexp?.["command"] === targetCmd &&
|
|
@@ -2634,18 +5240,41 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
|
|
|
2634
5240
|
// pin, so a moved or renamed project is repinned normally.
|
|
2635
5241
|
if (removed.length === 0 && envMatches && adoptableEntry(previousVexp))
|
|
2636
5242
|
return false;
|
|
5243
|
+
// A working entry pinned to this folder's absolute path. vexp's own (any
|
|
5244
|
+
// vexp install's, ours from before 3.4 included) moves its pin to
|
|
5245
|
+
// "${workspaceFolder}", once, command and args kept, so this setup and the
|
|
5246
|
+
// other install do not take the entry from each other. One the user wrote
|
|
5247
|
+
// (their own launcher: isVexpWrittenEntry false) is theirs and is left as
|
|
5248
|
+
// it is, as before 3.4: W1 moves vexp-written entries only, and the
|
|
5249
|
+
// rewrite was a diff in a file vexp does not own (review, 2026-11-07).
|
|
5250
|
+
const pinnedHere = removed.length === 0 &&
|
|
5251
|
+
pin === "${workspaceFolder}" &&
|
|
5252
|
+
typeof envPin(previousVexp?.env) === "string" &&
|
|
5253
|
+
sameFolder(envPin(previousVexp?.env), workspaceRoot) &&
|
|
5254
|
+
adoptableEntry(previousVexp);
|
|
5255
|
+
if (pinnedHere && !isVexpWrittenEntry(previousVexp))
|
|
5256
|
+
return false;
|
|
5257
|
+
const movesPin = pinnedHere && isVexpWrittenEntry(previousVexp);
|
|
2637
5258
|
const servers = existing.servers ?? {};
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
5259
|
+
const entry = movesPin
|
|
5260
|
+
? { ...previousVexp, env: targetEnv }
|
|
5261
|
+
: {
|
|
5262
|
+
type: "stdio",
|
|
5263
|
+
command: targetCmd,
|
|
5264
|
+
args: targetArgs,
|
|
5265
|
+
...(targetEnv ? { env: targetEnv } : {}),
|
|
5266
|
+
};
|
|
5267
|
+
servers["vexp"] = entry;
|
|
2644
5268
|
existing.servers = servers;
|
|
2645
5269
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
5270
|
+
// stripLegacyVexpEntries names each key it took out as "servers.<key>".
|
|
5271
|
+
const dropped = removed.map((r) => r.slice("servers.".length));
|
|
5272
|
+
const edited = read.existed
|
|
5273
|
+
? memberEditedText(p, existing, "servers", "vexp", entry, dropped)
|
|
5274
|
+
: { text: JSON.stringify(existing, null, 2), encoding: "utf-8" };
|
|
2646
5275
|
if (read.existed)
|
|
2647
5276
|
backupConfig(p);
|
|
2648
|
-
fs.writeFileSync(p,
|
|
5277
|
+
fs.writeFileSync(p, edited.text, edited.encoding);
|
|
2649
5278
|
return true;
|
|
2650
5279
|
}
|
|
2651
5280
|
/**
|
|
@@ -2706,8 +5335,18 @@ export function opencodeConfigTarget(workspaceRoot) {
|
|
|
2706
5335
|
* file is backed up before rewrite. Returns the workspace-relative path written,
|
|
2707
5336
|
* or null on a no-op / unparseable file.
|
|
2708
5337
|
*/
|
|
2709
|
-
/**
|
|
2710
|
-
*
|
|
5338
|
+
/**
|
|
5339
|
+
* Cline's globalStorage dirs across OSes, VS Code variants and the forks
|
|
5340
|
+
* Cline runs in: the marker that Cline is installed, and the parent of its
|
|
5341
|
+
* MCP settings file. A remote window (Remote-WSL, SSH, Dev Containers) runs
|
|
5342
|
+
* Cline on the remote side, whose editor server keeps its storage under
|
|
5343
|
+
* <home>/.vscode-server/data/User (and the forks' servers under their own
|
|
5344
|
+
* folder): with only the desktop folders listed, setup there wrote no
|
|
5345
|
+
* registry and said "no Cline install found" beside a Cline that was
|
|
5346
|
+
* installed, and so did one run in Cursor or Windsurf (review,
|
|
5347
|
+
* 2026-11-07, round 4). The first three stay first, in their old order.
|
|
5348
|
+
* Lockstep with the extension.
|
|
5349
|
+
*/
|
|
2711
5350
|
function clineStorageDirs() {
|
|
2712
5351
|
const home = os.homedir();
|
|
2713
5352
|
const base = process.platform === "win32"
|
|
@@ -2715,7 +5354,19 @@ function clineStorageDirs() {
|
|
|
2715
5354
|
: process.platform === "darwin"
|
|
2716
5355
|
? path.join(home, "Library", "Application Support")
|
|
2717
5356
|
: path.join(home, ".config");
|
|
2718
|
-
|
|
5357
|
+
const desktop = ["Code", "Code - Insiders", "VSCodium", "Cursor", "Windsurf"].map((variant) => path.join(base, variant, "User"));
|
|
5358
|
+
const remote = [".vscode-server", ".vscode-server-insiders", ".vscodium-server", ".cursor-server", ".windsurf-server"].map((server) => path.join(home, server, "data", "User"));
|
|
5359
|
+
return [...desktop, ...remote].map((user) => path.join(user, "globalStorage", "saoudrizwan.claude-dev"));
|
|
5360
|
+
}
|
|
5361
|
+
/**
|
|
5362
|
+
* Is Cline installed in any VS Code variant (its globalStorage folder is
|
|
5363
|
+
* there)? What noteClineMcpUnreachable is for: a registry left untouched,
|
|
5364
|
+
* one that does not parse, or one that already holds vexp's entry gets no
|
|
5365
|
+
* write either, and "no Cline install found" beside "left untouched"
|
|
5366
|
+
* contradicted it (review, 2026-11-07).
|
|
5367
|
+
*/
|
|
5368
|
+
function clineInstalled() {
|
|
5369
|
+
return clineStorageDirs().some((d) => fs.existsSync(d));
|
|
2719
5370
|
}
|
|
2720
5371
|
/**
|
|
2721
5372
|
* Cline: MCP servers live in a MACHINE-GLOBAL file per VS Code variant
|
|
@@ -2740,6 +5391,10 @@ export function configureClineMcp(workspaceRoot, binaryPath) {
|
|
|
2740
5391
|
continue;
|
|
2741
5392
|
}
|
|
2742
5393
|
const cfg = read.data;
|
|
5394
|
+
if (containerMisshapen(cfg, "mcpServers")) {
|
|
5395
|
+
leftUntouched(target, "mcpServers");
|
|
5396
|
+
continue;
|
|
5397
|
+
}
|
|
2743
5398
|
const servers = cfg.mcpServers ?? {};
|
|
2744
5399
|
const entry = {
|
|
2745
5400
|
command: binaryPath,
|
|
@@ -2763,17 +5418,34 @@ export function configureClineMcp(workspaceRoot, binaryPath) {
|
|
|
2763
5418
|
}
|
|
2764
5419
|
export function configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
2765
5420
|
const target = opencodeConfigTarget(workspaceRoot);
|
|
5421
|
+
// Pinned to the project: a link out of it, or to nothing, is left as it is.
|
|
5422
|
+
if (projectWriteRefused(target, workspaceRoot))
|
|
5423
|
+
return null;
|
|
2766
5424
|
const read = readJsonConfigSafe(target);
|
|
2767
5425
|
if (!read.ok) {
|
|
2768
5426
|
warnUnparseable(target);
|
|
2769
5427
|
return null;
|
|
2770
5428
|
}
|
|
2771
5429
|
const cfg = read.data;
|
|
5430
|
+
if (containerMisshapen(cfg, "mcp")) {
|
|
5431
|
+
leftUntouched(target, "mcp");
|
|
5432
|
+
return null;
|
|
5433
|
+
}
|
|
2772
5434
|
const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
|
|
2773
5435
|
const command = useNode ? [resolveNodeCommand(), mcpServerPath] : [binaryPath, "mcp"];
|
|
2774
5436
|
const env = { VEXP_WORKSPACE: workspaceRoot };
|
|
2775
5437
|
const mcp = cfg.mcp ?? {};
|
|
2776
5438
|
const prev = mcp["vexp"];
|
|
5439
|
+
// Committed and pinned by another checkout: see committedPin.
|
|
5440
|
+
const kept = committedPin(target, workspaceRoot, envPin(prev?.["env"]));
|
|
5441
|
+
if (kept) {
|
|
5442
|
+
if (!staleVexpInstall(prev))
|
|
5443
|
+
return noteCommittedPin(target, kept) || null;
|
|
5444
|
+
mcp["vexp"] = { ...prev, command };
|
|
5445
|
+
backupConfig(target);
|
|
5446
|
+
fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
|
|
5447
|
+
return path.relative(workspaceRoot, target);
|
|
5448
|
+
}
|
|
2777
5449
|
const envMatches = JSON.stringify(prev?.["env"]) === JSON.stringify(env);
|
|
2778
5450
|
const identical = prev?.["type"] === "local" &&
|
|
2779
5451
|
prev?.["enabled"] === true &&
|
|
@@ -2805,17 +5477,34 @@ export function configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
|
2805
5477
|
}
|
|
2806
5478
|
export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
2807
5479
|
const target = kiloConfigTarget(workspaceRoot);
|
|
5480
|
+
// Pinned to the project: a link out of it, or to nothing, is left as it is.
|
|
5481
|
+
if (projectWriteRefused(target, workspaceRoot))
|
|
5482
|
+
return null;
|
|
2808
5483
|
const read = readJsonConfigSafe(target);
|
|
2809
5484
|
if (!read.ok) {
|
|
2810
5485
|
warnUnparseable(target);
|
|
2811
5486
|
return null;
|
|
2812
5487
|
}
|
|
2813
5488
|
const cfg = read.data;
|
|
5489
|
+
if (containerMisshapen(cfg, "mcp")) {
|
|
5490
|
+
leftUntouched(target, "mcp");
|
|
5491
|
+
return null;
|
|
5492
|
+
}
|
|
2814
5493
|
const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
|
|
2815
5494
|
const command = useNode ? [resolveNodeCommand(), mcpServerPath] : [binaryPath, "mcp"];
|
|
2816
5495
|
const env = { VEXP_WORKSPACE: workspaceRoot };
|
|
2817
5496
|
const mcp = cfg.mcp ?? {};
|
|
2818
5497
|
const prev = mcp["vexp"];
|
|
5498
|
+
// Committed and pinned by another checkout: see committedPin.
|
|
5499
|
+
const kept = committedPin(target, workspaceRoot, envPin(prev?.["env"]));
|
|
5500
|
+
if (kept) {
|
|
5501
|
+
if (!staleVexpInstall(prev))
|
|
5502
|
+
return noteCommittedPin(target, kept) || null;
|
|
5503
|
+
mcp["vexp"] = { ...prev, command };
|
|
5504
|
+
backupConfig(target);
|
|
5505
|
+
fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
|
|
5506
|
+
return path.relative(workspaceRoot, target);
|
|
5507
|
+
}
|
|
2819
5508
|
const envMatches = JSON.stringify(prev?.["env"]) === JSON.stringify(env);
|
|
2820
5509
|
const identical = prev?.["type"] === "local" &&
|
|
2821
5510
|
prev?.["enabled"] === true &&
|
|
@@ -2840,12 +5529,29 @@ export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
|
2840
5529
|
return path.relative(workspaceRoot, target);
|
|
2841
5530
|
}
|
|
2842
5531
|
export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
|
|
5532
|
+
// The CLI pins Zed's entry too (VEXP_WORKSPACE inside `command`): a link
|
|
5533
|
+
// out of the project, or to nothing, is left as it is.
|
|
5534
|
+
if (pinnedProjectFile(p, workspaceRoot) && projectWriteRefused(p, workspaceRoot))
|
|
5535
|
+
return false;
|
|
2843
5536
|
const read = readJsonConfigSafe(p);
|
|
2844
5537
|
if (!read.ok) {
|
|
2845
5538
|
warnUnparseable(p);
|
|
2846
5539
|
return false;
|
|
2847
5540
|
}
|
|
2848
5541
|
const settings = read.data;
|
|
5542
|
+
// context_servers takes the entry; agent.tool_permissions.tools takes the
|
|
5543
|
+
// tools' approvals, and an array or a string there was spread into an
|
|
5544
|
+
// object of its characters.
|
|
5545
|
+
if (containerMisshapen(settings, "context_servers"))
|
|
5546
|
+
return leftUntouched(p, "context_servers");
|
|
5547
|
+
if (containerMisshapen(settings, "agent"))
|
|
5548
|
+
return leftUntouched(p, "agent");
|
|
5549
|
+
const agentIn = asPlainObject(settings.agent) ? settings.agent : {};
|
|
5550
|
+
if (containerMisshapen(agentIn, "tool_permissions"))
|
|
5551
|
+
return leftUntouched(p, "agent.tool_permissions");
|
|
5552
|
+
const tpIn = asPlainObject(agentIn.tool_permissions) ? agentIn.tool_permissions : {};
|
|
5553
|
+
if (containerMisshapen(tpIn, "tools"))
|
|
5554
|
+
return leftUntouched(p, "agent.tool_permissions.tools");
|
|
2849
5555
|
const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
|
|
2850
5556
|
const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
|
|
2851
5557
|
const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
|
|
@@ -2853,6 +5559,17 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
|
|
|
2853
5559
|
const beforeCs = settings.context_servers;
|
|
2854
5560
|
const previousVexp = beforeCs?.["vexp"];
|
|
2855
5561
|
const curCmd = previousVexp?.["command"];
|
|
5562
|
+
// Committed and pinned by another checkout: see committedPin. Zed nests
|
|
5563
|
+
// the env inside `command`, which keeps it.
|
|
5564
|
+
const kept = committedPin(p, workspaceRoot, envPin(asPlainObject(curCmd) ? curCmd.env : undefined));
|
|
5565
|
+
if (kept) {
|
|
5566
|
+
if (!staleVexpInstall(previousVexp))
|
|
5567
|
+
return noteCommittedPin(p, kept);
|
|
5568
|
+
beforeCs["vexp"] = { ...previousVexp, command: { ...curCmd, path: targetCmd, args: targetArgs } };
|
|
5569
|
+
backupConfig(p);
|
|
5570
|
+
fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
|
|
5571
|
+
return true;
|
|
5572
|
+
}
|
|
2856
5573
|
// Zed nests env inside `command`, unlike every other writer.
|
|
2857
5574
|
const envMatches = JSON.stringify(curCmd?.["env"]) === JSON.stringify(targetEnv);
|
|
2858
5575
|
const identical = curCmd?.["path"] === targetCmd &&
|
|
@@ -2908,6 +5625,8 @@ version) {
|
|
|
2908
5625
|
return false;
|
|
2909
5626
|
}
|
|
2910
5627
|
const config = read.data;
|
|
5628
|
+
if (containerMisshapen(config, "mcpServers"))
|
|
5629
|
+
return leftUntouched(configPath, "mcpServers");
|
|
2911
5630
|
const beforeServers = config.mcpServers;
|
|
2912
5631
|
const existing = beforeServers?.["vexp"];
|
|
2913
5632
|
const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
|
|
@@ -3049,8 +5768,17 @@ function isVexpGuardHookEntry(h) {
|
|
|
3049
5768
|
export function uninstallClaudeCodeHook(workspaceRoot) {
|
|
3050
5769
|
let removed = false;
|
|
3051
5770
|
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
3052
|
-
|
|
3053
|
-
|
|
5771
|
+
const scripts = ["vexp-guard.sh", "vexp-guard.cmd"].map((name) => path.join(hookDir, name));
|
|
5772
|
+
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
5773
|
+
const read = readJsonConfigSafe(settingsPath);
|
|
5774
|
+
const hooksIn = read.ok && read.existed ? (read.data.hooks ?? {}) : {};
|
|
5775
|
+
const preIn = Array.isArray(hooksIn.PreToolUse) ? hooksIn.PreToolUse : [];
|
|
5776
|
+
const deregisters = read.ok && read.existed && JSON.stringify(placeVexpHook(preIn, isVexpGuardHookEntry, null)) !== JSON.stringify(preIn);
|
|
5777
|
+
// A script or a settings.json committed to the repository: the guard
|
|
5778
|
+
// stays whole, script and entry together (committedCleanupBlocked).
|
|
5779
|
+
if (committedCleanupBlocked([...scripts, deregisters ? settingsPath : undefined], "Claude Code"))
|
|
5780
|
+
return false;
|
|
5781
|
+
for (const p of scripts) {
|
|
3054
5782
|
if (fs.existsSync(p)) {
|
|
3055
5783
|
try {
|
|
3056
5784
|
fs.unlinkSync(p);
|
|
@@ -3059,8 +5787,6 @@ export function uninstallClaudeCodeHook(workspaceRoot) {
|
|
|
3059
5787
|
catch { /* leave in place */ }
|
|
3060
5788
|
}
|
|
3061
5789
|
}
|
|
3062
|
-
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
3063
|
-
const read = readJsonConfigSafe(settingsPath);
|
|
3064
5790
|
if (read.ok && read.existed) {
|
|
3065
5791
|
const settings = read.data;
|
|
3066
5792
|
const hooks = (settings.hooks ?? {});
|
|
@@ -3242,7 +5968,8 @@ export function installClaudeCodeSessionContext(workspaceRoot, binaryPath) {
|
|
|
3242
5968
|
fs.mkdirSync(hookDir, { recursive: true });
|
|
3243
5969
|
const script = vexpSessionContextHookScript(binaryPath);
|
|
3244
5970
|
const existed = fs.existsSync(hookPath);
|
|
3245
|
-
|
|
5971
|
+
// A committed script that bakes another live engine stays (K1).
|
|
5972
|
+
const scriptIdentical = existed && hookScriptCurrent(hookPath, script, vexpSessionContextHookScript);
|
|
3246
5973
|
if (!scriptIdentical) {
|
|
3247
5974
|
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
3248
5975
|
}
|
|
@@ -3290,7 +6017,9 @@ export function installClaudeCodeStopGate(workspaceRoot, binaryPath) {
|
|
|
3290
6017
|
const gateOn = interventionMode() === "on";
|
|
3291
6018
|
const existed = fs.existsSync(hookPath);
|
|
3292
6019
|
const script = vexpStopGateHookScript(binaryPath);
|
|
3293
|
-
const scriptIdentical = gateOn && existed &&
|
|
6020
|
+
const scriptIdentical = gateOn && existed && hookScriptCurrent(hookPath, script, vexpStopGateHookScript);
|
|
6021
|
+
if (!gateOn && stopGateRemovalBlocked(hookPath, settingsPath))
|
|
6022
|
+
return null;
|
|
3294
6023
|
if (gateOn) {
|
|
3295
6024
|
if (!scriptIdentical) {
|
|
3296
6025
|
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
@@ -3338,6 +6067,26 @@ export function installClaudeCodeStopGate(workspaceRoot, binaryPath) {
|
|
|
3338
6067
|
}
|
|
3339
6068
|
return existed ? "updated" : "created";
|
|
3340
6069
|
}
|
|
6070
|
+
/** The edit-time hook is off: is its script or its settings.json entry committed (see stopGateRemovalBlocked)? */
|
|
6071
|
+
function editHintRemovalBlocked(editHookPath, settingsPath) {
|
|
6072
|
+
const read = readJsonConfigSafe(settingsPath);
|
|
6073
|
+
const hooks = read.ok && read.existed ? (read.data.hooks ?? {}) : {};
|
|
6074
|
+
const post = Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [];
|
|
6075
|
+
const deregisters = post.some((h) => isVexpEditHintHookEntry(h));
|
|
6076
|
+
return committedCleanupBlocked([editHookPath, deregisters ? settingsPath : undefined], "Claude Code");
|
|
6077
|
+
}
|
|
6078
|
+
/**
|
|
6079
|
+
* The gate is off and its script or settings.json entry is committed to
|
|
6080
|
+
* the repository: both stay (committedCleanupBlocked), so the entry never
|
|
6081
|
+
* names a script that is gone.
|
|
6082
|
+
*/
|
|
6083
|
+
function stopGateRemovalBlocked(hookPath, settingsPath) {
|
|
6084
|
+
const read = readJsonConfigSafe(settingsPath);
|
|
6085
|
+
const hooks = read.ok && read.existed ? (read.data.hooks ?? {}) : {};
|
|
6086
|
+
const stop = Array.isArray(hooks.Stop) ? hooks.Stop : [];
|
|
6087
|
+
const deregisters = stop.some((h) => isVexpHookEntry(h, "vexp-verify"));
|
|
6088
|
+
return committedCleanupBlocked([hookPath, deregisters ? settingsPath : undefined], "Claude Code");
|
|
6089
|
+
}
|
|
3341
6090
|
function isVexpEditHintHookEntry(entry) {
|
|
3342
6091
|
const cmds = JSON.stringify(entry ?? "");
|
|
3343
6092
|
return cmds.includes("vexp-edit-hint");
|
|
@@ -3402,8 +6151,17 @@ function isVexpSearchHookEntry(h) {
|
|
|
3402
6151
|
*/
|
|
3403
6152
|
function removeClaudeCodePreToolHooks(workspaceRoot) {
|
|
3404
6153
|
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
3405
|
-
|
|
3406
|
-
|
|
6154
|
+
const scripts = ["read-hint", "bash-cap"].map((name) => path.join(hookDir, `vexp-${name}.sh`));
|
|
6155
|
+
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
6156
|
+
const read = readJsonConfigSafe(settingsPath);
|
|
6157
|
+
const ours = (h) => /vexp-(read-hint|bash-cap)/.test(JSON.stringify(h ?? ""));
|
|
6158
|
+
const hooksIn = read.ok && read.existed ? (read.data.hooks ?? {}) : {};
|
|
6159
|
+
const preIn = Array.isArray(hooksIn.PreToolUse) ? hooksIn.PreToolUse : [];
|
|
6160
|
+
const deregisters = read.ok && read.existed && JSON.stringify(placeVexpHook(preIn, ours, null)) !== JSON.stringify(preIn);
|
|
6161
|
+
// Committed to the repository: the hooks stay whole (committedCleanupBlocked).
|
|
6162
|
+
if (committedCleanupBlocked([...scripts, deregisters ? settingsPath : undefined], "Claude Code"))
|
|
6163
|
+
return;
|
|
6164
|
+
for (const p of scripts) {
|
|
3407
6165
|
if (fs.existsSync(p)) {
|
|
3408
6166
|
try {
|
|
3409
6167
|
fs.unlinkSync(p);
|
|
@@ -3413,14 +6171,12 @@ function removeClaudeCodePreToolHooks(workspaceRoot) {
|
|
|
3413
6171
|
}
|
|
3414
6172
|
}
|
|
3415
6173
|
}
|
|
3416
|
-
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
3417
|
-
const read = readJsonConfigSafe(settingsPath);
|
|
3418
6174
|
if (!read.ok || !read.existed)
|
|
3419
6175
|
return;
|
|
3420
6176
|
const settings = read.data;
|
|
3421
6177
|
const hooks = (settings.hooks ?? {});
|
|
3422
6178
|
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3423
|
-
const filtered = placeVexpHook(existing,
|
|
6179
|
+
const filtered = placeVexpHook(existing, ours, null);
|
|
3424
6180
|
if (JSON.stringify(filtered) === JSON.stringify(existing))
|
|
3425
6181
|
return;
|
|
3426
6182
|
const merged = { ...hooks, PreToolUse: filtered };
|
|
@@ -3430,13 +6186,15 @@ function removeClaudeCodePreToolHooks(workspaceRoot) {
|
|
|
3430
6186
|
backupConfig(settingsPath);
|
|
3431
6187
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
3432
6188
|
}
|
|
3433
|
-
function installClaudeCodePreToolHook(workspaceRoot, binaryPath, name, matcher,
|
|
6189
|
+
function installClaudeCodePreToolHook(workspaceRoot, binaryPath, name, matcher, bake) {
|
|
3434
6190
|
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
3435
6191
|
const hookPath = path.join(hookDir, `vexp-${name}.sh`);
|
|
3436
6192
|
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
6193
|
+
const script = bake(binaryPath);
|
|
3437
6194
|
fs.mkdirSync(hookDir, { recursive: true });
|
|
3438
6195
|
const existed = fs.existsSync(hookPath);
|
|
3439
|
-
|
|
6196
|
+
// A committed script that bakes another live engine stays (K1).
|
|
6197
|
+
const scriptIdentical = existed && hookScriptCurrent(hookPath, script, bake);
|
|
3440
6198
|
if (!scriptIdentical)
|
|
3441
6199
|
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
3442
6200
|
const read = readJsonConfigSafe(settingsPath);
|
|
@@ -3470,10 +6228,10 @@ function installClaudeCodePreToolHook(workspaceRoot, binaryPath, name, matcher,
|
|
|
3470
6228
|
return existed ? "updated" : "created";
|
|
3471
6229
|
}
|
|
3472
6230
|
export function installClaudeCodeReadHint(workspaceRoot, binaryPath) {
|
|
3473
|
-
return installClaudeCodePreToolHook(workspaceRoot, binaryPath, "read-hint", "Read", bakeReadHintHook
|
|
6231
|
+
return installClaudeCodePreToolHook(workspaceRoot, binaryPath, "read-hint", "Read", bakeReadHintHook);
|
|
3474
6232
|
}
|
|
3475
6233
|
export function installClaudeCodeBashCap(workspaceRoot, binaryPath) {
|
|
3476
|
-
return installClaudeCodePreToolHook(workspaceRoot, binaryPath, "bash-cap", "Bash", bakeBashCapHook
|
|
6234
|
+
return installClaudeCodePreToolHook(workspaceRoot, binaryPath, "bash-cap", "Bash", bakeBashCapHook);
|
|
3477
6235
|
}
|
|
3478
6236
|
export function installClaudeCodeSearchHook(workspaceRoot, binaryPath) {
|
|
3479
6237
|
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
@@ -3482,7 +6240,8 @@ export function installClaudeCodeSearchHook(workspaceRoot, binaryPath) {
|
|
|
3482
6240
|
const script = vexpSearchHookScript(binaryPath);
|
|
3483
6241
|
fs.mkdirSync(hookDir, { recursive: true });
|
|
3484
6242
|
const existed = fs.existsSync(hookPath);
|
|
3485
|
-
|
|
6243
|
+
// A committed script that bakes another live engine stays (K1).
|
|
6244
|
+
const scriptIdentical = existed && hookScriptCurrent(hookPath, script, vexpSearchHookScript);
|
|
3486
6245
|
if (!scriptIdentical)
|
|
3487
6246
|
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
3488
6247
|
const read = readJsonConfigSafe(settingsPath);
|
|
@@ -3531,20 +6290,26 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
3531
6290
|
// InterventionMode above for the measurement.
|
|
3532
6291
|
const editHookPath = path.join(hookDir, "vexp-edit-hint.sh");
|
|
3533
6292
|
const couplingOn = interventionMode() === "on";
|
|
6293
|
+
// Off, and the edit hook or its settings.json entry committed to the
|
|
6294
|
+
// repository: both stay as they are (committedCleanupBlocked); the
|
|
6295
|
+
// prompt hook below is set up all the same.
|
|
6296
|
+
const keepEditHook = !couplingOn && editHintRemovalBlocked(editHookPath, settingsPath);
|
|
3534
6297
|
if (couplingOn) {
|
|
3535
6298
|
const editScript = bakeEditHintHook(binaryPath);
|
|
3536
|
-
|
|
3537
|
-
|
|
6299
|
+
// A committed script that bakes another live engine stays (K1), its
|
|
6300
|
+
// mode included: git tracks the exec bit.
|
|
6301
|
+
if (!hookScriptCurrent(editHookPath, editScript, bakeEditHintHook)) {
|
|
3538
6302
|
fs.writeFileSync(editHookPath, editScript, { mode: 0o755 });
|
|
3539
6303
|
}
|
|
3540
6304
|
try {
|
|
3541
|
-
|
|
6305
|
+
if (!hookScriptLeft(editHookPath))
|
|
6306
|
+
fs.chmodSync(editHookPath, 0o755);
|
|
3542
6307
|
}
|
|
3543
6308
|
catch {
|
|
3544
6309
|
/* Windows has no exec bit; Git Bash runs it anyway. */
|
|
3545
6310
|
}
|
|
3546
6311
|
}
|
|
3547
|
-
else if (fs.existsSync(editHookPath)) {
|
|
6312
|
+
else if (!keepEditHook && fs.existsSync(editHookPath)) {
|
|
3548
6313
|
// Converge an upgraded workspace without asking anyone to clean up.
|
|
3549
6314
|
try {
|
|
3550
6315
|
fs.unlinkSync(editHookPath);
|
|
@@ -3554,7 +6319,8 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
3554
6319
|
}
|
|
3555
6320
|
}
|
|
3556
6321
|
const existed = fs.existsSync(hookPath);
|
|
3557
|
-
|
|
6322
|
+
// A committed script that bakes another live engine stays (K1).
|
|
6323
|
+
const scriptIdentical = existed && hookScriptCurrent(hookPath, script, (bin) => vexpHintHookScript(bin, "claude-code"));
|
|
3558
6324
|
if (!scriptIdentical) {
|
|
3559
6325
|
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
3560
6326
|
}
|
|
@@ -3589,7 +6355,7 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
3589
6355
|
const existingPost = Array.isArray(hooks.PostToolUse)
|
|
3590
6356
|
? hooks.PostToolUse
|
|
3591
6357
|
: [];
|
|
3592
|
-
const filteredPost = placeVexpHook(existingPost, isVexpEditHintHookEntry, couplingOn
|
|
6358
|
+
const filteredPost = keepEditHook ? existingPost : placeVexpHook(existingPost, isVexpEditHintHookEntry, couplingOn
|
|
3593
6359
|
? {
|
|
3594
6360
|
matcher: "Edit|Write|NotebookEdit",
|
|
3595
6361
|
hooks: [
|
|
@@ -3650,7 +6416,8 @@ export function installClaudeCodeSubagentHook(workspaceRoot, binaryPath) {
|
|
|
3650
6416
|
}
|
|
3651
6417
|
fs.mkdirSync(hookDir, { recursive: true });
|
|
3652
6418
|
const existed = fs.existsSync(hookPath);
|
|
3653
|
-
|
|
6419
|
+
// A committed script that bakes another live engine stays (K1).
|
|
6420
|
+
const scriptIdentical = existed && hookScriptCurrent(hookPath, script, vexpSubagentHookScript);
|
|
3654
6421
|
if (!scriptIdentical)
|
|
3655
6422
|
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
3656
6423
|
const settings = read.data;
|
|
@@ -3695,7 +6462,8 @@ export function installOpencodeCompressPlugin(workspaceRoot, binaryPath, pluginD
|
|
|
3695
6462
|
const content = vexpOpencodeCompressPlugin(binaryPath);
|
|
3696
6463
|
fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
|
|
3697
6464
|
const existed = fs.existsSync(pluginPath);
|
|
3698
|
-
|
|
6465
|
+
// A committed plugin that bakes another live engine stays (K1).
|
|
6466
|
+
if (existed && hookScriptCurrent(pluginPath, content, vexpOpencodeCompressPlugin))
|
|
3699
6467
|
return null;
|
|
3700
6468
|
fs.writeFileSync(pluginPath, content);
|
|
3701
6469
|
return existed ? "updated" : "created";
|
|
@@ -3705,7 +6473,8 @@ export function installOpencodeHintPlugin(workspaceRoot, binaryPath, pluginDir)
|
|
|
3705
6473
|
const content = vexpOpencodeHintPlugin(binaryPath, opencodeFamilyAgent(pluginDir));
|
|
3706
6474
|
fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
|
|
3707
6475
|
const existed = fs.existsSync(pluginPath);
|
|
3708
|
-
|
|
6476
|
+
// A committed plugin that bakes another live engine stays (K1).
|
|
6477
|
+
if (existed && hookScriptCurrent(pluginPath, content, (bin) => vexpOpencodeHintPlugin(bin, opencodeFamilyAgent(pluginDir))))
|
|
3709
6478
|
return null;
|
|
3710
6479
|
fs.writeFileSync(pluginPath, content);
|
|
3711
6480
|
return existed ? "updated" : "created";
|
|
@@ -3727,7 +6496,9 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3727
6496
|
const script = vexpHintHookScript(binaryPath, "codex");
|
|
3728
6497
|
fs.mkdirSync(dir, { recursive: true });
|
|
3729
6498
|
const existed = fs.existsSync(hookPath);
|
|
3730
|
-
|
|
6499
|
+
// A committed script that bakes another live engine stays (K1), the
|
|
6500
|
+
// batch twin below too.
|
|
6501
|
+
let scriptIdentical = existed && hookScriptCurrent(hookPath, script, (bin) => vexpHintHookScript(bin, "codex"));
|
|
3731
6502
|
if (!scriptIdentical) {
|
|
3732
6503
|
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
3733
6504
|
}
|
|
@@ -3738,19 +6509,14 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3738
6509
|
// ~0.131, months before the version that requires the nested `hooks` shape
|
|
3739
6510
|
// we already write). Older builds ignore the field rather than reject the
|
|
3740
6511
|
// file — the handler is not deny_unknown_fields, unlike the top level.
|
|
6512
|
+
// The entry runs it through `cmd /d /c call` (codexHintEntry).
|
|
3741
6513
|
const cmdPath = path.join(dir, "vexp-hint.cmd");
|
|
3742
|
-
let cmdCommand;
|
|
3743
6514
|
if (process.platform === "win32") {
|
|
3744
6515
|
const cmdScript = vexpHintHookCmdScript(binaryPath);
|
|
3745
|
-
|
|
3746
|
-
if (!cmdExisted || fs.readFileSync(cmdPath, "utf-8") !== cmdScript) {
|
|
6516
|
+
if (!hookScriptCurrent(cmdPath, cmdScript, vexpHintHookCmdScript)) {
|
|
3747
6517
|
fs.writeFileSync(cmdPath, cmdScript);
|
|
3748
6518
|
scriptIdentical = false;
|
|
3749
6519
|
}
|
|
3750
|
-
// Through `cmd /d /c call`, not the bare path: Codex runs the line in
|
|
3751
|
-
// PowerShell by default, where a quoted path is only a string to print
|
|
3752
|
-
// (codexWindowsHookCommand has the details).
|
|
3753
|
-
cmdCommand = codexWindowsHookCommand(cmdPath);
|
|
3754
6520
|
}
|
|
3755
6521
|
let root = {};
|
|
3756
6522
|
if (fs.existsSync(hooksJsonPath)) {
|
|
@@ -3791,16 +6557,27 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3791
6557
|
// record over this line (codex-trust.ts codexHookHash), so a change would
|
|
3792
6558
|
// make every Codex user approve the hook again. The script it runs is
|
|
3793
6559
|
// POSIX sh all the same, and a new script needs no new approval.
|
|
3794
|
-
const ours =
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
6560
|
+
const ours = codexHintEntry(workspaceRoot);
|
|
6561
|
+
// Committed to the repository and written by another checkout: a vexp
|
|
6562
|
+
// hook whose definition is ours but for that checkout's path is left as it
|
|
6563
|
+
// is, and so is the file: a rewrite only pointed the committed file at
|
|
6564
|
+
// this checkout and made Codex ask for approval again (field report,
|
|
6565
|
+
// 2026-11-07). The hook runs the OTHER checkout's copy of the script, so
|
|
6566
|
+
// it is kept only while that checkout is a checkout of this repository on
|
|
6567
|
+
// this machine and its script is there (sameRepositoryCheckout): after a
|
|
6568
|
+
// clone, a move or a removed worktree it failed at every prompt, and a
|
|
6569
|
+
// rewrite costs one approval instead (review, 2026-11-07).
|
|
6570
|
+
const other = codexHookCheckout(wrapped.find((h) => isVexpHintHookEntry(h)));
|
|
6571
|
+
if (other !== undefined &&
|
|
6572
|
+
!sameFolder(other, upperDriveLetter(workspaceRoot).replace(/\\/g, "/")) &&
|
|
6573
|
+
legacyTopLevel.length === 0 &&
|
|
6574
|
+
codexHookScriptsThere(other, workspaceRoot)) {
|
|
6575
|
+
const theirs = wrapped.find((h) => isVexpHintHookEntry(h));
|
|
6576
|
+
if (JSON.stringify(theirs) === JSON.stringify(codexHintEntry(other)) && isGitTracked(hooksJsonPath)) {
|
|
6577
|
+
noteSetup(committedPinLine(hooksJsonPath, other));
|
|
6578
|
+
return scriptIdentical ? null : existed ? "updated" : "created";
|
|
6579
|
+
}
|
|
6580
|
+
}
|
|
3804
6581
|
const merged = placeCodexHintEntry(existing, ours);
|
|
3805
6582
|
const before = JSON.stringify(root);
|
|
3806
6583
|
codexHooks.UserPromptSubmit = merged;
|
|
@@ -3817,6 +6594,48 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3817
6594
|
}
|
|
3818
6595
|
return existed ? "updated" : "created";
|
|
3819
6596
|
}
|
|
6597
|
+
/**
|
|
6598
|
+
* Is `other`, the checkout a committed Codex hook names, a checkout of this
|
|
6599
|
+
* repository on this machine with the script the hook runs there
|
|
6600
|
+
* (vexp-hint.sh, and on Windows the vexp-hint.cmd commandWindows names)?
|
|
6601
|
+
* Lockstep with the extension's.
|
|
6602
|
+
*/
|
|
6603
|
+
function codexHookScriptsThere(other, workspaceRoot) {
|
|
6604
|
+
if (!sameRepositoryCheckout(other, workspaceRoot))
|
|
6605
|
+
return false;
|
|
6606
|
+
const dir = path.join(other, ".codex");
|
|
6607
|
+
return fs.existsSync(path.join(dir, "vexp-hint.sh")) && (process.platform !== "win32" || fs.existsSync(path.join(dir, "vexp-hint.cmd")));
|
|
6608
|
+
}
|
|
6609
|
+
/**
|
|
6610
|
+
* vexp's UserPromptSubmit group in `<root>/.codex/hooks.json`, as
|
|
6611
|
+
* installCodexHintHook writes it for `root` (the Windows `commandWindows`
|
|
6612
|
+
* twin on Windows only).
|
|
6613
|
+
*/
|
|
6614
|
+
function codexHintEntry(root) {
|
|
6615
|
+
const dir = path.join(upperDriveLetter(root), ".codex");
|
|
6616
|
+
const hookPath = path.join(dir, "vexp-hint.sh");
|
|
6617
|
+
// Through `cmd /d /c call`, not the bare path: Codex runs the line in
|
|
6618
|
+
// PowerShell by default, where a quoted path is only a string to print
|
|
6619
|
+
// (codexWindowsHookCommand has the details).
|
|
6620
|
+
const cmdCommand = process.platform === "win32" ? codexWindowsHookCommand(path.join(dir, "vexp-hint.cmd")) : undefined;
|
|
6621
|
+
return {
|
|
6622
|
+
hooks: [
|
|
6623
|
+
{
|
|
6624
|
+
type: "command",
|
|
6625
|
+
command: `bash "${hookPath.replace(/\\/g, "/")}"`,
|
|
6626
|
+
...(cmdCommand ? { commandWindows: cmdCommand } : {}),
|
|
6627
|
+
timeout: 5,
|
|
6628
|
+
},
|
|
6629
|
+
],
|
|
6630
|
+
};
|
|
6631
|
+
}
|
|
6632
|
+
/** The checkout a vexp Codex hook group names (`bash "<checkout>/.codex/vexp-hint.sh"`), forward slashes; undefined for anything else. */
|
|
6633
|
+
function codexHookCheckout(group) {
|
|
6634
|
+
const hooks = asPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
|
|
6635
|
+
const command = hooks.length === 1 && asPlainObject(hooks[0]) ? hooks[0].command : undefined;
|
|
6636
|
+
const m = typeof command === "string" ? /^bash "(.+)\/\.codex\/vexp-hint\.sh"$/.exec(command) : null;
|
|
6637
|
+
return m ? m[1] : undefined;
|
|
6638
|
+
}
|
|
3820
6639
|
/**
|
|
3821
6640
|
* vexp's UserPromptSubmit group, put where the previous one was.
|
|
3822
6641
|
*
|
|
@@ -3892,10 +6711,12 @@ function removeGuardScript(scriptPath) {
|
|
|
3892
6711
|
}
|
|
3893
6712
|
}
|
|
3894
6713
|
/** Remove the opencode guard plugin (2.3 A2 default). Both directory
|
|
3895
|
-
* spellings are checked — older builds wrote `.opencode/plugin/`.
|
|
6714
|
+
* spellings are checked — older builds wrote `.opencode/plugin/`. One
|
|
6715
|
+
* committed to the repository stays (committedCleanupBlocked). */
|
|
3896
6716
|
export function uninstallOpencodePlugin(workspaceRoot) {
|
|
3897
|
-
const
|
|
3898
|
-
const
|
|
6717
|
+
const remove = (p) => !committedCleanupBlocked([p], "Opencode") && removeGuardScript(p);
|
|
6718
|
+
const a = remove(path.join(workspaceRoot, ".opencode", "plugins", "vexp-guard.js"));
|
|
6719
|
+
const b = remove(path.join(workspaceRoot, ".opencode", "plugin", "vexp-guard.js"));
|
|
3899
6720
|
return a || b;
|
|
3900
6721
|
}
|
|
3901
6722
|
/**
|
|
@@ -4060,9 +6881,15 @@ export function installKiloPlugin(workspaceRoot) {
|
|
|
4060
6881
|
* the explicit `plugin` array entry from a root kilo.jsonc when present. */
|
|
4061
6882
|
export function uninstallKiloPlugin(workspaceRoot) {
|
|
4062
6883
|
const pluginPath = path.join(workspaceRoot, ".kilo", "plugins", "vexp-guard.js");
|
|
4063
|
-
let removed = removeGuardScript(pluginPath);
|
|
4064
6884
|
const target = kiloConfigTarget(workspaceRoot);
|
|
4065
6885
|
const read = readJsonConfigSafe(target);
|
|
6886
|
+
// The plugin or the kilo.jsonc that declares it committed to the
|
|
6887
|
+
// repository: both stay (committedCleanupBlocked).
|
|
6888
|
+
const declared = read.ok && read.existed && Array.isArray(read.data.plugin) &&
|
|
6889
|
+
read.data.plugin.some((e) => typeof e === "string" && e.includes("vexp-guard"));
|
|
6890
|
+
if (committedCleanupBlocked([pluginPath, declared ? target : undefined], "Kilo Code"))
|
|
6891
|
+
return false;
|
|
6892
|
+
let removed = removeGuardScript(pluginPath);
|
|
4066
6893
|
if (read.ok && read.existed) {
|
|
4067
6894
|
const cfg = read.data;
|
|
4068
6895
|
const list = Array.isArray(cfg.plugin) ? cfg.plugin : [];
|
|
@@ -4097,11 +6924,14 @@ export function installCursorEditHint(workspaceRoot, binaryPath) {
|
|
|
4097
6924
|
const script = bakeEditHintHook(binaryPath);
|
|
4098
6925
|
fs.mkdirSync(dir, { recursive: true });
|
|
4099
6926
|
const existed = fs.existsSync(scriptPath);
|
|
4100
|
-
|
|
6927
|
+
// A committed script that bakes another live engine stays (K1), its mode
|
|
6928
|
+
// included: git tracks the exec bit.
|
|
6929
|
+
if (!hookScriptCurrent(scriptPath, script, bakeEditHintHook)) {
|
|
4101
6930
|
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
|
4102
6931
|
}
|
|
4103
6932
|
try {
|
|
4104
|
-
|
|
6933
|
+
if (!hookScriptLeft(scriptPath))
|
|
6934
|
+
fs.chmodSync(scriptPath, 0o755);
|
|
4105
6935
|
}
|
|
4106
6936
|
catch {
|
|
4107
6937
|
/* no exec bit on Windows */
|
|
@@ -4115,6 +6945,21 @@ export function installCursorEditHint(workspaceRoot, binaryPath) {
|
|
|
4115
6945
|
const cfg = read.data;
|
|
4116
6946
|
const hooks = cfg.hooks ?? {};
|
|
4117
6947
|
const post = Array.isArray(hooks.postToolUse) ? hooks.postToolUse : [];
|
|
6948
|
+
// Committed to the repository and written by another checkout (the
|
|
6949
|
+
// command names the script by its absolute path): left as it is, as the
|
|
6950
|
+
// Codex hook is (committedPinLine), while that checkout is one of this
|
|
6951
|
+
// repository on this machine and the script it runs is there.
|
|
6952
|
+
const theirs = post.find((e) => asPlainObject(e) && typeof e.command === "string" && e.command.includes("vexp-edit-hint"));
|
|
6953
|
+
const m = asPlainObject(theirs) && typeof theirs.command === "string" ? /^bash "(.+)[\\/]\.cursor[\\/]hooks[\\/]vexp-edit-hint\.sh"$/.exec(theirs.command) : null;
|
|
6954
|
+
if (m &&
|
|
6955
|
+
!sameFolder(m[1], workspaceRoot) &&
|
|
6956
|
+
sameRepositoryCheckout(m[1], workspaceRoot) &&
|
|
6957
|
+
fs.existsSync(path.join(m[1], ".cursor", "hooks", "vexp-edit-hint.sh")) &&
|
|
6958
|
+
JSON.stringify(theirs) === JSON.stringify({ command: `bash "${path.join(m[1], ".cursor", "hooks", "vexp-edit-hint.sh")}"` }) &&
|
|
6959
|
+
isGitTracked(cfgPath)) {
|
|
6960
|
+
noteSetup(committedPinLine(cfgPath, m[1]));
|
|
6961
|
+
return null;
|
|
6962
|
+
}
|
|
4118
6963
|
const filtered = post.filter((e) => {
|
|
4119
6964
|
if (!e || typeof e !== "object")
|
|
4120
6965
|
return true;
|
|
@@ -4135,9 +6980,16 @@ export function installCursorEditHint(workspaceRoot, binaryPath) {
|
|
|
4135
6980
|
* our preToolUse entry from .cursor/hooks.json. */
|
|
4136
6981
|
export function uninstallCursorHook(workspaceRoot) {
|
|
4137
6982
|
const rel = path.join(".cursor", "hooks", "vexp-guard.js");
|
|
4138
|
-
let removed = removeGuardScript(path.join(workspaceRoot, rel));
|
|
4139
6983
|
const cfgPath = path.join(workspaceRoot, ".cursor", "hooks.json");
|
|
4140
6984
|
const read = readJsonConfigSafe(cfgPath);
|
|
6985
|
+
// The guard or the hooks.json that runs it committed to the repository:
|
|
6986
|
+
// both stay (committedCleanupBlocked).
|
|
6987
|
+
const hooksIn = read.ok && read.existed && asPlainObject(read.data.hooks) ? read.data.hooks : {};
|
|
6988
|
+
const registered = Array.isArray(hooksIn.preToolUse) &&
|
|
6989
|
+
hooksIn.preToolUse.some((e) => asPlainObject(e) && typeof e.command === "string" && e.command.includes("vexp-guard"));
|
|
6990
|
+
if (committedCleanupBlocked([path.join(workspaceRoot, rel), registered ? cfgPath : undefined], "Cursor"))
|
|
6991
|
+
return false;
|
|
6992
|
+
let removed = removeGuardScript(path.join(workspaceRoot, rel));
|
|
4141
6993
|
if (read.ok && read.existed) {
|
|
4142
6994
|
const cfg = read.data;
|
|
4143
6995
|
const hooks = cfg.hooks ?? {};
|