vexp-cli 2.2.2 → 2.2.4
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 +331 -39
- package/dist/cli.js +181 -28
- package/dist/hook-template.js +185 -3
- package/dist/workspace-repos.js +166 -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, VEXP_OPENCODE_GUARD } from "./hook-template.js";
|
|
13
|
+
import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD } from "./hook-template.js";
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Constants
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
@@ -291,8 +291,20 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
|
|
|
291
291
|
// Write MCP config for agents with mcpConfigFile (project-level JSON)
|
|
292
292
|
if (detector.mcpConfigFile) {
|
|
293
293
|
const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
|
|
294
|
-
const
|
|
295
|
-
|
|
294
|
+
const isKiro = detector.agent === "Kiro";
|
|
295
|
+
// Windsurf and Kiro auto-approve vexp's tools so the agent isn't prompted
|
|
296
|
+
// on every run_pipeline call. Kiro's field is `autoApprove`; others use
|
|
297
|
+
// `alwaysAllow`.
|
|
298
|
+
const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
|
|
299
|
+
const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
|
|
300
|
+
// Kiro resolves the MCP `command` via PATH, and a macOS GUI launch has no
|
|
301
|
+
// `node` on PATH — so the `node <mcp-server.cjs>` transport silently fails
|
|
302
|
+
// to start and run_pipeline never appears (the agent then falls back to
|
|
303
|
+
// grep/sed). Point Kiro straight at the vexp binary (`vexp-core mcp`, which
|
|
304
|
+
// reads VEXP_WORKSPACE from env) by withholding mcpServerPath — no `node`
|
|
305
|
+
// needed, an absolute path that always resolves.
|
|
306
|
+
const mcpSrv = isKiro ? undefined : mcpServerPath;
|
|
307
|
+
if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey)) {
|
|
296
308
|
mcpConfigs.push(detector.mcpConfigFile);
|
|
297
309
|
}
|
|
298
310
|
}
|
|
@@ -345,10 +357,47 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
|
|
|
345
357
|
const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
346
358
|
if (wrote)
|
|
347
359
|
mcpConfigs.push(wrote);
|
|
360
|
+
// Kilo v7 vendors opencode, so it takes the same guard plugin — the rules
|
|
361
|
+
// markdown alone was demonstrably not enough (a reported session loaded
|
|
362
|
+
// vexp.md, quoted it back, and still read five files by hand).
|
|
363
|
+
const kilo = installKiloPlugin(workspaceRoot);
|
|
364
|
+
if (kilo.plugin) {
|
|
365
|
+
results.push({
|
|
366
|
+
agent: "Kilo Code Guard",
|
|
367
|
+
configFile: path.join(".kilo", "plugins", "vexp-guard.js"),
|
|
368
|
+
content: VEXP_OPENCODE_GUARD,
|
|
369
|
+
alreadyExists: kilo.plugin === "updated",
|
|
370
|
+
action: kilo.plugin,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
if (kilo.registered)
|
|
374
|
+
mcpConfigs.push(path.relative(workspaceRoot, kiloConfigTarget(workspaceRoot)));
|
|
375
|
+
}
|
|
376
|
+
// Cursor: preToolUse hook matching Grep. Text search only - Cursor's native
|
|
377
|
+
// semantic search is not hookable.
|
|
378
|
+
if (detector.agent === "Cursor") {
|
|
379
|
+
const cur = installCursorHook(workspaceRoot);
|
|
380
|
+
if (cur.hook) {
|
|
381
|
+
results.push({
|
|
382
|
+
agent: "Cursor Guard",
|
|
383
|
+
configFile: path.join(".cursor", "hooks", "vexp-guard.js"),
|
|
384
|
+
content: VEXP_CURSOR_GUARD,
|
|
385
|
+
alreadyExists: cur.hook === "updated",
|
|
386
|
+
action: cur.hook,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (cur.registered)
|
|
390
|
+
mcpConfigs.push(path.join(".cursor", "hooks.json"));
|
|
348
391
|
}
|
|
349
|
-
// Opencode:
|
|
350
|
-
//
|
|
392
|
+
// Opencode: MCP lives under the `mcp` key in opencode.json(c) — opencode
|
|
393
|
+
// carries no `mcpConfigFile`, so without this call the generic writer above
|
|
394
|
+
// skips it and setup registers no tool at all. Plus the guard plugin that
|
|
395
|
+
// blocks grep/glob while the daemon is up (opencode's analogue of the
|
|
396
|
+
// Claude Code PreToolUse hook) — which is only safe once MCP is registered.
|
|
351
397
|
if (detector.agent === "Opencode") {
|
|
398
|
+
const wroteMcp = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
399
|
+
if (wroteMcp)
|
|
400
|
+
mcpConfigs.push(wroteMcp);
|
|
352
401
|
const pluginResult = installOpencodePlugin(workspaceRoot);
|
|
353
402
|
if (pluginResult) {
|
|
354
403
|
results.push({
|
|
@@ -492,8 +541,14 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
|
|
|
492
541
|
}
|
|
493
542
|
if (detector.mcpConfigFile) {
|
|
494
543
|
const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
|
|
495
|
-
const
|
|
496
|
-
|
|
544
|
+
const isKiro = detector.agent === "Kiro";
|
|
545
|
+
// See configureAgents(): Kiro auto-approves under `autoApprove` and is
|
|
546
|
+
// pointed at the vexp binary (no `node` on macOS GUI PATH) by withholding
|
|
547
|
+
// mcpServerPath.
|
|
548
|
+
const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
|
|
549
|
+
const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
|
|
550
|
+
const mcpSrv = isKiro ? undefined : mcpServerPath;
|
|
551
|
+
if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey))
|
|
497
552
|
mcpConfigs.push(detector.mcpConfigFile);
|
|
498
553
|
}
|
|
499
554
|
if (detector.agent === "Claude Code") {
|
|
@@ -523,8 +578,24 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
|
|
|
523
578
|
mcpConfigs.push(".zed/settings.json");
|
|
524
579
|
}
|
|
525
580
|
if (detector.agent === "Opencode") {
|
|
581
|
+
const wrote = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
582
|
+
if (wrote)
|
|
583
|
+
mcpConfigs.push(wrote);
|
|
526
584
|
installOpencodePlugin(workspaceRoot);
|
|
527
585
|
}
|
|
586
|
+
if (detector.agent === "Cursor") {
|
|
587
|
+
installCursorHook(workspaceRoot);
|
|
588
|
+
}
|
|
589
|
+
// Kilo carries no `mcpConfigFile` (its MCP lives under the `mcp` key inside
|
|
590
|
+
// kilo.jsonc), so without this branch the generic writer above skips it and
|
|
591
|
+
// picking Kilo explicitly used to write the rules file and NOTHING else — an
|
|
592
|
+
// agent told "always call run_pipeline" while the tool was never registered.
|
|
593
|
+
if (detector.agent === "Kilo Code") {
|
|
594
|
+
const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
|
|
595
|
+
if (wrote)
|
|
596
|
+
mcpConfigs.push(wrote);
|
|
597
|
+
installKiloPlugin(workspaceRoot);
|
|
598
|
+
}
|
|
528
599
|
results.push({ agent: detector.agent, configFile: detector.configFile, content, alreadyExists, action });
|
|
529
600
|
}
|
|
530
601
|
return { agents: results, mcpConfigs };
|
|
@@ -808,7 +879,10 @@ export function configureAntigravityGlobal(binaryPath, mcpServerPath) {
|
|
|
808
879
|
}
|
|
809
880
|
return writeMcpConfig(path.join(cfgDir, "mcp_config.json"), binaryPath, undefined, mcpServerPath, undefined);
|
|
810
881
|
}
|
|
811
|
-
export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot
|
|
882
|
+
export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot,
|
|
883
|
+
// Key under which the auto-approve tool list is written. Cursor/Windsurf use
|
|
884
|
+
// `alwaysAllow`; Kiro uses `autoApprove` (a `.kiro/settings/mcp.json` field).
|
|
885
|
+
approveKey = "alwaysAllow") {
|
|
812
886
|
const read = readJsonConfigSafe(mcpConfigPath);
|
|
813
887
|
if (!read.ok) {
|
|
814
888
|
warnUnparseable(mcpConfigPath);
|
|
@@ -847,7 +921,7 @@ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServer
|
|
|
847
921
|
command: targetCmd,
|
|
848
922
|
args: targetArgs,
|
|
849
923
|
...(targetEnv ? { env: targetEnv } : {}),
|
|
850
|
-
...(alwaysAllow && alwaysAllow.length > 0 ? { alwaysAllow } : {}),
|
|
924
|
+
...(alwaysAllow && alwaysAllow.length > 0 ? { [approveKey]: alwaysAllow } : {}),
|
|
851
925
|
};
|
|
852
926
|
existing.mcpServers = servers;
|
|
853
927
|
fs.mkdirSync(path.dirname(mcpConfigPath), { recursive: true });
|
|
@@ -1124,14 +1198,93 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
|
|
|
1124
1198
|
* an existing file is backed up before rewrite. Returns the workspace-relative
|
|
1125
1199
|
* path written, or null on a no-op / unparseable file.
|
|
1126
1200
|
*/
|
|
1127
|
-
|
|
1201
|
+
/**
|
|
1202
|
+
* The kilo.jsonc that Kilo will actually read: `.kilo/kilo.jsonc` wins over a
|
|
1203
|
+
* root `kilo.jsonc` when both exist, and a fresh setup gets the cleaner `.kilo/`
|
|
1204
|
+
* location. Shared by the MCP writer and the guard-plugin installer so the two
|
|
1205
|
+
* can never disagree about which file is live.
|
|
1206
|
+
*/
|
|
1207
|
+
export function kiloConfigTarget(workspaceRoot) {
|
|
1128
1208
|
const dotKilo = path.join(workspaceRoot, ".kilo", "kilo.jsonc");
|
|
1129
1209
|
const rootKilo = path.join(workspaceRoot, "kilo.jsonc");
|
|
1130
|
-
|
|
1210
|
+
return fs.existsSync(dotKilo)
|
|
1131
1211
|
? dotKilo
|
|
1132
1212
|
: fs.existsSync(rootKilo)
|
|
1133
1213
|
? rootKilo
|
|
1134
|
-
: dotKilo;
|
|
1214
|
+
: dotKilo;
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* The opencode config file that opencode will actually read for `mcp`:
|
|
1218
|
+
* `opencode.jsonc` wins over `opencode.json` when both exist (jsonc is the
|
|
1219
|
+
* variant users keep when they want comments, and it is the one they edit),
|
|
1220
|
+
* and a fresh setup gets plain `opencode.json` — opencode's documented default.
|
|
1221
|
+
* Project-scope only: we never touch the machine-global
|
|
1222
|
+
* `~/.config/opencode/opencode.json`, because a per-workspace VEXP_WORKSPACE pin
|
|
1223
|
+
* in a global file would mis-target every other project on the machine.
|
|
1224
|
+
*/
|
|
1225
|
+
export function opencodeConfigTarget(workspaceRoot) {
|
|
1226
|
+
const jsonc = path.join(workspaceRoot, "opencode.jsonc");
|
|
1227
|
+
const json = path.join(workspaceRoot, "opencode.json");
|
|
1228
|
+
return fs.existsSync(jsonc) ? jsonc : json;
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Opencode MCP setup. Opencode reads MCP servers from the top-level `mcp` key
|
|
1232
|
+
* of `opencode.json` / `opencode.jsonc` — the same shape Kilo Code uses, because
|
|
1233
|
+
* Kilo v7 vendors opencode wholesale: `{type:"local", command:[…], enabled:true}`.
|
|
1234
|
+
*
|
|
1235
|
+
* Until this existed, picking Opencode in `vexp setup` wrote AGENTS.md and the
|
|
1236
|
+
* guard plugin and NOTHING else — the worst possible end state, because the
|
|
1237
|
+
* guard blocks grep/glob whenever the daemon is healthy while `run_pipeline` was
|
|
1238
|
+
* never registered: the agent lost search and gained no replacement. Reported by
|
|
1239
|
+
* an AppSumo user who had to add the `mcp` block by hand.
|
|
1240
|
+
*
|
|
1241
|
+
* Other `mcp` entries and unrelated top-level keys are preserved; an existing
|
|
1242
|
+
* file is backed up before rewrite. Returns the workspace-relative path written,
|
|
1243
|
+
* or null on a no-op / unparseable file.
|
|
1244
|
+
*/
|
|
1245
|
+
export function configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
1246
|
+
const target = opencodeConfigTarget(workspaceRoot);
|
|
1247
|
+
const read = readJsonConfigSafe(target);
|
|
1248
|
+
if (!read.ok) {
|
|
1249
|
+
warnUnparseable(target);
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
const cfg = read.data;
|
|
1253
|
+
const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
|
|
1254
|
+
const command = useNode ? ["node", mcpServerPath] : [binaryPath, "mcp"];
|
|
1255
|
+
const env = { VEXP_WORKSPACE: workspaceRoot };
|
|
1256
|
+
const mcp = cfg.mcp ?? {};
|
|
1257
|
+
const prev = mcp["vexp"];
|
|
1258
|
+
const envMatches = JSON.stringify(prev?.["env"]) === JSON.stringify(env);
|
|
1259
|
+
const identical = prev?.["type"] === "local" &&
|
|
1260
|
+
prev?.["enabled"] === true &&
|
|
1261
|
+
Array.isArray(prev?.["command"]) &&
|
|
1262
|
+
JSON.stringify(prev["command"]) === JSON.stringify(command) &&
|
|
1263
|
+
envMatches;
|
|
1264
|
+
if (identical)
|
|
1265
|
+
return null;
|
|
1266
|
+
// A working entry is already here — either another vexp install, or the user's
|
|
1267
|
+
// own hand-written block (this bug made several people write one). Don't fight
|
|
1268
|
+
// it: only the install path may differ, and `envMatches` still gates on the
|
|
1269
|
+
// VEXP_WORKSPACE pin so a moved project is repinned normally.
|
|
1270
|
+
if (envMatches && prev?.["enabled"] === true && vexpEntryStillResolves(prev))
|
|
1271
|
+
return null;
|
|
1272
|
+
mcp["vexp"] = { type: "local", command, env, enabled: true };
|
|
1273
|
+
cfg.mcp = mcp;
|
|
1274
|
+
// `$schema` first if opencode's own key is absent — a fresh file we author
|
|
1275
|
+
// should look like the one opencode's docs tell users to write.
|
|
1276
|
+
if (cfg.$schema === undefined) {
|
|
1277
|
+
cfg.$schema = "https://opencode.ai/config.json";
|
|
1278
|
+
}
|
|
1279
|
+
if (read.existed)
|
|
1280
|
+
backupConfig(target);
|
|
1281
|
+
// A .jsonc target loses its comments here (we reserialize parsed JSON), which
|
|
1282
|
+
// is why the pre-write backup is not optional for this writer.
|
|
1283
|
+
fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
|
|
1284
|
+
return path.relative(workspaceRoot, target);
|
|
1285
|
+
}
|
|
1286
|
+
export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
1287
|
+
const target = kiloConfigTarget(workspaceRoot);
|
|
1135
1288
|
const read = readJsonConfigSafe(target);
|
|
1136
1289
|
if (!read.ok) {
|
|
1137
1290
|
warnUnparseable(target);
|
|
@@ -1403,14 +1556,13 @@ export function installClaudeCodeHook(workspaceRoot) {
|
|
|
1403
1556
|
* startup — no config-file merge required). Returns the action taken, or null
|
|
1404
1557
|
* when the file is already byte-identical.
|
|
1405
1558
|
*/
|
|
1406
|
-
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
fs.mkdirSync(pluginDir, { recursive: true });
|
|
1559
|
+
function writeGuardScript(scriptPath, content) {
|
|
1560
|
+
const pluginPath = scriptPath;
|
|
1561
|
+
fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
|
|
1410
1562
|
const existed = fs.existsSync(pluginPath);
|
|
1411
1563
|
if (existed) {
|
|
1412
1564
|
const current = fs.readFileSync(pluginPath, "utf-8");
|
|
1413
|
-
if (current ===
|
|
1565
|
+
if (current === content)
|
|
1414
1566
|
return null; // identical - skip
|
|
1415
1567
|
// Content differs: either an older vexp guard, or a copy the user tuned by
|
|
1416
1568
|
// hand. We refresh either way — refusing to touch a hand-edited plugin would
|
|
@@ -1418,9 +1570,118 @@ export function installOpencodePlugin(workspaceRoot) {
|
|
|
1418
1570
|
// recoverable at vexp-guard.js.vexp-bak instead of being silently discarded.
|
|
1419
1571
|
backupConfig(pluginPath);
|
|
1420
1572
|
}
|
|
1421
|
-
fs.writeFileSync(pluginPath,
|
|
1573
|
+
fs.writeFileSync(pluginPath, content, "utf-8");
|
|
1422
1574
|
return existed ? "updated" : "created";
|
|
1423
1575
|
}
|
|
1576
|
+
export function installOpencodePlugin(workspaceRoot) {
|
|
1577
|
+
return writeGuardScript(path.join(workspaceRoot, ".opencode", "plugins", "vexp-guard.js"), VEXP_OPENCODE_GUARD);
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Cursor enforcement — `preToolUse` hook with matcher `Grep`.
|
|
1581
|
+
*
|
|
1582
|
+
* Cursor is the only VS Code fork where this is possible: Windsurf's hooks are
|
|
1583
|
+
* action-category based (twelve fixed events, no matcher, no tool names) and
|
|
1584
|
+
* expose NO search event at all, so there is nothing there to redirect. Cursor
|
|
1585
|
+
* exposes `Grep` as a matchable tool and feeds `agent_message` back to the model,
|
|
1586
|
+
* which is the whole mechanism.
|
|
1587
|
+
*
|
|
1588
|
+
* Scope of the claim, deliberately narrow: this covers TEXT search. Cursor's
|
|
1589
|
+
* native semantic codebase search has no documented tool identifier, so it is not
|
|
1590
|
+
* hookable and not covered. Do not describe this as "Cursor is enforced".
|
|
1591
|
+
*
|
|
1592
|
+
* `command` is `node`, not a shell script: Cursor documents no PowerShell variant
|
|
1593
|
+
* and never says which shell runs `command` on Windows. A bash guard would repeat
|
|
1594
|
+
* the Claude Code hook's Windows bug (silently enforced nothing for releases).
|
|
1595
|
+
* The path is project-root-relative because Cursor runs PROJECT hooks from the
|
|
1596
|
+
* project root — `./hooks/x` there would resolve to `<project>/hooks/x`.
|
|
1597
|
+
*
|
|
1598
|
+
* `failClosed` is left at its default (false): a crashing guard must let the
|
|
1599
|
+
* agent work, not lock it out.
|
|
1600
|
+
*/
|
|
1601
|
+
export function installCursorHook(workspaceRoot) {
|
|
1602
|
+
const rel = path.join(".cursor", "hooks", "vexp-guard.js");
|
|
1603
|
+
const hookPath = path.join(workspaceRoot, rel);
|
|
1604
|
+
const hook = writeGuardScript(hookPath, VEXP_CURSOR_GUARD);
|
|
1605
|
+
const cfgPath = path.join(workspaceRoot, ".cursor", "hooks.json");
|
|
1606
|
+
const command = `node ${rel.split(path.sep).join("/")}`;
|
|
1607
|
+
const read = readJsonConfigSafe(cfgPath);
|
|
1608
|
+
if (!read.ok) {
|
|
1609
|
+
warnUnparseable(cfgPath);
|
|
1610
|
+
return { hook, registered: false };
|
|
1611
|
+
}
|
|
1612
|
+
const cfg = read.data;
|
|
1613
|
+
const hooks = cfg.hooks ?? {};
|
|
1614
|
+
const preToolUse = Array.isArray(hooks.preToolUse) ? [...hooks.preToolUse] : [];
|
|
1615
|
+
// Idempotent by COMMAND, not by array position: re-running setup must not
|
|
1616
|
+
// append a second copy, and a user's own preToolUse hooks must survive.
|
|
1617
|
+
const ours = (e) => !!e && typeof e === "object" && e.command === command;
|
|
1618
|
+
const existing = preToolUse.findIndex(ours);
|
|
1619
|
+
const entry = { command, matcher: "Grep" };
|
|
1620
|
+
if (existing >= 0 && JSON.stringify(preToolUse[existing]) === JSON.stringify(entry)) {
|
|
1621
|
+
return { hook, registered: false }; // already exactly right
|
|
1622
|
+
}
|
|
1623
|
+
if (existing >= 0)
|
|
1624
|
+
preToolUse[existing] = entry;
|
|
1625
|
+
else
|
|
1626
|
+
preToolUse.push(entry);
|
|
1627
|
+
hooks.preToolUse = preToolUse;
|
|
1628
|
+
cfg.hooks = hooks;
|
|
1629
|
+
if (cfg.version === undefined)
|
|
1630
|
+
cfg.version = 1;
|
|
1631
|
+
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
1632
|
+
if (read.existed)
|
|
1633
|
+
backupConfig(cfgPath);
|
|
1634
|
+
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), "utf-8");
|
|
1635
|
+
return { hook, registered: true };
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Kilo Code enforcement — the same plugin, because Kilo v7 IS opencode: the
|
|
1639
|
+
* rewrite vendors it wholesale (the loader lives at packages/opencode/src/ inside
|
|
1640
|
+
* Kilo-Org/kilocode), so `tool.execute.before`, the `input.tool` ids and the
|
|
1641
|
+
* grep/glob arg schemas are the same code, not merely a similar API.
|
|
1642
|
+
*
|
|
1643
|
+
* Loading is by auto-discovery: the loader globs `{plugin,plugins}/*.{ts,js}`
|
|
1644
|
+
* with cwd = the directory of the kilo.jsonc it read, and every match is
|
|
1645
|
+
* registered at startup with no config entry. So `.kilo/plugins/vexp-guard.js`
|
|
1646
|
+
* is picked up for free — and we prefer that to an explicit `plugin` array entry
|
|
1647
|
+
* because it leaves the user's config untouched.
|
|
1648
|
+
*
|
|
1649
|
+
* The one case auto-discovery misses: a project whose config is the ROOT
|
|
1650
|
+
* `kilo.jsonc`, where cwd is the repo root and the scanned directory would be
|
|
1651
|
+
* `<root>/plugins/` — we will not create a top-level `plugins/` dir in someone's
|
|
1652
|
+
* repo just to be found. There we keep the file in `.kilo/plugins/` and register
|
|
1653
|
+
* it explicitly, with the path relative to the config file (the loader resolves
|
|
1654
|
+
* path specs against the declaring config's own directory, NOT the project root).
|
|
1655
|
+
*
|
|
1656
|
+
* The glob is non-recursive, so the file must sit flat in that directory.
|
|
1657
|
+
*
|
|
1658
|
+
* NOTE: `KILO_PURE=1` skips all external plugins. A CI run with it set gets no
|
|
1659
|
+
* guard — correctly so, but it means "the guard is installed" is not the same
|
|
1660
|
+
* claim as "the guard ran".
|
|
1661
|
+
*/
|
|
1662
|
+
export function installKiloPlugin(workspaceRoot) {
|
|
1663
|
+
const pluginPath = path.join(workspaceRoot, ".kilo", "plugins", "vexp-guard.js");
|
|
1664
|
+
const plugin = writeGuardScript(pluginPath, VEXP_OPENCODE_GUARD);
|
|
1665
|
+
// `.kilo/kilo.jsonc` (the common case) → auto-discovery already covers us.
|
|
1666
|
+
const target = kiloConfigTarget(workspaceRoot);
|
|
1667
|
+
if (path.dirname(target) !== workspaceRoot)
|
|
1668
|
+
return { plugin, registered: false };
|
|
1669
|
+
const rel = "./" + path.relative(workspaceRoot, pluginPath).split(path.sep).join("/");
|
|
1670
|
+
const read = readJsonConfigSafe(target);
|
|
1671
|
+
if (!read.ok) {
|
|
1672
|
+
warnUnparseable(target);
|
|
1673
|
+
return { plugin, registered: false };
|
|
1674
|
+
}
|
|
1675
|
+
const cfg = read.data;
|
|
1676
|
+
const list = Array.isArray(cfg.plugin) ? cfg.plugin : [];
|
|
1677
|
+
if (list.includes(rel))
|
|
1678
|
+
return { plugin, registered: false }; // already declared
|
|
1679
|
+
cfg.plugin = [...list, rel];
|
|
1680
|
+
if (read.existed)
|
|
1681
|
+
backupConfig(target);
|
|
1682
|
+
fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
|
|
1683
|
+
return { plugin, registered: true };
|
|
1684
|
+
}
|
|
1424
1685
|
/**
|
|
1425
1686
|
* The single highest-leverage instruction we ship, and for a long time the one
|
|
1426
1687
|
* most agents never saw.
|
|
@@ -1802,34 +2063,65 @@ Intent auto-detection, hybrid ranking, session memory, auto-expanding budget.
|
|
|
1802
2063
|
<!-- /vexp -->`;
|
|
1803
2064
|
}
|
|
1804
2065
|
function genericTemplate(vars) {
|
|
1805
|
-
return `## vexp <!-- vexp v${vars.version} -->
|
|
2066
|
+
return `## vexp - Context-Aware AI Coding <!-- vexp v${vars.version} -->
|
|
1806
2067
|
|
|
1807
|
-
|
|
1808
|
-
|
|
2068
|
+
### MANDATORY: call run_pipeline FIRST - do NOT grep, glob, or read to explore
|
|
2069
|
+
For every task - bug fixes, features, refactors, questions about the code:
|
|
2070
|
+
**call \`run_pipeline\` before any other tool**. It runs context search + impact
|
|
2071
|
+
analysis + memory recall in a single call and returns compressed, graph-ranked
|
|
2072
|
+
results.
|
|
2073
|
+
|
|
2074
|
+
This holds even when you already know the file path. The pipeline returns the
|
|
2075
|
+
callers, the blast radius and the co-changed files that opening the file cannot.
|
|
2076
|
+
No tool here will stop you from searching manually, so honouring this rule is on
|
|
2077
|
+
you: "I already knew where to look" is not an exception, it is the exact case the
|
|
2078
|
+
rule exists for.
|
|
2079
|
+
|
|
2080
|
+
Do NOT use grep, glob, built-in codebase search, or shell commands to explore.
|
|
2081
|
+
Do NOT open files one by one to find your way around: use \`get_skeleton\`
|
|
2082
|
+
(detail: minimal/standard/detailed, 70-90% fewer tokens than reading the file).
|
|
2083
|
+
Read raw file content only when you need it to edit a specific line.
|
|
2084
|
+
|
|
2085
|
+
vexp indexes source inside the workspace and nothing else. Runtime logs, build
|
|
2086
|
+
output (dist/, .vite/, node_modules/) and files outside the repo are NOT indexed -
|
|
2087
|
+
read those directly, this rule does not cover them.
|
|
2088
|
+
|
|
2089
|
+
### Primary tool
|
|
2090
|
+
- \`run_pipeline\` - **USE THIS FOR EVERYTHING**. Auto-detects intent
|
|
2091
|
+
(debug/modify/refactor/explore) from your task. Includes file content for pivots.
|
|
2092
|
+
- \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
|
|
2093
|
+
- \`run_pipeline({ "task": "refactor db layer", "preset": "refactor" })\`
|
|
2094
|
+
- \`run_pipeline({ "task": "add auth", "observation": "using JWT" })\` - saves an insight in the same call
|
|
2095
|
+
|
|
2096
|
+
### Other MCP tools (only when run_pipeline is not enough)
|
|
2097
|
+
- \`get_skeleton\` - **preferred over reading a file**: signatures and structure, 3 detail levels
|
|
2098
|
+
- \`index_status\` - indexing status and health check
|
|
2099
|
+
- \`expand_vexp_ref\` - expand V-REF hash placeholders in v2 compact output
|
|
1809
2100
|
|
|
1810
|
-
|
|
1811
|
-
1. \`run_pipeline\` with your task description - ALWAYS FIRST (replaces all other tools)
|
|
1812
|
-
2. Make targeted changes based on the context returned
|
|
1813
|
-
3. \`run_pipeline\` again only if you need more context
|
|
2101
|
+
${QUERY_SHAPE}
|
|
1814
2102
|
|
|
1815
|
-
###
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
- \`
|
|
2103
|
+
### Workflow
|
|
2104
|
+
1. \`run_pipeline("your task")\` - ALWAYS FIRST. Returns pivots + impact + memories in 1 call
|
|
2105
|
+
2. Need more on a file? \`get_skeleton({ files: [...], detail: "detailed" })\` - not a raw read
|
|
2106
|
+
3. Make targeted changes based on the context returned
|
|
2107
|
+
4. \`run_pipeline\` again ONLY if you need more context while implementing
|
|
2108
|
+
5. Do NOT chain vexp calls - one \`run_pipeline\` replaces capsule + impact + memory + observation
|
|
1821
2109
|
|
|
1822
|
-
|
|
2110
|
+
### Sub-agents and background tasks
|
|
2111
|
+
- Sub-agents CAN and MUST call \`run_pipeline\` - always give them the task description
|
|
2112
|
+
- Do NOT spawn an agent to search freely: call \`run_pipeline\` first, then pass the
|
|
2113
|
+
returned context into the agent prompt
|
|
1823
2114
|
|
|
1824
|
-
###
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2115
|
+
### Fallback
|
|
2116
|
+
If \`run_pipeline\` returns \`status: "degraded"\` or 0 pivots with an INDEX EMPTY warning,
|
|
2117
|
+
the index is empty or still building. Use the built-in search and read tools directly
|
|
2118
|
+
until it is ready - do not stall waiting for vexp.
|
|
1828
2119
|
|
|
1829
|
-
### Smart
|
|
1830
|
-
Intent
|
|
2120
|
+
### Smart features (automatic - no action needed)
|
|
2121
|
+
Intent detection, hybrid keyword+semantic+graph ranking, session memory,
|
|
2122
|
+
change coupling, auto-expanding budget.
|
|
1831
2123
|
|
|
1832
|
-
### Multi-
|
|
2124
|
+
### Multi-repo
|
|
1833
2125
|
\`run_pipeline\` auto-queries all indexed repos. Use \`repos: ["alias"]\` to scope. Run \`index_status\` to see aliases.
|
|
1834
2126
|
<!-- /vexp -->`;
|
|
1835
2127
|
}
|
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import { runServe } from "./serve.js";
|
|
|
18
18
|
import { runDoctor } from "./doctor.js";
|
|
19
19
|
import { socketPathFor } from "./socket-path.js";
|
|
20
20
|
import { isTraceEnabled } from "./trace.js";
|
|
21
|
+
import { resolveParentWorkspace, listWorkspaceRepos, addRepoToWorkspace, } from "./workspace-repos.js";
|
|
21
22
|
const program = new Command();
|
|
22
23
|
program
|
|
23
24
|
.name("vexp")
|
|
@@ -54,8 +55,11 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
|
|
|
54
55
|
return;
|
|
55
56
|
if (AUTOSTART_SKIP.has(actionCmd.name()))
|
|
56
57
|
return;
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// Effective workspace: inside a connected secondary repo this resolves to
|
|
59
|
+
// the parent, so we boot (or reuse) the parent daemon instead of spawning
|
|
60
|
+
// a conflicting one for the child.
|
|
61
|
+
const eff = findEffectiveWorkspace();
|
|
62
|
+
if (!eff)
|
|
59
63
|
return;
|
|
60
64
|
let binaryPath;
|
|
61
65
|
try {
|
|
@@ -66,7 +70,7 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
|
|
|
66
70
|
}
|
|
67
71
|
// Full bootstrap: daemon + MCP + autostart-if-needed. All three steps are
|
|
68
72
|
// internally idempotent — no duplicate processes across invocations.
|
|
69
|
-
await ensureBootstrap(
|
|
73
|
+
await ensureBootstrap(eff.root, binaryPath);
|
|
70
74
|
});
|
|
71
75
|
// ────────────────────────────────────────────────────
|
|
72
76
|
// Resolve the platform binary (no network — bundled via npm)
|
|
@@ -83,7 +87,7 @@ function ensureBinary() {
|
|
|
83
87
|
// Tier limits are now enforced directly by the Rust daemon via license.jwt verification.
|
|
84
88
|
// Track whether we're in interactive REPL mode (don't call process.exit)
|
|
85
89
|
let replMode = false;
|
|
86
|
-
function runBinary(binaryPath, args) {
|
|
90
|
+
function runBinary(binaryPath, args, options) {
|
|
87
91
|
if (replMode) {
|
|
88
92
|
// In REPL mode, run synchronously and don't exit the process.
|
|
89
93
|
// CRITICAL: stdin must be "ignore" (not "inherit") — inheriting stdin
|
|
@@ -94,6 +98,7 @@ function runBinary(binaryPath, args) {
|
|
|
94
98
|
// CLI, never stdin, so ignoring it is safe.
|
|
95
99
|
const result = spawnSync(binaryPath, args, {
|
|
96
100
|
stdio: ["ignore", "inherit", "inherit"],
|
|
101
|
+
cwd: options?.cwd,
|
|
97
102
|
env: binaryEnv(binaryPath),
|
|
98
103
|
});
|
|
99
104
|
if (result.error) {
|
|
@@ -103,6 +108,7 @@ function runBinary(binaryPath, args) {
|
|
|
103
108
|
}
|
|
104
109
|
const child = spawn(binaryPath, args, {
|
|
105
110
|
stdio: "inherit",
|
|
111
|
+
cwd: options?.cwd,
|
|
106
112
|
env: binaryEnv(binaryPath),
|
|
107
113
|
});
|
|
108
114
|
child.on("exit", (code) => {
|
|
@@ -143,12 +149,16 @@ function runBinaryAsync(binaryPath, args, options) {
|
|
|
143
149
|
// ────────────────────────────────────────────────────
|
|
144
150
|
/**
|
|
145
151
|
* Walk up from cwd looking for `.vexp/manifest.json` — the marker that
|
|
146
|
-
* `vexp setup` has been run on this tree.
|
|
152
|
+
* `vexp setup` has been run on this tree. A `.vexp/parent_workspace.json`
|
|
153
|
+
* counts too: a freshly-connected secondary repo has the parent link before
|
|
154
|
+
* its first index writes a manifest, and it must already resolve to the
|
|
155
|
+
* parent workspace instead of looking unconfigured. Returns null if none found.
|
|
147
156
|
*/
|
|
148
157
|
function findConfiguredWorkspace(startDir = process.cwd()) {
|
|
149
158
|
let current = path.resolve(startDir);
|
|
150
159
|
while (true) {
|
|
151
|
-
if (fs.existsSync(path.join(current, ".vexp", "manifest.json"))
|
|
160
|
+
if (fs.existsSync(path.join(current, ".vexp", "manifest.json")) ||
|
|
161
|
+
fs.existsSync(path.join(current, ".vexp", "parent_workspace.json")))
|
|
152
162
|
return current;
|
|
153
163
|
const parent = path.dirname(current);
|
|
154
164
|
if (parent === current)
|
|
@@ -156,6 +166,22 @@ function findConfiguredWorkspace(startDir = process.cwd()) {
|
|
|
156
166
|
current = parent;
|
|
157
167
|
}
|
|
158
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Workspace the CLI should actually operate on. When cwd is inside a
|
|
171
|
+
* secondary repo of a multi-repo workspace (it carries a valid
|
|
172
|
+
* `.vexp/parent_workspace.json`), every daemon interaction targets the PARENT:
|
|
173
|
+
* spawning a second daemon in the child made both fight over the same repo
|
|
174
|
+
* DBs (double watchers, duplicate LLM in VRAM) until the user killed
|
|
175
|
+
* everything. `parent` is set only when redirected, so callers can tell the
|
|
176
|
+
* user what happened.
|
|
177
|
+
*/
|
|
178
|
+
function findEffectiveWorkspace(startDir = process.cwd()) {
|
|
179
|
+
const ws = findConfiguredWorkspace(startDir);
|
|
180
|
+
if (!ws)
|
|
181
|
+
return null;
|
|
182
|
+
const parent = resolveParentWorkspace(ws);
|
|
183
|
+
return parent ? { root: parent.parentRoot, parent } : { root: ws };
|
|
184
|
+
}
|
|
159
185
|
/** True if the Unix socket at `socketPath` accepts a connection within `timeoutMs`. */
|
|
160
186
|
function isSocketAlive(socketPath, timeoutMs = 300) {
|
|
161
187
|
return new Promise((resolve) => {
|
|
@@ -486,7 +512,13 @@ program
|
|
|
486
512
|
const args = ["daemon-cmd", action];
|
|
487
513
|
if (opts.follow)
|
|
488
514
|
args.push("--follow");
|
|
489
|
-
|
|
515
|
+
// Inside a connected secondary repo, daemon commands target the PARENT
|
|
516
|
+
// workspace daemon — the child must never run its own competing daemon.
|
|
517
|
+
const eff = findEffectiveWorkspace();
|
|
518
|
+
if (eff?.parent) {
|
|
519
|
+
console.log(chalk.cyan(`This repo is part of workspace "${eff.parent.workspaceName}" — targeting the parent daemon (${shortPath(eff.root)})`));
|
|
520
|
+
}
|
|
521
|
+
runBinary(binaryPath, args, eff?.parent ? { cwd: eff.root } : undefined);
|
|
490
522
|
});
|
|
491
523
|
program
|
|
492
524
|
.command("doctor")
|
|
@@ -997,10 +1029,10 @@ await checkForUpdate();
|
|
|
997
1029
|
// ── Sub-menu: Setup & Config ──
|
|
998
1030
|
const MENU_CONFIG = [
|
|
999
1031
|
{ key: "1", label: "init", hint: "Create .vexp/ and run first index", description: "Creates .vexp/ directory, runs a full index, installs git hooks.\n Run once when adding vexp to a new project." },
|
|
1000
|
-
{ key: "2", label: "index", hint: "Re-index the
|
|
1032
|
+
{ key: "2", label: "index", hint: "Re-index the workspace (all connected repos)", description: "Scans all source files, builds the AST graph (nodes + edges),\n and saves it to .vexp/index.db. In a multi-repo workspace every\n connected repo is re-indexed, not just the current one.\n Output: file count, node count, edge count, time elapsed." },
|
|
1001
1033
|
{ key: "3", label: "setup", hint: "Full setup (index + agents + hooks)", description: "One-command bootstrap: indexes the codebase, detects AI agents,\n writes MCP configs, installs git hooks." },
|
|
1002
1034
|
{ key: "4", label: "setup-agents", hint: "Choose which AI agents to configure", description: "Select agents (Claude, Cursor, Windsurf, Copilot, …) to configure.\n Creates config directories and writes MCP settings for each." },
|
|
1003
|
-
{ key: "5", label: "daemon-cmd", hint: "Manage daemon (start/stop/status/logs)", description: "Controls the background daemon:\n start
|
|
1035
|
+
{ key: "5", label: "daemon-cmd", hint: "Manage daemon (start/stop/restart/status/logs)", description: "Controls the background daemon:\n start → launches the daemon (indexes + watches for changes)\n stop → stops the running daemon\n restart → stop + start (reloads workspace.json, indexes new repos)\n status → shows node/edge/file counts\n logs → prints .vexp/vexp.log" },
|
|
1004
1036
|
{ key: "6", label: "setup-llm", hint: "Configure local LLM compression (vexp-devmind)", description: "Install, update, or disable the optional local LLM (vexp-devmind).\n Runs entirely on your machine (~3.5 GB download).\n Enables smart prompt preprocessing and higher-quality memory compression." },
|
|
1005
1037
|
];
|
|
1006
1038
|
// ── Sub-menu: Explore & Analyze ──
|
|
@@ -1015,6 +1047,11 @@ const MENU_SAVINGS = [
|
|
|
1015
1047
|
{ key: "1", label: "summary", hint: "View savings summary", description: "Shows total tokens saved, average saving %, and breakdown by tool.\n Choose a period: 7 / 14 / 30 / 60 / 90 days." },
|
|
1016
1048
|
{ key: "2", label: "export", hint: "Export report to .vexp/token-savings-report.md", description: "Generates a markdown report with breakdown by tool and daily trend,\n then saves it to .vexp/token-savings-report.md." },
|
|
1017
1049
|
];
|
|
1050
|
+
// ── Sub-menu: Repos & Workspace ──
|
|
1051
|
+
const MENU_REPOS = [
|
|
1052
|
+
{ key: "1", label: "list", hint: "Show repos connected to this workspace", description: "Lists the repositories of this multi-repo workspace\n (.vexp/workspace.json): alias, path, primary marker." },
|
|
1053
|
+
{ key: "2", label: "add", hint: "Connect another repository to this workspace", description: "Adds a repository to .vexp/workspace.json and links it back via\n .vexp/parent_workspace.json — same writes as the VS Code\n \"vexp: Add Repository\" command. Restart the daemon to index it." },
|
|
1054
|
+
];
|
|
1018
1055
|
// ── Sub-menu: License ──
|
|
1019
1056
|
const MENU_LICENSE = [
|
|
1020
1057
|
{ key: "1", label: "status", hint: "Show current license plan and limits", description: "Displays your current plan (free / pro / team), node limits,\n repo limits, and license expiry." },
|
|
@@ -1056,12 +1093,18 @@ async function printBanner() {
|
|
|
1056
1093
|
// banner says "not running", but the Rust daemon-cmd status probes the
|
|
1057
1094
|
// socket and reports "running". Using the socket here makes the banner
|
|
1058
1095
|
// agree with status.
|
|
1059
|
-
const
|
|
1060
|
-
if (!
|
|
1096
|
+
const eff = findEffectiveWorkspace();
|
|
1097
|
+
if (!eff) {
|
|
1061
1098
|
console.log(chalk.dim(" ○ Daemon: no workspace configured here"));
|
|
1062
1099
|
console.log(chalk.dim(" → type '1 > 3' to run full setup, or `vexp setup`"));
|
|
1063
1100
|
}
|
|
1064
1101
|
else {
|
|
1102
|
+
// Connected secondary repo: every daemon interaction targets the parent
|
|
1103
|
+
// workspace — say so up front, since the status below is the PARENT's.
|
|
1104
|
+
if (eff.parent) {
|
|
1105
|
+
console.log(chalk.cyan(` ◆ Workspace: this repo is part of "${eff.parent.workspaceName}" — using the parent daemon (${shortPath(eff.root)})`));
|
|
1106
|
+
}
|
|
1107
|
+
const ws = eff.root;
|
|
1065
1108
|
const socketAlive = await isSocketAlive(socketPathFor(ws));
|
|
1066
1109
|
if (socketAlive) {
|
|
1067
1110
|
// Display PID + uptime when we can read them; otherwise a plain "running".
|
|
@@ -1097,7 +1140,7 @@ async function printBanner() {
|
|
|
1097
1140
|
}
|
|
1098
1141
|
else if (installed) {
|
|
1099
1142
|
console.log(chalk.yellow(" ○ LLM: installed but not active"));
|
|
1100
|
-
console.log(chalk.dim(" → type '1 > 6 > start' to start it, or `vexp setup-llm --
|
|
1143
|
+
console.log(chalk.dim(" → type '1 > 6 > start' to start it, or `vexp setup-llm --enable`"));
|
|
1101
1144
|
}
|
|
1102
1145
|
else {
|
|
1103
1146
|
console.log(chalk.dim(" ○ LLM: not installed"));
|
|
@@ -1114,6 +1157,7 @@ function printMainMenu() {
|
|
|
1114
1157
|
console.log(` ${chalk.cyan.bold("2")} ${chalk.green("Explore & Analyze".padEnd(20))} ${chalk.dim("Query context, skeletons, impact, flow")}`);
|
|
1115
1158
|
console.log(` ${chalk.cyan.bold("3")} ${chalk.green("Token Savings".padEnd(20))} ${chalk.dim("View savings stats, export report")}`);
|
|
1116
1159
|
console.log(` ${chalk.cyan.bold("4")} ${chalk.green("License".padEnd(20))} ${chalk.dim("View or activate your license")}`);
|
|
1160
|
+
console.log(` ${chalk.cyan.bold("5")} ${chalk.green("Repos & Workspace".padEnd(20))} ${chalk.dim("List / connect workspace repositories")}`);
|
|
1117
1161
|
console.log(` ${chalk.cyan.bold("q")} ${chalk.red("Exit".padEnd(20))} ${chalk.dim("Quit")}`);
|
|
1118
1162
|
console.log("");
|
|
1119
1163
|
}
|
|
@@ -1136,11 +1180,13 @@ async function interactiveMode() {
|
|
|
1136
1180
|
// `vexp setup` still get a working state (daemon + MCP :7821 up, OS
|
|
1137
1181
|
// autostart installed for the next reboot). All steps are idempotent so
|
|
1138
1182
|
// running this alongside `vexp setup` never creates duplicates.
|
|
1139
|
-
const
|
|
1140
|
-
if (
|
|
1183
|
+
const eff = findEffectiveWorkspace();
|
|
1184
|
+
if (eff) {
|
|
1141
1185
|
try {
|
|
1142
1186
|
const binaryPath = getBinaryPath();
|
|
1143
|
-
|
|
1187
|
+
// Effective root: in a connected secondary repo this boots the PARENT
|
|
1188
|
+
// daemon (which serves this repo too) instead of a conflicting child one.
|
|
1189
|
+
await ensureBootstrap(eff.root, binaryPath);
|
|
1144
1190
|
}
|
|
1145
1191
|
catch { /* banner will reflect actual state */ }
|
|
1146
1192
|
}
|
|
@@ -1155,6 +1201,7 @@ async function interactiveMode() {
|
|
|
1155
1201
|
explore: chalk.cyan.bold(" vexp/explore> "),
|
|
1156
1202
|
savings: chalk.cyan.bold(" vexp/savings> "),
|
|
1157
1203
|
license: chalk.cyan.bold(" vexp/license> "),
|
|
1204
|
+
repos: chalk.cyan.bold(" vexp/repos> "),
|
|
1158
1205
|
};
|
|
1159
1206
|
while (true) {
|
|
1160
1207
|
const input = (await ask(rl, prompts[currentMenu])).trim().toLowerCase();
|
|
@@ -1190,8 +1237,13 @@ async function interactiveMode() {
|
|
|
1190
1237
|
printSubMenu("License", MENU_LICENSE);
|
|
1191
1238
|
continue;
|
|
1192
1239
|
}
|
|
1240
|
+
if (input === "5" || input === "repos") {
|
|
1241
|
+
currentMenu = "repos";
|
|
1242
|
+
printSubMenu("Repos & Workspace", MENU_REPOS);
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1193
1245
|
// Allow direct command names from any sub-menu
|
|
1194
|
-
const allItems = [...MENU_CONFIG, ...MENU_EXPLORE, ...MENU_SAVINGS, ...MENU_LICENSE];
|
|
1246
|
+
const allItems = [...MENU_CONFIG, ...MENU_EXPLORE, ...MENU_SAVINGS, ...MENU_LICENSE, ...MENU_REPOS];
|
|
1195
1247
|
const direct = allItems.find((m) => m.label === input);
|
|
1196
1248
|
if (direct) {
|
|
1197
1249
|
await executeCommand(direct.label, rl);
|
|
@@ -1199,12 +1251,12 @@ async function interactiveMode() {
|
|
|
1199
1251
|
printMainMenu();
|
|
1200
1252
|
continue;
|
|
1201
1253
|
}
|
|
1202
|
-
console.log(chalk.dim(" Choose 1-
|
|
1254
|
+
console.log(chalk.dim(" Choose 1-5 or type a command name. 'q' to exit.\n"));
|
|
1203
1255
|
continue;
|
|
1204
1256
|
}
|
|
1205
1257
|
// ── Sub-menus ──
|
|
1206
|
-
const menuMap = { config: MENU_CONFIG, explore: MENU_EXPLORE, savings: MENU_SAVINGS, license: MENU_LICENSE };
|
|
1207
|
-
const titles = { config: "Setup & Config", explore: "Explore & Analyze", savings: "Token Savings", license: "License" };
|
|
1258
|
+
const menuMap = { config: MENU_CONFIG, explore: MENU_EXPLORE, savings: MENU_SAVINGS, license: MENU_LICENSE, repos: MENU_REPOS };
|
|
1259
|
+
const titles = { config: "Setup & Config", explore: "Explore & Analyze", savings: "Token Savings", license: "License", repos: "Repos & Workspace" };
|
|
1208
1260
|
if (input === "b" || input === "back") {
|
|
1209
1261
|
currentMenu = "main";
|
|
1210
1262
|
printMainMenu();
|
|
@@ -1241,7 +1293,27 @@ async function executeCommand(label, rl) {
|
|
|
1241
1293
|
}
|
|
1242
1294
|
case "index": {
|
|
1243
1295
|
const binaryPath = ensureBinary();
|
|
1244
|
-
|
|
1296
|
+
// Workspace-aware: re-index every connected repo, not just the cwd
|
|
1297
|
+
// one. From a connected child this targets the parent workspace, so
|
|
1298
|
+
// the result matches what the daemon serves.
|
|
1299
|
+
const eff = findEffectiveWorkspace();
|
|
1300
|
+
const repos = eff ? listWorkspaceRepos(eff.root) : [];
|
|
1301
|
+
if (!eff || repos.length === 0) {
|
|
1302
|
+
runBinary(binaryPath, ["index"]);
|
|
1303
|
+
break;
|
|
1304
|
+
}
|
|
1305
|
+
if (eff.parent) {
|
|
1306
|
+
console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — indexing all its repos`));
|
|
1307
|
+
}
|
|
1308
|
+
console.log(chalk.dim(` Indexing ${repos.length} workspace repo(s)…\n`));
|
|
1309
|
+
for (const r of repos) {
|
|
1310
|
+
if (!r.exists) {
|
|
1311
|
+
console.log(chalk.yellow(` ⚠ Skipping "${r.alias}" — directory not found: ${shortPath(r.resolvedPath)}`));
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
console.log(chalk.bold(` ● ${r.alias}`));
|
|
1315
|
+
runBinary(binaryPath, ["index", r.resolvedPath]);
|
|
1316
|
+
}
|
|
1245
1317
|
break;
|
|
1246
1318
|
}
|
|
1247
1319
|
case "setup": {
|
|
@@ -1255,15 +1327,23 @@ async function executeCommand(label, rl) {
|
|
|
1255
1327
|
case "daemon-cmd": {
|
|
1256
1328
|
// Sticky sub-sub-menu: stay here until user types 'b' (back).
|
|
1257
1329
|
const binaryPath = ensureBinary();
|
|
1258
|
-
const valid = new Set(["start", "stop", "status", "logs"]);
|
|
1330
|
+
const valid = new Set(["start", "stop", "restart", "status", "logs"]);
|
|
1331
|
+
// Inside a connected secondary repo every action targets the parent
|
|
1332
|
+
// workspace daemon — running one here would fight over the same DBs.
|
|
1333
|
+
const eff = findEffectiveWorkspace();
|
|
1334
|
+
if (eff?.parent) {
|
|
1335
|
+
console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — daemon commands target the parent (${shortPath(eff.root)})`));
|
|
1336
|
+
}
|
|
1337
|
+
const daemonCwd = eff?.parent ? { cwd: eff.root } : undefined;
|
|
1259
1338
|
const printDaemonMenu = () => {
|
|
1260
1339
|
console.log("");
|
|
1261
1340
|
console.log(chalk.cyan(" ── Daemon ──"));
|
|
1262
|
-
console.log(` ${chalk.bold("start")}
|
|
1263
|
-
console.log(` ${chalk.bold("stop")}
|
|
1264
|
-
console.log(` ${chalk.bold("
|
|
1265
|
-
console.log(` ${chalk.bold("
|
|
1266
|
-
console.log(` ${chalk.
|
|
1341
|
+
console.log(` ${chalk.bold("start")} ${chalk.dim("Launch the daemon for this workspace")}`);
|
|
1342
|
+
console.log(` ${chalk.bold("stop")} ${chalk.dim("Stop the running daemon")}`);
|
|
1343
|
+
console.log(` ${chalk.bold("restart")} ${chalk.dim("Stop + start (reloads workspace.json, indexes new repos)")}`);
|
|
1344
|
+
console.log(` ${chalk.bold("status")} ${chalk.dim("Show node/edge/file counts and uptime")}`);
|
|
1345
|
+
console.log(` ${chalk.bold("logs")} ${chalk.dim("Tail .vexp/vexp.log")}`);
|
|
1346
|
+
console.log(` ${chalk.dim("b")} ${chalk.dim("Back to Setup & Config")}`);
|
|
1267
1347
|
console.log("");
|
|
1268
1348
|
};
|
|
1269
1349
|
printDaemonMenu();
|
|
@@ -1274,11 +1354,11 @@ async function executeCommand(label, rl) {
|
|
|
1274
1354
|
if (action === "b" || action === "back")
|
|
1275
1355
|
break;
|
|
1276
1356
|
if (valid.has(action)) {
|
|
1277
|
-
runBinary(binaryPath, ["daemon-cmd", action]);
|
|
1357
|
+
runBinary(binaryPath, ["daemon-cmd", action], daemonCwd);
|
|
1278
1358
|
printDaemonMenu();
|
|
1279
1359
|
continue;
|
|
1280
1360
|
}
|
|
1281
|
-
console.log(chalk.dim(" Unknown action. Use start, stop, status, logs, or 'b' to go back."));
|
|
1361
|
+
console.log(chalk.dim(" Unknown action. Use start, stop, restart, status, logs, or 'b' to go back."));
|
|
1282
1362
|
}
|
|
1283
1363
|
break;
|
|
1284
1364
|
}
|
|
@@ -1399,6 +1479,79 @@ async function executeCommand(label, rl) {
|
|
|
1399
1479
|
}
|
|
1400
1480
|
break;
|
|
1401
1481
|
}
|
|
1482
|
+
// ── Repos & Workspace commands ──
|
|
1483
|
+
case "list": {
|
|
1484
|
+
const eff = findEffectiveWorkspace();
|
|
1485
|
+
if (!eff) {
|
|
1486
|
+
console.log(chalk.yellow(" No configured workspace here — run setup first (1 > 3, or `vexp setup`)."));
|
|
1487
|
+
break;
|
|
1488
|
+
}
|
|
1489
|
+
if (eff.parent) {
|
|
1490
|
+
console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — showing the repos of ${shortPath(eff.root)}`));
|
|
1491
|
+
}
|
|
1492
|
+
const repos = listWorkspaceRepos(eff.root);
|
|
1493
|
+
if (repos.length === 0) {
|
|
1494
|
+
console.log(chalk.dim(" Single-repo workspace (no .vexp/workspace.json yet)."));
|
|
1495
|
+
console.log(chalk.dim(" Use 'add' to connect another repository."));
|
|
1496
|
+
break;
|
|
1497
|
+
}
|
|
1498
|
+
console.log("");
|
|
1499
|
+
for (const r of repos) {
|
|
1500
|
+
const marker = r.isPrimary ? chalk.cyan(" (primary)") : "";
|
|
1501
|
+
const missing = r.exists ? "" : chalk.red(" [missing]");
|
|
1502
|
+
console.log(` ${chalk.green("●")} ${chalk.bold(r.alias.padEnd(20))} ${chalk.dim(shortPath(r.resolvedPath))}${marker}${missing}`);
|
|
1503
|
+
}
|
|
1504
|
+
console.log(chalk.dim(`\n ${repos.length} repo(s) in workspace "${path.basename(eff.root)}"`));
|
|
1505
|
+
break;
|
|
1506
|
+
}
|
|
1507
|
+
case "add": {
|
|
1508
|
+
const eff = findEffectiveWorkspace();
|
|
1509
|
+
if (!eff) {
|
|
1510
|
+
console.log(chalk.yellow(" No configured workspace here — run setup first (1 > 3, or `vexp setup`)."));
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
if (eff.parent) {
|
|
1514
|
+
console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — the repository will be added to ${shortPath(eff.root)}`));
|
|
1515
|
+
}
|
|
1516
|
+
const repoPath = (await ask(rl, chalk.cyan(" Path of the repository to connect: "))).trim();
|
|
1517
|
+
if (!repoPath)
|
|
1518
|
+
break;
|
|
1519
|
+
const limits = readLicenseLimits();
|
|
1520
|
+
const result = addRepoToWorkspace(eff.root, repoPath, limits.maxRepos);
|
|
1521
|
+
if (result.status === "invalid") {
|
|
1522
|
+
console.log(chalk.red(` ✗ ${result.reason}`));
|
|
1523
|
+
break;
|
|
1524
|
+
}
|
|
1525
|
+
if (result.status === "limit") {
|
|
1526
|
+
console.log(chalk.yellow(` Your ${limits.plan} plan supports up to ${result.maxRepos} repo${result.maxRepos === 1 ? "" : "s"}.`));
|
|
1527
|
+
console.log(chalk.dim(" Upgrade at https://vexp.dev/#pricing to add more repositories."));
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
if (result.status === "exists") {
|
|
1531
|
+
console.log(chalk.dim(` "${result.alias}" is already part of this workspace.`));
|
|
1532
|
+
break;
|
|
1533
|
+
}
|
|
1534
|
+
console.log(chalk.green(` ✓ Added "${result.alias}" to workspace "${path.basename(eff.root)}"`));
|
|
1535
|
+
// A live 2.2.3+ daemon hot-reloads workspace.json (2s poll): the new
|
|
1536
|
+
// repo is indexed and watched automatically, no restart needed. Only
|
|
1537
|
+
// when the daemon is down do we offer to start it.
|
|
1538
|
+
if (await isSocketAlive(socketPathFor(eff.root))) {
|
|
1539
|
+
console.log(chalk.dim(` The running daemon will detect the change and index "${result.alias}" automatically within seconds.`));
|
|
1540
|
+
}
|
|
1541
|
+
else {
|
|
1542
|
+
const yn = (await ask(rl, chalk.cyan(` Start the daemon now to index "${result.alias}"? [Y/n]: `))).trim().toLowerCase();
|
|
1543
|
+
if (yn === "" || yn === "y" || yn === "yes") {
|
|
1544
|
+
const binaryPath = ensureBinary();
|
|
1545
|
+
console.log(chalk.dim(" Starting vexp daemon…\n"));
|
|
1546
|
+
runBinary(binaryPath, ["daemon-cmd", "start"], { cwd: eff.root });
|
|
1547
|
+
console.log(chalk.green(` ✓ Now indexing "${result.alias}".`));
|
|
1548
|
+
}
|
|
1549
|
+
else {
|
|
1550
|
+
console.log(chalk.dim(` Run 'vexp daemon-cmd start' in ${shortPath(eff.root)} later to index it.`));
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
break;
|
|
1554
|
+
}
|
|
1402
1555
|
// ── License commands ──
|
|
1403
1556
|
case "status": {
|
|
1404
1557
|
const limits = readLicenseLimits();
|
package/dist/hook-template.js
CHANGED
|
@@ -58,6 +58,12 @@ esac
|
|
|
58
58
|
* Error aborts the tool call. Written to `.opencode/plugins/vexp-guard.js` by
|
|
59
59
|
* `vexp setup-agents` (opencode auto-loads that directory at startup).
|
|
60
60
|
*
|
|
61
|
+
* The SAME file is installed for Kilo Code at `.kilo/plugins/vexp-guard.js`
|
|
62
|
+
* (installKiloPlugin), because Kilo v7 is opencode vendored — its plugin loader
|
|
63
|
+
* lives at packages/opencode/src/config/plugin.ts inside Kilo-Org/kilocode, so
|
|
64
|
+
* the hook name, the `input.tool` ids and the grep/glob arg schemas are the same
|
|
65
|
+
* code and not merely a compatible API.
|
|
66
|
+
*
|
|
61
67
|
* It blocks the native `grep`/`glob` tools AND shelled-out tree search
|
|
62
68
|
* (grep/rg/find/... via bash) WHILE the vexp daemon is healthy, and fails OPEN
|
|
63
69
|
* when the daemon is down so search still works without an index — mirroring
|
|
@@ -66,11 +72,14 @@ esac
|
|
|
66
72
|
* The literal body is deliberately ASCII-only and free of backticks, `${...}`
|
|
67
73
|
* and backslash escapes so it can be embedded verbatim in a TS template literal
|
|
68
74
|
* (the VS Code extension keeps a byte-identical copy, enforced by the lockstep
|
|
69
|
-
* test in packages/vexp-cli/test/
|
|
75
|
+
* test in packages/vexp-cli/test/guard-lockstep.test.ts — which is where that
|
|
76
|
+
* guarantee actually lives; this comment used to point at hook-template.test.ts,
|
|
77
|
+
* which only ever compared the CLI constant with itself). Do NOT introduce any
|
|
70
78
|
* of those characters here without updating that test's un-escape logic.
|
|
71
79
|
*/
|
|
72
|
-
export const VEXP_OPENCODE_GUARD = `// vexp-guard - opencode plugin (generated by 'vexp setup-agents'; it is
|
|
73
|
-
// on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak
|
|
80
|
+
export const VEXP_OPENCODE_GUARD = `// vexp-guard - opencode / Kilo Code plugin (generated by 'vexp setup-agents'; it is
|
|
81
|
+
// rewritten on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak
|
|
82
|
+
// first). Kilo v7 vendors opencode, so one plugin serves both unchanged.
|
|
74
83
|
//
|
|
75
84
|
// Blocks opencode's native grep/glob ONLY where vexp has a better answer: indexed
|
|
76
85
|
// source inside the workspace, while the daemon is healthy. Two deliberate limits:
|
|
@@ -192,3 +201,176 @@ export const VexpGuard = async ({ directory, worktree }) => {
|
|
|
192
201
|
};
|
|
193
202
|
};
|
|
194
203
|
`;
|
|
204
|
+
/**
|
|
205
|
+
* Cursor `preToolUse` hook. Same policy as the Claude Code hook and the
|
|
206
|
+
* opencode/Kilo plugin — block text search while the vexp daemon is healthy,
|
|
207
|
+
* never touch the shell, never block a target vexp did not index — expressed in
|
|
208
|
+
* the third dialect we now have to speak.
|
|
209
|
+
*
|
|
210
|
+
* Written in NODE, not bash, and that is deliberate. The Claude Code guard is a
|
|
211
|
+
* bash script and it silently enforced NOTHING on Windows for several releases
|
|
212
|
+
* (`[ -S "$SOCK" ]` can never match a named pipe), which is the single most
|
|
213
|
+
* expensive bug this file has produced. Cursor documents no PowerShell variant
|
|
214
|
+
* of `command` and does not say which shell runs it on Windows, so a bash guard
|
|
215
|
+
* here would be that bug again by construction. Anyone who installed vexp has
|
|
216
|
+
* node on PATH; a node script is the one interpreter we can count on everywhere.
|
|
217
|
+
*
|
|
218
|
+
* Denial is emitted on BOTH documented channels — the stdout verdict object and
|
|
219
|
+
* exit code 2 — because Cursor's docs describe both and we cannot test which one
|
|
220
|
+
* this build actually honours. Belt and braces on a schema we are trusting on
|
|
221
|
+
* documentation alone.
|
|
222
|
+
*
|
|
223
|
+
* Fails OPEN in every uncertain case: daemon down, unparseable input, an unknown
|
|
224
|
+
* payload shape. An over-eager guard strands the agent with no way to look at
|
|
225
|
+
* anything; an under-eager one costs tokens. Those are not symmetric.
|
|
226
|
+
*
|
|
227
|
+
* ASCII-only, no backticks and no ${...}: it is embedded verbatim in a TS
|
|
228
|
+
* template literal in two packages (lockstep test in test/guard-lockstep.test.ts).
|
|
229
|
+
*/
|
|
230
|
+
export const VEXP_CURSOR_GUARD = `#!/usr/bin/env node
|
|
231
|
+
// vexp-guard - Cursor preToolUse hook (generated by 'vexp setup-agents'; rewritten
|
|
232
|
+
// on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak first).
|
|
233
|
+
//
|
|
234
|
+
// Blocks Cursor's Grep tool ONLY where vexp has a better answer: indexed source
|
|
235
|
+
// inside the workspace, while the daemon is healthy. Never blocks Shell - that
|
|
236
|
+
// valve is what keeps the agent able to reach logs, build output and anything
|
|
237
|
+
// outside the repo, and blocking it teaches evasion instead of redirection.
|
|
238
|
+
//
|
|
239
|
+
// NOTE: Cursor's native semantic codebase search is NOT exposed to hooks, so it
|
|
240
|
+
// cannot be redirected. This guard covers text search only.
|
|
241
|
+
const fs = require("node:fs");
|
|
242
|
+
const path = require("node:path");
|
|
243
|
+
|
|
244
|
+
function findVexpDir(start) {
|
|
245
|
+
let dir = start;
|
|
246
|
+
for (;;) {
|
|
247
|
+
if (fs.existsSync(path.join(dir, ".vexp"))) return path.join(dir, ".vexp");
|
|
248
|
+
const parent = path.dirname(dir);
|
|
249
|
+
if (parent === dir) return null;
|
|
250
|
+
dir = parent;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function pidAlive(pidFile) {
|
|
255
|
+
let pid = 0;
|
|
256
|
+
try { pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); } catch (e) { return false; }
|
|
257
|
+
if (!pid) return false;
|
|
258
|
+
try { process.kill(pid, 0); return true; } catch (e) { return !!(e && e.code === "EPERM"); }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The daemon writes .vexp/healthy when up and removes it on graceful shutdown.
|
|
262
|
+
// Unix also requires a live socket AND a live pid: healthy + socket both linger
|
|
263
|
+
// after a kill -9, so the pid check is what makes fail-open actually work.
|
|
264
|
+
// Windows has no socket file, so the daemon drops .vexp/daemon.pipe instead.
|
|
265
|
+
function daemonHealthy(vexpDir) {
|
|
266
|
+
if (!vexpDir || !fs.existsSync(path.join(vexpDir, "healthy"))) return false;
|
|
267
|
+
if (process.platform === "win32") return fs.existsSync(path.join(vexpDir, "daemon.pipe"));
|
|
268
|
+
return fs.existsSync(path.join(vexpDir, "daemon.sock")) && pidAlive(path.join(vexpDir, "daemon.pid"));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const NON_INDEXED_DIRS = [
|
|
272
|
+
"node_modules", "dist", "build", "out", "coverage", "target", "vendor",
|
|
273
|
+
".vite", ".next", ".nuxt", ".output", ".turbo", ".cache", ".git", ".vexp"
|
|
274
|
+
];
|
|
275
|
+
const NON_INDEXED_HINTS = [".log", ".lock", "-lock.", ".map", ".min.js"];
|
|
276
|
+
|
|
277
|
+
// Is this search target inside what vexp indexed? Only then is "call
|
|
278
|
+
// run_pipeline instead" real advice rather than a dead end. Anything that cannot
|
|
279
|
+
// be resolved confidently returns false (allow).
|
|
280
|
+
function isIndexedTarget(root, target) {
|
|
281
|
+
if (!target) return true; // no path given: a whole-workspace search is vexp's job
|
|
282
|
+
const s = String(target);
|
|
283
|
+
const low = s.toLowerCase();
|
|
284
|
+
for (const h of NON_INDEXED_HINTS) if (low.indexOf(h) !== -1) return false;
|
|
285
|
+
let rel;
|
|
286
|
+
try {
|
|
287
|
+
rel = path.relative(root, path.isAbsolute(s) ? s : path.join(root, s));
|
|
288
|
+
} catch (e) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
if (rel === "..") return false;
|
|
292
|
+
if (rel.indexOf(".." + path.sep) === 0) return false; // escapes the workspace
|
|
293
|
+
if (path.isAbsolute(rel)) return false; // different root or drive
|
|
294
|
+
const segs = rel.split(path.sep);
|
|
295
|
+
for (const g of segs) if (NON_INDEXED_DIRS.indexOf(g) !== -1) return false;
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function allow() {
|
|
300
|
+
process.stdout.write(JSON.stringify({ permission: "allow" }));
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function deny(toolName) {
|
|
305
|
+
const reason =
|
|
306
|
+
"vexp daemon is running. AGENT INSTRUCTION: call run_pipeline instead of " +
|
|
307
|
+
toolName + " - it returns pre-indexed, graph-ranked context in one call." +
|
|
308
|
+
" This is policy, not a transient failure: do NOT work around it with shell" +
|
|
309
|
+
" search or by writing a script.";
|
|
310
|
+
// Both documented channels: the verdict object AND exit 2.
|
|
311
|
+
process.stdout.write(JSON.stringify({
|
|
312
|
+
permission: "deny",
|
|
313
|
+
agent_message: reason,
|
|
314
|
+
user_message: "vexp blocked a text search; the agent was told to use run_pipeline."
|
|
315
|
+
}));
|
|
316
|
+
process.stderr.write(reason);
|
|
317
|
+
process.exit(2);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Cursor documents tool_input for Shell only; the payload shape for a Grep call
|
|
321
|
+
// appears NOWHERE in its docs. Rather than guess key names - a guess that fires,
|
|
322
|
+
// reads undefined and then blocks everything or nothing - we do not read keys at
|
|
323
|
+
// all. We scan the VALUES of tool_input for anything path-shaped. That is immune
|
|
324
|
+
// to whatever the keys turn out to be called, and to them being renamed later.
|
|
325
|
+
//
|
|
326
|
+
// The search pattern itself is one of those values and may look like a path. It
|
|
327
|
+
// can only ever push us toward ALLOW (a pattern mentioning node_modules reads as
|
|
328
|
+
// a non-indexed target), never toward a wrongful block - which is the direction
|
|
329
|
+
// we want to be wrong in.
|
|
330
|
+
function candidateTargets(toolInput) {
|
|
331
|
+
if (!toolInput) return [];
|
|
332
|
+
// MCP hooks receive tool_input as a JSON *string*, so the type is not uniform.
|
|
333
|
+
let obj = toolInput;
|
|
334
|
+
if (typeof obj === "string") {
|
|
335
|
+
try { obj = JSON.parse(obj); } catch (e) { return []; }
|
|
336
|
+
}
|
|
337
|
+
if (typeof obj !== "object") return [];
|
|
338
|
+
// EVERY string value, with no "does this look like a path" pre-filter. An
|
|
339
|
+
// earlier version required a separator or a dot and therefore missed bare
|
|
340
|
+
// directory names - "dist", "build", "node_modules" - which are exactly the
|
|
341
|
+
// targets the carve-out exists to protect. Handing the search pattern itself
|
|
342
|
+
// to isIndexedTarget is harmless: a pattern that reads as non-indexed only
|
|
343
|
+
// ever produces an ALLOW, and a pattern that reads as indexed changes nothing.
|
|
344
|
+
const out = [];
|
|
345
|
+
for (const v of Object.values(obj)) {
|
|
346
|
+
if (typeof v === "string" && v) out.push(v);
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function main(input) {
|
|
352
|
+
const toolName = input && input.tool_name;
|
|
353
|
+
// Grep only. Shell is never blocked - it is the valve for everything vexp
|
|
354
|
+
// cannot index - and Read is never blocked because an agent must read to edit.
|
|
355
|
+
if (toolName !== "Grep") return allow();
|
|
356
|
+
|
|
357
|
+
const roots = (input && input.workspace_roots) || [];
|
|
358
|
+
const start = roots[0] || (input && input.cwd) || process.cwd();
|
|
359
|
+
const vexpDir = findVexpDir(start);
|
|
360
|
+
if (!daemonHealthy(vexpDir)) return allow(); // no live index -> allow
|
|
361
|
+
const root = path.dirname(vexpDir);
|
|
362
|
+
|
|
363
|
+
for (const t of candidateTargets(input.tool_input)) {
|
|
364
|
+
if (!isIndexedTarget(root, t)) return allow(); // vexp has no answer here
|
|
365
|
+
}
|
|
366
|
+
deny(toolName);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let raw = "";
|
|
370
|
+
process.stdin.on("data", (c) => { raw += c; });
|
|
371
|
+
process.stdin.on("end", () => {
|
|
372
|
+
let input;
|
|
373
|
+
try { input = JSON.parse(raw); } catch (e) { return allow(); }
|
|
374
|
+
try { main(input); } catch (e) { allow(); } // any surprise -> fail open
|
|
375
|
+
});
|
|
376
|
+
`;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
/** Expand a leading `~` — readline input arrives unexpanded, unlike a shell arg. */
|
|
5
|
+
export function expandHome(p) {
|
|
6
|
+
if (p === "~")
|
|
7
|
+
return os.homedir();
|
|
8
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
|
9
|
+
return path.join(os.homedir(), p.slice(2));
|
|
10
|
+
}
|
|
11
|
+
return p;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a `repos[].path` entry like vexp-core's `resolve_repo_path`:
|
|
15
|
+
* absolute paths are preserved, relative paths resolve against the workspace
|
|
16
|
+
* ROOT. Configs written before 2.2.1 stored paths relative to `.vexp/`, so
|
|
17
|
+
* when the root-relative answer is not a directory we fall back to the legacy
|
|
18
|
+
* base — same order as the Rust resolver, so list/add agree with the daemon.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveRepoEntryPath(workspaceRoot, raw) {
|
|
21
|
+
if (path.isAbsolute(raw))
|
|
22
|
+
return path.normalize(raw);
|
|
23
|
+
const fromRoot = path.resolve(workspaceRoot, raw);
|
|
24
|
+
if (fs.existsSync(fromRoot))
|
|
25
|
+
return fromRoot;
|
|
26
|
+
const fromLegacy = path.resolve(workspaceRoot, ".vexp", raw);
|
|
27
|
+
if (fs.existsSync(fromLegacy))
|
|
28
|
+
return fromLegacy;
|
|
29
|
+
return fromRoot;
|
|
30
|
+
}
|
|
31
|
+
function readWorkspaceConfig(configPath) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
34
|
+
if (!parsed || !Array.isArray(parsed.repos))
|
|
35
|
+
return null;
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* If `childRoot` is a secondary repo of a multi-repo workspace, return the
|
|
44
|
+
* parent workspace info. The link is `.vexp/parent_workspace.json` (written on
|
|
45
|
+
* add, both by the VS Code plugin and by `addRepoToWorkspace` below).
|
|
46
|
+
*
|
|
47
|
+
* Returns null — i.e. "treat this repo as standalone" — unless the parent
|
|
48
|
+
* config still exists AND still lists this repo: a stale parent_workspace.json
|
|
49
|
+
* left behind after the repo was disconnected must not hijack the CLI.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveParentWorkspace(childRoot) {
|
|
52
|
+
let configPath;
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(fs.readFileSync(path.join(childRoot, ".vexp", "parent_workspace.json"), "utf-8"));
|
|
55
|
+
if (typeof raw?.workspace_config !== "string")
|
|
56
|
+
return null;
|
|
57
|
+
configPath = raw.workspace_config;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (!fs.existsSync(configPath))
|
|
63
|
+
return null;
|
|
64
|
+
// workspace.json lives in <parentRoot>/.vexp/
|
|
65
|
+
const parentRoot = path.dirname(path.dirname(configPath));
|
|
66
|
+
if (path.resolve(parentRoot) === path.resolve(childRoot))
|
|
67
|
+
return null;
|
|
68
|
+
const config = readWorkspaceConfig(configPath);
|
|
69
|
+
if (!config)
|
|
70
|
+
return null;
|
|
71
|
+
const childResolved = path.resolve(childRoot);
|
|
72
|
+
const listed = config.repos.some((r) => {
|
|
73
|
+
if (typeof r?.path !== "string")
|
|
74
|
+
return false;
|
|
75
|
+
return path.resolve(resolveRepoEntryPath(parentRoot, r.path)) === childResolved;
|
|
76
|
+
});
|
|
77
|
+
if (!listed)
|
|
78
|
+
return null;
|
|
79
|
+
return {
|
|
80
|
+
parentRoot,
|
|
81
|
+
workspaceName: config.name ?? path.basename(parentRoot),
|
|
82
|
+
configPath,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Repos connected to the workspace at `workspaceRoot`. Empty array when there
|
|
87
|
+
* is no workspace.json (single-repo workspace).
|
|
88
|
+
*/
|
|
89
|
+
export function listWorkspaceRepos(workspaceRoot) {
|
|
90
|
+
const configPath = path.join(workspaceRoot, ".vexp", "workspace.json");
|
|
91
|
+
const config = readWorkspaceConfig(configPath);
|
|
92
|
+
if (!config)
|
|
93
|
+
return [];
|
|
94
|
+
const rootResolved = path.resolve(workspaceRoot);
|
|
95
|
+
return config.repos
|
|
96
|
+
.filter((r) => typeof r?.path === "string" && typeof r?.alias === "string")
|
|
97
|
+
.map((r) => {
|
|
98
|
+
const resolvedPath = resolveRepoEntryPath(workspaceRoot, r.path);
|
|
99
|
+
return {
|
|
100
|
+
alias: r.alias,
|
|
101
|
+
rawPath: r.path,
|
|
102
|
+
resolvedPath,
|
|
103
|
+
isPrimary: path.resolve(resolvedPath) === rootResolved,
|
|
104
|
+
exists: fs.existsSync(resolvedPath),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Connect `newRepoPath` to the workspace at `workspaceRoot` — the exact write
|
|
110
|
+
* sequence of the VS Code `vexp.setupMultiRepo` command:
|
|
111
|
+
* 1. plan-limit guard BEFORE writing anything (maxRepos, 0 = unlimited)
|
|
112
|
+
* 2. read-or-create workspace.json (primary entry is `"."` — the 2.2.1+
|
|
113
|
+
* root-relative convention)
|
|
114
|
+
* 3. append `{ alias, path: <absolute> }` unless already present
|
|
115
|
+
* 4. write `.vexp/parent_workspace.json` in the child so opening the child
|
|
116
|
+
* resolves back to this workspace
|
|
117
|
+
*
|
|
118
|
+
* Indexing is NOT triggered here — the caller decides whether to restart the
|
|
119
|
+
* daemon (mirrors the plugin's "Restart / Later" prompt).
|
|
120
|
+
*/
|
|
121
|
+
export function addRepoToWorkspace(workspaceRoot, newRepoPath, maxRepos) {
|
|
122
|
+
const resolved = path.resolve(expandHome(newRepoPath.trim()));
|
|
123
|
+
let stat;
|
|
124
|
+
try {
|
|
125
|
+
stat = fs.statSync(resolved);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { status: "invalid", reason: `Directory not found: ${resolved}` };
|
|
129
|
+
}
|
|
130
|
+
if (!stat.isDirectory()) {
|
|
131
|
+
return { status: "invalid", reason: `Not a directory: ${resolved}` };
|
|
132
|
+
}
|
|
133
|
+
if (path.resolve(workspaceRoot) === resolved) {
|
|
134
|
+
return { status: "invalid", reason: "That is the workspace root itself — it is already the primary repo." };
|
|
135
|
+
}
|
|
136
|
+
const configPath = path.join(workspaceRoot, ".vexp", "workspace.json");
|
|
137
|
+
const alias = path.basename(resolved);
|
|
138
|
+
// Plan-limit guard: same semantics as the plugin — count existing entries,
|
|
139
|
+
// block only genuinely-new repos, never after a partial write.
|
|
140
|
+
let config = readWorkspaceConfig(configPath);
|
|
141
|
+
const currentCount = config ? config.repos.length : 1; // primary always exists
|
|
142
|
+
const alreadyExists = config?.repos.some((r) => r.alias === alias ||
|
|
143
|
+
r.path === resolved ||
|
|
144
|
+
path.resolve(resolveRepoEntryPath(workspaceRoot, r.path)) === resolved) ?? false;
|
|
145
|
+
if (alreadyExists) {
|
|
146
|
+
return { status: "exists", alias };
|
|
147
|
+
}
|
|
148
|
+
if (maxRepos > 0 && currentCount >= maxRepos) {
|
|
149
|
+
return { status: "limit", maxRepos, current: currentCount };
|
|
150
|
+
}
|
|
151
|
+
if (!config) {
|
|
152
|
+
config = {
|
|
153
|
+
name: path.basename(workspaceRoot),
|
|
154
|
+
repos: [{ alias: path.basename(workspaceRoot), path: "." }],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
config.repos.push({ alias, path: resolved });
|
|
158
|
+
fs.mkdirSync(path.join(workspaceRoot, ".vexp"), { recursive: true });
|
|
159
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
160
|
+
// Bidirectional link in the child, so `vexp` run there targets this
|
|
161
|
+
// workspace's daemon instead of spawning a conflicting one.
|
|
162
|
+
const childVexpDir = path.join(resolved, ".vexp");
|
|
163
|
+
fs.mkdirSync(childVexpDir, { recursive: true });
|
|
164
|
+
fs.writeFileSync(path.join(childVexpDir, "parent_workspace.json"), JSON.stringify({ workspace_config: configPath }, null, 2));
|
|
165
|
+
return { status: "added", alias, configPath };
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vexp-cli",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.4",
|
|
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.4",
|
|
104
|
+
"@vexp/core-linux-arm64": "2.2.4",
|
|
105
|
+
"@vexp/core-darwin-x64": "2.2.4",
|
|
106
|
+
"@vexp/core-darwin-arm64": "2.2.4",
|
|
107
|
+
"@vexp/core-win32-x64": "2.2.4"
|
|
108
108
|
}
|
|
109
109
|
}
|