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 +1 -1
- package/CHANGELOG.md +5 -0
- package/cli/hooks.js +599 -197
- package/cli/init.js +66 -14
- package/cli/status.js +47 -25
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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(
|
|
88
|
-
|
|
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
|
-
//
|
|
91
|
-
const
|
|
92
|
-
'/opt/homebrew/bin/node',
|
|
93
|
-
'/usr/local/bin/node',
|
|
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
|
|
96
|
-
try {
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
*
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
*
|
|
135
|
-
*
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
161
|
-
*
|
|
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
|
|
165
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
200
|
-
const
|
|
201
|
-
SessionStart:
|
|
202
|
-
SessionEnd:
|
|
203
|
-
UserPromptSubmit:
|
|
204
|
-
PreToolUse:
|
|
205
|
-
PostToolUse:
|
|
206
|
-
PostToolUseFailure:
|
|
207
|
-
Stop:
|
|
208
|
-
PreCompact:
|
|
209
|
-
SubagentStart:
|
|
210
|
-
SubagentStop:
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
356
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =>
|
|
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
|
-
//
|
|
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
|
-
|
|
377
|
-
|
|
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):
|
|
783
|
+
// Nested format (Claude Code): { matcher, hooks: [{ command }] }
|
|
392
784
|
if (Array.isArray(entry?.hooks)) {
|
|
393
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
349
|
-
+ 'Fix:
|
|
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;
|