vexp-cli 2.6.2 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-config.js +90 -2
- package/dist/cli.js +38 -7
- package/dist/doctor.js +163 -22
- package/dist/hook-template.js +49 -0
- package/dist/license.js +17 -0
- package/mcp/mcp-server.cjs +43 -43
- 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, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin } from "./hook-template.js";
|
|
13
|
+
import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSearchHookScript, vexpHintHookCmdScript, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin } from "./hook-template.js";
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Constants
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
@@ -403,6 +403,7 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
|
|
|
403
403
|
// Event-driven hint hook: default-on (non-blocking, fail-open — the
|
|
404
404
|
// 2.3 A2 opt-in applies to DENY hooks, this one cannot block).
|
|
405
405
|
const hintResult = installClaudeCodeHintHook(workspaceRoot, binaryPath);
|
|
406
|
+
installClaudeCodeSearchHook(workspaceRoot, binaryPath);
|
|
406
407
|
installClaudeCodeStopGate(workspaceRoot, binaryPath);
|
|
407
408
|
installClaudeCodeSessionContext(workspaceRoot, binaryPath);
|
|
408
409
|
if (hintResult) {
|
|
@@ -2026,6 +2027,69 @@ function isVexpHintHookEntry(h) {
|
|
|
2026
2027
|
* opt-in applies to DENY hooks, which add a failure mode; this one cannot
|
|
2027
2028
|
* block anything).
|
|
2028
2029
|
*/
|
|
2030
|
+
/** Does this PreToolUse entry belong to us? */
|
|
2031
|
+
function isVexpSearchHookEntry(h) {
|
|
2032
|
+
if (!h || typeof h !== "object")
|
|
2033
|
+
return false;
|
|
2034
|
+
const hks = h.hooks;
|
|
2035
|
+
return Array.isArray(hks) && hks.some((hook) => typeof hook?.command === "string" &&
|
|
2036
|
+
hook.command.includes("vexp-search"));
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* v4 M1: install the search answer as a PreToolUse hook on Bash.
|
|
2040
|
+
*
|
|
2041
|
+
* Matcher is Bash and nothing else. The hook rewrites only a recursive grep
|
|
2042
|
+
* for names this index defines, keeps the original command after a `||`, and
|
|
2043
|
+
* prints nothing at all in every other case — so a session where vexp has no
|
|
2044
|
+
* opinion is byte-for-byte a session without vexp.
|
|
2045
|
+
*
|
|
2046
|
+
* Why a hook rather than a tool: 9 of 227 measured agent sessions called the
|
|
2047
|
+
* MCP tools. All 227 called Bash.
|
|
2048
|
+
*/
|
|
2049
|
+
export function installClaudeCodeSearchHook(workspaceRoot, binaryPath) {
|
|
2050
|
+
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
2051
|
+
const hookPath = path.join(hookDir, "vexp-search.sh");
|
|
2052
|
+
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
2053
|
+
const script = vexpSearchHookScript(binaryPath);
|
|
2054
|
+
fs.mkdirSync(hookDir, { recursive: true });
|
|
2055
|
+
const existed = fs.existsSync(hookPath);
|
|
2056
|
+
const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
|
|
2057
|
+
if (!scriptIdentical)
|
|
2058
|
+
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
2059
|
+
const read = readJsonConfigSafe(settingsPath);
|
|
2060
|
+
if (!read.ok) {
|
|
2061
|
+
warnUnparseable(settingsPath);
|
|
2062
|
+
return scriptIdentical ? null : existed ? "updated" : "created";
|
|
2063
|
+
}
|
|
2064
|
+
const settings = read.data;
|
|
2065
|
+
const hooks = (settings.hooks ?? {});
|
|
2066
|
+
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
2067
|
+
const filtered = existing.filter((h) => !isVexpSearchHookEntry(h));
|
|
2068
|
+
// Same shell-form-with-quoted-path shape as every other hook we write: exec
|
|
2069
|
+
// form cannot run a .sh on Windows, and an unquoted path word-splits on
|
|
2070
|
+
// "C:\Program Files" (both learned the hard way, c4a0b9e).
|
|
2071
|
+
filtered.push({
|
|
2072
|
+
matcher: "Bash",
|
|
2073
|
+
hooks: [
|
|
2074
|
+
{
|
|
2075
|
+
type: "command",
|
|
2076
|
+
command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-search.sh"',
|
|
2077
|
+
timeout: 10,
|
|
2078
|
+
},
|
|
2079
|
+
],
|
|
2080
|
+
});
|
|
2081
|
+
const merged = { ...hooks, PreToolUse: filtered };
|
|
2082
|
+
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
2083
|
+
if (scriptIdentical && settingsIdentical)
|
|
2084
|
+
return null;
|
|
2085
|
+
settings.hooks = merged;
|
|
2086
|
+
if (!settingsIdentical) {
|
|
2087
|
+
if (read.existed)
|
|
2088
|
+
backupConfig(settingsPath);
|
|
2089
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
2090
|
+
}
|
|
2091
|
+
return existed ? "updated" : "created";
|
|
2092
|
+
}
|
|
2029
2093
|
export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
2030
2094
|
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
2031
2095
|
const hookPath = path.join(hookDir, "vexp-hint.sh");
|
|
@@ -2100,10 +2164,33 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
2100
2164
|
const script = vexpHintHookScript(binaryPath);
|
|
2101
2165
|
fs.mkdirSync(dir, { recursive: true });
|
|
2102
2166
|
const existed = fs.existsSync(hookPath);
|
|
2103
|
-
|
|
2167
|
+
let scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
|
|
2104
2168
|
if (!scriptIdentical) {
|
|
2105
2169
|
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
2106
2170
|
}
|
|
2171
|
+
// Windows gets a batch twin. Codex runs a hook command through
|
|
2172
|
+
// `cmd.exe /C` (COMSPEC, codex-rs/hooks command_runner), so `bash "..."`
|
|
2173
|
+
// reaches a shell that has never heard of bash: a user with neither Git
|
|
2174
|
+
// Bash nor WSL got "bash not found" on every prompt and orientation was
|
|
2175
|
+
// inert. Codex's own answer is `commandWindows`, an optional per-OS
|
|
2176
|
+
// override on the command handler (openai/codex#22159, ~0.131, months
|
|
2177
|
+
// before the version that requires the nested `hooks` shape we already
|
|
2178
|
+
// write). Older builds ignore the field rather than reject the file —
|
|
2179
|
+
// the handler is not deny_unknown_fields, unlike the top level.
|
|
2180
|
+
const cmdPath = path.join(dir, "vexp-hint.cmd");
|
|
2181
|
+
let cmdCommand;
|
|
2182
|
+
if (process.platform === "win32") {
|
|
2183
|
+
const cmdScript = vexpHintHookCmdScript(binaryPath);
|
|
2184
|
+
const cmdExisted = fs.existsSync(cmdPath);
|
|
2185
|
+
if (!cmdExisted || fs.readFileSync(cmdPath, "utf-8") !== cmdScript) {
|
|
2186
|
+
fs.writeFileSync(cmdPath, cmdScript);
|
|
2187
|
+
scriptIdentical = false;
|
|
2188
|
+
}
|
|
2189
|
+
// Quoted: Codex hands `cmd /C` the whole line wrapped in one more pair
|
|
2190
|
+
// of quotes, and cmd then strips the outermost pair — which leaves this
|
|
2191
|
+
// path quoted and therefore safe to contain spaces.
|
|
2192
|
+
cmdCommand = `"${cmdPath}"`;
|
|
2193
|
+
}
|
|
2107
2194
|
let root = {};
|
|
2108
2195
|
if (fs.existsSync(hooksJsonPath)) {
|
|
2109
2196
|
try {
|
|
@@ -2141,6 +2228,7 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
2141
2228
|
{
|
|
2142
2229
|
type: "command",
|
|
2143
2230
|
command: `bash "${hookPath.replace(/\\/g, "/")}"`,
|
|
2231
|
+
...(cmdCommand ? { commandWindows: cmdCommand } : {}),
|
|
2144
2232
|
timeout: 5,
|
|
2145
2233
|
},
|
|
2146
2234
|
],
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { checkbox, confirm } from "@inquirer/prompts";
|
|
|
10
10
|
import { getBinaryPath, getInstalledVersion, getMcpServerPath, binaryEnv } from "./binary.js";
|
|
11
11
|
import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets } from "./agent-config.js";
|
|
12
12
|
import { CLI_VERSION } from "./version.js";
|
|
13
|
-
import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, } from "./license.js";
|
|
13
|
+
import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, tryOnlineRefresh, } from "./license.js";
|
|
14
14
|
import { checkForUpdate } from "./update-check.js";
|
|
15
15
|
import { ensureMcpHttpServer, mcpHttpStatus } from "./mcp-supervisor.js";
|
|
16
16
|
import { installAutostart, uninstallAutostart, autostartStatus, migrateClaudeUnpinIfNeeded } from "./autostart.js";
|
|
@@ -1057,6 +1057,18 @@ program
|
|
|
1057
1057
|
console.log(` Plan: ${claims.plan}`);
|
|
1058
1058
|
console.log(` Email: ${claims.sub}`);
|
|
1059
1059
|
console.log(` Expires: ${new Date(claims.exp * 1000).toLocaleDateString()}`);
|
|
1060
|
+
// Register this machine NOW, while the user is watching, instead of
|
|
1061
|
+
// whenever a later command happens to fall outside the 24h refresh
|
|
1062
|
+
// window. The dashboard tells people to "open vexp on any machine to
|
|
1063
|
+
// register it" and activating IS that — a tier-4 user read 0 of 10
|
|
1064
|
+
// devices immediately after activating and reasonably thought something
|
|
1065
|
+
// had failed. Best effort by design: the refresh has its own short
|
|
1066
|
+
// timeout and never throws, so an offline activation still succeeds and
|
|
1067
|
+
// simply registers later.
|
|
1068
|
+
try {
|
|
1069
|
+
await tryOnlineRefresh(key.trim());
|
|
1070
|
+
}
|
|
1071
|
+
catch { /* offline or server down: registration happens on a later run */ }
|
|
1060
1072
|
console.log("");
|
|
1061
1073
|
}
|
|
1062
1074
|
catch (err) {
|
|
@@ -1076,15 +1088,28 @@ program
|
|
|
1076
1088
|
.description("Show current license status")
|
|
1077
1089
|
.action(() => {
|
|
1078
1090
|
const limits = readLicenseLimits();
|
|
1091
|
+
// AppSumo lifetime tiers print as the raw internal string ("tier3") with a
|
|
1092
|
+
// "Renews" date one month out — which is the local token's expiry, not the
|
|
1093
|
+
// licence's. It rolls forward by itself on every check-in. A tier-3 buyer
|
|
1094
|
+
// read that as a monthly deadline and re-activated his key by hand every
|
|
1095
|
+
// month for nothing, and he was right to be alarmed by what it said.
|
|
1096
|
+
const ltd = /^tier[1-4]$/.test(limits.plan);
|
|
1097
|
+
const planLabel = ltd
|
|
1098
|
+
? `Lifetime · Tier ${limits.plan.slice(4)}`
|
|
1099
|
+
: limits.plan;
|
|
1079
1100
|
console.log(chalk.bold("\nvexp License Status\n"));
|
|
1080
|
-
console.log(` Plan: ${
|
|
1101
|
+
console.log(` Plan: ${planLabel}`);
|
|
1081
1102
|
if (limits.email)
|
|
1082
1103
|
console.log(` Email: ${limits.email}`);
|
|
1083
1104
|
console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
|
|
1084
1105
|
console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
|
|
1085
1106
|
console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
|
|
1086
|
-
if (limits.renewsAt)
|
|
1087
|
-
console.log(
|
|
1107
|
+
if (limits.renewsAt) {
|
|
1108
|
+
console.log(ltd
|
|
1109
|
+
? ` Renewal: none — lifetime licence (local token auto-refreshes, ` +
|
|
1110
|
+
`current one valid to ${limits.renewsAt.toLocaleDateString()})`
|
|
1111
|
+
: ` Renews: ${limits.renewsAt.toLocaleDateString()}`);
|
|
1112
|
+
}
|
|
1088
1113
|
const blocked = readDeviceBlocked();
|
|
1089
1114
|
if (blocked) {
|
|
1090
1115
|
console.log("");
|
|
@@ -1708,14 +1733,20 @@ async function executeCommand(label, rl) {
|
|
|
1708
1733
|
// ── License commands ──
|
|
1709
1734
|
case "status": {
|
|
1710
1735
|
const limits = readLicenseLimits();
|
|
1711
|
-
|
|
1736
|
+
// Same wording as `vexp license`: a lifetime tier must not be shown as
|
|
1737
|
+
// a raw "tier3" with a monthly renewal date it does not have.
|
|
1738
|
+
const ltd = /^tier[1-4]$/.test(limits.plan);
|
|
1739
|
+
console.log(` Plan: ${ltd ? `Lifetime · Tier ${limits.plan.slice(4)}` : limits.plan}`);
|
|
1712
1740
|
if (limits.email)
|
|
1713
1741
|
console.log(` Email: ${limits.email}`);
|
|
1714
1742
|
console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
|
|
1715
1743
|
console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
|
|
1716
1744
|
console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
|
|
1717
|
-
if (limits.renewsAt)
|
|
1718
|
-
console.log(
|
|
1745
|
+
if (limits.renewsAt) {
|
|
1746
|
+
console.log(ltd
|
|
1747
|
+
? ` Renewal: none — lifetime licence (token auto-refreshes, valid to ${limits.renewsAt.toLocaleDateString()})`
|
|
1748
|
+
: ` Renews: ${limits.renewsAt.toLocaleDateString()}`);
|
|
1749
|
+
}
|
|
1719
1750
|
break;
|
|
1720
1751
|
}
|
|
1721
1752
|
case "activate": {
|
package/dist/doctor.js
CHANGED
|
@@ -103,6 +103,47 @@ function isAlive(pid) {
|
|
|
103
103
|
return false;
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The git-hooks verdict, as data.
|
|
108
|
+
*
|
|
109
|
+
* Extracted because it was wrong in a way no test could catch: an overriding
|
|
110
|
+
* `core.hooksPath` was reported as a FAILURE claiming "the index does not
|
|
111
|
+
* refresh on commit/merge/checkout", and the remedy offered was to edit a
|
|
112
|
+
* file that moon, husky and lefthook all regenerate. The claim is untrue —
|
|
113
|
+
* the daemon watches the tree live and reconciles it against disk every five
|
|
114
|
+
* minutes — and the remedy was unusable. A user spent a round trip on it.
|
|
115
|
+
*/
|
|
116
|
+
export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
|
|
117
|
+
const ourHooksDir = path.join(repoRoot, ".git", "hooks");
|
|
118
|
+
if (!hooksPath) {
|
|
119
|
+
if (installedCount === 3) {
|
|
120
|
+
return { level: OK, message: "vexp hooks present in .git/hooks and git will run them" };
|
|
121
|
+
}
|
|
122
|
+
if (installedCount === 0) {
|
|
123
|
+
return { level: OK, message: "no vexp git hooks (index refreshes on demand)" };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
level: WARN,
|
|
127
|
+
message: `only ${installedCount}/3 vexp git hooks present — re-run 'vexp hooks install'`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const resolved = path.isAbsolute(hooksPath) ? hooksPath : path.join(repoRoot, hooksPath);
|
|
131
|
+
if (path.resolve(resolved) === path.resolve(ourHooksDir)) {
|
|
132
|
+
return {
|
|
133
|
+
level: OK,
|
|
134
|
+
message: `core.hooksPath points at .git/hooks (${installedCount}/3 vexp hooks present)`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (installedCount === 0) {
|
|
138
|
+
return { level: OK, message: `core.hooksPath = ${resolved} (no vexp git hooks installed here)` };
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
level: WARN,
|
|
142
|
+
message: `core.hooksPath = ${resolved} — git runs hooks ONLY from there, so the ${installedCount} vexp hook(s) in .git/hooks never run.\n` +
|
|
143
|
+
` the index still refreshes: the daemon watches the tree live and reconciles it against disk every 5 minutes (VEXP_RECONCILE_INTERVAL_SECS).\n` +
|
|
144
|
+
` the hooks only make that immediate — if you want them and a tool manages this directory (moon, husky, lefthook), add 'vexp index --finalize || true' through ITS config, not the generated file.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
106
147
|
export async function runDoctor() {
|
|
107
148
|
const home = vexpHome();
|
|
108
149
|
let warns = 0;
|
|
@@ -149,7 +190,26 @@ export async function runDoctor() {
|
|
|
149
190
|
: st.compressor ?? "unknown (older daemon)";
|
|
150
191
|
line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
|
|
151
192
|
if (st.llm_configured_but_inactive === true) {
|
|
152
|
-
|
|
193
|
+
// The daemon knows WHY, and doctor was throwing it away: it printed
|
|
194
|
+
// the same "restart to load the model" the sidebar prints, to a user
|
|
195
|
+
// whose model fails on every start. Restarting is advice only when
|
|
196
|
+
// the cause can change between starts.
|
|
197
|
+
const why = typeof st.llm_inactive_reason === "string" ? st.llm_inactive_reason : undefined;
|
|
198
|
+
const restartIsPointless = why !== undefined &&
|
|
199
|
+
/vanilla build|model load failed|incomplete|did not survive|worker did not start/i.test(why);
|
|
200
|
+
// A daemon still running its startup sync has not REACHED the model
|
|
201
|
+
// yet. Restarting there is worse than useless: it starts the sync
|
|
202
|
+
// over, and the user never arrives.
|
|
203
|
+
const stillStarting = why !== undefined && /still completing its startup/i.test(why);
|
|
204
|
+
line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed.` +
|
|
205
|
+
(why ? `\n reason: ${why}` : "") +
|
|
206
|
+
(stillStarting
|
|
207
|
+
? `\n wait for the sync to finish — do NOT restart, that begins it again` +
|
|
208
|
+
`\n the daemon logs 'LlmWorker active' when the model is up`
|
|
209
|
+
: restartIsPointless
|
|
210
|
+
? `\n restarting will NOT change this — fix the cause above`
|
|
211
|
+
: `\n run 'vexp daemon-cmd restart' to load the model`) +
|
|
212
|
+
`\n daemon: v${st.daemon_version ?? "?"} at ${st.workspace_root ?? "?"}`);
|
|
153
213
|
}
|
|
154
214
|
// 2.4.0 upgrade guard (CLI side): a daemon surviving an upgrade keeps
|
|
155
215
|
// serving the OLD feature set and its gaps read as product bugs
|
|
@@ -435,27 +495,8 @@ export async function runDoctor() {
|
|
|
435
495
|
return false;
|
|
436
496
|
}
|
|
437
497
|
});
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
line(OK, "vexp hooks present in .git/hooks and git will run them");
|
|
441
|
-
else if (installed.length === 0)
|
|
442
|
-
line(OK, "no vexp git hooks (index refreshes on demand)");
|
|
443
|
-
else
|
|
444
|
-
line(WARN, `only ${installed.length}/3 vexp git hooks present — re-run 'vexp hooks install'`);
|
|
445
|
-
}
|
|
446
|
-
else {
|
|
447
|
-
const resolved = path.isAbsolute(hooksPath) ? hooksPath : path.join(ws.root, hooksPath);
|
|
448
|
-
const sameDir = path.resolve(resolved) === path.resolve(ours);
|
|
449
|
-
if (sameDir) {
|
|
450
|
-
line(OK, `core.hooksPath points at .git/hooks (${installed.length}/3 vexp hooks present)`);
|
|
451
|
-
}
|
|
452
|
-
else if (installed.length > 0) {
|
|
453
|
-
line(BAD, `core.hooksPath = ${resolved} — git runs hooks ONLY from there, so the ${installed.length} vexp hook(s) in .git/hooks NEVER run and the index does not refresh on commit/merge/checkout. Add 'vexp index --finalize || true' to ${path.join(resolved, "pre-commit")}, or 'git config --unset core.hooksPath' for this repo.`);
|
|
454
|
-
}
|
|
455
|
-
else {
|
|
456
|
-
line(OK, `core.hooksPath = ${resolved} (no vexp git hooks installed here)`);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
498
|
+
const verdict = gitHooksVerdict(hooksPath, ws.root, installed.length);
|
|
499
|
+
line(verdict.level, verdict.message);
|
|
459
500
|
}
|
|
460
501
|
console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
|
|
461
502
|
{
|
|
@@ -527,6 +568,106 @@ export async function runDoctor() {
|
|
|
527
568
|
}
|
|
528
569
|
}
|
|
529
570
|
}
|
|
571
|
+
// 5b-bis) Codex orientation hook. The section above it reports Codex's MCP
|
|
572
|
+
// config and stops there, so a Codex user could have half the product wired
|
|
573
|
+
// and read nothing but [OK]: the tools come from ~/.codex/config.toml, the
|
|
574
|
+
// per-prompt orientation comes from .codex/hooks.json, and until now only
|
|
575
|
+
// the first was ever checked. Two users reached that blind spot from
|
|
576
|
+
// opposite directions in one week — one could not tell whether orientation
|
|
577
|
+
// was configured, the other could not tell whether it ran.
|
|
578
|
+
console.log(chalk.bold("\nCodex orientation hook (.codex/hooks.json)"));
|
|
579
|
+
{
|
|
580
|
+
const hooksJson = path.join(ws.root, ".codex", "hooks.json");
|
|
581
|
+
const scriptPath = path.join(ws.root, ".codex", "vexp-hint.sh");
|
|
582
|
+
const codexMcp = (() => {
|
|
583
|
+
try {
|
|
584
|
+
return fs
|
|
585
|
+
.readFileSync(path.join(os.homedir(), ".codex", "config.toml"), "utf-8")
|
|
586
|
+
.includes("vexp");
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
})();
|
|
592
|
+
let cfg = null;
|
|
593
|
+
try {
|
|
594
|
+
cfg = JSON.parse(fs.readFileSync(hooksJson, "utf-8"));
|
|
595
|
+
}
|
|
596
|
+
catch { /* absent */ }
|
|
597
|
+
if (!cfg) {
|
|
598
|
+
// Only a finding for someone who actually uses Codex here.
|
|
599
|
+
line(codexMcp ? WARN : OK, codexMcp
|
|
600
|
+
? "no .codex/hooks.json, but vexp MCP is configured for Codex — the tools work and the per-prompt orientation was never installed. Run 'vexp setup' here (needs Codex >= 0.129)."
|
|
601
|
+
: "no .codex/hooks.json (Codex not configured here)");
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
// Two shapes exist in the wild: the installer nests under "hooks", and
|
|
605
|
+
// files from earlier versions (or edited by hand) carry the event at the
|
|
606
|
+
// top level. Reading only one of them would leave the other undiagnosable
|
|
607
|
+
// — and the flat one is what the user who reported this had on disk.
|
|
608
|
+
const events = (Array.isArray(cfg?.hooks?.UserPromptSubmit)
|
|
609
|
+
? cfg.hooks.UserPromptSubmit
|
|
610
|
+
: Array.isArray(cfg?.UserPromptSubmit)
|
|
611
|
+
? cfg.UserPromptSubmit
|
|
612
|
+
: []);
|
|
613
|
+
const hook = events
|
|
614
|
+
.flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
|
|
615
|
+
.find((h) => typeof h?.command === "string" && h.command.includes("vexp-hint"));
|
|
616
|
+
if (!hook) {
|
|
617
|
+
line(WARN, "hooks.json has no vexp UserPromptSubmit entry — re-run 'vexp setup'.");
|
|
618
|
+
}
|
|
619
|
+
else if (!fs.existsSync(scriptPath)) {
|
|
620
|
+
line(BAD, "hooks.json points at .codex/vexp-hint.sh but the script is missing — the hook fails on every prompt.");
|
|
621
|
+
}
|
|
622
|
+
else if (process.platform === "win32" && typeof hook.commandWindows !== "string") {
|
|
623
|
+
// On Windows Codex runs the command through cmd.exe, which has no
|
|
624
|
+
// bash: without the commandWindows override the entry is inert on
|
|
625
|
+
// every prompt unless Git Bash happens to be on PATH. Probing it
|
|
626
|
+
// through bash here would hide exactly that.
|
|
627
|
+
line(BAD, "the entry has no 'commandWindows' — Codex runs hooks through cmd.exe on Windows, which cannot run the bash script, so orientation never fires (unless Git Bash is on PATH). Re-run 'vexp setup' to write the batch twin.");
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
// Run it, through the same shell Codex would use: cmd.exe on
|
|
631
|
+
// Windows (COMSPEC), sh elsewhere. The script bakes an absolute path
|
|
632
|
+
// to the vexp binary and exits 0 when that path is not executable, so
|
|
633
|
+
// a stale or wrong-profile path leaves NO trace anywhere: no
|
|
634
|
+
// orientation, no error, forever. A Windows user found exactly that
|
|
635
|
+
// by reading the generated file.
|
|
636
|
+
const isWin = process.platform === "win32";
|
|
637
|
+
const cmdLine = (isWin ? hook.commandWindows : hook.command);
|
|
638
|
+
const r = spawnSync(isWin ? (process.env.COMSPEC || "cmd.exe") : "sh", [isWin ? "/c" : "-c", cmdLine], {
|
|
639
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
640
|
+
input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
|
|
641
|
+
timeout: 10000,
|
|
642
|
+
encoding: "utf-8",
|
|
643
|
+
});
|
|
644
|
+
const baked = (() => {
|
|
645
|
+
try {
|
|
646
|
+
// One pattern covers both twins: VEXP_BIN="path" (bash) and
|
|
647
|
+
// set "VEXP_BIN=path" (cmd).
|
|
648
|
+
const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
|
|
649
|
+
return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
})();
|
|
655
|
+
if (r.error) {
|
|
656
|
+
line(BAD, `hook DID NOT RUN: ${r.error.code ?? r.error.message} — orientation is inert.`);
|
|
657
|
+
}
|
|
658
|
+
else if (r.status !== 0) {
|
|
659
|
+
line(BAD, `hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 160)}` : ""} — Codex continues without vexp.`);
|
|
660
|
+
}
|
|
661
|
+
else if (baked && !fs.existsSync(baked)) {
|
|
662
|
+
line(BAD, `hook runs but the binary it points at does not exist: ${baked} — it exits silently on every prompt. Re-run 'vexp setup' as the user that owns this install.`);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
|
|
666
|
+
line(OK, `UserPromptSubmit hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
530
671
|
// 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
|
|
531
672
|
// hooks fail OPEN too (`failClosed` defaults to false), so a guard that
|
|
532
673
|
// cannot spawn silently enforces nothing there as well. The guard's stdin
|
package/dist/hook-template.js
CHANGED
|
@@ -397,6 +397,55 @@ exit 0
|
|
|
397
397
|
export function vexpHintHookScript(binaryPath) {
|
|
398
398
|
return VEXP_HINT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
|
|
399
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* Windows twin of the hint hook. Codex runs a hook command through
|
|
402
|
+
* `cmd.exe /C` (COMSPEC) on Windows, and cmd has no `bash`: a Windows user
|
|
403
|
+
* with neither Git Bash nor WSL got "bash not found" on every prompt, so
|
|
404
|
+
* orientation was inert and nothing said so. The logic is four lines and
|
|
405
|
+
* none of it is shell-specific, so it translates exactly — and `exit /b 0`
|
|
406
|
+
* on the last line keeps the same fail-open contract as the bash twin.
|
|
407
|
+
*
|
|
408
|
+
* CRLF on purpose: a batch file with bare LF endings is parsed
|
|
409
|
+
* inconsistently by cmd, and this one has to run on the machines least
|
|
410
|
+
* likely to have a POSIX toolchain that would normalise it.
|
|
411
|
+
*/
|
|
412
|
+
export const VEXP_HINT_HOOK_CMD = [
|
|
413
|
+
"@echo off",
|
|
414
|
+
"REM vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.",
|
|
415
|
+
'set "VEXP_BIN=__VEXP_BIN__"',
|
|
416
|
+
'if not exist "%VEXP_BIN%" exit /b 0',
|
|
417
|
+
'"%VEXP_BIN%" prompt-hint 2>nul',
|
|
418
|
+
"exit /b 0",
|
|
419
|
+
"",
|
|
420
|
+
].join("\r\n");
|
|
421
|
+
/** Bake the binary path into the Windows hint hook. Backslashes stay. */
|
|
422
|
+
export function vexpHintHookCmdScript(binaryPath) {
|
|
423
|
+
return VEXP_HINT_HOOK_CMD.replace("__VEXP_BIN__", binaryPath.replace(/\//g, "\\"));
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* v4 M1: the search answer (PreToolUse on Bash).
|
|
427
|
+
*
|
|
428
|
+
* Of 227 measured agent sessions with vexp installed, 9 called it — 4%. Bash
|
|
429
|
+
* is called in all of them. So this stops asking the model to adopt anything:
|
|
430
|
+
* when it is about to grep the repository for a name the index knows, the
|
|
431
|
+
* command is rewritten to ask vexp first, with the original grep preserved
|
|
432
|
+
* after a `||` so the failure mode is exactly "vexp is not installed".
|
|
433
|
+
*
|
|
434
|
+
* All the logic is in the binary (`vexp-core search-hook`), like prompt-hint:
|
|
435
|
+
* the script is a cross-OS wrapper that exits silently when anything is
|
|
436
|
+
* missing.
|
|
437
|
+
*/
|
|
438
|
+
export const VEXP_SEARCH_HOOK = `#!/bin/bash
|
|
439
|
+
# vexp-search: answer a grep with structure instead of lines. Fails open.
|
|
440
|
+
VEXP_BIN="__VEXP_BIN__"
|
|
441
|
+
[ -x "$VEXP_BIN" ] || exit 0
|
|
442
|
+
"$VEXP_BIN" search-hook 2>/dev/null
|
|
443
|
+
exit 0
|
|
444
|
+
`;
|
|
445
|
+
/** Bake the binary path into the search hook script. */
|
|
446
|
+
export function vexpSearchHookScript(binaryPath) {
|
|
447
|
+
return VEXP_SEARCH_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
|
|
448
|
+
}
|
|
400
449
|
/**
|
|
401
450
|
* Horizon F2a: Stop-hook verification gate (Claude Code). All logic lives
|
|
402
451
|
* in the Rust binary (stop-gate): mechanical completion check via daemon,
|
package/dist/license.js
CHANGED
|
@@ -71,6 +71,15 @@ function readLastCheck() {
|
|
|
71
71
|
return 0;
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
/** Forget when we last checked in, so the next refresh is not throttled. */
|
|
75
|
+
export function clearLastCheck() {
|
|
76
|
+
try {
|
|
77
|
+
fs.rmSync(getLastCheckPath(), { force: true });
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* best effort: a stale stamp only delays registration, never breaks it */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
74
83
|
function writeLastCheck(ts) {
|
|
75
84
|
try {
|
|
76
85
|
fs.mkdirSync(path.dirname(getLastCheckPath()), { recursive: true });
|
|
@@ -289,6 +298,14 @@ export function activateLicense(jwt) {
|
|
|
289
298
|
// leaving it would shadow the just-activated tier until it expired. The next
|
|
290
299
|
// online refresh re-creates fresh.jwt at the new tier.
|
|
291
300
|
removeFreshToken();
|
|
301
|
+
// Activation is the one moment we KNOW the user is at this machine, so it is
|
|
302
|
+
// the moment to register it. Clearing the throttle stamp makes the very next
|
|
303
|
+
// refresh run instead of waiting out the 24h backoff — without it the device
|
|
304
|
+
// does not appear on the dashboard until some later command happens to fall
|
|
305
|
+
// outside the window, and the page meanwhile says "Open vexp on any machine
|
|
306
|
+
// to register it" to someone who just did exactly that. Reported by a tier-4
|
|
307
|
+
// user whose dashboard read 0 of 10 right after activating.
|
|
308
|
+
clearLastCheck();
|
|
292
309
|
return claims;
|
|
293
310
|
}
|
|
294
311
|
/** Remove the current license file (and any cached freshToken) */
|