ai-lens 0.8.67 → 0.8.69

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
+ fe949b0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
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.69 — 2026-05-27
6
+ - feat: per-machine launcher (`~/.ai-lens/client/run.sh` / `run.cmd`) now also accepts an optional script path as its first argument. With no args it still execs the sibling `capture.js` (default install), but `~/.ai-lens/client/run.sh path/to/some/capture.js` execs that script with the launcher's resolved node — letting bootstrap-style workflows (e.g. meta-cursor) route through the launcher for proper node resolution while keeping `capture.js` under git in the workspace repo.
7
+ - feat: new `--install-launcher` flag for `init`. Forces launcher installation even when `--no-hooks` is set, so the meta-cursor setup skill can wire up `~/.ai-lens/client/run.sh` without touching the static hook templates in the workspace repo.
8
+
9
+ ## 0.8.68 — 2026-05-27
10
+ - 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.
11
+ - 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`.
12
+ - 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.
13
+
5
14
  ## 0.8.67 — 2026-05-26
6
15
  - 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
16
 
package/cli/hooks.js CHANGED
@@ -64,81 +64,360 @@ 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 {}
204
+ }
205
+
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' };
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Per-machine launcher (run.sh / run.cmd)
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Filename of the launcher in the client directory.
238
+ */
239
+ export function launcherFilename(platform = process.platform) {
240
+ return platform === 'win32' ? 'run.cmd' : 'run.sh';
241
+ }
242
+
243
+ /**
244
+ * Write a per-machine launcher script with the resolved node path baked in.
245
+ *
246
+ * The launcher acts as a node-resolution shim that supports two invocation modes:
247
+ *
248
+ * 1. No arguments — run the sibling capture.js (the default install flow where
249
+ * both the launcher and capture.js live in ~/.ai-lens/client/).
250
+ * 2. Script path as $1 — run that script with the resolved node. This is the
251
+ * repo-path mode (e.g. meta-cursor static hooks pointing at
252
+ * `internal/analytics/ai-lens/client/capture.js`) where capture.js auto-
253
+ * updates via `git pull` and only the node binary needs per-machine baking.
254
+ *
255
+ * In both cases the launcher is independent of HOME / USERPROFILE / cwd — for
256
+ * mode 1 the path is derived from `dirname $0`; for mode 2 it's whatever the
257
+ * caller passes through (typically a workspace-relative path that resolves
258
+ * against the cwd Cursor/Claude Code set when firing the hook).
259
+ *
260
+ * @param {object} opts
261
+ * @param {string} opts.clientDir — directory where the launcher (and capture.js) live
262
+ * @param {string} opts.nodePath — absolute path to the node binary to bake in
263
+ * @param {string} [opts.platform]
264
+ */
265
+ export function writeLauncher({ clientDir = CLIENT_INSTALL_DIR, nodePath, platform = process.platform } = {}) {
266
+ if (!nodePath) throw new Error('writeLauncher: nodePath is required');
267
+ if (!clientDir) throw new Error('writeLauncher: clientDir is required');
268
+
269
+ if (platform === 'win32') {
270
+ const escaped = nodePath.replace(/"/g, '""');
271
+ const content =
272
+ '@echo off\r\n'
273
+ + 'if "%~1"=="" goto default\r\n'
274
+ + `"${escaped}" %*\r\n`
275
+ + 'goto :eof\r\n'
276
+ + ':default\r\n'
277
+ + `"${escaped}" "%~dp0capture.js"\r\n`;
278
+ const target = join(clientDir, 'run.cmd');
279
+ writeFileSync(target, content);
280
+ return target;
99
281
  }
100
282
 
101
- // Fallback: rely on PATH at hook execution time
102
- return '/usr/bin/env node';
283
+ const escaped = shellEscape(nodePath, 'linux'); // POSIX single-quote escape
284
+ const content =
285
+ '#!/bin/sh\n'
286
+ + `# AI Lens per-machine launcher. Two modes:\n`
287
+ + `# $0 → exec node on sibling capture.js (default install)\n`
288
+ + `# $0 <script> → exec node on <script> with remaining args (repo-path mode)\n`
289
+ + 'if [ $# -gt 0 ]; then\n'
290
+ + ` exec ${escaped} "$@"\n`
291
+ + 'fi\n'
292
+ + 'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\n'
293
+ + `exec ${escaped} "$DIR/capture.js"\n`;
294
+ const target = join(clientDir, 'run.sh');
295
+ writeFileSync(target, content);
296
+ try { chmodSync(target, 0o755); } catch { /* best effort */ }
297
+ return target;
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Hook command construction
302
+ // ---------------------------------------------------------------------------
303
+
304
+ // Lazy default ctx for callers that don't pass one (legacy tests, TOOL_CONFIGS at
305
+ // import-time). Resolver isn't called at module load — only when captureCommand runs.
306
+ function resolveDefaultCtx() {
307
+ const nodeResolution = findStableNodePath();
308
+ return {
309
+ nodeResolution,
310
+ platform: process.platform,
311
+ clientDir: CLIENT_INSTALL_DIR,
312
+ };
103
313
  }
104
314
 
105
315
  /**
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.
316
+ * Build the hook command string. By default the command is the per-machine
317
+ * launcher (run.sh / run.cmd) at clientDir; in `--use-repo-path` mode
318
+ * (customPath given) it falls back to the legacy `<node> <capture.js>` form
319
+ * because the launcher only lives in the installed client dir.
320
+ *
321
+ * @param {object} [opts]
322
+ * @param {boolean} [opts.useTilde] — write ~/.ai-lens/client/... (portable path for project-hooks)
323
+ * @param {boolean} [opts.rawPath] — don't quote the target (Claude Code config wants raw)
324
+ * @param {string} [opts.customPath] — absolute repo-mode path to capture.js (--use-repo-path)
325
+ * @param {object} [opts.ctx] — { nodeResolution, platform, clientDir }; lazy-resolved if omitted
109
326
  */
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).
327
+ export function captureCommand(opts = {}) {
328
+ // Backward-compat: old positional signature captureCommand(useTilde, rawPath, customPath)
329
+ if (typeof opts === 'boolean') {
330
+ opts = { useTilde: opts, rawPath: arguments[1], customPath: arguments[2] ?? null };
331
+ }
332
+ const { useTilde = false, rawPath = false, customPath = null, shell = null } = opts;
333
+ const ctx = opts.ctx ?? resolveDefaultCtx();
334
+ // Tolerate partial ctx (e.g. only nodeResolution supplied) by filling in module
335
+ // defaults keeps callers from having to repeat platform/clientDir everywhere.
336
+ const platform = ctx.platform ?? process.platform;
337
+ const clientDir = ctx.clientDir ?? CLIENT_INSTALL_DIR;
338
+ const nodeResolution = ctx.nodeResolution;
339
+ const isWin = platform === 'win32';
340
+ // shell hint distinguishes cmd.exe (Claude Code) from PowerShell (Cursor) on
341
+ // Windows the two need different escaping for paths with spaces.
342
+ const isPS = shell === 'powershell';
343
+
344
+ // --use-repo-path: legacy <node> <capture.js> form.
345
+ if (customPath != null) {
346
+ if (!nodeResolution || !nodeResolution.path) {
347
+ throw new Error('captureCommand: nodeResolution is required for --use-repo-path');
348
+ }
349
+ const capturePath = customPath.replace(/\\/g, '/');
350
+ // Even in rawPath mode (Claude Code "do not unconditionally wrap"), the script
351
+ // path MUST be quoted when it contains spaces — otherwise /bin/sh, cmd.exe and
352
+ // PowerShell all split it into separate argv tokens (e.g. `/Users/foo bar/...`).
353
+ // Use shell-appropriate quoting: double-quote on Windows (works for both
354
+ // cmd.exe and PowerShell), POSIX single-quote elsewhere.
355
+ const pathPart = rawPath
356
+ ? quoteIfHasSpaces(capturePath, platform)
357
+ : shellEscape(capturePath, platform);
358
+ const nodeNorm = nodeResolution.path.replace(/\\/g, '/');
359
+ if (isWin && rawPath && nodeNorm.includes(' ')) {
360
+ const quotedNode = `"${nodeNorm.replace(/"/g, '""')}"`;
361
+ // PowerShell (Cursor) handles a quoted first token natively — caller will
362
+ // prepend `& `, giving `& "<node>" <path>`.
363
+ if (isPS) return `${quotedNode} ${pathPart}`;
364
+ // cmd.exe (Claude Code) doesn't always treat a quoted first token as a
365
+ // command — `call` is the canonical workaround. Avoids the previous bare-
366
+ // `node` fallback that depended on PATH (C:\Program Files\nodejs\node.exe
367
+ // wouldn't be reachable from launchd-like environments).
368
+ return `call ${quotedNode} ${pathPart}`;
369
+ }
370
+ return `${shellEscape(nodeNorm, platform)} ${pathPart}`;
371
+ }
372
+
373
+ // Launcher form. Path inside the command — tilde for project-hooks portability on POSIX;
374
+ // absolute on Windows (PowerShell/cmd don't expand ~).
375
+ const filename = launcherFilename(platform);
376
+ let launcherPath;
377
+ if (useTilde && !isWin) {
378
+ launcherPath = `~/.ai-lens/client/${filename}`;
379
+ } else {
380
+ launcherPath = join(clientDir, filename).replace(/\\/g, '/');
381
+ }
382
+
383
+ // POSIX: quote the launcher path unless it's the unquoted tilde form (which must
384
+ // remain unquoted for shell tilde expansion). rawPath (Claude Code) leaves the
385
+ // tilde form bare too — Claude Code passes commands to /bin/sh which expands ~.
386
+ if (!isWin) {
387
+ if (useTilde) return launcherPath; // unquoted ~/path
388
+ return shellEscape(launcherPath, 'linux');
389
+ }
390
+
391
+ // Windows: cmd.exe (Claude Code, rawPath=true) needs `call "..."` when the
392
+ // launcher path contains spaces; otherwise plain quoted path works.
393
+ // PowerShell (Cursor) handles `"..."` via the & prefix (added by cursorCaptureCommand).
394
+ const quoted = `"${launcherPath.replace(/"/g, '""')}"`;
395
+ if (rawPath && launcherPath.includes(' ') && !isPS) {
396
+ return `call ${quoted}`;
397
+ }
398
+ return quoted;
399
+ }
400
+
133
401
  /**
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).
402
+ * Cursor wrapper: PowerShell needs `& "..."` (call operator) to invoke a quoted path.
136
403
  */
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;
404
+ export function cursorCaptureCommand(opts = {}) {
405
+ if (typeof opts === 'boolean') {
406
+ opts = { useTilde: opts, customPath: arguments[1] ?? null };
407
+ }
408
+ const { useTilde = false, customPath = null } = opts;
409
+ const ctx = opts.ctx ?? resolveDefaultCtx();
410
+ const platform = ctx.platform ?? process.platform;
411
+ // PowerShell doesn't expand ~ inside double-quoted strings — fall back to absolute.
412
+ const effectiveUseTilde = platform === 'win32' ? false : useTilde;
413
+ // Cursor on Windows runs hooks via PowerShell, so use the PS-compatible shell
414
+ // hint when building the underlying command (avoids the cmd.exe `call` prefix
415
+ // which PowerShell doesn't understand).
416
+ const shell = platform === 'win32' ? 'powershell' : null;
417
+ const cmd = customPath != null
418
+ ? captureCommand({ useTilde: false, rawPath: true, customPath, ctx, shell })
419
+ : captureCommand({ useTilde: effectiveUseTilde, ctx, shell });
420
+ return platform === 'win32' ? `& ${cmd}` : cmd;
142
421
  }
143
422
 
144
423
  // ---------------------------------------------------------------------------
@@ -157,20 +436,47 @@ export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
157
436
  }
158
437
 
159
438
  /**
160
- * Copy client/ files from the package source to ~/.ai-lens/client/.
161
- * Works from npx cache, global install, and local dev.
439
+ * Copy client/*.js to ~/.ai-lens/client/ and (optionally) write the launcher.
440
+ *
441
+ * Default behaviour matches a real install: writes the launcher so the hook
442
+ * command target (`run.sh` / `run.cmd`) is always present. Tests that don't
443
+ * want the launcher in their tmpdir can pass `writeLauncher: false`. If the
444
+ * caller passes `writeLauncher: true` without a `nodeResolution`, we resolve
445
+ * one lazily — same heuristic init.js uses — so a tests-style call like
446
+ * `installClientFiles()` (no args) restores a working install.
447
+ *
448
+ * @param {object} [opts]
449
+ * @param {string} [opts.sourceDir]
450
+ * @param {string} [opts.clientDir]
451
+ * @param {{ path: string, stable: boolean }} [opts.nodeResolution] — auto-resolved if absent when writeLauncher=true
452
+ * @param {string} [opts.platform]
453
+ * @param {boolean} [opts.writeLauncher] — default true; pass false to skip launcher
162
454
  */
163
- export function installClientFiles() {
164
- const sourceDir = join(__dirname, '..', 'client');
165
- mkdirSync(CLIENT_INSTALL_DIR, { recursive: true });
455
+ export function installClientFiles(opts = {}) {
456
+ const {
457
+ sourceDir = join(__dirname, '..', 'client'),
458
+ clientDir = CLIENT_INSTALL_DIR,
459
+ platform = process.platform,
460
+ writeLauncher: shouldWriteLauncher = true,
461
+ } = opts;
462
+ let { nodeResolution = null } = opts;
463
+
464
+ if (shouldWriteLauncher && (!nodeResolution || !nodeResolution.path)) {
465
+ nodeResolution = findStableNodePath();
466
+ if (!nodeResolution || !nodeResolution.path) {
467
+ throw new Error('installClientFiles: writeLauncher=true but no node binary could be resolved');
468
+ }
469
+ }
470
+
471
+ mkdirSync(clientDir, { recursive: true });
166
472
 
167
473
  for (const file of listClientFiles(sourceDir)) {
168
- copyFileSync(join(sourceDir, file), join(CLIENT_INSTALL_DIR, file));
474
+ copyFileSync(join(sourceDir, file), join(clientDir, file));
169
475
  }
170
476
 
171
477
  // ESM needs package.json with "type": "module"
172
478
  writeFileSync(
173
- join(CLIENT_INSTALL_DIR, 'package.json'),
479
+ join(clientDir, 'package.json'),
174
480
  '{"type":"module"}\n',
175
481
  );
176
482
 
@@ -178,9 +484,13 @@ export function installClientFiles() {
178
484
  // packageRoot lets sender.js find bin/ai-lens.js for background status reports.
179
485
  const { version, commit } = getVersionInfo();
180
486
  writeFileSync(
181
- join(CLIENT_INSTALL_DIR, 'version.json'),
487
+ join(clientDir, 'version.json'),
182
488
  JSON.stringify({ version, commit, packageRoot: PKG_ROOT }) + '\n',
183
489
  );
490
+
491
+ if (shouldWriteLauncher) {
492
+ writeLauncher({ clientDir, nodePath: nodeResolution.path, platform });
493
+ }
184
494
  }
185
495
 
186
496
  /**
@@ -196,126 +506,143 @@ export function removeClientFiles() {
196
506
  // Hook definitions per tool
197
507
  // ---------------------------------------------------------------------------
198
508
 
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) }] }),
509
+ // Claude Code hook event names used by both global (absolute path) and project (tilde) modes.
510
+ const CLAUDE_HOOK_SPEC = {
511
+ SessionStart: { matcher: '' },
512
+ SessionEnd: { matcher: '' },
513
+ UserPromptSubmit: { matcher: '' },
514
+ PreToolUse: { matcher: 'EnterPlanMode|ExitPlanMode' },
515
+ PostToolUse: { matcher: '' },
516
+ PostToolUseFailure: { matcher: '' },
517
+ Stop: { matcher: '' },
518
+ PreCompact: { matcher: '' },
519
+ SubagentStart: { matcher: '' },
520
+ SubagentStop: { matcher: '' },
211
521
  };
212
522
 
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
- };
523
+ const CURSOR_HOOK_NAMES = [
524
+ 'sessionStart', 'beforeSubmitPrompt', 'postToolUse', 'postToolUseFailure',
525
+ 'afterFileEdit', 'afterShellExecution', 'afterMCPExecution',
526
+ 'subagentStart', 'subagentStop', 'preCompact',
527
+ 'afterAgentResponse', 'afterAgentThought', 'stop', 'sessionEnd',
528
+ ];
231
529
 
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;
530
+ // Wrap captureCommand in a memoised getter so makeXxxHookDefs(null) does NOT
531
+ // trigger node-path resolution at builder time — the first .hookDefs[name]()
532
+ // call does it instead, and subsequent calls reuse the cached string.
533
+ function memoizeCmd(produce) {
534
+ let cached;
535
+ let resolved = false;
536
+ return () => {
537
+ if (!resolved) { cached = produce(); resolved = true; }
538
+ return cached;
244
539
  };
245
540
  }
246
541
 
542
+ /**
543
+ * Build Claude Code hookDefs bound to a ctx. Claude Code's settings.json is the
544
+ * user's shared config, so we always emit a tilde path for portability —
545
+ * `mode` is accepted for symmetry with the Cursor builder but does not change
546
+ * the path style.
547
+ *
548
+ * The captureCommand call is deferred until the first hookDef thunk runs, so
549
+ * importing this module (or building TOOL_CONFIGS with ctx=null) never
550
+ * probes node candidates by itself.
551
+ */
552
+ export function makeClaudeHookDefs(ctx = null, _mode = 'global') {
553
+ const getCmd = memoizeCmd(() => captureCommand({ useTilde: true, rawPath: true, ctx }));
554
+ const defs = {};
555
+ for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
556
+ defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
557
+ }
558
+ return defs;
559
+ }
560
+
561
+ /**
562
+ * Build Cursor hookDefs bound to a ctx. mode: 'global' (default) | 'project'.
563
+ * Cursor does not expand ~ in hook commands, so global hooks bake the absolute
564
+ * client-dir path. Project-hooks use ~/.ai-lens/... for portability across
565
+ * teammates who clone the repo.
566
+ *
567
+ * captureCommand is deferred (see makeClaudeHookDefs).
568
+ */
569
+ export function makeCursorHookDefs(ctx = null, mode = 'global') {
570
+ const useTilde = mode === 'project';
571
+ const getCmd = memoizeCmd(() => cursorCaptureCommand({ useTilde, ctx }));
572
+ const defs = {};
573
+ for (const name of CURSOR_HOOK_NAMES) {
574
+ defs[name] = () => ({ command: getCmd() });
575
+ }
576
+ return defs;
577
+ }
578
+
247
579
  /**
248
580
  * Claude Code hook defs that point to a custom path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
249
581
  * @param {string} capturePath - Absolute path to client/capture.js.
582
+ * @param {object} [ctx]
250
583
  */
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
- };
584
+ export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
585
+ const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, ctx }));
586
+ const defs = {};
587
+ for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
588
+ defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
589
+ }
590
+ return defs;
265
591
  }
266
592
 
267
593
  /**
268
594
  * Cursor hook defs that point to a custom path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
269
595
  * @param {string} capturePath - Absolute path to client/capture.js.
596
+ * @param {object} [ctx]
270
597
  */
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
- };
598
+ export function getCursorHookDefsWithPath(capturePath, ctx = null) {
599
+ const getCmd = memoizeCmd(() => cursorCaptureCommand({ customPath: capturePath, ctx }));
600
+ const defs = {};
601
+ for (const name of CURSOR_HOOK_NAMES) {
602
+ defs[name] = () => ({ command: getCmd() });
603
+ }
604
+ return defs;
289
605
  }
290
606
 
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
- ];
607
+ /**
608
+ * Build the array of TOOL_CONFIGS (Claude Code + Cursor at user-global paths).
609
+ * Pass ctx for production use; omit for the legacy TOOL_CONFIGS export
610
+ * (hookDefs lazy-resolve node path at first call).
611
+ */
612
+ export function makeToolConfigs(ctx = null) {
613
+ return [
614
+ {
615
+ name: 'Claude Code',
616
+ dirPath: join(homedir(), '.claude'),
617
+ configPath: join(homedir(), '.claude', 'settings.json'),
618
+ hookDefs: makeClaudeHookDefs(ctx, 'global'),
619
+ topLevelFields: {},
620
+ sharedConfig: true,
621
+ // Older init versions wrote hooks here — clean up on init/remove
622
+ legacyConfigPaths: [join(homedir(), '.claude', 'hooks.json')],
623
+ },
624
+ {
625
+ name: 'Cursor',
626
+ dirPath: join(homedir(), '.cursor'),
627
+ configPath: join(homedir(), '.cursor', 'hooks.json'),
628
+ hookDefs: makeCursorHookDefs(ctx, 'global'),
629
+ topLevelFields: { version: 1 },
630
+ },
631
+ ];
632
+ }
633
+
634
+ // Backward-compat: lazy-bound TOOL_CONFIGS so import doesn't trigger node resolution.
635
+ export const TOOL_CONFIGS = makeToolConfigs();
310
636
 
311
637
  /**
312
638
  * Cursor tool config with hooks in project's .cursor/ instead of ~/.cursor/.
313
639
  * Use for init (--project-hooks) and remove (when project has .cursor/hooks.json).
314
640
  * @param {string} projectRoot - Absolute path to project root (e.g. process.cwd()).
315
641
  * @param {string} [label] - Display name (default: 'Cursor (project)').
642
+ * @param {object} [ctx]
316
643
  * @returns {{ name: string, dirPath: string, configPath: string, hookDefs: object, topLevelFields: object }}
317
644
  */
318
- export function getCursorToolConfig(projectRoot, label = 'Cursor (project)') {
645
+ export function getCursorToolConfig(projectRoot, label = 'Cursor (project)', ctx = null) {
319
646
  const base = TOOL_CONFIGS.find(t => t.name === 'Cursor');
320
647
  if (!base) return null;
321
648
  return {
@@ -323,7 +650,7 @@ export function getCursorToolConfig(projectRoot, label = 'Cursor (project)') {
323
650
  name: label,
324
651
  dirPath: join(projectRoot, '.cursor'),
325
652
  configPath: join(projectRoot, '.cursor', 'hooks.json'),
326
- hookDefs: CURSOR_HOOKS_TILDE,
653
+ hookDefs: makeCursorHookDefs(ctx, 'project'),
327
654
  };
328
655
  }
329
656
 
@@ -332,9 +659,10 @@ export function getCursorToolConfig(projectRoot, label = 'Cursor (project)') {
332
659
  * Use for init (--project-hooks) and remove (when project has .claude/settings.json with AI Lens).
333
660
  * @param {string} projectRoot - Absolute path to project root (e.g. process.cwd()).
334
661
  * @param {string} [label] - Display name (default: 'Claude Code (project)').
662
+ * @param {object} [ctx]
335
663
  * @returns {{ name: string, dirPath: string, configPath: string, hookDefs: object, topLevelFields: object, sharedConfig: boolean, legacyConfigPaths: array }}
336
664
  */
337
- export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (project)') {
665
+ export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (project)', ctx = null) {
338
666
  const base = TOOL_CONFIGS.find(t => t.name === 'Claude Code');
339
667
  if (!base) return null;
340
668
  return {
@@ -342,7 +670,7 @@ export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (proje
342
670
  name: label,
343
671
  dirPath: join(projectRoot, '.claude'),
344
672
  configPath: join(projectRoot, '.claude', 'settings.json'),
345
- hookDefs: CLAUDE_CODE_HOOKS_TILDE,
673
+ hookDefs: makeClaudeHookDefs(ctx, 'project'),
346
674
  sharedConfig: false,
347
675
  legacyConfigPaths: [],
348
676
  };
@@ -352,62 +680,160 @@ export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (proje
352
680
  // AI Lens hook detection
353
681
  // ---------------------------------------------------------------------------
354
682
 
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) {
683
+ // Recognises any AI Lens hook target: launcher (run.sh / run.cmd) or capture.js
684
+ // (legacy install, repo-path mode). Returns { isAiLens, kind } where kind is
685
+ // 'launcher' | 'captureJs' for matched commands.
686
+ //
687
+ // We deliberately require an `ai-lens/` segment so we never classify an unrelated
688
+ // user hook like `node client/capture.js` as AI Lens (init/remove would otherwise
689
+ // strip it). init.js is responsible for emitting paths that contain `ai-lens/`
690
+ // in every install mode, including --use-repo-path --project-hooks.
691
+ export function isAiLensCommand(cmd) {
357
692
  const n = (cmd || '').replace(/\\/g, '/');
358
- return n.includes('.ai-lens/client/capture.js') || n.includes('ai-lens/client/capture.js');
693
+ if (n.includes('.ai-lens/client/run.sh') || n.includes('.ai-lens/client/run.cmd')) {
694
+ return { isAiLens: true, kind: 'launcher' };
695
+ }
696
+ if (n.includes('.ai-lens/client/capture.js') || n.includes('ai-lens/client/capture.js')) {
697
+ return { isAiLens: true, kind: 'captureJs' };
698
+ }
699
+ return { isAiLens: false, kind: null };
700
+ }
701
+
702
+ // Deprecated alias for back-compat with any external callers.
703
+ export function isAiLensCapturePath(cmd) {
704
+ return isAiLensCommand(cmd).isAiLens;
359
705
  }
360
706
 
361
707
  export function isAiLensHook(entry) {
362
708
  // Flat format (Cursor): { command: "..." }
363
- if (isAiLensCapturePath(entry?.command || '')) return true;
709
+ if (entry?.command != null && isAiLensCommand(entry.command).isAiLens) return true;
364
710
  // Nested format (Claude Code): { matcher, hooks: [{ command: "..." }] }
365
711
  if (Array.isArray(entry?.hooks)) {
366
- return entry.hooks.some(h => isAiLensCapturePath(h?.command || ''));
712
+ return entry.hooks.some(h => isAiLensCommand(h?.command || '').isAiLens);
367
713
  }
368
714
  return false;
369
715
  }
370
716
 
717
+ /**
718
+ * Parse a hook command into normalized parts. Only recognises the formats we emit:
719
+ * <launcher-path>
720
+ * <node-path> <capture.js-path>
721
+ * & <launcher-path> (Cursor / PowerShell)
722
+ * call <launcher-path> (Claude Code / cmd.exe with spaces)
723
+ *
724
+ * Returns { kind, path, prefix, nodePrefix } where:
725
+ * kind: 'launcher' | 'captureJs' | 'unknown'
726
+ * path: normalised target path (trailing capture.js / run.sh / run.cmd token)
727
+ * prefix: 'none' | 'cursor-ps' (& ) | 'win-claude' (call )
728
+ * nodePrefix: normalised node binary (captureJs only), or null
729
+ */
730
+ export function _parseHookCommand(cmd) {
731
+ if (typeof cmd !== 'string' || cmd.length === 0) {
732
+ return { kind: 'unknown', path: null, prefix: 'none', nodePrefix: null };
733
+ }
734
+ let rest = cmd.trim();
735
+ let prefix = 'none';
736
+ if (rest.startsWith('& ')) {
737
+ prefix = 'cursor-ps';
738
+ rest = rest.slice(2).trim();
739
+ } else if (rest.toLowerCase().startsWith('call ')) {
740
+ prefix = 'win-claude';
741
+ rest = rest.slice(5).trim();
742
+ }
743
+
744
+ const tokens = tokenizeHookCommand(rest);
745
+ if (tokens.length === 0) {
746
+ return { kind: 'unknown', path: null, prefix, nodePrefix: null };
747
+ }
748
+ const first = normalizePath(tokens[0]);
749
+
750
+ if (first.endsWith('/run.sh') || first.endsWith('/run.cmd') || first.endsWith('run.sh') || first.endsWith('run.cmd')) {
751
+ return { kind: 'launcher', path: first, prefix, nodePrefix: null };
752
+ }
753
+
754
+ if (tokens.length >= 2) {
755
+ const second = normalizePath(tokens[1]);
756
+ if (second.endsWith('capture.js')) {
757
+ return { kind: 'captureJs', path: second, prefix, nodePrefix: first };
758
+ }
759
+ }
760
+
761
+ return { kind: 'unknown', path: null, prefix, nodePrefix: null };
762
+ }
763
+
764
+ // Narrow tokenizer: splits on spaces but respects matched single or double quotes.
765
+ // Does NOT handle escapes — not needed for our emitted commands.
766
+ function tokenizeHookCommand(s) {
767
+ const tokens = [];
768
+ let buf = '';
769
+ let quote = null;
770
+ for (let i = 0; i < s.length; i++) {
771
+ const ch = s[i];
772
+ if (quote) {
773
+ if (ch === quote) {
774
+ quote = null;
775
+ } else {
776
+ buf += ch;
777
+ }
778
+ continue;
779
+ }
780
+ if (ch === '"' || ch === "'") {
781
+ quote = ch;
782
+ continue;
783
+ }
784
+ if (ch === ' ' || ch === '\t') {
785
+ if (buf.length > 0) {
786
+ tokens.push(buf);
787
+ buf = '';
788
+ }
789
+ continue;
790
+ }
791
+ buf += ch;
792
+ }
793
+ if (buf.length > 0) tokens.push(buf);
794
+ return tokens;
795
+ }
796
+
797
+ function normalizePath(p) {
798
+ return (p || '').replace(/\\/g, '/');
799
+ }
800
+
371
801
  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)
802
+ // Flat format (Cursor): single command per entry.
375
803
  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;
804
+ const expectedCmd = expected?.command || expected?.hooks?.[0]?.command || '';
805
+ return commandsMatch(entry.command, expectedCmd);
390
806
  }
391
- // Nested format (Claude Code): valid path + matcher must match when expected has matcher
807
+ // Nested format (Claude Code): { matcher, hooks: [{ command }] }
392
808
  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
- }
809
+ const expectedCmd = expected?.hooks?.[0]?.command || '';
399
810
  if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
400
- return true;
811
+ return entry.hooks.some(h => commandsMatch(h?.command || '', expectedCmd));
401
812
  }
402
813
  return false;
403
814
  }
404
815
 
816
+ function commandsMatch(entryCmd, expectedCmd) {
817
+ const e = _parseHookCommand(entryCmd);
818
+ const x = _parseHookCommand(expectedCmd);
819
+ if (x.kind === 'unknown') {
820
+ // Best-effort fallback: literal compare after slash-normalisation.
821
+ return normalizePath(entryCmd) === normalizePath(expectedCmd);
822
+ }
823
+ if (e.kind !== x.kind) return false;
824
+ if (e.prefix !== x.prefix) return false;
825
+ if (e.path !== x.path) return false;
826
+ if (x.kind === 'captureJs' && e.nodePrefix !== x.nodePrefix) return false;
827
+ return true;
828
+ }
829
+
405
830
  // ---------------------------------------------------------------------------
406
831
  // Tool detection
407
832
  // ---------------------------------------------------------------------------
408
833
 
409
- export function detectInstalledTools() {
410
- return TOOL_CONFIGS.filter(t => existsSync(t.dirPath));
834
+ export function detectInstalledTools(ctx = null) {
835
+ const tools = ctx ? makeToolConfigs(ctx) : TOOL_CONFIGS;
836
+ return tools.filter(t => existsSync(t.dirPath));
411
837
  }
412
838
 
413
839
  // ---------------------------------------------------------------------------
package/cli/init.js CHANGED
@@ -20,7 +20,9 @@ import {
20
20
  getClaudeCodeHookDefsWithPath, getCursorHookDefsWithPath,
21
21
  cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
22
22
  checkHooksDisabled, enableHooks,
23
+ findStableNodePath, isVersionPinnedNodePath, writeLauncher,
23
24
  } from './hooks.js';
25
+ import { mkdirSync } from 'node:fs';
24
26
  import { scanNestedClaudeProjects, summarizeNestedProjects } from './scan.js';
25
27
 
26
28
  function ask(question) {
@@ -173,10 +175,10 @@ function getTrackedRoots(projects, fallbackRoot) {
173
175
  return projects.split(',').map(path => path.trim()).filter(Boolean);
174
176
  }
175
177
 
176
- function makeNestedClaudeTool(projectDir, capturePathInHooks) {
177
- const tool = getClaudeCodeToolConfig(projectDir, `Claude Code (${projectDir})`);
178
+ function makeNestedClaudeTool(projectDir, capturePathInHooks, ctx = null) {
179
+ const tool = getClaudeCodeToolConfig(projectDir, `Claude Code (${projectDir})`, ctx);
178
180
  if (capturePathInHooks) {
179
- tool.hookDefs = getClaudeCodeHookDefsWithPath(capturePathInHooks);
181
+ tool.hookDefs = getClaudeCodeHookDefsWithPath(capturePathInHooks, ctx);
180
182
  }
181
183
  return tool;
182
184
  }
@@ -350,6 +352,9 @@ function getInitArgs() {
350
352
  case '--use-repo-path':
351
353
  flags.useRepoPath = true;
352
354
  break;
355
+ case '--install-launcher':
356
+ flags.installLauncher = true;
357
+ break;
353
358
  case '--mcp-scope':
354
359
  if (i + 1 < args.length) flags.mcpScope = args[++i];
355
360
  else process.stderr.write('Warning: --mcp-scope requires a value\n');
@@ -358,7 +363,7 @@ function getInitArgs() {
358
363
  const a = args[i];
359
364
  if (a.startsWith('-')) {
360
365
  process.stderr.write(`Warning: unknown flag "${a}" — did you mean --${a.replace(/^-+/, '')}?\n`);
361
- } else if (['server', 'projects', 'yes', 'no-mcp', 'no-hooks', 'project-hooks', 'use-repo-path', 'mcp-scope'].includes(a)) {
366
+ } else if (['server', 'projects', 'yes', 'no-mcp', 'no-hooks', 'project-hooks', 'use-repo-path', 'install-launcher', 'mcp-scope'].includes(a)) {
362
367
  process.stderr.write(`Warning: unexpected argument "${a}" — did you mean --${a}?\n`);
363
368
  }
364
369
  }
@@ -379,15 +384,53 @@ export default async function init() {
379
384
  heading(`AI Lens — Init v${version} (${commit})`);
380
385
  detail(`capture.js: ${CAPTURE_PATH}`);
381
386
 
382
- // Detect installed tools
387
+ // Resolve a stable node binary path up-front. The same resolution is baked into
388
+ // the per-machine launcher (run.sh / run.cmd) and into hook commands. We only
389
+ // need it when hooks will actually be written; --no-hooks skips it so users
390
+ // without a discoverable node can still set up MCP / config.
391
+ //
392
+ // --project-hooks ALWAYS writes hooks regardless of whether ~/.cursor or
393
+ // ~/.claude exist globally — so willWriteHooks must consider it.
394
+ //
395
+ // --install-launcher (added 0.8.69) forces launcher installation even when
396
+ // --no-hooks. Used by bootstrap workflows like meta-cursor where static hook
397
+ // templates point at ~/.ai-lens/client/run.sh but init must NOT overwrite
398
+ // those templates with the dynamic hook form.
399
+ const globalTools = detectInstalledTools();
400
+ const willWriteHooks = !flags.noHooks && (globalTools.length > 0 || !!flags.projectHooks);
401
+ const willInstallLauncher = willWriteHooks || !!flags.installLauncher;
402
+ let nodeResolution = null;
403
+ let ctx = null;
404
+ if (willInstallLauncher) {
405
+ nodeResolution = findStableNodePath();
406
+ if (!nodeResolution) {
407
+ error('Could not find any node binary on this system.');
408
+ info(' Install node via nvm/asdf/fnm/volta or `brew install node`, then re-run init.');
409
+ process.exit(1);
410
+ }
411
+ if (!nodeResolution.stable) {
412
+ warn(`Resolved node at ${nodeResolution.path}, but this path is version-pinned and will break on upgrade.`);
413
+ info(' Install node via a managed tool (nvm/asdf/fnm/volta) or create a stable symlink:');
414
+ info(' sudo ln -s "$(which node)" /usr/local/bin/node');
415
+ info(' Then re-run init. (Continuing with the version-pinned path for now.)');
416
+ } else {
417
+ detail(` Resolved node: ${nodeResolution.path} (${nodeResolution.source})`);
418
+ }
419
+ if (willWriteHooks) {
420
+ ctx = { nodeResolution, platform: process.platform, clientDir: join(homedir(), '.ai-lens', 'client') };
421
+ }
422
+ }
423
+
424
+ // Detect installed tools — re-detect with ctx now that it's available.
383
425
  heading('Detecting installed AI tools...');
384
- let tools = detectInstalledTools();
426
+ let tools = detectInstalledTools(ctx);
385
427
 
386
- // When --project-hooks: put Cursor and Claude Code hooks in project's .cursor/ and .claude/ instead of ~/
428
+ // When --project-hooks: put Cursor and Claude Code hooks in project's .cursor/ and .claude/ instead of ~/.
429
+ // Project tools are added unconditionally, so this path is exercised even when no global tools exist.
387
430
  if (flags.projectHooks) {
388
431
  const projectRoot = resolve(process.cwd());
389
- const cursorProject = getCursorToolConfig(projectRoot);
390
- const claudeProject = getClaudeCodeToolConfig(projectRoot);
432
+ const cursorProject = getCursorToolConfig(projectRoot, undefined, ctx);
433
+ const claudeProject = getClaudeCodeToolConfig(projectRoot, undefined, ctx);
391
434
  if (cursorProject) {
392
435
  tools = tools.filter(t => t.name !== 'Cursor');
393
436
  tools.push(cursorProject);
@@ -412,7 +455,19 @@ export default async function init() {
412
455
  if (flags.projectHooks) {
413
456
  const projectRoot = resolve(process.cwd());
414
457
  pathInHooks = relative(projectRoot, repoPathAbs).replace(/\\/g, '/');
415
- info(' Project hooks will use relative path to capture.js (from project root).');
458
+ // From inside the ai-lens package itself, `relative()` produces a bare
459
+ // `client/capture.js` that doesn't contain `ai-lens/` — isAiLensCommand
460
+ // wouldn't recognise it, and using a generic match would risk classifying
461
+ // unrelated user hooks as AI Lens. Fall back to the tilde/absolute form
462
+ // so the emitted path always carries the `ai-lens/` identifier.
463
+ if (!/(?:^|\/)ai-lens\//.test(pathInHooks)) {
464
+ pathInHooks = repoPathAbs.startsWith(home)
465
+ ? ('~' + repoPathAbs.slice(home.length)).replace(/\\/g, '/')
466
+ : repoPathAbs.replace(/\\/g, '/');
467
+ info(' Project root is inside ai-lens itself — using portable path with ai-lens/ segment for hook recognition.');
468
+ } else {
469
+ info(' Project hooks will use relative path to capture.js (from project root).');
470
+ }
416
471
  } else {
417
472
  pathInHooks = repoPathAbs.startsWith(home)
418
473
  ? ('~' + repoPathAbs.slice(home.length)).replace(/\\/g, '/')
@@ -420,8 +475,8 @@ export default async function init() {
420
475
  info(' Hooks will point to capture.js in this package (portable path).');
421
476
  }
422
477
  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);
478
+ if (tool.name.startsWith('Claude Code')) tool.hookDefs = getClaudeCodeHookDefsWithPath(pathInHooks, ctx);
479
+ else if (tool.name.startsWith('Cursor')) tool.hookDefs = getCursorHookDefsWithPath(pathInHooks, ctx);
425
480
  }
426
481
  } else if (flags.projectHooks) {
427
482
  info(' Project hooks use path ~/.ai-lens/client/capture.js.');
@@ -502,16 +557,39 @@ export default async function init() {
502
557
  // Build new config in memory — saved after "Proceed?" confirmation
503
558
  const newConfig = { ...currentConfig, serverUrl, projects };
504
559
 
505
- // Install client files to ~/.ai-lens/client/ (skip when --use-repo-path)
560
+ // Install client files to ~/.ai-lens/client/ (skip when --use-repo-path because
561
+ // capture.js comes from the monorepo / package source in that mode).
506
562
  if (!flags.useRepoPath) {
507
563
  heading('Installing client files...');
508
564
  try {
509
- installClientFiles();
565
+ installClientFiles({
566
+ nodeResolution,
567
+ platform: process.platform,
568
+ writeLauncher: willWriteHooks,
569
+ });
510
570
  success(' Copied client files to ~/.ai-lens/client/');
571
+ if (willWriteHooks) {
572
+ detail(' Wrote per-machine launcher (run.sh / run.cmd).');
573
+ }
511
574
  } catch (err) {
512
575
  error(` Failed to install client files: ${err.message}`);
513
576
  return;
514
577
  }
578
+ } else if (willInstallLauncher) {
579
+ // --use-repo-path with --install-launcher: keep capture.js out of the install
580
+ // dir (it lives in the monorepo, auto-updates via git pull) but still create
581
+ // the per-machine launcher so static hooks can route through it for proper
582
+ // node resolution. The meta-cursor bootstrap is the primary consumer.
583
+ heading('Installing launcher (--install-launcher) ...');
584
+ try {
585
+ const clientDir = join(homedir(), '.ai-lens', 'client');
586
+ mkdirSync(clientDir, { recursive: true });
587
+ writeLauncher({ clientDir, nodePath: nodeResolution.path, platform: process.platform });
588
+ success(' Wrote per-machine launcher to ~/.ai-lens/client/');
589
+ } catch (err) {
590
+ error(` Failed to write launcher: ${err.message}`);
591
+ return;
592
+ }
515
593
  } else {
516
594
  detail(' Skipping client install (--use-repo-path: using package copy).');
517
595
  }
@@ -638,7 +716,7 @@ export default async function init() {
638
716
  const capturePathInHooks = flags.useRepoPath
639
717
  ? relative(result.projectDir, resolve(REPO_CAPTURE_PATH)).replace(/\\/g, '/')
640
718
  : null;
641
- const tool = makeNestedClaudeTool(result.projectDir, capturePathInHooks);
719
+ const tool = makeNestedClaudeTool(result.projectDir, capturePathInHooks, ctx);
642
720
  tool.configPath = join(tool.dirPath, result.installTarget);
643
721
  return {
644
722
  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.69",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {