ai-lens 0.8.67 → 0.8.68

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/.commithash CHANGED
@@ -1 +1 @@
1
- cf6dbc7
1
+ 72eb1f0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
4
4
 
5
+ ## 0.8.68 — 2026-05-27
6
+ - fix: hooks now resolve node from nvm, asdf, fnm, volta and n in addition to Homebrew. Previously `init` fell back to `/usr/bin/env node`, which made GUI Cursor on macOS silently lose every event because launchd's minimal PATH doesn't include node. Affected users with custom node installs (e.g. `/Users/<you>/node/bin/node`) saw hundreds of dropped events with no visible error.
7
+ - feat: `init` now installs a per-machine launcher (`~/.ai-lens/client/run.sh` on macOS/Linux, `run.cmd` on Windows) with the resolved node path baked in. Hook commands invoke the launcher directly, so they no longer depend on the GUI app's PATH and survive `brew upgrade node`.
8
+ - fix: `init` now fails loudly if no node binary can be resolved, and warns when the only available node path is version-pinned (e.g. `/opt/homebrew/Cellar/node/24.10.0/bin/node`) and will break on upgrade.
9
+
5
10
  ## 0.8.67 — 2026-05-26
6
11
  - fix: sessions launched from a subdirectory of a repo (e.g. `cd scripts/asr-worker && claude`) now attribute to the repo root instead of the subdirectory. Previously the dashboard showed deep subfolders as if they were the project — a session that ran 74 events inside `meetings-lens/scripts/asr-worker` and 7 at the repo root was attributed to `asr-worker`. SessionStart cwd is now walked up to the nearest `.git` like file-path refinement already did.
7
12
 
package/cli/hooks.js CHANGED
@@ -64,81 +64,336 @@ export function saveLensConfig(config) {
64
64
  * Escape a string for safe embedding in a single-quoted shell context.
65
65
  * Standard POSIX approach: replace each ' with '\'' (end quote, escaped quote, start quote).
66
66
  */
67
- export function shellEscape(str) {
68
- if (typeof str !== 'string') return process.platform === 'win32' ? '""' : "''";
69
- // Windows cmd.exe: double-quote, escape inner double quotes by doubling them
70
- if (process.platform === 'win32') return '"' + str.replace(/"/g, '""') + '"';
67
+ export function shellEscape(str, platform = process.platform) {
68
+ if (typeof str !== 'string') return platform === 'win32' ? '""' : "''";
69
+ if (platform === 'win32') return '"' + str.replace(/"/g, '""') + '"';
71
70
  return "'" + str.replace(/'/g, "'\\''") + "'";
72
71
  }
73
72
 
74
- // Resolve a stable node path that survives version upgrades.
75
- // process.execPath often points to a versioned path (e.g. /opt/homebrew/Cellar/node/24.10.0/bin/node)
76
- // that breaks after `brew upgrade node`. We prefer symlinks that point to the current version.
77
- // We intentionally avoid `|| node` fallback it was tried and reverted (1dfdd25) because
78
- // it masks capture failures (re-runs with empty stdin, exits 0).
79
- function findStableNodePath() {
80
- // On Windows, Unix paths (/usr/bin/env, /opt/homebrew/...) don't exist.
81
- // Claude Code executes hooks via cmd.exe / CreateProcess, so we need a
82
- // native Windows path. process.execPath is the most reliable option.
83
- if (process.platform === 'win32') return process.execPath;
73
+ /**
74
+ * Lighter form of shellEscape: only wrap when the path actually needs it.
75
+ * Used by rawPath mode where Claude Code's settings.json schema expects
76
+ * unwrapped paths for the common case, but spaces / tabs still need quoting
77
+ * to keep /bin/sh, cmd.exe and PowerShell from splitting argv.
78
+ */
79
+ function quoteIfHasSpaces(path, platform = process.platform) {
80
+ if (typeof path !== 'string') return shellEscape(path, platform);
81
+ if (!/[\s'"]/.test(path)) return path;
82
+ return shellEscape(path, platform);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Stable node-path resolution
87
+ // ---------------------------------------------------------------------------
88
+
89
+ // Markers that identify a node binary living inside a version-pinned directory
90
+ // (managed by a version manager or Homebrew Cellar). Such paths break after
91
+ // brew upgrade node / nvm install / asdf install / etc.
92
+ const VERSION_PINNED_MARKERS = [
93
+ '/Cellar/', // Homebrew (e.g. /opt/homebrew/Cellar/node/24.10.0/bin/node)
94
+ '/.nvm/versions/', // nvm (~/.nvm/versions/node/v22.1.0/bin/node)
95
+ '/.asdf/installs/', // asdf (~/.asdf/installs/nodejs/22.1.0/bin/node)
96
+ '/.fnm/node-versions/', // fnm (~/.fnm/node-versions/v22.1.0/installation/bin/node)
97
+ '/.volta/tools/image/', // volta (~/.volta/tools/image/node/22.1.0/bin/node)
98
+ '/n/versions/', // n (~/n/versions/node/22.1.0/bin/node)
99
+ ];
100
+
101
+ /**
102
+ * True if path is inside a version-pinned directory (will break on upgrade).
103
+ * The /node-v\d/ pattern matches segment-style names like /node-v22.1.0/, not stray
104
+ * substrings like /node-bin/.
105
+ */
106
+ export function isVersionPinnedNodePath(path) {
107
+ if (!path || typeof path !== 'string') return false;
108
+ const p = path.replace(/\\/g, '/');
109
+ if (/\/node-v\d/.test(p)) return true;
110
+ for (const marker of VERSION_PINNED_MARKERS) {
111
+ if (p.includes(marker)) return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ /**
117
+ * Inside `parentDir`, pick the most recent semver-named subdirectory (e.g. "v22.1.0").
118
+ * Returns the directory name or null if no semver-shaped subdirectory exists.
119
+ */
120
+ function pickLatestSemverDir(parentDir) {
121
+ let entries;
122
+ try {
123
+ entries = readdirSync(parentDir);
124
+ } catch {
125
+ return null;
126
+ }
127
+ const parsed = entries
128
+ .map(name => {
129
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(name);
130
+ if (!m) return null;
131
+ return { name, version: [Number(m[1]), Number(m[2]), Number(m[3])] };
132
+ })
133
+ .filter(Boolean);
134
+ if (parsed.length === 0) return null;
135
+ parsed.sort((a, b) => {
136
+ for (let i = 0; i < 3; i++) {
137
+ if (a.version[i] !== b.version[i]) return b.version[i] - a.version[i];
138
+ }
139
+ return 0;
140
+ });
141
+ return parsed[0].name;
142
+ }
143
+
144
+ const DEFAULT_FS = {
145
+ existsSync,
146
+ lstatSync,
147
+ };
84
148
 
85
- // If process.execPath is already a symlink (e.g. /usr/local/bin/node), use it directly
149
+ /**
150
+ * Resolve a stable node binary path with metadata about whether it will
151
+ * survive `brew upgrade node` / version-manager updates.
152
+ *
153
+ * @param {object} [opts]
154
+ * @param {string} [opts.home] — home directory (default: homedir())
155
+ * @param {string} [opts.execPath] — node binary that ran us (default: process.execPath)
156
+ * @param {string} [opts.platform] — 'darwin' | 'linux' | 'win32' (default: process.platform)
157
+ * @param {object} [opts.fs] — { existsSync, lstatSync } overrides for tests
158
+ * @returns {{ path: string, stable: boolean, source: string } | null}
159
+ */
160
+ export function findStableNodePath({
161
+ home = homedir(),
162
+ execPath = process.execPath,
163
+ platform = process.platform,
164
+ fs = DEFAULT_FS,
165
+ } = {}) {
166
+ if (!execPath) return null;
167
+
168
+ // Windows: Unix candidates don't exist. process.execPath is the only meaningful
169
+ // value; we accept it whether or not it's "stable" because there's no better source.
170
+ if (platform === 'win32') {
171
+ return { path: execPath, stable: !isVersionPinnedNodePath(execPath), source: 'execPathStable' };
172
+ }
173
+
174
+ // 1. execPath itself if it's a symlink — it presumably points to current version.
86
175
  try {
87
- if (lstatSync(process.execPath).isSymbolicLink()) return process.execPath;
88
- } catch {}
176
+ if (fs.lstatSync(execPath).isSymbolicLink()) {
177
+ return { path: execPath, stable: true, source: 'symlink' };
178
+ }
179
+ } catch { /* ignore */ }
180
+
181
+ // 2. execPath as a stable absolute (not in a version-pinned dir).
182
+ if (!isVersionPinnedNodePath(execPath)) {
183
+ return { path: execPath, stable: true, source: 'execPathStable' };
184
+ }
89
185
 
90
- // Try stable symlinks that survive version upgrades
91
- const candidates = [
92
- '/opt/homebrew/bin/node', // Homebrew (macOS ARM)
93
- '/usr/local/bin/node', // Homebrew (macOS x86), manual installs
186
+ // 3. Homebrew symlinks (typically user-managed and current).
187
+ const homebrewCandidates = [
188
+ { path: '/opt/homebrew/bin/node', source: 'brew' }, // macOS ARM
189
+ { path: '/usr/local/bin/node', source: 'brew' }, // macOS Intel, manual installs
94
190
  ];
95
- for (const p of candidates) {
96
- try {
97
- if (existsSync(p)) return p;
98
- } catch {}
191
+ for (const c of homebrewCandidates) {
192
+ try { if (fs.existsSync(c.path)) return { path: c.path, stable: true, source: c.source }; } catch {}
193
+ }
194
+
195
+ // 4. Version managers (each picks its current/latest version).
196
+ // Preferred over /usr/bin/node because distro-packaged node is often older than
197
+ // the version a developer actually uses via nvm/asdf/fnm/volta. Picking distro
198
+ // node can bake a stale runtime into the launcher and break capture.js.
199
+ const nvmDir = join(home, '.nvm', 'versions', 'node');
200
+ const nvmLatest = pickLatestSemverDir(nvmDir);
201
+ if (nvmLatest) {
202
+ const p = join(nvmDir, nvmLatest, 'bin', 'node');
203
+ try { if (fs.existsSync(p)) return { path: p, stable: true, source: 'nvm' }; } catch {}
99
204
  }
100
205
 
101
- // Fallback: rely on PATH at hook execution time
102
- return '/usr/bin/env node';
206
+ const asdfDir = join(home, '.asdf', 'installs', 'nodejs');
207
+ const asdfLatest = pickLatestSemverDir(asdfDir);
208
+ if (asdfLatest) {
209
+ const p = join(asdfDir, asdfLatest, 'bin', 'node');
210
+ try { if (fs.existsSync(p)) return { path: p, stable: true, source: 'asdf' }; } catch {}
211
+ }
212
+
213
+ const fnmDefault = join(home, '.fnm', 'aliases', 'default', 'bin', 'node');
214
+ try { if (fs.existsSync(fnmDefault)) return { path: fnmDefault, stable: true, source: 'fnm' }; } catch {}
215
+
216
+ const voltaShim = join(home, '.volta', 'bin', 'node');
217
+ try { if (fs.existsSync(voltaShim)) return { path: voltaShim, stable: true, source: 'volta' }; } catch {}
218
+
219
+ const nCandidates = [join(home, 'n', 'bin', 'node'), '/usr/local/n/bin/node'];
220
+ for (const p of nCandidates) {
221
+ try { if (fs.existsSync(p)) return { path: p, stable: true, source: 'n' }; } catch {}
222
+ }
223
+
224
+ // 5. Distro-packaged /usr/bin/node — last system candidate before fallback.
225
+ try { if (fs.existsSync('/usr/bin/node')) return { path: '/usr/bin/node', stable: true, source: 'sysBin' }; } catch {}
226
+
227
+ // 6. Last resort: execPath as-is (version-pinned). Better than env-PATH lookup,
228
+ // but the caller should warn the user — this hook will break on upgrade.
229
+ return { path: execPath, stable: false, source: 'execPathFallback' };
103
230
  }
104
231
 
232
+ // ---------------------------------------------------------------------------
233
+ // Per-machine launcher (run.sh / run.cmd)
234
+ // ---------------------------------------------------------------------------
235
+
105
236
  /**
106
- * @param {boolean} [useTilde] - If true, use ~/.ai-lens/client/capture.js (for project-level hooks so path is portable).
107
- * @param {boolean} [rawPath] - If true, do not quote the path (for Claude Code config; path written without quotes).
108
- * @param {string} [customPath] - If set, use this path (e.g. REPO_CAPTURE_PATH for --use-repo-path); rawPath implied.
237
+ * Filename of the launcher in the client directory.
109
238
  */
110
- export function captureCommand(useTilde = false, rawPath = false, customPath = null) {
111
- const nodePath = findStableNodePath();
112
- const capturePath = customPath != null
113
- ? customPath.replace(/\\/g, '/')
114
- : useTilde
115
- ? '~/.ai-lens/client/capture.js'
116
- : CAPTURE_PATH.replace(/\\/g, '/');
117
- const pathPart = (customPath != null || rawPath) ? capturePath : shellEscape(capturePath);
118
- if (nodePath === '/usr/bin/env node') return `/usr/bin/env node ${pathPart}`;
119
- // Claude Code on Windows executes hooks via cmd.exe. A quoted executable path
120
- // at the start of the line (e.g. "C:/Program Files/node.exe" args) is not
121
- // recognised as a command by cmd.exe. Use bare `node` for Claude Code hooks
122
- // (rawPath=true) when the path contains spaces; Cursor hooks use PowerShell
123
- // where quoting works fine.
124
- if (process.platform === 'win32' && rawPath && nodePath.includes(' ')) {
125
- return `node ${pathPart}`;
126
- }
127
- return `${shellEscape(nodePath.replace(/\\/g, '/'))} ${pathPart}`;
128
- }
129
-
130
- // Cursor on Windows executes hooks via PowerShell, which treats a quoted path like
131
- // "node.exe" as a string expression, not a command invocation. The & (call) operator
132
- // is required. Claude Code uses bash or cmd.exe where & is not needed (and breaks bash).
239
+ export function launcherFilename(platform = process.platform) {
240
+ return platform === 'win32' ? 'run.cmd' : 'run.sh';
241
+ }
242
+
133
243
  /**
134
- * @param {boolean} [useTilde] - If true, use ~/.ai-lens/client/capture.js (for --project-hooks).
135
- * @param {string} [customPath] - If set, use this path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
244
+ * Write a per-machine launcher script with the resolved node path baked in.
245
+ * The launcher uses dirname-of-script to find capture.js, so it's independent
246
+ * of HOME / USERPROFILE / cwd at execution time.
247
+ *
248
+ * @param {object} opts
249
+ * @param {string} opts.clientDir — directory where the launcher (and capture.js) live
250
+ * @param {string} opts.nodePath — absolute path to the node binary to bake in
251
+ * @param {string} [opts.platform]
136
252
  */
137
- export function cursorCaptureCommand(useTilde = false, customPath = null) {
138
- // On Windows, PowerShell does not expand ~ inside quoted strings — use absolute path.
139
- const effectiveUseTilde = process.platform === 'win32' ? false : useTilde;
140
- const cmd = customPath != null ? captureCommand(false, true, customPath) : captureCommand(effectiveUseTilde);
141
- return process.platform === 'win32' ? `& ${cmd}` : cmd;
253
+ export function writeLauncher({ clientDir = CLIENT_INSTALL_DIR, nodePath, platform = process.platform } = {}) {
254
+ if (!nodePath) throw new Error('writeLauncher: nodePath is required');
255
+ if (!clientDir) throw new Error('writeLauncher: clientDir is required');
256
+
257
+ if (platform === 'win32') {
258
+ const escaped = nodePath.replace(/"/g, '""');
259
+ const content = `@echo off\r\n"${escaped}" "%~dp0capture.js" %*\r\n`;
260
+ const target = join(clientDir, 'run.cmd');
261
+ writeFileSync(target, content);
262
+ return target;
263
+ }
264
+
265
+ const escaped = shellEscape(nodePath, 'linux'); // POSIX single-quote escape
266
+ const content =
267
+ '#!/bin/sh\n'
268
+ + 'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\n'
269
+ + `exec ${escaped} "$DIR/capture.js" "$@"\n`;
270
+ const target = join(clientDir, 'run.sh');
271
+ writeFileSync(target, content);
272
+ try { chmodSync(target, 0o755); } catch { /* best effort */ }
273
+ return target;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Hook command construction
278
+ // ---------------------------------------------------------------------------
279
+
280
+ // Lazy default ctx for callers that don't pass one (legacy tests, TOOL_CONFIGS at
281
+ // import-time). Resolver isn't called at module load — only when captureCommand runs.
282
+ function resolveDefaultCtx() {
283
+ const nodeResolution = findStableNodePath();
284
+ return {
285
+ nodeResolution,
286
+ platform: process.platform,
287
+ clientDir: CLIENT_INSTALL_DIR,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Build the hook command string. By default the command is the per-machine
293
+ * launcher (run.sh / run.cmd) at clientDir; in `--use-repo-path` mode
294
+ * (customPath given) it falls back to the legacy `<node> <capture.js>` form
295
+ * because the launcher only lives in the installed client dir.
296
+ *
297
+ * @param {object} [opts]
298
+ * @param {boolean} [opts.useTilde] — write ~/.ai-lens/client/... (portable path for project-hooks)
299
+ * @param {boolean} [opts.rawPath] — don't quote the target (Claude Code config wants raw)
300
+ * @param {string} [opts.customPath] — absolute repo-mode path to capture.js (--use-repo-path)
301
+ * @param {object} [opts.ctx] — { nodeResolution, platform, clientDir }; lazy-resolved if omitted
302
+ */
303
+ export function captureCommand(opts = {}) {
304
+ // Backward-compat: old positional signature captureCommand(useTilde, rawPath, customPath)
305
+ if (typeof opts === 'boolean') {
306
+ opts = { useTilde: opts, rawPath: arguments[1], customPath: arguments[2] ?? null };
307
+ }
308
+ const { useTilde = false, rawPath = false, customPath = null, shell = null } = opts;
309
+ const ctx = opts.ctx ?? resolveDefaultCtx();
310
+ // Tolerate partial ctx (e.g. only nodeResolution supplied) by filling in module
311
+ // defaults — keeps callers from having to repeat platform/clientDir everywhere.
312
+ const platform = ctx.platform ?? process.platform;
313
+ const clientDir = ctx.clientDir ?? CLIENT_INSTALL_DIR;
314
+ const nodeResolution = ctx.nodeResolution;
315
+ const isWin = platform === 'win32';
316
+ // shell hint distinguishes cmd.exe (Claude Code) from PowerShell (Cursor) on
317
+ // Windows — the two need different escaping for paths with spaces.
318
+ const isPS = shell === 'powershell';
319
+
320
+ // --use-repo-path: legacy <node> <capture.js> form.
321
+ if (customPath != null) {
322
+ if (!nodeResolution || !nodeResolution.path) {
323
+ throw new Error('captureCommand: nodeResolution is required for --use-repo-path');
324
+ }
325
+ const capturePath = customPath.replace(/\\/g, '/');
326
+ // Even in rawPath mode (Claude Code "do not unconditionally wrap"), the script
327
+ // path MUST be quoted when it contains spaces — otherwise /bin/sh, cmd.exe and
328
+ // PowerShell all split it into separate argv tokens (e.g. `/Users/foo bar/...`).
329
+ // Use shell-appropriate quoting: double-quote on Windows (works for both
330
+ // cmd.exe and PowerShell), POSIX single-quote elsewhere.
331
+ const pathPart = rawPath
332
+ ? quoteIfHasSpaces(capturePath, platform)
333
+ : shellEscape(capturePath, platform);
334
+ const nodeNorm = nodeResolution.path.replace(/\\/g, '/');
335
+ if (isWin && rawPath && nodeNorm.includes(' ')) {
336
+ const quotedNode = `"${nodeNorm.replace(/"/g, '""')}"`;
337
+ // PowerShell (Cursor) handles a quoted first token natively — caller will
338
+ // prepend `& `, giving `& "<node>" <path>`.
339
+ if (isPS) return `${quotedNode} ${pathPart}`;
340
+ // cmd.exe (Claude Code) doesn't always treat a quoted first token as a
341
+ // command — `call` is the canonical workaround. Avoids the previous bare-
342
+ // `node` fallback that depended on PATH (C:\Program Files\nodejs\node.exe
343
+ // wouldn't be reachable from launchd-like environments).
344
+ return `call ${quotedNode} ${pathPart}`;
345
+ }
346
+ return `${shellEscape(nodeNorm, platform)} ${pathPart}`;
347
+ }
348
+
349
+ // Launcher form. Path inside the command — tilde for project-hooks portability on POSIX;
350
+ // absolute on Windows (PowerShell/cmd don't expand ~).
351
+ const filename = launcherFilename(platform);
352
+ let launcherPath;
353
+ if (useTilde && !isWin) {
354
+ launcherPath = `~/.ai-lens/client/${filename}`;
355
+ } else {
356
+ launcherPath = join(clientDir, filename).replace(/\\/g, '/');
357
+ }
358
+
359
+ // POSIX: quote the launcher path unless it's the unquoted tilde form (which must
360
+ // remain unquoted for shell tilde expansion). rawPath (Claude Code) leaves the
361
+ // tilde form bare too — Claude Code passes commands to /bin/sh which expands ~.
362
+ if (!isWin) {
363
+ if (useTilde) return launcherPath; // unquoted ~/path
364
+ return shellEscape(launcherPath, 'linux');
365
+ }
366
+
367
+ // Windows: cmd.exe (Claude Code, rawPath=true) needs `call "..."` when the
368
+ // launcher path contains spaces; otherwise plain quoted path works.
369
+ // PowerShell (Cursor) handles `"..."` via the & prefix (added by cursorCaptureCommand).
370
+ const quoted = `"${launcherPath.replace(/"/g, '""')}"`;
371
+ if (rawPath && launcherPath.includes(' ') && !isPS) {
372
+ return `call ${quoted}`;
373
+ }
374
+ return quoted;
375
+ }
376
+
377
+ /**
378
+ * Cursor wrapper: PowerShell needs `& "..."` (call operator) to invoke a quoted path.
379
+ */
380
+ export function cursorCaptureCommand(opts = {}) {
381
+ if (typeof opts === 'boolean') {
382
+ opts = { useTilde: opts, customPath: arguments[1] ?? null };
383
+ }
384
+ const { useTilde = false, customPath = null } = opts;
385
+ const ctx = opts.ctx ?? resolveDefaultCtx();
386
+ const platform = ctx.platform ?? process.platform;
387
+ // PowerShell doesn't expand ~ inside double-quoted strings — fall back to absolute.
388
+ const effectiveUseTilde = platform === 'win32' ? false : useTilde;
389
+ // Cursor on Windows runs hooks via PowerShell, so use the PS-compatible shell
390
+ // hint when building the underlying command (avoids the cmd.exe `call` prefix
391
+ // which PowerShell doesn't understand).
392
+ const shell = platform === 'win32' ? 'powershell' : null;
393
+ const cmd = customPath != null
394
+ ? captureCommand({ useTilde: false, rawPath: true, customPath, ctx, shell })
395
+ : captureCommand({ useTilde: effectiveUseTilde, ctx, shell });
396
+ return platform === 'win32' ? `& ${cmd}` : cmd;
142
397
  }
143
398
 
144
399
  // ---------------------------------------------------------------------------
@@ -157,20 +412,47 @@ export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
157
412
  }
158
413
 
159
414
  /**
160
- * Copy client/ files from the package source to ~/.ai-lens/client/.
161
- * Works from npx cache, global install, and local dev.
415
+ * Copy client/*.js to ~/.ai-lens/client/ and (optionally) write the launcher.
416
+ *
417
+ * Default behaviour matches a real install: writes the launcher so the hook
418
+ * command target (`run.sh` / `run.cmd`) is always present. Tests that don't
419
+ * want the launcher in their tmpdir can pass `writeLauncher: false`. If the
420
+ * caller passes `writeLauncher: true` without a `nodeResolution`, we resolve
421
+ * one lazily — same heuristic init.js uses — so a tests-style call like
422
+ * `installClientFiles()` (no args) restores a working install.
423
+ *
424
+ * @param {object} [opts]
425
+ * @param {string} [opts.sourceDir]
426
+ * @param {string} [opts.clientDir]
427
+ * @param {{ path: string, stable: boolean }} [opts.nodeResolution] — auto-resolved if absent when writeLauncher=true
428
+ * @param {string} [opts.platform]
429
+ * @param {boolean} [opts.writeLauncher] — default true; pass false to skip launcher
162
430
  */
163
- export function installClientFiles() {
164
- const sourceDir = join(__dirname, '..', 'client');
165
- mkdirSync(CLIENT_INSTALL_DIR, { recursive: true });
431
+ export function installClientFiles(opts = {}) {
432
+ const {
433
+ sourceDir = join(__dirname, '..', 'client'),
434
+ clientDir = CLIENT_INSTALL_DIR,
435
+ platform = process.platform,
436
+ writeLauncher: shouldWriteLauncher = true,
437
+ } = opts;
438
+ let { nodeResolution = null } = opts;
439
+
440
+ if (shouldWriteLauncher && (!nodeResolution || !nodeResolution.path)) {
441
+ nodeResolution = findStableNodePath();
442
+ if (!nodeResolution || !nodeResolution.path) {
443
+ throw new Error('installClientFiles: writeLauncher=true but no node binary could be resolved');
444
+ }
445
+ }
446
+
447
+ mkdirSync(clientDir, { recursive: true });
166
448
 
167
449
  for (const file of listClientFiles(sourceDir)) {
168
- copyFileSync(join(sourceDir, file), join(CLIENT_INSTALL_DIR, file));
450
+ copyFileSync(join(sourceDir, file), join(clientDir, file));
169
451
  }
170
452
 
171
453
  // ESM needs package.json with "type": "module"
172
454
  writeFileSync(
173
- join(CLIENT_INSTALL_DIR, 'package.json'),
455
+ join(clientDir, 'package.json'),
174
456
  '{"type":"module"}\n',
175
457
  );
176
458
 
@@ -178,9 +460,13 @@ export function installClientFiles() {
178
460
  // packageRoot lets sender.js find bin/ai-lens.js for background status reports.
179
461
  const { version, commit } = getVersionInfo();
180
462
  writeFileSync(
181
- join(CLIENT_INSTALL_DIR, 'version.json'),
463
+ join(clientDir, 'version.json'),
182
464
  JSON.stringify({ version, commit, packageRoot: PKG_ROOT }) + '\n',
183
465
  );
466
+
467
+ if (shouldWriteLauncher) {
468
+ writeLauncher({ clientDir, nodePath: nodeResolution.path, platform });
469
+ }
184
470
  }
185
471
 
186
472
  /**
@@ -196,126 +482,143 @@ export function removeClientFiles() {
196
482
  // Hook definitions per tool
197
483
  // ---------------------------------------------------------------------------
198
484
 
199
- // Use tilde path (~/.ai-lens/...) so settings are portable (no absolute paths).
200
- const CLAUDE_CODE_HOOKS = {
201
- SessionStart: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
202
- SessionEnd: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
203
- UserPromptSubmit: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
204
- PreToolUse: () => ({ matcher: 'EnterPlanMode|ExitPlanMode', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
205
- PostToolUse: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
206
- PostToolUseFailure: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
207
- Stop: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
208
- PreCompact: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
209
- SubagentStart: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
210
- SubagentStop: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
485
+ // Claude Code hook event names used by both global (absolute path) and project (tilde) modes.
486
+ const CLAUDE_HOOK_SPEC = {
487
+ SessionStart: { matcher: '' },
488
+ SessionEnd: { matcher: '' },
489
+ UserPromptSubmit: { matcher: '' },
490
+ PreToolUse: { matcher: 'EnterPlanMode|ExitPlanMode' },
491
+ PostToolUse: { matcher: '' },
492
+ PostToolUseFailure: { matcher: '' },
493
+ Stop: { matcher: '' },
494
+ PreCompact: { matcher: '' },
495
+ SubagentStart: { matcher: '' },
496
+ SubagentStop: { matcher: '' },
211
497
  };
212
498
 
213
- // Use absolute path — Cursor does not expand ~ in hook commands (it passes the
214
- // literal string to Node, which resolves it relative to CWD).
215
- const CURSOR_HOOKS = {
216
- sessionStart: () => ({ command: cursorCaptureCommand(false) }),
217
- beforeSubmitPrompt: () => ({ command: cursorCaptureCommand(false) }),
218
- postToolUse: () => ({ command: cursorCaptureCommand(false) }),
219
- postToolUseFailure: () => ({ command: cursorCaptureCommand(false) }),
220
- afterFileEdit: () => ({ command: cursorCaptureCommand(false) }),
221
- afterShellExecution: () => ({ command: cursorCaptureCommand(false) }),
222
- afterMCPExecution: () => ({ command: cursorCaptureCommand(false) }),
223
- subagentStart: () => ({ command: cursorCaptureCommand(false) }),
224
- subagentStop: () => ({ command: cursorCaptureCommand(false) }),
225
- preCompact: () => ({ command: cursorCaptureCommand(false) }),
226
- afterAgentResponse: () => ({ command: cursorCaptureCommand(false) }),
227
- afterAgentThought: () => ({ command: cursorCaptureCommand(false) }),
228
- stop: () => ({ command: cursorCaptureCommand(false) }),
229
- sessionEnd: () => ({ command: cursorCaptureCommand(false) }),
230
- };
499
+ const CURSOR_HOOK_NAMES = [
500
+ 'sessionStart', 'beforeSubmitPrompt', 'postToolUse', 'postToolUseFailure',
501
+ 'afterFileEdit', 'afterShellExecution', 'afterMCPExecution',
502
+ 'subagentStart', 'subagentStop', 'preCompact',
503
+ 'afterAgentResponse', 'afterAgentThought', 'stop', 'sessionEnd',
504
+ ];
231
505
 
232
- // Same as CURSOR_HOOKS but command uses ~/.ai-lens/... (for --project-hooks)
233
- const CURSOR_HOOKS_TILDE = Object.fromEntries(
234
- Object.keys(CURSOR_HOOKS).map(k => [k, () => ({ command: cursorCaptureCommand(true) })]),
235
- );
236
-
237
- // Same as CLAUDE_CODE_HOOKS but command uses ~/.ai-lens/... (for --project-hooks), path without quotes
238
- const CLAUDE_CODE_HOOKS_TILDE = {};
239
- for (const [k, fn] of Object.entries(CLAUDE_CODE_HOOKS)) {
240
- CLAUDE_CODE_HOOKS_TILDE[k] = () => {
241
- const r = fn();
242
- r.hooks[0].command = captureCommand(true, true);
243
- return r;
506
+ // Wrap captureCommand in a memoised getter so makeXxxHookDefs(null) does NOT
507
+ // trigger node-path resolution at builder time — the first .hookDefs[name]()
508
+ // call does it instead, and subsequent calls reuse the cached string.
509
+ function memoizeCmd(produce) {
510
+ let cached;
511
+ let resolved = false;
512
+ return () => {
513
+ if (!resolved) { cached = produce(); resolved = true; }
514
+ return cached;
244
515
  };
245
516
  }
246
517
 
518
+ /**
519
+ * Build Claude Code hookDefs bound to a ctx. Claude Code's settings.json is the
520
+ * user's shared config, so we always emit a tilde path for portability —
521
+ * `mode` is accepted for symmetry with the Cursor builder but does not change
522
+ * the path style.
523
+ *
524
+ * The captureCommand call is deferred until the first hookDef thunk runs, so
525
+ * importing this module (or building TOOL_CONFIGS with ctx=null) never
526
+ * probes node candidates by itself.
527
+ */
528
+ export function makeClaudeHookDefs(ctx = null, _mode = 'global') {
529
+ const getCmd = memoizeCmd(() => captureCommand({ useTilde: true, rawPath: true, ctx }));
530
+ const defs = {};
531
+ for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
532
+ defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
533
+ }
534
+ return defs;
535
+ }
536
+
537
+ /**
538
+ * Build Cursor hookDefs bound to a ctx. mode: 'global' (default) | 'project'.
539
+ * Cursor does not expand ~ in hook commands, so global hooks bake the absolute
540
+ * client-dir path. Project-hooks use ~/.ai-lens/... for portability across
541
+ * teammates who clone the repo.
542
+ *
543
+ * captureCommand is deferred (see makeClaudeHookDefs).
544
+ */
545
+ export function makeCursorHookDefs(ctx = null, mode = 'global') {
546
+ const useTilde = mode === 'project';
547
+ const getCmd = memoizeCmd(() => cursorCaptureCommand({ useTilde, ctx }));
548
+ const defs = {};
549
+ for (const name of CURSOR_HOOK_NAMES) {
550
+ defs[name] = () => ({ command: getCmd() });
551
+ }
552
+ return defs;
553
+ }
554
+
247
555
  /**
248
556
  * Claude Code hook defs that point to a custom path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
249
557
  * @param {string} capturePath - Absolute path to client/capture.js.
558
+ * @param {object} [ctx]
250
559
  */
251
- export function getClaudeCodeHookDefsWithPath(capturePath) {
252
- const cmd = captureCommand(false, true, capturePath);
253
- return {
254
- SessionStart: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
255
- SessionEnd: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
256
- UserPromptSubmit: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
257
- PreToolUse: () => ({ matcher: 'EnterPlanMode|ExitPlanMode', hooks: [{ type: 'command', command: cmd }] }),
258
- PostToolUse: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
259
- PostToolUseFailure: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
260
- Stop: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
261
- PreCompact: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
262
- SubagentStart: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
263
- SubagentStop: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
264
- };
560
+ export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
561
+ const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, ctx }));
562
+ const defs = {};
563
+ for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
564
+ defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
565
+ }
566
+ return defs;
265
567
  }
266
568
 
267
569
  /**
268
570
  * Cursor hook defs that point to a custom path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
269
571
  * @param {string} capturePath - Absolute path to client/capture.js.
572
+ * @param {object} [ctx]
270
573
  */
271
- export function getCursorHookDefsWithPath(capturePath) {
272
- const command = cursorCaptureCommand(false, capturePath);
273
- return {
274
- sessionStart: () => ({ command }),
275
- beforeSubmitPrompt: () => ({ command }),
276
- postToolUse: () => ({ command }),
277
- postToolUseFailure: () => ({ command }),
278
- afterFileEdit: () => ({ command }),
279
- afterShellExecution: () => ({ command }),
280
- afterMCPExecution: () => ({ command }),
281
- subagentStart: () => ({ command }),
282
- subagentStop: () => ({ command }),
283
- preCompact: () => ({ command }),
284
- afterAgentResponse: () => ({ command }),
285
- afterAgentThought: () => ({ command }),
286
- stop: () => ({ command }),
287
- sessionEnd: () => ({ command }),
288
- };
574
+ export function getCursorHookDefsWithPath(capturePath, ctx = null) {
575
+ const getCmd = memoizeCmd(() => cursorCaptureCommand({ customPath: capturePath, ctx }));
576
+ const defs = {};
577
+ for (const name of CURSOR_HOOK_NAMES) {
578
+ defs[name] = () => ({ command: getCmd() });
579
+ }
580
+ return defs;
289
581
  }
290
582
 
291
- export const TOOL_CONFIGS = [
292
- {
293
- name: 'Claude Code',
294
- dirPath: join(homedir(), '.claude'),
295
- configPath: join(homedir(), '.claude', 'settings.json'),
296
- hookDefs: CLAUDE_CODE_HOOKS,
297
- topLevelFields: {},
298
- sharedConfig: true,
299
- // Older init versions wrote hooks here — clean up on init/remove
300
- legacyConfigPaths: [join(homedir(), '.claude', 'hooks.json')],
301
- },
302
- {
303
- name: 'Cursor',
304
- dirPath: join(homedir(), '.cursor'),
305
- configPath: join(homedir(), '.cursor', 'hooks.json'),
306
- hookDefs: CURSOR_HOOKS,
307
- topLevelFields: { version: 1 },
308
- },
309
- ];
583
+ /**
584
+ * Build the array of TOOL_CONFIGS (Claude Code + Cursor at user-global paths).
585
+ * Pass ctx for production use; omit for the legacy TOOL_CONFIGS export
586
+ * (hookDefs lazy-resolve node path at first call).
587
+ */
588
+ export function makeToolConfigs(ctx = null) {
589
+ return [
590
+ {
591
+ name: 'Claude Code',
592
+ dirPath: join(homedir(), '.claude'),
593
+ configPath: join(homedir(), '.claude', 'settings.json'),
594
+ hookDefs: makeClaudeHookDefs(ctx, 'global'),
595
+ topLevelFields: {},
596
+ sharedConfig: true,
597
+ // Older init versions wrote hooks here — clean up on init/remove
598
+ legacyConfigPaths: [join(homedir(), '.claude', 'hooks.json')],
599
+ },
600
+ {
601
+ name: 'Cursor',
602
+ dirPath: join(homedir(), '.cursor'),
603
+ configPath: join(homedir(), '.cursor', 'hooks.json'),
604
+ hookDefs: makeCursorHookDefs(ctx, 'global'),
605
+ topLevelFields: { version: 1 },
606
+ },
607
+ ];
608
+ }
609
+
610
+ // Backward-compat: lazy-bound TOOL_CONFIGS so import doesn't trigger node resolution.
611
+ export const TOOL_CONFIGS = makeToolConfigs();
310
612
 
311
613
  /**
312
614
  * Cursor tool config with hooks in project's .cursor/ instead of ~/.cursor/.
313
615
  * Use for init (--project-hooks) and remove (when project has .cursor/hooks.json).
314
616
  * @param {string} projectRoot - Absolute path to project root (e.g. process.cwd()).
315
617
  * @param {string} [label] - Display name (default: 'Cursor (project)').
618
+ * @param {object} [ctx]
316
619
  * @returns {{ name: string, dirPath: string, configPath: string, hookDefs: object, topLevelFields: object }}
317
620
  */
318
- export function getCursorToolConfig(projectRoot, label = 'Cursor (project)') {
621
+ export function getCursorToolConfig(projectRoot, label = 'Cursor (project)', ctx = null) {
319
622
  const base = TOOL_CONFIGS.find(t => t.name === 'Cursor');
320
623
  if (!base) return null;
321
624
  return {
@@ -323,7 +626,7 @@ export function getCursorToolConfig(projectRoot, label = 'Cursor (project)') {
323
626
  name: label,
324
627
  dirPath: join(projectRoot, '.cursor'),
325
628
  configPath: join(projectRoot, '.cursor', 'hooks.json'),
326
- hookDefs: CURSOR_HOOKS_TILDE,
629
+ hookDefs: makeCursorHookDefs(ctx, 'project'),
327
630
  };
328
631
  }
329
632
 
@@ -332,9 +635,10 @@ export function getCursorToolConfig(projectRoot, label = 'Cursor (project)') {
332
635
  * Use for init (--project-hooks) and remove (when project has .claude/settings.json with AI Lens).
333
636
  * @param {string} projectRoot - Absolute path to project root (e.g. process.cwd()).
334
637
  * @param {string} [label] - Display name (default: 'Claude Code (project)').
638
+ * @param {object} [ctx]
335
639
  * @returns {{ name: string, dirPath: string, configPath: string, hookDefs: object, topLevelFields: object, sharedConfig: boolean, legacyConfigPaths: array }}
336
640
  */
337
- export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (project)') {
641
+ export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (project)', ctx = null) {
338
642
  const base = TOOL_CONFIGS.find(t => t.name === 'Claude Code');
339
643
  if (!base) return null;
340
644
  return {
@@ -342,7 +646,7 @@ export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (proje
342
646
  name: label,
343
647
  dirPath: join(projectRoot, '.claude'),
344
648
  configPath: join(projectRoot, '.claude', 'settings.json'),
345
- hookDefs: CLAUDE_CODE_HOOKS_TILDE,
649
+ hookDefs: makeClaudeHookDefs(ctx, 'project'),
346
650
  sharedConfig: false,
347
651
  legacyConfigPaths: [],
348
652
  };
@@ -352,62 +656,160 @@ export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (proje
352
656
  // AI Lens hook detection
353
657
  // ---------------------------------------------------------------------------
354
658
 
355
- // Both ~/.ai-lens/client/capture.js and repo path (e.g. internal/analytics/ai-lens/client/capture.js) are valid.
356
- function isAiLensCapturePath(cmd) {
659
+ // Recognises any AI Lens hook target: launcher (run.sh / run.cmd) or capture.js
660
+ // (legacy install, repo-path mode). Returns { isAiLens, kind } where kind is
661
+ // 'launcher' | 'captureJs' for matched commands.
662
+ //
663
+ // We deliberately require an `ai-lens/` segment so we never classify an unrelated
664
+ // user hook like `node client/capture.js` as AI Lens (init/remove would otherwise
665
+ // strip it). init.js is responsible for emitting paths that contain `ai-lens/`
666
+ // in every install mode, including --use-repo-path --project-hooks.
667
+ export function isAiLensCommand(cmd) {
357
668
  const n = (cmd || '').replace(/\\/g, '/');
358
- return n.includes('.ai-lens/client/capture.js') || n.includes('ai-lens/client/capture.js');
669
+ if (n.includes('.ai-lens/client/run.sh') || n.includes('.ai-lens/client/run.cmd')) {
670
+ return { isAiLens: true, kind: 'launcher' };
671
+ }
672
+ if (n.includes('.ai-lens/client/capture.js') || n.includes('ai-lens/client/capture.js')) {
673
+ return { isAiLens: true, kind: 'captureJs' };
674
+ }
675
+ return { isAiLens: false, kind: null };
676
+ }
677
+
678
+ // Deprecated alias for back-compat with any external callers.
679
+ export function isAiLensCapturePath(cmd) {
680
+ return isAiLensCommand(cmd).isAiLens;
359
681
  }
360
682
 
361
683
  export function isAiLensHook(entry) {
362
684
  // Flat format (Cursor): { command: "..." }
363
- if (isAiLensCapturePath(entry?.command || '')) return true;
685
+ if (entry?.command != null && isAiLensCommand(entry.command).isAiLens) return true;
364
686
  // Nested format (Claude Code): { matcher, hooks: [{ command: "..." }] }
365
687
  if (Array.isArray(entry?.hooks)) {
366
- return entry.hooks.some(h => isAiLensCapturePath(h?.command || ''));
688
+ return entry.hooks.some(h => isAiLensCommand(h?.command || '').isAiLens);
367
689
  }
368
690
  return false;
369
691
  }
370
692
 
693
+ /**
694
+ * Parse a hook command into normalized parts. Only recognises the formats we emit:
695
+ * <launcher-path>
696
+ * <node-path> <capture.js-path>
697
+ * & <launcher-path> (Cursor / PowerShell)
698
+ * call <launcher-path> (Claude Code / cmd.exe with spaces)
699
+ *
700
+ * Returns { kind, path, prefix, nodePrefix } where:
701
+ * kind: 'launcher' | 'captureJs' | 'unknown'
702
+ * path: normalised target path (trailing capture.js / run.sh / run.cmd token)
703
+ * prefix: 'none' | 'cursor-ps' (& ) | 'win-claude' (call )
704
+ * nodePrefix: normalised node binary (captureJs only), or null
705
+ */
706
+ export function _parseHookCommand(cmd) {
707
+ if (typeof cmd !== 'string' || cmd.length === 0) {
708
+ return { kind: 'unknown', path: null, prefix: 'none', nodePrefix: null };
709
+ }
710
+ let rest = cmd.trim();
711
+ let prefix = 'none';
712
+ if (rest.startsWith('& ')) {
713
+ prefix = 'cursor-ps';
714
+ rest = rest.slice(2).trim();
715
+ } else if (rest.toLowerCase().startsWith('call ')) {
716
+ prefix = 'win-claude';
717
+ rest = rest.slice(5).trim();
718
+ }
719
+
720
+ const tokens = tokenizeHookCommand(rest);
721
+ if (tokens.length === 0) {
722
+ return { kind: 'unknown', path: null, prefix, nodePrefix: null };
723
+ }
724
+ const first = normalizePath(tokens[0]);
725
+
726
+ if (first.endsWith('/run.sh') || first.endsWith('/run.cmd') || first.endsWith('run.sh') || first.endsWith('run.cmd')) {
727
+ return { kind: 'launcher', path: first, prefix, nodePrefix: null };
728
+ }
729
+
730
+ if (tokens.length >= 2) {
731
+ const second = normalizePath(tokens[1]);
732
+ if (second.endsWith('capture.js')) {
733
+ return { kind: 'captureJs', path: second, prefix, nodePrefix: first };
734
+ }
735
+ }
736
+
737
+ return { kind: 'unknown', path: null, prefix, nodePrefix: null };
738
+ }
739
+
740
+ // Narrow tokenizer: splits on spaces but respects matched single or double quotes.
741
+ // Does NOT handle escapes — not needed for our emitted commands.
742
+ function tokenizeHookCommand(s) {
743
+ const tokens = [];
744
+ let buf = '';
745
+ let quote = null;
746
+ for (let i = 0; i < s.length; i++) {
747
+ const ch = s[i];
748
+ if (quote) {
749
+ if (ch === quote) {
750
+ quote = null;
751
+ } else {
752
+ buf += ch;
753
+ }
754
+ continue;
755
+ }
756
+ if (ch === '"' || ch === "'") {
757
+ quote = ch;
758
+ continue;
759
+ }
760
+ if (ch === ' ' || ch === '\t') {
761
+ if (buf.length > 0) {
762
+ tokens.push(buf);
763
+ buf = '';
764
+ }
765
+ continue;
766
+ }
767
+ buf += ch;
768
+ }
769
+ if (buf.length > 0) tokens.push(buf);
770
+ return tokens;
771
+ }
772
+
773
+ function normalizePath(p) {
774
+ return (p || '').replace(/\\/g, '/');
775
+ }
776
+
371
777
  function isCurrentAiLensHook(entry, expected) {
372
- // Any valid AI Lens capture path is "current" (tilde or repo path from --use-repo-path).
373
- const norm = s => (s || '').replace(/\\/g, '/');
374
- // Flat format (Cursor)
778
+ // Flat format (Cursor): single command per entry.
375
779
  if (entry?.command != null) {
376
- if (isAiLensCapturePath(entry.command)) {
377
- const expectedCmd = expected?.command || '';
378
- // On Windows, Cursor hooks require & prefix for PowerShell invocation.
379
- // If expected has & but entry doesn't, treat as outdated so init updates it.
380
- if (expectedCmd.startsWith('& ') && !entry.command.startsWith('& ')) return false;
381
- // On Windows, tilde is not expanded in double-quoted strings by PowerShell.
382
- // If entry uses ~/ but expected uses an absolute path, treat as outdated.
383
- const entryHasTilde = entry.command.includes('~/.ai-lens/');
384
- const expectedHasTilde = expectedCmd.includes('~/.ai-lens/');
385
- if (entryHasTilde && !expectedHasTilde) return false;
386
- return true;
387
- }
388
- if (norm(entry.command) === norm(expected?.command || expected?.hooks?.[0]?.command || '')) return true;
389
- return false;
780
+ const expectedCmd = expected?.command || expected?.hooks?.[0]?.command || '';
781
+ return commandsMatch(entry.command, expectedCmd);
390
782
  }
391
- // Nested format (Claude Code): valid path + matcher must match when expected has matcher
783
+ // Nested format (Claude Code): { matcher, hooks: [{ command }] }
392
784
  if (Array.isArray(entry?.hooks)) {
393
- const hasValidPath = entry.hooks.some(h => isAiLensCapturePath(h?.command));
394
- if (!hasValidPath && !entry.hooks.some(h => norm(h?.command) === norm(expected?.hooks?.[0]?.command || ''))) return false;
395
- if (!hasValidPath) {
396
- if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
397
- return true;
398
- }
785
+ const expectedCmd = expected?.hooks?.[0]?.command || '';
399
786
  if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
400
- return true;
787
+ return entry.hooks.some(h => commandsMatch(h?.command || '', expectedCmd));
401
788
  }
402
789
  return false;
403
790
  }
404
791
 
792
+ function commandsMatch(entryCmd, expectedCmd) {
793
+ const e = _parseHookCommand(entryCmd);
794
+ const x = _parseHookCommand(expectedCmd);
795
+ if (x.kind === 'unknown') {
796
+ // Best-effort fallback: literal compare after slash-normalisation.
797
+ return normalizePath(entryCmd) === normalizePath(expectedCmd);
798
+ }
799
+ if (e.kind !== x.kind) return false;
800
+ if (e.prefix !== x.prefix) return false;
801
+ if (e.path !== x.path) return false;
802
+ if (x.kind === 'captureJs' && e.nodePrefix !== x.nodePrefix) return false;
803
+ return true;
804
+ }
805
+
405
806
  // ---------------------------------------------------------------------------
406
807
  // Tool detection
407
808
  // ---------------------------------------------------------------------------
408
809
 
409
- export function detectInstalledTools() {
410
- return TOOL_CONFIGS.filter(t => existsSync(t.dirPath));
810
+ export function detectInstalledTools(ctx = null) {
811
+ const tools = ctx ? makeToolConfigs(ctx) : TOOL_CONFIGS;
812
+ return tools.filter(t => existsSync(t.dirPath));
411
813
  }
412
814
 
413
815
  // ---------------------------------------------------------------------------
package/cli/init.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  getClaudeCodeHookDefsWithPath, getCursorHookDefsWithPath,
21
21
  cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
22
22
  checkHooksDisabled, enableHooks,
23
+ findStableNodePath, isVersionPinnedNodePath,
23
24
  } from './hooks.js';
24
25
  import { scanNestedClaudeProjects, summarizeNestedProjects } from './scan.js';
25
26
 
@@ -173,10 +174,10 @@ function getTrackedRoots(projects, fallbackRoot) {
173
174
  return projects.split(',').map(path => path.trim()).filter(Boolean);
174
175
  }
175
176
 
176
- function makeNestedClaudeTool(projectDir, capturePathInHooks) {
177
- const tool = getClaudeCodeToolConfig(projectDir, `Claude Code (${projectDir})`);
177
+ function makeNestedClaudeTool(projectDir, capturePathInHooks, ctx = null) {
178
+ const tool = getClaudeCodeToolConfig(projectDir, `Claude Code (${projectDir})`, ctx);
178
179
  if (capturePathInHooks) {
179
- tool.hookDefs = getClaudeCodeHookDefsWithPath(capturePathInHooks);
180
+ tool.hookDefs = getClaudeCodeHookDefsWithPath(capturePathInHooks, ctx);
180
181
  }
181
182
  return tool;
182
183
  }
@@ -379,15 +380,45 @@ export default async function init() {
379
380
  heading(`AI Lens — Init v${version} (${commit})`);
380
381
  detail(`capture.js: ${CAPTURE_PATH}`);
381
382
 
382
- // Detect installed tools
383
+ // Resolve a stable node binary path up-front. The same resolution is baked into
384
+ // the per-machine launcher (run.sh / run.cmd) and into hook commands. We only
385
+ // need it when hooks will actually be written; --no-hooks skips it so users
386
+ // without a discoverable node can still set up MCP / config.
387
+ //
388
+ // --project-hooks ALWAYS writes hooks regardless of whether ~/.cursor or
389
+ // ~/.claude exist globally — so willWriteHooks must consider it.
390
+ const globalTools = detectInstalledTools();
391
+ const willWriteHooks = !flags.noHooks && (globalTools.length > 0 || !!flags.projectHooks);
392
+ let nodeResolution = null;
393
+ let ctx = null;
394
+ if (willWriteHooks) {
395
+ nodeResolution = findStableNodePath();
396
+ if (!nodeResolution) {
397
+ error('Could not find any node binary on this system.');
398
+ info(' Install node via nvm/asdf/fnm/volta or `brew install node`, then re-run init.');
399
+ process.exit(1);
400
+ }
401
+ if (!nodeResolution.stable) {
402
+ warn(`Resolved node at ${nodeResolution.path}, but this path is version-pinned and will break on upgrade.`);
403
+ info(' Install node via a managed tool (nvm/asdf/fnm/volta) or create a stable symlink:');
404
+ info(' sudo ln -s "$(which node)" /usr/local/bin/node');
405
+ info(' Then re-run init. (Continuing with the version-pinned path for now.)');
406
+ } else {
407
+ detail(` Resolved node: ${nodeResolution.path} (${nodeResolution.source})`);
408
+ }
409
+ ctx = { nodeResolution, platform: process.platform, clientDir: join(homedir(), '.ai-lens', 'client') };
410
+ }
411
+
412
+ // Detect installed tools — re-detect with ctx now that it's available.
383
413
  heading('Detecting installed AI tools...');
384
- let tools = detectInstalledTools();
414
+ let tools = detectInstalledTools(ctx);
385
415
 
386
- // When --project-hooks: put Cursor and Claude Code hooks in project's .cursor/ and .claude/ instead of ~/
416
+ // When --project-hooks: put Cursor and Claude Code hooks in project's .cursor/ and .claude/ instead of ~/.
417
+ // Project tools are added unconditionally, so this path is exercised even when no global tools exist.
387
418
  if (flags.projectHooks) {
388
419
  const projectRoot = resolve(process.cwd());
389
- const cursorProject = getCursorToolConfig(projectRoot);
390
- const claudeProject = getClaudeCodeToolConfig(projectRoot);
420
+ const cursorProject = getCursorToolConfig(projectRoot, undefined, ctx);
421
+ const claudeProject = getClaudeCodeToolConfig(projectRoot, undefined, ctx);
391
422
  if (cursorProject) {
392
423
  tools = tools.filter(t => t.name !== 'Cursor');
393
424
  tools.push(cursorProject);
@@ -412,7 +443,19 @@ export default async function init() {
412
443
  if (flags.projectHooks) {
413
444
  const projectRoot = resolve(process.cwd());
414
445
  pathInHooks = relative(projectRoot, repoPathAbs).replace(/\\/g, '/');
415
- info(' Project hooks will use relative path to capture.js (from project root).');
446
+ // From inside the ai-lens package itself, `relative()` produces a bare
447
+ // `client/capture.js` that doesn't contain `ai-lens/` — isAiLensCommand
448
+ // wouldn't recognise it, and using a generic match would risk classifying
449
+ // unrelated user hooks as AI Lens. Fall back to the tilde/absolute form
450
+ // so the emitted path always carries the `ai-lens/` identifier.
451
+ if (!/(?:^|\/)ai-lens\//.test(pathInHooks)) {
452
+ pathInHooks = repoPathAbs.startsWith(home)
453
+ ? ('~' + repoPathAbs.slice(home.length)).replace(/\\/g, '/')
454
+ : repoPathAbs.replace(/\\/g, '/');
455
+ info(' Project root is inside ai-lens itself — using portable path with ai-lens/ segment for hook recognition.');
456
+ } else {
457
+ info(' Project hooks will use relative path to capture.js (from project root).');
458
+ }
416
459
  } else {
417
460
  pathInHooks = repoPathAbs.startsWith(home)
418
461
  ? ('~' + repoPathAbs.slice(home.length)).replace(/\\/g, '/')
@@ -420,8 +463,8 @@ export default async function init() {
420
463
  info(' Hooks will point to capture.js in this package (portable path).');
421
464
  }
422
465
  for (const tool of tools) {
423
- if (tool.name.startsWith('Claude Code')) tool.hookDefs = getClaudeCodeHookDefsWithPath(pathInHooks);
424
- else if (tool.name.startsWith('Cursor')) tool.hookDefs = getCursorHookDefsWithPath(pathInHooks);
466
+ if (tool.name.startsWith('Claude Code')) tool.hookDefs = getClaudeCodeHookDefsWithPath(pathInHooks, ctx);
467
+ else if (tool.name.startsWith('Cursor')) tool.hookDefs = getCursorHookDefsWithPath(pathInHooks, ctx);
425
468
  }
426
469
  } else if (flags.projectHooks) {
427
470
  info(' Project hooks use path ~/.ai-lens/client/capture.js.');
@@ -502,12 +545,21 @@ export default async function init() {
502
545
  // Build new config in memory — saved after "Proceed?" confirmation
503
546
  const newConfig = { ...currentConfig, serverUrl, projects };
504
547
 
505
- // Install client files to ~/.ai-lens/client/ (skip when --use-repo-path)
548
+ // Install client files to ~/.ai-lens/client/ (skip when --use-repo-path).
549
+ // Launcher (run.sh / run.cmd) is written only when we will actually wire up hooks
550
+ // — --no-hooks installs leave it out (and don't require a resolved node path).
506
551
  if (!flags.useRepoPath) {
507
552
  heading('Installing client files...');
508
553
  try {
509
- installClientFiles();
554
+ installClientFiles({
555
+ nodeResolution,
556
+ platform: process.platform,
557
+ writeLauncher: willWriteHooks,
558
+ });
510
559
  success(' Copied client files to ~/.ai-lens/client/');
560
+ if (willWriteHooks) {
561
+ detail(' Wrote per-machine launcher (run.sh / run.cmd).');
562
+ }
511
563
  } catch (err) {
512
564
  error(` Failed to install client files: ${err.message}`);
513
565
  return;
@@ -638,7 +690,7 @@ export default async function init() {
638
690
  const capturePathInHooks = flags.useRepoPath
639
691
  ? relative(result.projectDir, resolve(REPO_CAPTURE_PATH)).replace(/\\/g, '/')
640
692
  : null;
641
- const tool = makeNestedClaudeTool(result.projectDir, capturePathInHooks);
693
+ const tool = makeNestedClaudeTool(result.projectDir, capturePathInHooks, ctx);
642
694
  tool.configPath = join(tool.dirPath, result.installTarget);
643
695
  return {
644
696
  tool,
package/cli/status.js CHANGED
@@ -61,6 +61,13 @@ function relativeTime(dateStr) {
61
61
  // Individual checks — each returns { ok, summary, detail }
62
62
  // ---------------------------------------------------------------------------
63
63
 
64
+ // Recognises both the legacy `<node> capture.js` form and the per-machine launcher
65
+ // (run.sh / run.cmd) introduced in 0.8.68.
66
+ function isAiLensCommandString(cmd) {
67
+ if (!cmd) return false;
68
+ return cmd.includes('capture.js') || cmd.includes('run.sh') || cmd.includes('run.cmd');
69
+ }
70
+
64
71
  function extractHookCommand(tool) {
65
72
  try {
66
73
  const raw = readFileSync(tool.configPath, 'utf-8');
@@ -71,11 +78,11 @@ function extractHookCommand(tool) {
71
78
  if (!Array.isArray(entries)) continue;
72
79
  for (const entry of entries) {
73
80
  // Flat format (Cursor): { command: "..." }
74
- if (entry?.command?.includes('capture.js')) return entry.command;
81
+ if (isAiLensCommandString(entry?.command)) return entry.command;
75
82
  // Nested format (Claude Code): { hooks: [{ command: "..." }] }
76
83
  if (Array.isArray(entry?.hooks)) {
77
84
  for (const h of entry.hooks) {
78
- if (h?.command?.includes('capture.js')) return h.command;
85
+ if (isAiLensCommandString(h?.command)) return h.command;
79
86
  }
80
87
  }
81
88
  }
@@ -98,13 +105,19 @@ function expandTilde(pathStr) {
98
105
  * Returns { ok, summary, detail } for the status output.
99
106
  */
100
107
  function detectInstallMode(tools) {
101
- const copyDir = join(homedir(), '.ai-lens', 'client') + '/';
108
+ // Normalise both sides to forward slashes before comparing — captureCommand
109
+ // always emits forward-slash paths into the hook command, but join() on
110
+ // Windows returns backslashes, so a raw startsWith() check would miss every
111
+ // copy-mode install on Windows and mis-label it as repo-path.
112
+ const copyDir = (join(homedir(), '.ai-lens', 'client') + '/').replace(/\\/g, '/');
102
113
  const paths = [];
103
114
  for (const tool of tools) {
104
115
  const cmd = extractHookCommand(tool);
105
116
  if (!cmd) continue;
106
- const m = cmd.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
107
- if (m) paths.push({ tool: tool.name, raw: m[1] || m[2], resolved: expandTilde(m[1] || m[2]) });
117
+ // Match either a launcher (run.sh / run.cmd) or a legacy capture.js path. The launcher
118
+ // always lives in the install dir, so it's by definition copy-mode.
119
+ const m = cmd.match(/["']([^"']*(?:capture\.js|run\.(?:sh|cmd)))["']|(\S*(?:capture\.js|run\.(?:sh|cmd)))/);
120
+ if (m) paths.push({ tool: tool.name, raw: m[1] || m[2], resolved: expandTilde(m[1] || m[2]).replace(/\\/g, '/') });
108
121
  }
109
122
  if (paths.length === 0) {
110
123
  return { ok: null, summary: 'unknown', detail: 'No hook commands found — cannot determine install mode' };
@@ -134,25 +147,32 @@ function validateHookCommandPaths(tool) {
134
147
  if (!command) return null;
135
148
 
136
149
  const issues = [];
137
-
138
- // Extract capture.js path using 'capture.js' as anchor (expand ~ so existsSync works)
139
- const captureMatch = command.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
140
- if (captureMatch) {
141
- const capturePath = expandTilde(captureMatch[1] || captureMatch[2]);
142
- if (!existsSync(capturePath)) {
143
- issues.push(`capture.js not found at: ${capturePath}`);
150
+ const launcherMatch = command.match(/["']([^"']*run\.(?:sh|cmd))["']|(\S*run\.(?:sh|cmd))/);
151
+
152
+ if (launcherMatch) {
153
+ // Launcher form (0.8.68+): one path, no separate node validation — the node binary
154
+ // baked into the launcher is invoked from inside the script, not the hook command.
155
+ const launcherPath = expandTilde(launcherMatch[1] || launcherMatch[2]);
156
+ if (!existsSync(launcherPath)) {
157
+ issues.push(`launcher not found at: ${launcherPath}`);
144
158
  }
145
- }
146
-
147
- // Extract node path (first token). Skip if /usr/bin/env node (node resolved via PATH)
148
- // Strip "& " prefix (PowerShell call operator, added on Windows) before matching.
149
- const cmdForNode = command.replace(/^& /, '');
150
- if (!cmdForNode.startsWith('/usr/bin/env node')) {
151
- const nodeMatch = cmdForNode.match(/^["']([^"']+)["']|^(\S+)/);
152
- if (nodeMatch) {
153
- const nodePath = expandTilde(nodeMatch[1] || nodeMatch[2]);
154
- if (nodePath !== 'node' && !existsSync(nodePath)) {
155
- issues.push(`node not found at: ${nodePath}`);
159
+ } else {
160
+ // Legacy `<node> capture.js` form.
161
+ const captureMatch = command.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
162
+ if (captureMatch) {
163
+ const capturePath = expandTilde(captureMatch[1] || captureMatch[2]);
164
+ if (!existsSync(capturePath)) {
165
+ issues.push(`capture.js not found at: ${capturePath}`);
166
+ }
167
+ }
168
+ const cmdForNode = command.replace(/^& /, '');
169
+ if (!cmdForNode.startsWith('/usr/bin/env node')) {
170
+ const nodeMatch = cmdForNode.match(/^["']([^"']+)["']|^(\S+)/);
171
+ if (nodeMatch) {
172
+ const nodePath = expandTilde(nodeMatch[1] || nodeMatch[2]);
173
+ if (nodePath !== 'node' && !existsSync(nodePath)) {
174
+ issues.push(`node not found at: ${nodePath}`);
175
+ }
156
176
  }
157
177
  }
158
178
  }
@@ -345,8 +365,10 @@ function checkCaptureRun(installedTools) {
345
365
  const stderr = (guiResult.stderr || '').trim();
346
366
  guiDetail = guiOk ? 'exit 0' : `Exit code: ${guiResult.status}\nError: ${stderr || '(no stderr)'}`;
347
367
  if (!guiOk && stderr.includes('No such file or directory')) {
348
- guiDetail += '\nHint: node is not in the system PATH. GUI apps (Cursor) cannot find it.\n'
349
- + 'Fix: sudo ln -s $(which node) /usr/local/bin/node';
368
+ guiDetail += '\nHint: node is not in the system PATH for GUI apps (Cursor).\n'
369
+ + 'Fix: re-run `ai-lens init` — it installs a launcher that bakes in your node path.\n'
370
+ + 'If init still fails, install node via nvm/asdf/fnm/volta or symlink it:\n'
371
+ + ' sudo ln -s "$(which node)" /usr/local/bin/node';
350
372
  }
351
373
  } catch (err) {
352
374
  guiOk = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.67",
3
+ "version": "0.8.68",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {