vexp-cli 2.1.7 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-config.js +254 -9
- package/dist/cli.js +9 -7
- package/dist/doctor.js +5 -14
- package/dist/hook-template.js +90 -0
- package/dist/socket-path.js +40 -0
- package/package.json +6 -6
package/dist/agent-config.js
CHANGED
|
@@ -10,7 +10,7 @@ import * as fs from "fs";
|
|
|
10
10
|
import * as path from "path";
|
|
11
11
|
import * as os from "os";
|
|
12
12
|
import * as crypto from "crypto";
|
|
13
|
-
import { VEXP_GUARD_HOOK } from "./hook-template.js";
|
|
13
|
+
import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD } from "./hook-template.js";
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Constants
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
@@ -47,7 +47,11 @@ function workspaceHash(workspaceRoot) {
|
|
|
47
47
|
hash ^= BigInt(byte);
|
|
48
48
|
hash = (hash * prime) & mask;
|
|
49
49
|
}
|
|
50
|
-
|
|
50
|
+
// NOT zero-padded — see doctor.ts:socketForWorkspace. Rust renders the u64
|
|
51
|
+
// with `format!("{:x}")` and slices [..8]; padding to 16 first shifts every
|
|
52
|
+
// digit right whenever the hash has a leading zero nibble (~6% of paths),
|
|
53
|
+
// which silently breaks the lockstep this comment demands.
|
|
54
|
+
return hash.toString(16).slice(0, 8);
|
|
51
55
|
}
|
|
52
56
|
/**
|
|
53
57
|
* Remove every legacy vexp entry from a JSON-shaped MCP config object.
|
|
@@ -150,15 +154,27 @@ const AGENT_DETECTORS = [
|
|
|
150
154
|
{
|
|
151
155
|
agent: "Opencode",
|
|
152
156
|
detectPath: "opencode.json",
|
|
157
|
+
// opencode also reads `opencode.jsonc` (and a global ~/.config/opencode/),
|
|
158
|
+
// so `opencode.json` alone missed the many users who keep a commented config.
|
|
159
|
+
detectPaths: ["opencode.jsonc"],
|
|
153
160
|
configFile: "AGENTS.md",
|
|
154
161
|
templateName: "agents-md",
|
|
162
|
+
// Enforcement (block grep/glob when the daemon is up) is installed as an
|
|
163
|
+
// opencode plugin at .opencode/plugins/vexp-guard.js — see installOpencodePlugin().
|
|
155
164
|
},
|
|
156
165
|
{
|
|
157
166
|
agent: "Kilo Code",
|
|
158
167
|
detectPath: ".kilocode",
|
|
159
|
-
|
|
168
|
+
// Current Kilo Code (kilo.ai) uses `.kilo/` + `kilo.jsonc`; keep `.kilocode`
|
|
169
|
+
// for older installs. Any one marker counts as detected.
|
|
170
|
+
detectPaths: [".kilo", "kilo.jsonc", "kilo.json"],
|
|
171
|
+
// Rules: a `.kilocode/rules/` DIRECTORY is auto-included for backward
|
|
172
|
+
// compatibility; a bare `.kilocode/rules.md` file is NOT (the old target,
|
|
173
|
+
// which silently never loaded). Write into the directory instead.
|
|
174
|
+
configFile: ".kilocode/rules/vexp.md",
|
|
160
175
|
templateName: "generic",
|
|
161
|
-
|
|
176
|
+
// MCP is NOT a separate .kilocode/mcp.json anymore — it lives under the
|
|
177
|
+
// `mcp` key in kilo.jsonc. Handled by configureKiloMcp() below.
|
|
162
178
|
},
|
|
163
179
|
{
|
|
164
180
|
agent: "Kiro",
|
|
@@ -323,6 +339,27 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
|
|
|
323
339
|
mcpConfigs.push(".zed/settings.json");
|
|
324
340
|
}
|
|
325
341
|
}
|
|
342
|
+
// Kilo Code: MCP lives under the `mcp` key in kilo.jsonc (root or .kilo/),
|
|
343
|
+
// not a separate mcp.json (that file is no longer read).
|
|
344
|
+
if (detector.agent === "Kilo Code") {
|
|
345
|
+
const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
346
|
+
if (wrote)
|
|
347
|
+
mcpConfigs.push(wrote);
|
|
348
|
+
}
|
|
349
|
+
// Opencode: install the guard plugin that blocks grep/glob while the daemon
|
|
350
|
+
// is up (opencode's analogue of the Claude Code PreToolUse hook).
|
|
351
|
+
if (detector.agent === "Opencode") {
|
|
352
|
+
const pluginResult = installOpencodePlugin(workspaceRoot);
|
|
353
|
+
if (pluginResult) {
|
|
354
|
+
results.push({
|
|
355
|
+
agent: "Opencode Guard",
|
|
356
|
+
configFile: ".opencode/plugins/vexp-guard.js",
|
|
357
|
+
content: VEXP_OPENCODE_GUARD,
|
|
358
|
+
alreadyExists: pluginResult === "updated",
|
|
359
|
+
action: pluginResult,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
326
363
|
results.push({
|
|
327
364
|
agent: detector.agent,
|
|
328
365
|
configFile: detector.configFile,
|
|
@@ -431,6 +468,9 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
|
|
|
431
468
|
if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot))
|
|
432
469
|
mcpConfigs.push(".zed/settings.json");
|
|
433
470
|
}
|
|
471
|
+
if (detector.agent === "Opencode") {
|
|
472
|
+
installOpencodePlugin(workspaceRoot);
|
|
473
|
+
}
|
|
434
474
|
results.push({ agent: detector.agent, configFile: detector.configFile, content, alreadyExists, action });
|
|
435
475
|
}
|
|
436
476
|
return { agents: results, mcpConfigs };
|
|
@@ -523,6 +563,65 @@ export function readJsonConfigSafe(filePath) {
|
|
|
523
563
|
return { data: {}, ok: false, existed: true };
|
|
524
564
|
}
|
|
525
565
|
/** Copy an existing config to <file>.vexp-bak before overwriting it. */
|
|
566
|
+
/**
|
|
567
|
+
* True when the vexp entry already in a config still resolves to something
|
|
568
|
+
* runnable — even if it points at a DIFFERENT vexp install than ours.
|
|
569
|
+
*
|
|
570
|
+
* Mirrors `vexpEntryStillResolves` in
|
|
571
|
+
* packages/vexp-vscode/src/providers/agent-auto-config.ts; duplicated because
|
|
572
|
+
* the CLI cannot depend on the extension at runtime.
|
|
573
|
+
*
|
|
574
|
+
* `mcp-server.cjs` is a standalone script that reaches the daemon over its
|
|
575
|
+
* socket; it does not care which install it was launched from. So an entry
|
|
576
|
+
* naming another install is not stale, it is just someone else's path — and
|
|
577
|
+
* rewriting it starts a fight the user sees as "my MCP config gets
|
|
578
|
+
* overwritten every time I open a different IDE". Every VS Code fork carries
|
|
579
|
+
* the extension and writes these same files with its own absolute extension
|
|
580
|
+
* path, while the CLI writes `command: "node"` plus its npm-global path — a
|
|
581
|
+
* third shape — so `vexp setup` and any IDE activation also undid each other.
|
|
582
|
+
* The old value-equality check could never settle it: a different path is
|
|
583
|
+
* never identical, so whoever ran last won.
|
|
584
|
+
*
|
|
585
|
+
* Staleness is handled by the check rather than by rewriting: editors reap the
|
|
586
|
+
* previous extension directory on upgrade (and the path embeds the version),
|
|
587
|
+
* so an entry left by an uninstalled build fails here and is rewritten.
|
|
588
|
+
*
|
|
589
|
+
* Accepts every shape we emit:
|
|
590
|
+
* {command: "node", args: ["…/mcp-server.cjs"]} most JSON configs
|
|
591
|
+
* {command: "…/vexp-core", args: ["mcp"]} binary-subcommand
|
|
592
|
+
* {command: {path, args}} Zed
|
|
593
|
+
* {command: ["node", "…/mcp-server.cjs"]} Kilo Code
|
|
594
|
+
*/
|
|
595
|
+
function vexpEntryStillResolves(entry) {
|
|
596
|
+
if (!entry || typeof entry !== "object")
|
|
597
|
+
return false;
|
|
598
|
+
const e = entry;
|
|
599
|
+
let command;
|
|
600
|
+
let rawArgs;
|
|
601
|
+
if (Array.isArray(e.command)) {
|
|
602
|
+
command = e.command[0];
|
|
603
|
+
rawArgs = e.command.slice(1);
|
|
604
|
+
}
|
|
605
|
+
else if (e.command !== null && typeof e.command === "object") {
|
|
606
|
+
const nested = e.command;
|
|
607
|
+
command = nested.path;
|
|
608
|
+
rawArgs = nested.args;
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
command = e.command;
|
|
612
|
+
rawArgs = e.args;
|
|
613
|
+
}
|
|
614
|
+
if (typeof command !== "string" || command.length === 0)
|
|
615
|
+
return false;
|
|
616
|
+
// An interpreter pinned by absolute path must still exist. A bare "node" is
|
|
617
|
+
// resolved via PATH at spawn time, so there is nothing to verify here.
|
|
618
|
+
if (path.isAbsolute(command) && !fs.existsSync(command))
|
|
619
|
+
return false;
|
|
620
|
+
// The script it runs, when the entry names one. `args: ["mcp"]` names a
|
|
621
|
+
// subcommand of the binary in `command`, already checked above.
|
|
622
|
+
const script = (Array.isArray(rawArgs) ? rawArgs : []).find((a) => typeof a === "string" && path.isAbsolute(a) && /\.[cm]?js$/.test(a));
|
|
623
|
+
return script === undefined || fs.existsSync(script);
|
|
624
|
+
}
|
|
526
625
|
function backupConfig(filePath) {
|
|
527
626
|
try {
|
|
528
627
|
if (fs.existsSync(filePath))
|
|
@@ -671,10 +770,11 @@ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServer
|
|
|
671
770
|
const currentCmd = previousVexp?.["command"];
|
|
672
771
|
const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
|
|
673
772
|
const prevEnv = previousVexp?.env;
|
|
773
|
+
const envMatches = JSON.stringify(prevEnv) === JSON.stringify(targetEnv);
|
|
674
774
|
const identical = currentCmd === targetCmd &&
|
|
675
775
|
prevArgs !== undefined &&
|
|
676
776
|
JSON.stringify(prevArgs) === JSON.stringify(targetArgs) &&
|
|
677
|
-
|
|
777
|
+
envMatches;
|
|
678
778
|
// Legacy cleanup: remove any vexp/vexp-* keys; if the canonical 'vexp'
|
|
679
779
|
// entry points at a command path that no longer exists on disk, drop it
|
|
680
780
|
// too so we don't keep a stale pointer.
|
|
@@ -683,6 +783,11 @@ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServer
|
|
|
683
783
|
if (identical && removed.length === 0) {
|
|
684
784
|
return false; // Already up to date and no legacy to clean
|
|
685
785
|
}
|
|
786
|
+
// Another vexp install already left a working entry here — don't fight it.
|
|
787
|
+
// Only the install path may differ: `envMatches` still gates on the
|
|
788
|
+
// VEXP_WORKSPACE pin, so a moved or renamed project is repinned normally.
|
|
789
|
+
if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
|
|
790
|
+
return false;
|
|
686
791
|
const servers = existing.mcpServers ?? {};
|
|
687
792
|
servers["vexp"] = {
|
|
688
793
|
command: targetCmd,
|
|
@@ -751,6 +856,34 @@ function tomlString(value) {
|
|
|
751
856
|
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
752
857
|
}
|
|
753
858
|
/** Extract the existing canonical [mcp_servers.vexp] block (incl. subsections). */
|
|
859
|
+
/** Inverse of `tomlString` for the two forms it emits: a literal `'…'` (no
|
|
860
|
+
* escapes) or a basic `"…"` with `\\` / `\"` escaped. */
|
|
861
|
+
function parseTomlString(raw) {
|
|
862
|
+
const literal = /^'([^']*)'$/.exec(raw);
|
|
863
|
+
if (literal)
|
|
864
|
+
return literal[1];
|
|
865
|
+
const basic = /^"((?:[^"\\]|\\.)*)"$/.exec(raw);
|
|
866
|
+
if (basic)
|
|
867
|
+
return basic[1].replace(/\\(["\\])/g, "$1");
|
|
868
|
+
return undefined;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* The binary an existing managed *direct* section points at, when it is still
|
|
872
|
+
* on disk. Undefined for an http section, a malformed one, or one naming a
|
|
873
|
+
* binary that is gone — each of which must be rebuilt from our own path.
|
|
874
|
+
*
|
|
875
|
+
* Mirrors `codexDirectBinary` in
|
|
876
|
+
* packages/vexp-vscode/src/providers/agent-auto-config.ts.
|
|
877
|
+
*/
|
|
878
|
+
function codexDirectBinary(section) {
|
|
879
|
+
const m = /^\s*command\s*=\s*(.+?)\s*$/m.exec(section);
|
|
880
|
+
if (!m)
|
|
881
|
+
return undefined;
|
|
882
|
+
const cmd = parseTomlString(m[1]);
|
|
883
|
+
if (!cmd || !path.isAbsolute(cmd) || !fs.existsSync(cmd))
|
|
884
|
+
return undefined;
|
|
885
|
+
return cmd;
|
|
886
|
+
}
|
|
754
887
|
function extractCodexVexpSection(content) {
|
|
755
888
|
const m = content.match(CANONICAL_VEXP_TOML_SECTION_RE);
|
|
756
889
|
return m ? m.join("\n") : "";
|
|
@@ -825,7 +958,7 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
|
|
|
825
958
|
// stanza Codex can cache → "url is not supported for stdio". Pass the binary only
|
|
826
959
|
// when it exists; buildCodexSection(direct, undefined) returns null → we then
|
|
827
960
|
// PRESERVE the existing managed section instead of clobbering it with a url.
|
|
828
|
-
const
|
|
961
|
+
const ourCoreBinary = binaryPath && fs.existsSync(binaryPath) ? binaryPath : undefined;
|
|
829
962
|
let content = "";
|
|
830
963
|
if (fs.existsSync(configPath))
|
|
831
964
|
content = fs.readFileSync(configPath, "utf-8");
|
|
@@ -847,6 +980,15 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
|
|
|
847
980
|
}
|
|
848
981
|
return false;
|
|
849
982
|
}
|
|
983
|
+
// Step 2b: adopt the binary a previous install already pinned here, when it
|
|
984
|
+
// still exists. Our own sections are not "user-managed", so `vexp setup` and
|
|
985
|
+
// every IDE activation rewrote this file with their own binary path — and the
|
|
986
|
+
// file is machine-global while Codex is detected by AGENTS.md, so it churned
|
|
987
|
+
// for every project at once. Adopting rather than skipping is what keeps this
|
|
988
|
+
// safe: the binary is interchangeable, but the workspace pin below is not and
|
|
989
|
+
// must still follow the project being configured. See the twin comment in
|
|
990
|
+
// vexp-vscode/src/providers/agent-auto-config.ts.
|
|
991
|
+
const coreBinaryPath = (transport === "direct" ? codexDirectBinary(existingSection) : undefined) ?? ourCoreBinary;
|
|
850
992
|
const newSection = buildCodexSection({ transport, coreBinaryPath, workspaceRoot, mcpPort, token, home });
|
|
851
993
|
if (!newSection) {
|
|
852
994
|
// direct requested but binary missing → preserve the existing section (only
|
|
@@ -884,14 +1026,22 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
|
|
|
884
1026
|
const beforeServers = existing.servers;
|
|
885
1027
|
const previousVexp = beforeServers?.["vexp"];
|
|
886
1028
|
const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
|
|
1029
|
+
const envMatches = JSON.stringify(previousVexp?.env) === JSON.stringify(targetEnv);
|
|
887
1030
|
const identical = previousVexp?.["command"] === targetCmd &&
|
|
888
1031
|
prevArgs !== undefined &&
|
|
889
1032
|
JSON.stringify(prevArgs) === JSON.stringify(targetArgs) &&
|
|
890
|
-
|
|
1033
|
+
envMatches;
|
|
891
1034
|
const removed = stripLegacyVexpEntries(existing, "servers");
|
|
892
1035
|
logRemoval(removed);
|
|
893
1036
|
if (identical && removed.length === 0)
|
|
894
1037
|
return false;
|
|
1038
|
+
// Another vexp install already left a working entry here — don't fight it.
|
|
1039
|
+
// This file is the worst offender: every VS Code fork reaches it through the
|
|
1040
|
+
// Copilot branch, which is gated only on `.github/` existing. Only the
|
|
1041
|
+
// install path may differ — `envMatches` still gates on the VEXP_WORKSPACE
|
|
1042
|
+
// pin, so a moved or renamed project is repinned normally.
|
|
1043
|
+
if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
|
|
1044
|
+
return false;
|
|
895
1045
|
const servers = existing.servers ?? {};
|
|
896
1046
|
servers["vexp"] = {
|
|
897
1047
|
type: "stdio",
|
|
@@ -909,6 +1059,58 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
|
|
|
909
1059
|
/**
|
|
910
1060
|
* Write context_servers in .zed/settings.json for Zed.
|
|
911
1061
|
*/
|
|
1062
|
+
/**
|
|
1063
|
+
* Kilo Code MCP setup. Current Kilo Code (kilo.ai) reads MCP servers from the
|
|
1064
|
+
* top-level `mcp` key inside `kilo.jsonc` — NOT a separate `.kilocode/mcp.json`
|
|
1065
|
+
* (that path is no longer read). The config file is `.kilo/kilo.jsonc`
|
|
1066
|
+
* (preferred) or `kilo.jsonc` at the project root; `.kilo/` wins if both exist,
|
|
1067
|
+
* so we merge into whichever is present and create the clean `.kilo/` location
|
|
1068
|
+
* on a fresh setup. Kilo's local-server shape is `{type:"local", command:[…],
|
|
1069
|
+
* enabled:true}`. Other `mcp` entries (and other top-level keys) are preserved;
|
|
1070
|
+
* an existing file is backed up before rewrite. Returns the workspace-relative
|
|
1071
|
+
* path written, or null on a no-op / unparseable file.
|
|
1072
|
+
*/
|
|
1073
|
+
export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
1074
|
+
const dotKilo = path.join(workspaceRoot, ".kilo", "kilo.jsonc");
|
|
1075
|
+
const rootKilo = path.join(workspaceRoot, "kilo.jsonc");
|
|
1076
|
+
const target = fs.existsSync(dotKilo)
|
|
1077
|
+
? dotKilo
|
|
1078
|
+
: fs.existsSync(rootKilo)
|
|
1079
|
+
? rootKilo
|
|
1080
|
+
: dotKilo; // fresh setup → the recommended cleaner location
|
|
1081
|
+
const read = readJsonConfigSafe(target);
|
|
1082
|
+
if (!read.ok) {
|
|
1083
|
+
warnUnparseable(target);
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
const cfg = read.data;
|
|
1087
|
+
const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
|
|
1088
|
+
const command = useNode ? ["node", mcpServerPath] : [binaryPath, "mcp"];
|
|
1089
|
+
const env = { VEXP_WORKSPACE: workspaceRoot };
|
|
1090
|
+
const mcp = cfg.mcp ?? {};
|
|
1091
|
+
const prev = mcp["vexp"];
|
|
1092
|
+
const envMatches = JSON.stringify(prev?.["env"]) === JSON.stringify(env);
|
|
1093
|
+
const identical = prev?.["type"] === "local" &&
|
|
1094
|
+
prev?.["enabled"] === true &&
|
|
1095
|
+
Array.isArray(prev?.["command"]) &&
|
|
1096
|
+
JSON.stringify(prev["command"]) === JSON.stringify(command) &&
|
|
1097
|
+
envMatches;
|
|
1098
|
+
if (identical)
|
|
1099
|
+
return null;
|
|
1100
|
+
// Another vexp install already left a working entry here — don't fight it.
|
|
1101
|
+
// Kilo's shape is `command: ["node", "<script>"]`. Only the install path may
|
|
1102
|
+
// differ: `envMatches` still gates on the VEXP_WORKSPACE pin, and `enabled`
|
|
1103
|
+
// must be true or the user turned it off deliberately.
|
|
1104
|
+
if (envMatches && prev?.["enabled"] === true && vexpEntryStillResolves(prev))
|
|
1105
|
+
return null;
|
|
1106
|
+
mcp["vexp"] = { type: "local", command, env, enabled: true };
|
|
1107
|
+
cfg.mcp = mcp;
|
|
1108
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1109
|
+
if (read.existed)
|
|
1110
|
+
backupConfig(target);
|
|
1111
|
+
fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
|
|
1112
|
+
return path.relative(workspaceRoot, target);
|
|
1113
|
+
}
|
|
912
1114
|
export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
|
|
913
1115
|
const read = readJsonConfigSafe(p);
|
|
914
1116
|
if (!read.ok) {
|
|
@@ -923,14 +1125,21 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
|
|
|
923
1125
|
const beforeCs = settings.context_servers;
|
|
924
1126
|
const previousVexp = beforeCs?.["vexp"];
|
|
925
1127
|
const curCmd = previousVexp?.["command"];
|
|
1128
|
+
// Zed nests env inside `command`, unlike every other writer.
|
|
1129
|
+
const envMatches = JSON.stringify(curCmd?.["env"]) === JSON.stringify(targetEnv);
|
|
926
1130
|
const identical = curCmd?.["path"] === targetCmd &&
|
|
927
1131
|
Array.isArray(curCmd?.["args"]) &&
|
|
928
1132
|
JSON.stringify(curCmd["args"]) === JSON.stringify(targetArgs) &&
|
|
929
|
-
|
|
1133
|
+
envMatches;
|
|
930
1134
|
const removed = stripLegacyVexpEntries(settings, "context_servers");
|
|
931
1135
|
logRemoval(removed);
|
|
932
1136
|
if (identical && removed.length === 0)
|
|
933
1137
|
return false;
|
|
1138
|
+
// Another vexp install already left a working entry here — don't fight it.
|
|
1139
|
+
// Only the install path may differ: `envMatches` still gates on the
|
|
1140
|
+
// VEXP_WORKSPACE pin, so a moved or renamed project is repinned normally.
|
|
1141
|
+
if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
|
|
1142
|
+
return false;
|
|
934
1143
|
const cs = settings.context_servers ?? {};
|
|
935
1144
|
cs["vexp"] = {
|
|
936
1145
|
command: {
|
|
@@ -991,6 +1200,13 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
|
|
|
991
1200
|
logRemoval(removed);
|
|
992
1201
|
if (identical && removed.length === 0)
|
|
993
1202
|
return false;
|
|
1203
|
+
// Another vexp install already left a working entry here — don't fight it.
|
|
1204
|
+
// `prevEnv === undefined` is load-bearing, not a nicety: an entry still
|
|
1205
|
+
// pinning VEXP_WORKSPACE must be rewritten no matter whose it is, because
|
|
1206
|
+
// this file applies to EVERY project and the pin forces all parallel
|
|
1207
|
+
// sessions onto one daemon (the migration described above).
|
|
1208
|
+
if (removed.length === 0 && prevEnv === undefined && vexpEntryStillResolves(existing))
|
|
1209
|
+
return false;
|
|
994
1210
|
const servers = config.mcpServers ?? {};
|
|
995
1211
|
servers["vexp"] = {
|
|
996
1212
|
command: desiredCommand,
|
|
@@ -1121,6 +1337,31 @@ export function installClaudeCodeHook(workspaceRoot) {
|
|
|
1121
1337
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1122
1338
|
return existed ? "updated" : "created";
|
|
1123
1339
|
}
|
|
1340
|
+
// ---------------------------------------------------------------------------
|
|
1341
|
+
// opencode plugin - blocks grep/glob (and shelled-out search) when the daemon
|
|
1342
|
+
// is healthy. opencode has no PreToolUse hook, but auto-loads plugins from
|
|
1343
|
+
// .opencode/plugins/ and lets a `tool.execute.before` hook abort a tool by
|
|
1344
|
+
// throwing — the same enforcement the Claude Code guard hook provides.
|
|
1345
|
+
// ---------------------------------------------------------------------------
|
|
1346
|
+
/**
|
|
1347
|
+
* Install the vexp-guard plugin for opencode.
|
|
1348
|
+
* Writes .opencode/plugins/vexp-guard.js (opencode auto-loads the directory at
|
|
1349
|
+
* startup — no config-file merge required). Returns the action taken, or null
|
|
1350
|
+
* when the file is already byte-identical.
|
|
1351
|
+
*/
|
|
1352
|
+
export function installOpencodePlugin(workspaceRoot) {
|
|
1353
|
+
const pluginDir = path.join(workspaceRoot, ".opencode", "plugins");
|
|
1354
|
+
const pluginPath = path.join(pluginDir, "vexp-guard.js");
|
|
1355
|
+
fs.mkdirSync(pluginDir, { recursive: true });
|
|
1356
|
+
const existed = fs.existsSync(pluginPath);
|
|
1357
|
+
if (existed) {
|
|
1358
|
+
const current = fs.readFileSync(pluginPath, "utf-8");
|
|
1359
|
+
if (current === VEXP_OPENCODE_GUARD)
|
|
1360
|
+
return null; // identical - skip
|
|
1361
|
+
}
|
|
1362
|
+
fs.writeFileSync(pluginPath, VEXP_OPENCODE_GUARD, "utf-8");
|
|
1363
|
+
return existed ? "updated" : "created";
|
|
1364
|
+
}
|
|
1124
1365
|
function generateAgentConfig(template, vars) {
|
|
1125
1366
|
switch (template) {
|
|
1126
1367
|
case "claude-code":
|
|
@@ -1197,6 +1438,9 @@ Only use Read when you need exact raw content to edit a specific line.
|
|
|
1197
1438
|
- **Session Memory**: auto-captures observations; memories auto-surfaced in results
|
|
1198
1439
|
- **LSP Bridge**: VS Code captures type-resolved call edges
|
|
1199
1440
|
- **Change Coupling**: co-changed files included as related context
|
|
1441
|
+
- **Query tips**: include real identifiers (ClassName, function_name) or file paths
|
|
1442
|
+
in the task for precise matches - pure natural-language phrasing falls back to
|
|
1443
|
+
text ranking and is less reliable
|
|
1200
1444
|
|
|
1201
1445
|
### Advanced Parameters
|
|
1202
1446
|
- \`preset: "debug"\` - forces debug mode (capsule+tests+impact+memory)
|
|
@@ -1238,7 +1482,8 @@ vexp returns pre-indexed, graph-ranked context in a single call.
|
|
|
1238
1482
|
rather than letting them search the codebase independently
|
|
1239
1483
|
|
|
1240
1484
|
### Tips
|
|
1241
|
-
-
|
|
1485
|
+
- Include real identifiers (class/function names) or file paths in the task - literal
|
|
1486
|
+
matches rank best; pure natural-language phrasing falls back to text ranking
|
|
1242
1487
|
- Add \`include_tests: true\` when debugging
|
|
1243
1488
|
- Use \`preset: "refactor"\` for deep impact analysis
|
|
1244
1489
|
|
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import { ensureMcpHttpServer, mcpHttpStatus } from "./mcp-supervisor.js";
|
|
|
16
16
|
import { installAutostart, uninstallAutostart, autostartStatus, migrateClaudeUnpinIfNeeded } from "./autostart.js";
|
|
17
17
|
import { runServe } from "./serve.js";
|
|
18
18
|
import { runDoctor } from "./doctor.js";
|
|
19
|
+
import { socketPathFor } from "./socket-path.js";
|
|
19
20
|
import { isTraceEnabled } from "./trace.js";
|
|
20
21
|
const program = new Command();
|
|
21
22
|
program
|
|
@@ -158,7 +159,9 @@ function findConfiguredWorkspace(startDir = process.cwd()) {
|
|
|
158
159
|
/** True if the Unix socket at `socketPath` accepts a connection within `timeoutMs`. */
|
|
159
160
|
function isSocketAlive(socketPath, timeoutMs = 300) {
|
|
160
161
|
return new Promise((resolve) => {
|
|
161
|
-
|
|
162
|
+
// A Windows named pipe is not a filesystem entry, so the existsSync
|
|
163
|
+
// pre-check would reject every live daemon. Only unix sockets are files.
|
|
164
|
+
if (process.platform !== "win32" && !fs.existsSync(socketPath)) {
|
|
162
165
|
resolve(false);
|
|
163
166
|
return;
|
|
164
167
|
}
|
|
@@ -198,8 +201,7 @@ function isDaemonPidAlive(workspaceRoot) {
|
|
|
198
201
|
async function ensureDaemonRunning(workspaceRoot, binaryPath) {
|
|
199
202
|
if (process.env.VEXP_NO_AUTOSTART === "1")
|
|
200
203
|
return;
|
|
201
|
-
|
|
202
|
-
if (await isSocketAlive(socketPath))
|
|
204
|
+
if (await isSocketAlive(socketPathFor(workspaceRoot)))
|
|
203
205
|
return;
|
|
204
206
|
try {
|
|
205
207
|
await runBinaryAsync(binaryPath, ["daemon-cmd", "start"], {
|
|
@@ -252,8 +254,9 @@ async function startBackgroundServices(binaryPath, workspaceRoot) {
|
|
|
252
254
|
const spinnerDaemon = ora("Starting vexp daemon...").start();
|
|
253
255
|
try {
|
|
254
256
|
await runBinaryAsync(binaryPath, ["daemon-cmd", "start"], { silent: true, cwd: workspaceRoot });
|
|
255
|
-
|
|
256
|
-
|
|
257
|
+
// Connect rather than stat: on Windows the daemon binds a named pipe and
|
|
258
|
+
// there is no socket file to find, so this always reported failure.
|
|
259
|
+
if (await isSocketAlive(socketPathFor(workspaceRoot))) {
|
|
257
260
|
spinnerDaemon.succeed("vexp daemon started");
|
|
258
261
|
}
|
|
259
262
|
else {
|
|
@@ -1043,8 +1046,7 @@ async function printBanner() {
|
|
|
1043
1046
|
console.log(chalk.dim(" → type '1 > 3' to run full setup, or `vexp setup`"));
|
|
1044
1047
|
}
|
|
1045
1048
|
else {
|
|
1046
|
-
const
|
|
1047
|
-
const socketAlive = await isSocketAlive(socketPath);
|
|
1049
|
+
const socketAlive = await isSocketAlive(socketPathFor(ws));
|
|
1048
1050
|
if (socketAlive) {
|
|
1049
1051
|
// Display PID + uptime when we can read them; otherwise a plain "running".
|
|
1050
1052
|
const { pid } = isDaemonPidAlive(ws);
|
package/dist/doctor.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as os from "os";
|
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as net from "net";
|
|
5
5
|
import chalk from "chalk";
|
|
6
|
+
import { socketPathFor } from "./socket-path.js";
|
|
6
7
|
// `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
|
|
7
8
|
// Surfaces the failure modes behind the Codex drift report: stale daemons.json
|
|
8
9
|
// entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
|
|
@@ -43,20 +44,10 @@ function resolveWorkspace() {
|
|
|
43
44
|
return { root: process.env.CLAUDE_PROJECT_DIR, source: "CLAUDE_PROJECT_DIR" };
|
|
44
45
|
return { root: discoverWorkspaceRoot(process.cwd()), source: "cwd-discovery" };
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const prime = BigInt("0x100000001b3");
|
|
51
|
-
const mask = BigInt("0xffffffffffffffff");
|
|
52
|
-
for (const b of Buffer.from(root.toLowerCase(), "utf-8")) {
|
|
53
|
-
hash ^= BigInt(b);
|
|
54
|
-
hash = (hash * prime) & mask;
|
|
55
|
-
}
|
|
56
|
-
return `\\\\.\\pipe\\vexp-${hash.toString(16).padStart(16, "0").slice(0, 8)}`;
|
|
57
|
-
}
|
|
58
|
-
return path.join(root, ".vexp", "daemon.sock");
|
|
59
|
-
}
|
|
47
|
+
// Was a fifth hand-rolled copy of the hash, and one of the two that zero-padded
|
|
48
|
+
// it — so doctor probed a pipe name the daemon never binds for ~6% of Windows
|
|
49
|
+
// workspaces and declared a healthy daemon down.
|
|
50
|
+
const socketForWorkspace = socketPathFor;
|
|
60
51
|
function reachable(sock, timeoutMs = 600) {
|
|
61
52
|
return new Promise((resolve) => {
|
|
62
53
|
const c = process.platform === "win32" ? net.createConnection(sock) : net.createConnection({ path: sock });
|
package/dist/hook-template.js
CHANGED
|
@@ -51,3 +51,93 @@ case "$(uname -s 2>/dev/null)" in
|
|
|
51
51
|
;;
|
|
52
52
|
esac
|
|
53
53
|
`;
|
|
54
|
+
/**
|
|
55
|
+
* opencode plugin that enforces the same "use run_pipeline, not grep/glob"
|
|
56
|
+
* policy the Claude Code PreToolUse hook applies — opencode has no hook system,
|
|
57
|
+
* but its plugin API exposes a `tool.execute.before` hook where throwing an
|
|
58
|
+
* Error aborts the tool call. Written to `.opencode/plugins/vexp-guard.js` by
|
|
59
|
+
* `vexp setup-agents` (opencode auto-loads that directory at startup).
|
|
60
|
+
*
|
|
61
|
+
* It blocks the native `grep`/`glob` tools AND shelled-out tree search
|
|
62
|
+
* (grep/rg/find/... via bash) WHILE the vexp daemon is healthy, and fails OPEN
|
|
63
|
+
* when the daemon is down so search still works without an index — mirroring
|
|
64
|
+
* VEXP_GUARD_HOOK's healthy-marker + live-endpoint gate.
|
|
65
|
+
*
|
|
66
|
+
* The literal body is deliberately ASCII-only and free of backticks, `${...}`
|
|
67
|
+
* and backslash escapes so it can be embedded verbatim in a TS template literal
|
|
68
|
+
* (the VS Code extension keeps a byte-identical copy, enforced by the lockstep
|
|
69
|
+
* test in packages/vexp-cli/test/hook-template.test.ts). Do NOT introduce any
|
|
70
|
+
* of those characters here without updating that test's un-escape logic.
|
|
71
|
+
*/
|
|
72
|
+
export const VEXP_OPENCODE_GUARD = `// vexp-guard - opencode plugin (generated by 'vexp setup-agents').
|
|
73
|
+
// Forces the agent onto vexp's run_pipeline by blocking opencode's native
|
|
74
|
+
// grep/glob (and shelled-out tree search) WHILE the vexp daemon is healthy.
|
|
75
|
+
// Fails OPEN when the daemon is down, so native search still works with no index.
|
|
76
|
+
// Mirrors vexp's Claude Code PreToolUse guard (.claude/hooks/vexp-guard.sh).
|
|
77
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
78
|
+
import { join, dirname } from "node:path";
|
|
79
|
+
|
|
80
|
+
// Walk up from start to the first ancestor that owns a .vexp dir.
|
|
81
|
+
function findVexpDir(start) {
|
|
82
|
+
let dir = start;
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (existsSync(join(dir, ".vexp"))) return join(dir, ".vexp");
|
|
85
|
+
const parent = dirname(dir);
|
|
86
|
+
if (parent === dir) return null;
|
|
87
|
+
dir = parent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Is the pid in .vexp/daemon.pid a live process? Guards stale files left after
|
|
92
|
+
// a kill -9: healthy + socket can linger, so existence alone is not enough.
|
|
93
|
+
function pidAlive(pidFile) {
|
|
94
|
+
let pid = 0;
|
|
95
|
+
try { pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10); } catch (e) { return false; }
|
|
96
|
+
if (!pid) return false;
|
|
97
|
+
try { process.kill(pid, 0); return true; } catch (e) { return !!(e && e.code === "EPERM"); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The daemon writes .vexp/healthy when up and removes it on graceful shutdown.
|
|
101
|
+
// Unix: also require a live socket AND a live pid. Windows has no socket file,
|
|
102
|
+
// so the daemon drops a .vexp/daemon.pipe marker instead. A dead daemon fails
|
|
103
|
+
// open so native search still works.
|
|
104
|
+
function daemonHealthy(vexpDir) {
|
|
105
|
+
if (!vexpDir || !existsSync(join(vexpDir, "healthy"))) return false;
|
|
106
|
+
if (process.platform === "win32") return existsSync(join(vexpDir, "daemon.pipe"));
|
|
107
|
+
return existsSync(join(vexpDir, "daemon.sock")) && pidAlive(join(vexpDir, "daemon.pid"));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const SEARCH_TOOLS = new Set(["grep", "glob"]);
|
|
111
|
+
const SHELL_SEARCH_BINS = ["rg", "grep", "egrep", "fgrep", "ag", "ack", "find", "fd", "fdfind"];
|
|
112
|
+
|
|
113
|
+
// True when a bash command's first token is a tree-search binary (grep/rg/find).
|
|
114
|
+
function isShellSearch(command) {
|
|
115
|
+
const t = (command || "").trim();
|
|
116
|
+
for (const b of SHELL_SEARCH_BINS) {
|
|
117
|
+
if (t === b || t.indexOf(b + " ") === 0) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const VexpGuard = async ({ directory, worktree }) => {
|
|
123
|
+
const root = worktree || directory || ".";
|
|
124
|
+
let vexpDir = null;
|
|
125
|
+
return {
|
|
126
|
+
"tool.execute.before": async (input, output) => {
|
|
127
|
+
if (!vexpDir) vexpDir = findVexpDir(root);
|
|
128
|
+
if (!daemonHealthy(vexpDir)) return;
|
|
129
|
+
if (SEARCH_TOOLS.has(input.tool)) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
"vexp daemon is running - call run_pipeline instead of " + input.tool +
|
|
132
|
+
" (pre-indexed, graph-ranked, ~60% fewer tokens)."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (input.tool === "bash" && isShellSearch(output && output.args && output.args.command)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"vexp daemon is running - call run_pipeline instead of shell search (grep/rg/find)."
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
`;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
/**
|
|
3
|
+
* Where the daemon listens for a given workspace root.
|
|
4
|
+
*
|
|
5
|
+
* MUST stay in lockstep with `get_socket_path` in vexp-core/src/utils.rs.
|
|
6
|
+
* Duplicated rather than imported because the CLI cannot depend on the MCP
|
|
7
|
+
* package at runtime; `test/workspace-hash-lockstep.test.ts` pins the parts
|
|
8
|
+
* that silently drift.
|
|
9
|
+
*
|
|
10
|
+
* Two things here are easy to get wrong, and both were:
|
|
11
|
+
*
|
|
12
|
+
* - Windows has no socket file. The daemon binds a named pipe whose name is
|
|
13
|
+
* the FNV-1a hash of the LOWERCASED root (drive letters vary by caller).
|
|
14
|
+
* Sites that hardcoded `.vexp/daemon.sock` probed a file that never exists
|
|
15
|
+
* on Windows, so the CLI reported a healthy daemon as stopped.
|
|
16
|
+
* - The hash is NOT zero-padded: Rust renders the u64 with `format!("{:x}")`
|
|
17
|
+
* and slices [..8].
|
|
18
|
+
*/
|
|
19
|
+
export function socketPathFor(workspaceRoot) {
|
|
20
|
+
if (process.platform === "win32") {
|
|
21
|
+
return `\\\\.\\pipe\\vexp-${fnvHash(workspaceRoot.toLowerCase()).slice(0, 8)}`;
|
|
22
|
+
}
|
|
23
|
+
const candidate = path.join(workspaceRoot, ".vexp", "daemon.sock");
|
|
24
|
+
// macOS/BSD sockaddr_un caps the path at 104 bytes; vexp-core falls back to
|
|
25
|
+
// /tmp past 100 chars, so a deeply-nested workspace listens there instead.
|
|
26
|
+
if (candidate.length <= 100)
|
|
27
|
+
return candidate;
|
|
28
|
+
return `/tmp/vexp-${fnvHash(workspaceRoot).slice(0, 12)}.sock`;
|
|
29
|
+
}
|
|
30
|
+
/** FNV-1a 64-bit, hex, unpadded — mirrors md5_hash() in vexp-core/src/utils.rs. */
|
|
31
|
+
export function fnvHash(input) {
|
|
32
|
+
let hash = BigInt("0xcbf29ce484222325");
|
|
33
|
+
const prime = BigInt("0x100000001b3");
|
|
34
|
+
const mask = BigInt("0xffffffffffffffff");
|
|
35
|
+
for (const byte of Buffer.from(input, "utf-8")) {
|
|
36
|
+
hash ^= BigInt(byte);
|
|
37
|
+
hash = (hash * prime) & mask;
|
|
38
|
+
}
|
|
39
|
+
return hash.toString(16);
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vexp-cli",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Vexp — Context Engine for AI Coding Agents. Pre-indexes your codebase into a dependency graph and delivers ranked context to any MCP-compatible agent. 58% lower cost per task, 90% fewer tool calls (SWE-bench Verified). Works with Claude Code, Cursor, Copilot, Windsurf, Codex, Cline, Aider, and 12+ agents. Local-first. Your code never leaves your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -100,10 +100,10 @@
|
|
|
100
100
|
"node": ">=20.0.0"
|
|
101
101
|
},
|
|
102
102
|
"optionalDependencies": {
|
|
103
|
-
"@vexp/core-linux-x64": "2.1
|
|
104
|
-
"@vexp/core-linux-arm64": "2.1
|
|
105
|
-
"@vexp/core-darwin-x64": "2.1
|
|
106
|
-
"@vexp/core-darwin-arm64": "2.1
|
|
107
|
-
"@vexp/core-win32-x64": "2.1
|
|
103
|
+
"@vexp/core-linux-x64": "2.2.1",
|
|
104
|
+
"@vexp/core-linux-arm64": "2.2.1",
|
|
105
|
+
"@vexp/core-darwin-x64": "2.2.1",
|
|
106
|
+
"@vexp/core-darwin-arm64": "2.2.1",
|
|
107
|
+
"@vexp/core-win32-x64": "2.2.1"
|
|
108
108
|
}
|
|
109
109
|
}
|