vexp-cli 3.3.9 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { isStateHome } from "./state-home.js";
4
+ // Linked git worktrees, as the CLI sees them.
5
+ //
6
+ // Agent answers (agent-consent.ts) and the setup writers need to know when a
7
+ // folder is a linked worktree of another checkout: the answers given in the
8
+ // main checkout apply to its worktrees, and the files committed there are
9
+ // checked out in every worktree, where a setup must not repin them (field
10
+ // report, 2026-11-07). The rule is the one vexp-core (utils.rs
11
+ // git_project_root / worktree_consent_source), vexp-mcp (daemon-client.ts)
12
+ // and the extension (vexp-vscode src/git-worktree.ts) apply:
13
+ // test/git-worktree.lockstep.test.ts runs the same fixtures through all
14
+ // three ports.
15
+ //
16
+ // Detection is by the content of the worktree's .git FILE, never by a path
17
+ // convention: harnesses put worktrees anywhere (.claude/worktrees/,
18
+ // .worktrees/, a sibling folder).
19
+ /**
20
+ * The main checkout of a linked git worktree, or null when `dir` is not
21
+ * one. A linked worktree has a `.git` FILE whose `gitdir:` line points into
22
+ * `<main>/.git/worktrees/<name>`; git 2.48's `worktree.useRelativePaths`
23
+ * writes that path relative to the worktree. A submodule's gitdir points at
24
+ * `<parent>/.git/modules/<name>`, or, inside a linked worktree, at
25
+ * `<main>/.git/worktrees/<wt>/modules/<name>`, and is its own project: null. Lockstep
26
+ * with vexp-core utils::git_project_root / is_linked_worktree and the
27
+ * extension's linkedWorktreeMain.
28
+ */
29
+ export function linkedWorktreeMain(dir) {
30
+ let content;
31
+ try {
32
+ const dotGit = path.join(dir, ".git");
33
+ if (!fs.statSync(dotGit).isFile())
34
+ return null;
35
+ content = fs.readFileSync(dotGit, "utf-8");
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ const gitdir = content
41
+ .split(/\r?\n/)
42
+ .map((l) => l.trim())
43
+ .find((l) => l.startsWith("gitdir:"))
44
+ ?.slice("gitdir:".length)
45
+ .trim()
46
+ .replace(/\\/g, "/");
47
+ const at = gitdir ? gitdir.indexOf("/.git/worktrees/") : -1;
48
+ if (!gitdir || at <= 0)
49
+ return null;
50
+ // A worktree's admin dir is one folder, <common>/.git/worktrees/<name>. A
51
+ // submodule checked out inside a linked worktree has its git dir under
52
+ // that admin dir (<main>/.git/worktrees/<wt>/modules/<path>): it is its
53
+ // own project, as a submodule of the main checkout is, and reading it as
54
+ // a worktree of <main> lent it <main>'s consent and a daemon. Lockstep
55
+ // with vexp-core utils::git_project_root.
56
+ const name = gitdir.slice(at + "/.git/worktrees/".length).replace(/\/+$/, "");
57
+ if (!name || name.includes("/"))
58
+ return null;
59
+ const main = path.resolve(dir, gitdir.slice(0, at));
60
+ // A gitdir naming the folder itself is not a link to another checkout.
61
+ return path.resolve(main) === path.resolve(dir) ? null : main;
62
+ }
63
+ /**
64
+ * Is `other` a checkout of the same repository as `root`, on this machine?
65
+ * A folder that is there, whose main checkout (the one a linked worktree's
66
+ * .git file names, else the folder itself) is `root`'s: a worktree's main
67
+ * checkout, a sibling worktree, the main checkout's live worktree. Not a
68
+ * folder that is missing here (a teammate's clone, a renamed or moved
69
+ * project, a removed worktree) and not an independent clone with its own
70
+ * .git. A committed file's pin or hook path is kept (W3) only for such a
71
+ * folder: kept for any other, a fresh clone or a moved repository got a
72
+ * vexp server pinned to a folder that does not exist, and a Codex hook that
73
+ * failed at every prompt, where 3.3.9 had pointed the file at the checkout
74
+ * (review, 2026-11-07). Filesystem only, no git. Lockstep with the
75
+ * extension's.
76
+ */
77
+ export function sameRepositoryCheckout(other, root) {
78
+ try {
79
+ if (!fs.statSync(other).isDirectory())
80
+ return false;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ const mainOf = (d) => linkedWorktreeMain(d) ?? d;
86
+ const key = (p) => {
87
+ const r = path.resolve(p).replace(/[\\/]+$/, "");
88
+ const k = r.length > 0 ? r : path.resolve(p);
89
+ return process.platform === "win32" ? k.toLowerCase() : k;
90
+ };
91
+ const a = mainOf(other);
92
+ const b = mainOf(root);
93
+ if (key(a) === key(b))
94
+ return true;
95
+ try {
96
+ return key(fs.realpathSync.native(a)) === key(fs.realpathSync.native(b));
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ /**
103
+ * The main checkout a linked worktree takes its consent from: set up with
104
+ * vexp (manifest.json or index.db in its .vexp/) and not a folder the engine
105
+ * refuses whatever it holds (the home directory, a filesystem root). null
106
+ * for anything that is not a linked worktree. Lockstep with vexp-core
107
+ * utils::worktree_consent_source, vexp-mcp worktreeConsentSource and the
108
+ * extension's worktreeConsentSource.
109
+ */
110
+ export function worktreeConsentSource(dir) {
111
+ const main = linkedWorktreeMain(dir);
112
+ if (!main)
113
+ return null;
114
+ const resolved = path.resolve(main);
115
+ // VEXP_ALLOW_HOME_WORKSPACE=1 lifts both refusals, as in the extension's
116
+ // refusedWhateverItHolds (isStateHome already answers false then).
117
+ const fsRoot = process.env.VEXP_ALLOW_HOME_WORKSPACE !== "1" && path.dirname(resolved) === resolved;
118
+ if (isStateHome(resolved) || fsRoot)
119
+ return null;
120
+ const vexp = path.join(main, ".vexp");
121
+ return fs.existsSync(path.join(vexp, "manifest.json")) || fs.existsSync(path.join(vexp, "index.db")) ? main : null;
122
+ }
123
+ /**
124
+ * Does the vexp extension start this folder's daemon when a window opens on
125
+ * it? Lockstep with the extension's startsAtActivation (non-null): the
126
+ * folder has an index of its own (.vexp/index.db), or, not on Windows, it
127
+ * is a linked worktree of a checkout set up for vexp (worktreeConsentSource),
128
+ * whose first start builds its index. Not on Windows: a live daemon holds
129
+ * the worktree's index open, and Windows then refuses `git worktree remove`.
130
+ * What `vexp doctor` says about a daemon that is down: a fresh worktree of
131
+ * a set-up repository was told to press "Start Indexing", while opening its
132
+ * window is enough (review, 2026-11-07).
133
+ */
134
+ export function extensionStartsOnOpen(root, platform = process.platform) {
135
+ return fs.existsSync(path.join(root, ".vexp", "index.db")) || (platform !== "win32" && worktreeConsentSource(root) !== null);
136
+ }
@@ -0,0 +1,271 @@
1
+ import * as fs from "fs";
2
+ // Committed hook scripts (K1), as `vexp setup` sees them. Twin:
3
+ // packages/vexp-vscode/src/providers/hook-scripts.ts, the same code and the
4
+ // same line word for word (vexp-vscode test/hook-scripts-lockstep.test.ts).
5
+ //
6
+ // Why: every hook script vexp writes into a project bakes the absolute path
7
+ // of the engine that wrote it (VEXP_BIN), because Claude Code and Codex run
8
+ // the script with no vexp on PATH. A team that commits .claude/hooks/ and
9
+ // .codex/ then saw every checkout rewrite them whenever that path changed:
10
+ // an extension update (a new versioned folder), a teammate's machine, a
11
+ // container with another HOME, and `vexp setup` against the extension's
12
+ // copy (the extension adds an upgrade fallback the CLI does not). git status
13
+ // showed them modified in every clone and worktree, and an agent's `git add
14
+ // -A` committed the churn (field report, 2026-11-07). A committed script
15
+ // whose text is exactly what this vexp writes, for the engine path it
16
+ // bakes, and that still finds a working engine, is left as it is.
17
+ /**
18
+ * The extension's upgrade fallback, inserted after the VEXP_BIN line of
19
+ * every sh hook script it writes (vexp-vscode agent-auto-config.ts
20
+ * bakeVexpBin, where the reasoning lives). The CLI does not write it: its
21
+ * own paths are version-stable. It recognizes it, so a committed script
22
+ * the extension wrote is not rewritten into the CLI's form. Byte for byte
23
+ * the extension's.
24
+ */
25
+ export const VEXP_BIN_FALLBACK = `
26
+ # An extension upgrade removes the versioned folder above; fall back to the
27
+ # newest installed vexp extension binary. Managed by vexp.
28
+ if [ ! -x "$VEXP_BIN" ]; then
29
+ case "$VEXP_BIN" in
30
+ */extensions/vexp.vexp-vscode-*)
31
+ _vexp_ext_root="\${VEXP_BIN%%/vexp.vexp-vscode-*}"
32
+ _vexp_latest="$(ls -d "$_vexp_ext_root"/vexp.vexp-vscode-*/binaries/*/vexp-core* 2>/dev/null | sort -V 2>/dev/null | tail -n 1)"
33
+ [ -n "$_vexp_latest" ] && [ -x "$_vexp_latest" ] && VEXP_BIN="$_vexp_latest"
34
+ ;;
35
+ esac
36
+ fi`;
37
+ /**
38
+ * `plain`, a sh hook script as the CLI bakes it, in the extension's form:
39
+ * VEXP_BIN_FALLBACK right after its `VEXP_BIN="..."` line, which is where
40
+ * bakeVexpBin puts it. undefined for a script with no such line (the
41
+ * Windows .cmd twin, the opencode / Kilo plugins): neither twin adds a
42
+ * fallback there.
43
+ */
44
+ export function withExtensionFallback(plain) {
45
+ const m = /^VEXP_BIN="[^"\r\n]*"$/m.exec(plain);
46
+ if (!m)
47
+ return undefined;
48
+ const at = m.index + m[0].length;
49
+ return plain.slice(0, at) + VEXP_BIN_FALLBACK + plain.slice(at);
50
+ }
51
+ /**
52
+ * The engine path a hook script bakes: the sh scripts' `VEXP_BIN="..."`,
53
+ * the .cmd twin's `set "VEXP_BIN=..."`, the opencode / Kilo plugins'
54
+ * `const VEXP_BIN = "...";` and `const BIN = "...";`. The first such line;
55
+ * undefined when the text has none.
56
+ */
57
+ export function bakedEnginePath(text) {
58
+ const patterns = [
59
+ /^VEXP_BIN="([^"\r\n]*)"\r?$/m,
60
+ /^set "VEXP_BIN=([^"\r\n]*)"\r?$/m,
61
+ /^const (?:VEXP_)?BIN = "([^"\r\n]*)";\r?$/m,
62
+ ];
63
+ for (const re of patterns) {
64
+ const m = re.exec(text);
65
+ if (m && m[1] !== "")
66
+ return m[1];
67
+ }
68
+ return undefined;
69
+ }
70
+ function executable(p) {
71
+ try {
72
+ fs.accessSync(p, fs.constants.X_OK);
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ function isFile(p) {
80
+ try {
81
+ return fs.statSync(p).isFile();
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ function dirNames(dir) {
88
+ try {
89
+ return fs.readdirSync(dir);
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ }
95
+ const isDigit = (c) => c >= "0" && c <= "9";
96
+ const isAlpha = (c) => (c >= "A" && c <= "Z") || (c >= "a" && c <= "z");
97
+ /** gnulib filevercmp's file_prefixlen: the length of `s` without a suffix like `.tar.gz` or `.exe`. */
98
+ function filePrefixLen(s) {
99
+ const n = s.length;
100
+ let prefixlen = 0;
101
+ for (let i = 0;;) {
102
+ if (i === n)
103
+ return prefixlen;
104
+ i++;
105
+ prefixlen = i;
106
+ while (i + 1 < n && s[i] === "." && (isAlpha(s[i + 1]) || s[i + 1] === "~")) {
107
+ for (i += 2; i < n && (isAlpha(s[i]) || isDigit(s[i]) || s[i] === "~"); i++) {
108
+ /* the suffix goes on */
109
+ }
110
+ }
111
+ }
112
+ }
113
+ /** gnulib filevercmp's order(): where the byte at `pos` sorts. */
114
+ function order(s, pos, len) {
115
+ if (pos === len)
116
+ return -1;
117
+ const c = s[pos];
118
+ if (isDigit(c))
119
+ return 0;
120
+ if (isAlpha(c))
121
+ return c.charCodeAt(0);
122
+ if (c === "~")
123
+ return -2;
124
+ return c.charCodeAt(0) + 256;
125
+ }
126
+ /** gnulib filevercmp's verrevcmp, over the first `alen` and `blen` characters. */
127
+ function verrevcmp(a, alen, b, blen) {
128
+ let i = 0;
129
+ let j = 0;
130
+ while (i < alen || j < blen) {
131
+ let firstDiff = 0;
132
+ while ((i < alen && !isDigit(a[i])) || (j < blen && !isDigit(b[j]))) {
133
+ const ac = order(a, i, alen);
134
+ const bc = order(b, j, blen);
135
+ if (ac !== bc)
136
+ return ac - bc;
137
+ i++;
138
+ j++;
139
+ }
140
+ while (i < alen && a[i] === "0")
141
+ i++;
142
+ while (j < blen && b[j] === "0")
143
+ j++;
144
+ while (i < alen && j < blen && isDigit(a[i]) && isDigit(b[j])) {
145
+ if (!firstDiff)
146
+ firstDiff = a.charCodeAt(i) - b.charCodeAt(j);
147
+ i++;
148
+ j++;
149
+ }
150
+ if (i < alen && isDigit(a[i]))
151
+ return 1;
152
+ if (j < blen && isDigit(b[j]))
153
+ return -1;
154
+ if (firstDiff)
155
+ return firstDiff;
156
+ }
157
+ return 0;
158
+ }
159
+ /**
160
+ * `sort -V` as GNU coreutils orders two lines (gnulib filevercmp, then the
161
+ * whole line byte by byte when they tie), which is what the fallback's
162
+ * `sort -V | tail -n 1` uses to pick the newest build: 3.3.10 after 3.3.9.
163
+ * Negative, zero or positive, like a comparator.
164
+ */
165
+ export function versionSortCompare(a, b) {
166
+ const byVersion = (() => {
167
+ if (a === "" || b === "")
168
+ return (b === "" ? 1 : 0) - (a === "" ? 1 : 0);
169
+ if (a[0] === ".") {
170
+ if (b[0] !== ".")
171
+ return -1;
172
+ const adot = a.length === 1;
173
+ const bdot = b.length === 1;
174
+ if (adot || bdot)
175
+ return (bdot ? 1 : 0) - (adot ? 1 : 0);
176
+ const adotdot = a[1] === "." && a.length === 2;
177
+ const bdotdot = b[1] === "." && b.length === 2;
178
+ if (adotdot || bdotdot)
179
+ return (bdotdot ? 1 : 0) - (adotdot ? 1 : 0);
180
+ }
181
+ else if (b[0] === ".") {
182
+ return 1;
183
+ }
184
+ const ap = filePrefixLen(a);
185
+ const bp = filePrefixLen(b);
186
+ const onePass = ap === a.length && bp === b.length;
187
+ const r = verrevcmp(a, ap, b, bp);
188
+ return r || onePass ? r : verrevcmp(a, a.length, b, b.length);
189
+ })();
190
+ if (byVersion !== 0)
191
+ return byVersion;
192
+ return a < b ? -1 : a > b ? 1 : 0;
193
+ }
194
+ /**
195
+ * The build VEXP_BIN_FALLBACK runs for `bin`, by the rules of its sh code:
196
+ * only for a path inside a VS Code extensions folder
197
+ * (`*\/extensions/vexp.vexp-vscode-*`), the newest (`sort -V`) of
198
+ * `<that folder>/vexp.vexp-vscode-*\/binaries/*\/vexp-core*`, and only when
199
+ * it is executable. undefined when the fallback would pick none. The sh
200
+ * code does not run at all while `bin` itself is executable.
201
+ */
202
+ export function extensionFallbackEngine(bin) {
203
+ if (!bin.includes("/extensions/vexp.vexp-vscode-"))
204
+ return undefined;
205
+ // ${VEXP_BIN%%/vexp.vexp-vscode-*}: everything before the first match.
206
+ const root = bin.slice(0, bin.indexOf("/vexp.vexp-vscode-"));
207
+ // The glob, as sh expands it: no name that starts with a dot under a `*`.
208
+ const found = [];
209
+ for (const build of dirNames(root || "/")) {
210
+ if (!build.startsWith("vexp.vexp-vscode-"))
211
+ continue;
212
+ const binaries = `${root}/${build}/binaries`;
213
+ for (const platform of dirNames(binaries)) {
214
+ if (platform.startsWith("."))
215
+ continue;
216
+ for (const name of dirNames(`${binaries}/${platform}`)) {
217
+ if (name.startsWith("vexp-core"))
218
+ found.push(`${binaries}/${platform}/${name}`);
219
+ }
220
+ }
221
+ }
222
+ if (found.length === 0)
223
+ return undefined;
224
+ const newest = found.reduce((a, b) => (versionSortCompare(a, b) >= 0 ? a : b));
225
+ return executable(newest) && isFile(newest) ? newest : undefined;
226
+ }
227
+ /**
228
+ * Would a hook script that bakes `bin` run an engine right now? The sh
229
+ * scripts run `$VEXP_BIN` when it is executable (and an executable that is
230
+ * no file runs nothing); a script with the extension's fallback
231
+ * (`withFallback`) otherwise runs the build extensionFallbackEngine names.
232
+ */
233
+ export function hookEngineWorks(bin, withFallback) {
234
+ if (executable(bin))
235
+ return isFile(bin);
236
+ return withFallback && extensionFallbackEngine(bin) !== undefined;
237
+ }
238
+ /**
239
+ * K1: may the hook script `text` (what is on disk) stay as it is? Yes when
240
+ * it is exactly what this vexp writes for the engine path it bakes
241
+ * (`plainFor(bin)`, the CLI's form, or that with the extension's fallback),
242
+ * that script would find a working engine now, and `tracked()` (git, asked
243
+ * last) says the file is committed. A script whose template changed, whose
244
+ * engine is gone with no fallback that finds one, or that git does not
245
+ * track is written as before.
246
+ */
247
+ export function committedHookScriptStays(text, plainFor, tracked) {
248
+ const bin = bakedEnginePath(text);
249
+ if (bin === undefined)
250
+ return false;
251
+ const plain = plainFor(bin);
252
+ const extension = withExtensionFallback(plain);
253
+ if (text !== plain && text !== extension)
254
+ return false;
255
+ if (!hookEngineWorks(bin, text === extension))
256
+ return false;
257
+ return tracked();
258
+ }
259
+ /** Why a committed hook script was left as it is (K1). ASCII, word for word the extension's. */
260
+ export function committedHookScriptLine(file) {
261
+ return `${file} is committed to this repository and its vexp engine path still works here; vexp left it as it is`;
262
+ }
263
+ /** The script on disk at `file`, or undefined when there is none to read. */
264
+ export function readHookScript(file) {
265
+ try {
266
+ return fs.readFileSync(file, "utf-8");
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ }
@@ -11,6 +11,39 @@ export function planLabel(plan) {
11
11
  export function isLifetimePlan(plan) {
12
12
  return /^tier[1-4]$/.test(plan);
13
13
  }
14
+ /**
15
+ * The tool counts `vexp license` states, as the servers define them. The
16
+ * line said "no (7/10)" from v1.2.16 on, long after the surface grew, and
17
+ * "shows 4 by default" for the native server alone. Held to the sources by
18
+ * test/license.tool-counts.test.ts: vexp-core's build_all_tool_defs,
19
+ * FREE_TOOLS and CORE_LISTED_TOOLS (mcp_stdio.rs), and the Node server's
20
+ * MINIMAL_TOOL_DEFINITIONS (vexp-mcp/src/index.ts).
21
+ */
22
+ export const TOOL_COUNTS = {
23
+ /** Every tool vexp-core's MCP server defines. */
24
+ native: 15,
25
+ /** Those its tools/call serves on a plan without all_tools. */
26
+ nativeFree: 9,
27
+ /** What vexp-core lists by default. */
28
+ nativeListed: 4,
29
+ /** What the Node MCP server (mcp-server.cjs) lists by default: the same
30
+ * four since it lists expand_vexp_ref too. */
31
+ nodeListed: 4,
32
+ };
33
+ /** The "All tools" lines of `vexp license` and the menu's status, unindented. */
34
+ export function allToolsLines(allTools) {
35
+ const c = TOOL_COUNTS;
36
+ if (!allTools)
37
+ return [`All tools: no (the Free set: ${c.nativeFree} of vexp-core's ${c.native} tools)`];
38
+ // One number while both servers list as many; both, should they part again.
39
+ const shown = c.nodeListed === c.nativeListed
40
+ ? `${c.nativeListed} by default`
41
+ : `${c.nativeListed} by default, ${c.nodeListed} through the Node MCP server`;
42
+ return [
43
+ "All tools: yes",
44
+ `(the MCP tool list shows ${shown}, to keep the catalog small; every tool stays callable - VEXP_ALL_TOOLS=1 lists them all)`,
45
+ ];
46
+ }
14
47
  /** "Last check-in: <date>" / "never", for `vexp license` and the menu. */
15
48
  export function lastCheckInText() {
16
49
  const at = lastCheckIn();
package/dist/serve.js CHANGED
@@ -184,7 +184,8 @@ export function isOwnerEntry(ws, sock) {
184
184
  * and took a 16-core host down: ~1,585% CPU, 2 GB/s of reads, hard reset
185
185
  * (field incident, 2026-09-02, tier 3). Worktree daemons run on demand
186
186
  * only; resurrection is for canonical workspaces. A submodule's gitdir
187
- * points at `/.git/modules/` and is left alone. Mirrors vexp-core
187
+ * points at `/.git/modules/` (or, inside a linked worktree,
188
+ * `/.git/worktrees/<wt>/modules/`) and is left alone. Mirrors vexp-core
188
189
  * utils::git_project_root. */
189
190
  export function isLinkedWorktree(ws) {
190
191
  try {
@@ -197,7 +198,11 @@ export function isLinkedWorktree(ws) {
197
198
  .find((l) => l.trim().startsWith("gitdir:"));
198
199
  if (!gitdir)
199
200
  return false;
200
- return gitdir.replace(/\\/g, "/").includes("/.git/worktrees/");
201
+ // Only `<common>/.git/worktrees/<name>`, one folder deep: a submodule
202
+ // checked out inside a linked worktree (`.git/worktrees/<wt>/modules/
203
+ // <path>`) is its own project. Lockstep with linkedWorktreeMain
204
+ // (git-worktree.ts) and vexp-core utils::git_project_root.
205
+ return /\/\.git\/worktrees\/[^/]+\/?$/.test(gitdir.slice(gitdir.indexOf("gitdir:") + "gitdir:".length).replace(/\\/g, "/").trim());
201
206
  }
202
207
  catch {
203
208
  return false;