vexp-cli 2.2.0 → 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 +189 -7
- 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,8 +154,13 @@ 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",
|
|
@@ -337,6 +346,20 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
|
|
|
337
346
|
if (wrote)
|
|
338
347
|
mcpConfigs.push(wrote);
|
|
339
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
|
+
}
|
|
340
363
|
results.push({
|
|
341
364
|
agent: detector.agent,
|
|
342
365
|
configFile: detector.configFile,
|
|
@@ -445,6 +468,9 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
|
|
|
445
468
|
if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot))
|
|
446
469
|
mcpConfigs.push(".zed/settings.json");
|
|
447
470
|
}
|
|
471
|
+
if (detector.agent === "Opencode") {
|
|
472
|
+
installOpencodePlugin(workspaceRoot);
|
|
473
|
+
}
|
|
448
474
|
results.push({ agent: detector.agent, configFile: detector.configFile, content, alreadyExists, action });
|
|
449
475
|
}
|
|
450
476
|
return { agents: results, mcpConfigs };
|
|
@@ -537,6 +563,65 @@ export function readJsonConfigSafe(filePath) {
|
|
|
537
563
|
return { data: {}, ok: false, existed: true };
|
|
538
564
|
}
|
|
539
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
|
+
}
|
|
540
625
|
function backupConfig(filePath) {
|
|
541
626
|
try {
|
|
542
627
|
if (fs.existsSync(filePath))
|
|
@@ -685,10 +770,11 @@ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServer
|
|
|
685
770
|
const currentCmd = previousVexp?.["command"];
|
|
686
771
|
const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
|
|
687
772
|
const prevEnv = previousVexp?.env;
|
|
773
|
+
const envMatches = JSON.stringify(prevEnv) === JSON.stringify(targetEnv);
|
|
688
774
|
const identical = currentCmd === targetCmd &&
|
|
689
775
|
prevArgs !== undefined &&
|
|
690
776
|
JSON.stringify(prevArgs) === JSON.stringify(targetArgs) &&
|
|
691
|
-
|
|
777
|
+
envMatches;
|
|
692
778
|
// Legacy cleanup: remove any vexp/vexp-* keys; if the canonical 'vexp'
|
|
693
779
|
// entry points at a command path that no longer exists on disk, drop it
|
|
694
780
|
// too so we don't keep a stale pointer.
|
|
@@ -697,6 +783,11 @@ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServer
|
|
|
697
783
|
if (identical && removed.length === 0) {
|
|
698
784
|
return false; // Already up to date and no legacy to clean
|
|
699
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;
|
|
700
791
|
const servers = existing.mcpServers ?? {};
|
|
701
792
|
servers["vexp"] = {
|
|
702
793
|
command: targetCmd,
|
|
@@ -765,6 +856,34 @@ function tomlString(value) {
|
|
|
765
856
|
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
766
857
|
}
|
|
767
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
|
+
}
|
|
768
887
|
function extractCodexVexpSection(content) {
|
|
769
888
|
const m = content.match(CANONICAL_VEXP_TOML_SECTION_RE);
|
|
770
889
|
return m ? m.join("\n") : "";
|
|
@@ -839,7 +958,7 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
|
|
|
839
958
|
// stanza Codex can cache → "url is not supported for stdio". Pass the binary only
|
|
840
959
|
// when it exists; buildCodexSection(direct, undefined) returns null → we then
|
|
841
960
|
// PRESERVE the existing managed section instead of clobbering it with a url.
|
|
842
|
-
const
|
|
961
|
+
const ourCoreBinary = binaryPath && fs.existsSync(binaryPath) ? binaryPath : undefined;
|
|
843
962
|
let content = "";
|
|
844
963
|
if (fs.existsSync(configPath))
|
|
845
964
|
content = fs.readFileSync(configPath, "utf-8");
|
|
@@ -861,6 +980,15 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
|
|
|
861
980
|
}
|
|
862
981
|
return false;
|
|
863
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;
|
|
864
992
|
const newSection = buildCodexSection({ transport, coreBinaryPath, workspaceRoot, mcpPort, token, home });
|
|
865
993
|
if (!newSection) {
|
|
866
994
|
// direct requested but binary missing → preserve the existing section (only
|
|
@@ -898,14 +1026,22 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
|
|
|
898
1026
|
const beforeServers = existing.servers;
|
|
899
1027
|
const previousVexp = beforeServers?.["vexp"];
|
|
900
1028
|
const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
|
|
1029
|
+
const envMatches = JSON.stringify(previousVexp?.env) === JSON.stringify(targetEnv);
|
|
901
1030
|
const identical = previousVexp?.["command"] === targetCmd &&
|
|
902
1031
|
prevArgs !== undefined &&
|
|
903
1032
|
JSON.stringify(prevArgs) === JSON.stringify(targetArgs) &&
|
|
904
|
-
|
|
1033
|
+
envMatches;
|
|
905
1034
|
const removed = stripLegacyVexpEntries(existing, "servers");
|
|
906
1035
|
logRemoval(removed);
|
|
907
1036
|
if (identical && removed.length === 0)
|
|
908
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;
|
|
909
1045
|
const servers = existing.servers ?? {};
|
|
910
1046
|
servers["vexp"] = {
|
|
911
1047
|
type: "stdio",
|
|
@@ -953,13 +1089,20 @@ export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
|
953
1089
|
const env = { VEXP_WORKSPACE: workspaceRoot };
|
|
954
1090
|
const mcp = cfg.mcp ?? {};
|
|
955
1091
|
const prev = mcp["vexp"];
|
|
1092
|
+
const envMatches = JSON.stringify(prev?.["env"]) === JSON.stringify(env);
|
|
956
1093
|
const identical = prev?.["type"] === "local" &&
|
|
957
1094
|
prev?.["enabled"] === true &&
|
|
958
1095
|
Array.isArray(prev?.["command"]) &&
|
|
959
1096
|
JSON.stringify(prev["command"]) === JSON.stringify(command) &&
|
|
960
|
-
|
|
1097
|
+
envMatches;
|
|
961
1098
|
if (identical)
|
|
962
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;
|
|
963
1106
|
mcp["vexp"] = { type: "local", command, env, enabled: true };
|
|
964
1107
|
cfg.mcp = mcp;
|
|
965
1108
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
@@ -982,14 +1125,21 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
|
|
|
982
1125
|
const beforeCs = settings.context_servers;
|
|
983
1126
|
const previousVexp = beforeCs?.["vexp"];
|
|
984
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);
|
|
985
1130
|
const identical = curCmd?.["path"] === targetCmd &&
|
|
986
1131
|
Array.isArray(curCmd?.["args"]) &&
|
|
987
1132
|
JSON.stringify(curCmd["args"]) === JSON.stringify(targetArgs) &&
|
|
988
|
-
|
|
1133
|
+
envMatches;
|
|
989
1134
|
const removed = stripLegacyVexpEntries(settings, "context_servers");
|
|
990
1135
|
logRemoval(removed);
|
|
991
1136
|
if (identical && removed.length === 0)
|
|
992
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;
|
|
993
1143
|
const cs = settings.context_servers ?? {};
|
|
994
1144
|
cs["vexp"] = {
|
|
995
1145
|
command: {
|
|
@@ -1050,6 +1200,13 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
|
|
|
1050
1200
|
logRemoval(removed);
|
|
1051
1201
|
if (identical && removed.length === 0)
|
|
1052
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;
|
|
1053
1210
|
const servers = config.mcpServers ?? {};
|
|
1054
1211
|
servers["vexp"] = {
|
|
1055
1212
|
command: desiredCommand,
|
|
@@ -1180,6 +1337,31 @@ export function installClaudeCodeHook(workspaceRoot) {
|
|
|
1180
1337
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1181
1338
|
return existed ? "updated" : "created";
|
|
1182
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
|
+
}
|
|
1183
1365
|
function generateAgentConfig(template, vars) {
|
|
1184
1366
|
switch (template) {
|
|
1185
1367
|
case "claude-code":
|
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.2.
|
|
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.2.
|
|
104
|
-
"@vexp/core-linux-arm64": "2.2.
|
|
105
|
-
"@vexp/core-darwin-x64": "2.2.
|
|
106
|
-
"@vexp/core-darwin-arm64": "2.2.
|
|
107
|
-
"@vexp/core-win32-x64": "2.2.
|
|
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
|
}
|