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 +1 -1
- package/CHANGELOG.md +9 -0
- package/cli/hooks.js +623 -197
- package/cli/init.js +93 -15
- package/cli/status.js +47 -25
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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
|
|
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 {}
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
*
|
|
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(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
161
|
-
*
|
|
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
|
|
165
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
200
|
-
const
|
|
201
|
-
SessionStart:
|
|
202
|
-
SessionEnd:
|
|
203
|
-
UserPromptSubmit:
|
|
204
|
-
PreToolUse:
|
|
205
|
-
PostToolUse:
|
|
206
|
-
PostToolUseFailure:
|
|
207
|
-
Stop:
|
|
208
|
-
PreCompact:
|
|
209
|
-
SubagentStart:
|
|
210
|
-
SubagentStop:
|
|
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
|
-
|
|
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
|
-
};
|
|
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
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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
|
-
};
|
|
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
|
|
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
|
-
};
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
356
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =>
|
|
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
|
-
//
|
|
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
|
-
|
|
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;
|
|
804
|
+
const expectedCmd = expected?.command || expected?.hooks?.[0]?.command || '';
|
|
805
|
+
return commandsMatch(entry.command, expectedCmd);
|
|
390
806
|
}
|
|
391
|
-
// Nested format (Claude Code):
|
|
807
|
+
// Nested format (Claude Code): { matcher, hooks: [{ command }] }
|
|
392
808
|
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
|
-
}
|
|
809
|
+
const expectedCmd = expected?.hooks?.[0]?.command || '';
|
|
399
810
|
if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
|
|
400
|
-
return
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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;
|