ai-lens 0.8.35 → 0.8.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.commithash CHANGED
@@ -1 +1 @@
1
- b3f67d5
1
+ 888a8ca
package/README.md CHANGED
@@ -21,6 +21,18 @@ This will:
21
21
 
22
22
  Re-running is safe — it updates outdated hooks and skips current ones.
23
23
 
24
+ ### Deploying hooks in a specific project (project-level hooks)
25
+
26
+ To write hooks into the project directory (`.cursor/hooks.json` and `.claude/settings.json`) instead of global `~/.cursor/` and `~/.claude/`, run from the project root:
27
+
28
+ ```bash
29
+ npx git+ssh://git@rantsports.gitlab.yandexcloud.net:ai-first-workspace/internal/analytics/ai-lens.git init --server https://ai-lens.rantsports.com --no-mcp --project-hooks
30
+ ```
31
+
32
+ Add `--use-repo-path` to run `capture.js` directly from the package (repo or npx cache) instead of copying to `~/.ai-lens/client/`. Useful when the repo is next to the workspace.
33
+
34
+ Hooks use the path `~/.ai-lens/client/capture.js` by default (or the package path with `--use-repo-path`); the config can be committed to the repo.
35
+
24
36
  Configure the server URL and optionally filter projects:
25
37
 
26
38
  ```bash
package/bin/ai-lens.js CHANGED
@@ -39,6 +39,7 @@ switch (command) {
39
39
  console.log(' --server URL Server URL (default: saved or http://localhost:3000)');
40
40
  console.log(' --yes, -y Non-interactive: accept all defaults, no prompts');
41
41
  console.log(' --projects LIST Comma-separated project paths to track');
42
+ console.log(' --no-hooks Skip writing hooks and MCP (config + auth only)');
42
43
  console.log(' --no-mcp Skip MCP server registration');
43
44
  console.log(' --mcp-scope S MCP scope: user, local, or project (default: user)');
44
45
  console.log(' remove Remove AI Lens hooks and client files');
package/cli/hooks.js CHANGED
@@ -30,6 +30,9 @@ const CONFIG_PATH = join(homedir(), '.ai-lens', 'config.json');
30
30
  // Hooks always point to the installed copy at ~/.ai-lens/client/capture.js
31
31
  export const CAPTURE_PATH = join(CLIENT_INSTALL_DIR, 'capture.js');
32
32
 
33
+ /** Path to capture.js in this package (repo or npx cache). Use with --use-repo-path to run from source. */
34
+ export const REPO_CAPTURE_PATH = join(PKG_ROOT, 'client', 'capture.js');
35
+
33
36
  // ---------------------------------------------------------------------------
34
37
  // AI Lens config (~/.ai-lens/config.json)
35
38
  // ---------------------------------------------------------------------------
@@ -99,23 +102,33 @@ function findStableNodePath() {
99
102
  return '/usr/bin/env node';
100
103
  }
101
104
 
102
- export function captureCommand() {
105
+ /**
106
+ * @param {boolean} [useTilde] - If true, use ~/.ai-lens/client/capture.js (for project-level hooks so path is portable).
107
+ * @param {boolean} [rawPath] - If true, do not quote the path (for Claude Code config; path written without quotes).
108
+ * @param {string} [customPath] - If set, use this path (e.g. REPO_CAPTURE_PATH for --use-repo-path); rawPath implied.
109
+ */
110
+ export function captureCommand(useTilde = false, rawPath = false, customPath = null) {
103
111
  const nodePath = findStableNodePath();
104
- // Normalize to forward slashes: both cmd.exe and Node.js accept forward slashes on Windows,
105
- // and bash-layer environments (e.g. Git Bash) misinterpret backslashes as escape sequences.
106
- const capturePath = CAPTURE_PATH.replace(/\\/g, '/');
107
- const escaped = shellEscape(capturePath);
108
- // /usr/bin/env doesn't need shell-escaping, but named paths do
112
+ const capturePath = customPath != null
113
+ ? customPath.replace(/\\/g, '/')
114
+ : useTilde
115
+ ? '~/.ai-lens/client/capture.js'
116
+ : CAPTURE_PATH.replace(/\\/g, '/');
117
+ const pathPart = (customPath != null || rawPath) ? capturePath : shellEscape(capturePath);
109
118
  return nodePath === '/usr/bin/env node'
110
- ? `/usr/bin/env node ${escaped}`
111
- : `${shellEscape(nodePath.replace(/\\/g, '/'))} ${escaped}`;
119
+ ? `/usr/bin/env node ${pathPart}`
120
+ : `${shellEscape(nodePath.replace(/\\/g, '/'))} ${pathPart}`;
112
121
  }
113
122
 
114
123
  // Cursor on Windows executes hooks via PowerShell, which treats a quoted path like
115
124
  // "node.exe" as a string expression, not a command invocation. The & (call) operator
116
125
  // is required. Claude Code uses bash or cmd.exe where & is not needed (and breaks bash).
117
- export function cursorCaptureCommand() {
118
- const cmd = captureCommand();
126
+ /**
127
+ * @param {boolean} [useTilde] - If true, use ~/.ai-lens/client/capture.js (for --project-hooks).
128
+ * @param {string} [customPath] - If set, use this path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
129
+ */
130
+ export function cursorCaptureCommand(useTilde = false, customPath = null) {
131
+ const cmd = customPath != null ? captureCommand(false, true, customPath) : captureCommand(useTilde);
119
132
  return process.platform === 'win32' ? `& ${cmd}` : cmd;
120
133
  }
121
134
 
@@ -164,36 +177,97 @@ export function removeClientFiles() {
164
177
  // Hook definitions per tool
165
178
  // ---------------------------------------------------------------------------
166
179
 
180
+ // Use tilde path (~/.ai-lens/...) so settings are portable (no absolute paths).
167
181
  const CLAUDE_CODE_HOOKS = {
168
- SessionStart: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
169
- SessionEnd: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
170
- UserPromptSubmit: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
171
- PreToolUse: () => ({ matcher: 'EnterPlanMode|ExitPlanMode', hooks: [{ type: 'command', command: captureCommand() }] }),
172
- PostToolUse: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
173
- PostToolUseFailure: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
174
- Stop: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
175
- PreCompact: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
176
- SubagentStart: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
177
- SubagentStop: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
182
+ SessionStart: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
183
+ SessionEnd: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
184
+ UserPromptSubmit: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
185
+ PreToolUse: () => ({ matcher: 'EnterPlanMode|ExitPlanMode', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
186
+ PostToolUse: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
187
+ PostToolUseFailure: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
188
+ Stop: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
189
+ PreCompact: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
190
+ SubagentStart: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
191
+ SubagentStop: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand(true, true) }] }),
178
192
  };
179
193
 
194
+ // Use tilde path (~/.ai-lens/...) so settings are portable (no absolute paths).
180
195
  const CURSOR_HOOKS = {
181
- sessionStart: () => ({ command: cursorCaptureCommand() }),
182
- beforeSubmitPrompt: () => ({ command: cursorCaptureCommand() }),
183
- postToolUse: () => ({ command: cursorCaptureCommand() }),
184
- postToolUseFailure: () => ({ command: cursorCaptureCommand() }),
185
- afterFileEdit: () => ({ command: cursorCaptureCommand() }),
186
- afterShellExecution: () => ({ command: cursorCaptureCommand() }),
187
- afterMCPExecution: () => ({ command: cursorCaptureCommand() }),
188
- subagentStart: () => ({ command: cursorCaptureCommand() }),
189
- subagentStop: () => ({ command: cursorCaptureCommand() }),
190
- preCompact: () => ({ command: cursorCaptureCommand() }),
191
- afterAgentResponse: () => ({ command: cursorCaptureCommand() }),
192
- afterAgentThought: () => ({ command: cursorCaptureCommand() }),
193
- stop: () => ({ command: cursorCaptureCommand() }),
194
- sessionEnd: () => ({ command: cursorCaptureCommand() }),
196
+ sessionStart: () => ({ command: cursorCaptureCommand(true) }),
197
+ beforeSubmitPrompt: () => ({ command: cursorCaptureCommand(true) }),
198
+ postToolUse: () => ({ command: cursorCaptureCommand(true) }),
199
+ postToolUseFailure: () => ({ command: cursorCaptureCommand(true) }),
200
+ afterFileEdit: () => ({ command: cursorCaptureCommand(true) }),
201
+ afterShellExecution: () => ({ command: cursorCaptureCommand(true) }),
202
+ afterMCPExecution: () => ({ command: cursorCaptureCommand(true) }),
203
+ subagentStart: () => ({ command: cursorCaptureCommand(true) }),
204
+ subagentStop: () => ({ command: cursorCaptureCommand(true) }),
205
+ preCompact: () => ({ command: cursorCaptureCommand(true) }),
206
+ afterAgentResponse: () => ({ command: cursorCaptureCommand(true) }),
207
+ afterAgentThought: () => ({ command: cursorCaptureCommand(true) }),
208
+ stop: () => ({ command: cursorCaptureCommand(true) }),
209
+ sessionEnd: () => ({ command: cursorCaptureCommand(true) }),
195
210
  };
196
211
 
212
+ // Same as CURSOR_HOOKS but command uses ~/.ai-lens/... (for --project-hooks)
213
+ const CURSOR_HOOKS_TILDE = Object.fromEntries(
214
+ Object.keys(CURSOR_HOOKS).map(k => [k, () => ({ command: cursorCaptureCommand(true) })]),
215
+ );
216
+
217
+ // Same as CLAUDE_CODE_HOOKS but command uses ~/.ai-lens/... (for --project-hooks), path without quotes
218
+ const CLAUDE_CODE_HOOKS_TILDE = {};
219
+ for (const [k, fn] of Object.entries(CLAUDE_CODE_HOOKS)) {
220
+ CLAUDE_CODE_HOOKS_TILDE[k] = () => {
221
+ const r = fn();
222
+ r.hooks[0].command = captureCommand(true, true);
223
+ return r;
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Claude Code hook defs that point to a custom path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
229
+ * @param {string} capturePath - Absolute path to client/capture.js.
230
+ */
231
+ export function getClaudeCodeHookDefsWithPath(capturePath) {
232
+ const cmd = captureCommand(false, true, capturePath);
233
+ return {
234
+ SessionStart: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
235
+ SessionEnd: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
236
+ UserPromptSubmit: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
237
+ PreToolUse: () => ({ matcher: 'EnterPlanMode|ExitPlanMode', hooks: [{ type: 'command', command: cmd }] }),
238
+ PostToolUse: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
239
+ PostToolUseFailure: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
240
+ Stop: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
241
+ PreCompact: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
242
+ SubagentStart: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
243
+ SubagentStop: () => ({ matcher: '', hooks: [{ type: 'command', command: cmd }] }),
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Cursor hook defs that point to a custom path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
249
+ * @param {string} capturePath - Absolute path to client/capture.js.
250
+ */
251
+ export function getCursorHookDefsWithPath(capturePath) {
252
+ const command = cursorCaptureCommand(false, capturePath);
253
+ return {
254
+ sessionStart: () => ({ command }),
255
+ beforeSubmitPrompt: () => ({ command }),
256
+ postToolUse: () => ({ command }),
257
+ postToolUseFailure: () => ({ command }),
258
+ afterFileEdit: () => ({ command }),
259
+ afterShellExecution: () => ({ command }),
260
+ afterMCPExecution: () => ({ command }),
261
+ subagentStart: () => ({ command }),
262
+ subagentStop: () => ({ command }),
263
+ preCompact: () => ({ command }),
264
+ afterAgentResponse: () => ({ command }),
265
+ afterAgentThought: () => ({ command }),
266
+ stop: () => ({ command }),
267
+ sessionEnd: () => ({ command }),
268
+ };
269
+ }
270
+
197
271
  export const TOOL_CONFIGS = [
198
272
  {
199
273
  name: 'Claude Code',
@@ -229,6 +303,28 @@ export function getCursorToolConfig(projectRoot, label = 'Cursor (project)') {
229
303
  name: label,
230
304
  dirPath: join(projectRoot, '.cursor'),
231
305
  configPath: join(projectRoot, '.cursor', 'hooks.json'),
306
+ hookDefs: CURSOR_HOOKS_TILDE,
307
+ };
308
+ }
309
+
310
+ /**
311
+ * Claude Code tool config with hooks in project's .claude/ instead of ~/.claude/.
312
+ * Use for init (--project-hooks) and remove (when project has .claude/settings.json with AI Lens).
313
+ * @param {string} projectRoot - Absolute path to project root (e.g. process.cwd()).
314
+ * @param {string} [label] - Display name (default: 'Claude Code (project)').
315
+ * @returns {{ name: string, dirPath: string, configPath: string, hookDefs: object, topLevelFields: object, sharedConfig: boolean, legacyConfigPaths: array }}
316
+ */
317
+ export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (project)') {
318
+ const base = TOOL_CONFIGS.find(t => t.name === 'Claude Code');
319
+ if (!base) return null;
320
+ return {
321
+ ...base,
322
+ name: label,
323
+ dirPath: join(projectRoot, '.claude'),
324
+ configPath: join(projectRoot, '.claude', 'settings.json'),
325
+ hookDefs: CLAUDE_CODE_HOOKS_TILDE,
326
+ sharedConfig: false,
327
+ legacyConfigPaths: [],
232
328
  };
233
329
  }
234
330
 
@@ -236,34 +332,39 @@ export function getCursorToolConfig(projectRoot, label = 'Cursor (project)') {
236
332
  // AI Lens hook detection
237
333
  // ---------------------------------------------------------------------------
238
334
 
335
+ // Both ~/.ai-lens/client/capture.js and repo path (e.g. internal/analytics/ai-lens/client/capture.js) are valid.
336
+ function isAiLensCapturePath(cmd) {
337
+ const n = (cmd || '').replace(/\\/g, '/');
338
+ return n.includes('.ai-lens/client/capture.js') || n.includes('ai-lens/client/capture.js');
339
+ }
340
+
239
341
  export function isAiLensHook(entry) {
240
- // Match by canonical suffix so we also catch Windows 8.3 short-name paths
241
- // (e.g. C:/Users/08A4~1/.ai-lens/...) that differ from homedir().
242
- const SUFFIX = '.ai-lens/client/capture.js';
243
- const matches = cmd => cmd.replace(/\\/g, '/').includes(SUFFIX);
244
342
  // Flat format (Cursor): { command: "..." }
245
- if (matches(entry?.command || '')) return true;
343
+ if (isAiLensCapturePath(entry?.command || '')) return true;
246
344
  // Nested format (Claude Code): { matcher, hooks: [{ command: "..." }] }
247
345
  if (Array.isArray(entry?.hooks)) {
248
- return entry.hooks.some(h => matches(h?.command || ''));
346
+ return entry.hooks.some(h => isAiLensCapturePath(h?.command || ''));
249
347
  }
250
348
  return false;
251
349
  }
252
350
 
253
351
  function isCurrentAiLensHook(entry, expected) {
254
- // Extract expected command from the hook definition (supports per-tool commands,
255
- // e.g. Cursor uses & prefix on Windows for PowerShell, Claude Code does not).
256
- const expected_cmd = expected?.command
257
- || expected?.hooks?.[0]?.command
258
- || captureCommand(); // fallback
259
- // Normalize separators so hooks installed before path-normalization fix still match
352
+ // Any valid AI Lens capture path is "current" (tilde or repo path from --use-repo-path).
260
353
  const norm = s => (s || '').replace(/\\/g, '/');
261
354
  // Flat format (Cursor)
262
- if (norm(entry?.command) === norm(expected_cmd)) return true;
263
- // Nested format (Claude Code): command + matcher must match
355
+ if (entry?.command != null) {
356
+ if (isAiLensCapturePath(entry.command)) return true;
357
+ if (norm(entry.command) === norm(expected?.command || expected?.hooks?.[0]?.command || '')) return true;
358
+ return false;
359
+ }
360
+ // Nested format (Claude Code): valid path + matcher must match when expected has matcher
264
361
  if (Array.isArray(entry?.hooks)) {
265
- if (!entry.hooks.some(h => norm(h?.command) === norm(expected_cmd))) return false;
266
- // Matcher must match expected definition (e.g. PreToolUse needs specific tool filter)
362
+ const hasValidPath = entry.hooks.some(h => isAiLensCapturePath(h?.command));
363
+ if (!hasValidPath && !entry.hooks.some(h => norm(h?.command) === norm(expected?.hooks?.[0]?.command || ''))) return false;
364
+ if (!hasValidPath) {
365
+ if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
366
+ return true;
367
+ }
267
368
  if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
268
369
  return true;
269
370
  }
package/cli/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { execSync, spawn } from 'node:child_process';
3
3
  import { existsSync, copyFileSync, readdirSync } from 'node:fs';
4
- import { join, resolve } from 'node:path';
4
+ import { join, resolve, relative } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { request as httpRequest } from 'node:http';
7
7
  import { request as httpsRequest } from 'node:https';
@@ -13,9 +13,10 @@ import {
13
13
  import { getGitIdentity } from '../client/config.js';
14
14
  import { migrateIfNeeded } from '../client/sender.js';
15
15
  import {
16
- CAPTURE_PATH, detectInstalledTools, getCursorToolConfig,
16
+ CAPTURE_PATH, REPO_CAPTURE_PATH, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig,
17
17
  analyzeToolHooks, buildMergedConfig, writeHooksConfig, describePlan,
18
18
  installClientFiles, readLensConfig, saveLensConfig, getVersionInfo,
19
+ getClaudeCodeHookDefsWithPath, getCursorHookDefsWithPath,
19
20
  cleanupLegacyHooks, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
20
21
  checkHooksDisabled, enableHooks,
21
22
  } from './hooks.js';
@@ -298,9 +299,15 @@ function getInitArgs() {
298
299
  case '--no-mcp':
299
300
  flags.noMcp = true;
300
301
  break;
302
+ case '--no-hooks':
303
+ flags.noHooks = true;
304
+ break;
301
305
  case '--project-hooks':
302
306
  flags.projectHooks = true;
303
307
  break;
308
+ case '--use-repo-path':
309
+ flags.useRepoPath = true;
310
+ break;
304
311
  case '--mcp-scope':
305
312
  if (i + 1 < args.length) flags.mcpScope = args[++i];
306
313
  else process.stderr.write('Warning: --mcp-scope requires a value\n');
@@ -309,7 +316,7 @@ function getInitArgs() {
309
316
  const a = args[i];
310
317
  if (a.startsWith('-')) {
311
318
  process.stderr.write(`Warning: unknown flag "${a}" — did you mean --${a.replace(/^-+/, '')}?\n`);
312
- } else if (['server', 'projects', 'yes', 'no-mcp', 'project-hooks', 'mcp-scope'].includes(a)) {
319
+ } else if (['server', 'projects', 'yes', 'no-mcp', 'no-hooks', 'project-hooks', 'use-repo-path', 'mcp-scope'].includes(a)) {
313
320
  process.stderr.write(`Warning: unexpected argument "${a}" — did you mean --${a}?\n`);
314
321
  }
315
322
  }
@@ -334,15 +341,48 @@ export default async function init() {
334
341
  heading('Detecting installed AI tools...');
335
342
  let tools = detectInstalledTools();
336
343
 
337
- // When --project-hooks: put Cursor hooks in project's .cursor/ instead of ~/.cursor/
344
+ // When --project-hooks: put Cursor and Claude Code hooks in project's .cursor/ and .claude/ instead of ~/
338
345
  if (flags.projectHooks) {
339
346
  const projectRoot = resolve(process.cwd());
340
347
  const cursorProject = getCursorToolConfig(projectRoot);
348
+ const claudeProject = getClaudeCodeToolConfig(projectRoot);
341
349
  if (cursorProject) {
342
350
  tools = tools.filter(t => t.name !== 'Cursor');
343
351
  tools.push(cursorProject);
344
352
  info(' Cursor hooks will be written to this project (.cursor/hooks.json).');
345
353
  }
354
+ if (claudeProject) {
355
+ tools = tools.filter(t => t.name !== 'Claude Code');
356
+ tools.push(claudeProject);
357
+ info(' Claude Code hooks will be written to this project (.claude/settings.json).');
358
+ }
359
+ }
360
+
361
+ // When --use-repo-path: run capture.js from this package; in hooks use relative or tilde path (never absolute).
362
+ if (flags.useRepoPath) {
363
+ if (!existsSync(REPO_CAPTURE_PATH)) {
364
+ error(` --use-repo-path: capture.js not found at ${REPO_CAPTURE_PATH}`);
365
+ process.exit(1);
366
+ }
367
+ const repoPathAbs = resolve(REPO_CAPTURE_PATH);
368
+ const home = homedir();
369
+ let pathInHooks;
370
+ if (flags.projectHooks) {
371
+ const projectRoot = resolve(process.cwd());
372
+ pathInHooks = relative(projectRoot, repoPathAbs).replace(/\\/g, '/');
373
+ info(' Project hooks will use relative path to capture.js (from project root).');
374
+ } else {
375
+ pathInHooks = repoPathAbs.startsWith(home)
376
+ ? ('~' + repoPathAbs.slice(home.length)).replace(/\\/g, '/')
377
+ : repoPathAbs.replace(/\\/g, '/');
378
+ info(' Hooks will point to capture.js in this package (portable path).');
379
+ }
380
+ for (const tool of tools) {
381
+ if (tool.name.startsWith('Claude Code')) tool.hookDefs = getClaudeCodeHookDefsWithPath(pathInHooks);
382
+ else if (tool.name.startsWith('Cursor')) tool.hookDefs = getCursorHookDefsWithPath(pathInHooks);
383
+ }
384
+ } else if (flags.projectHooks) {
385
+ info(' Project hooks use path ~/.ai-lens/client/capture.js.');
346
386
  }
347
387
 
348
388
  if (tools.length === 0) {
@@ -421,14 +461,18 @@ export default async function init() {
421
461
  // Build new config in memory — saved after "Proceed?" confirmation
422
462
  const newConfig = { ...currentConfig, serverUrl, projects };
423
463
 
424
- // Install client files to ~/.ai-lens/client/
425
- heading('Installing client files...');
426
- try {
427
- installClientFiles();
428
- success(' Copied client files to ~/.ai-lens/client/');
429
- } catch (err) {
430
- error(` Failed to install client files: ${err.message}`);
431
- return;
464
+ // Install client files to ~/.ai-lens/client/ (skip when --use-repo-path)
465
+ if (!flags.useRepoPath) {
466
+ heading('Installing client files...');
467
+ try {
468
+ installClientFiles();
469
+ success(' Copied client files to ~/.ai-lens/client/');
470
+ } catch (err) {
471
+ error(` Failed to install client files: ${err.message}`);
472
+ return;
473
+ }
474
+ } else {
475
+ detail(' Skipping client install (--use-repo-path: using package copy).');
432
476
  }
433
477
 
434
478
  // Authentication
@@ -486,163 +530,168 @@ export default async function init() {
486
530
  }
487
531
  }
488
532
 
489
- // Analyze each tool
490
- heading('Analyzing hook configurations...');
491
- const analyses = tools.map(tool => ({
492
- tool,
493
- analysis: analyzeToolHooks(tool),
494
- }));
495
-
496
- for (const { tool, analysis } of analyses) {
497
- const labels = {
498
- fresh: 'no hooks.json — will create',
499
- current: 'AI Lens hooks up-to-date',
500
- outdated: 'AI Lens hooks need updating',
501
- absent: 'hooks.json exists, no AI Lens hooks — will add',
502
- malformed: `hooks.json malformed — backed up, will recreate`,
503
- };
504
- const label = labels[analysis.status] || analysis.status;
505
- if (analysis.status === 'current') {
506
- success(` ${tool.name}: ${label}`);
507
- } else if (analysis.status === 'malformed') {
508
- warn(` ${tool.name}: ${label}`);
509
- } else {
510
- info(` ${tool.name}: ${label}`);
511
- }
512
- }
513
- blank();
514
-
515
- // Filter to tools that need changes
516
- const pending = analyses.filter(a => a.analysis.status !== 'current');
517
-
518
- if (pending.length === 0) {
533
+ if (flags.noHooks) {
534
+ info('--no-hooks: skipping hook configuration and MCP setup');
519
535
  saveLensConfig(newConfig);
520
-
521
- // Clean up legacy hook locations (safe: hooks are already current)
522
- for (const { tool } of analyses) {
523
- try {
524
- for (const lr of cleanupLegacyHooks(tool)) {
525
- success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
526
- }
527
- } catch (err) {
528
- // non-fatal — hooks are current, legacy cleanup is best-effort
529
- }
530
- }
531
-
532
- success('Hooks are up-to-date.');
533
536
  } else {
534
- // Show plan
535
- heading('Plan:');
536
- for (const { tool, analysis } of pending) {
537
- const plan = describePlan(tool, analysis);
538
- if (!plan) continue;
539
- info(` ${plan.description}`);
537
+ // Analyze each tool
538
+ heading('Analyzing hook configurations...');
539
+ const analyses = tools.map(tool => ({
540
+ tool,
541
+ analysis: analyzeToolHooks(tool),
542
+ }));
543
+
544
+ for (const { tool, analysis } of analyses) {
545
+ const labels = {
546
+ fresh: 'no hooks.json — will create',
547
+ current: 'AI Lens hooks up-to-date',
548
+ outdated: 'AI Lens hooks need updating',
549
+ absent: 'hooks.json exists, no AI Lens hooks — will add',
550
+ malformed: `hooks.json malformed — backed up, will recreate`,
551
+ };
552
+ const label = labels[analysis.status] || analysis.status;
553
+ if (analysis.status === 'current') {
554
+ success(` ${tool.name}: ${label}`);
555
+ } else if (analysis.status === 'malformed') {
556
+ warn(` ${tool.name}: ${label}`);
557
+ } else {
558
+ info(` ${tool.name}: ${label}`);
559
+ }
540
560
  }
541
561
  blank();
542
562
 
543
- // Confirm
544
- if (!auto) {
545
- const answer = await ask('Proceed? [Y/n] ');
546
- if (answer && !['y', 'yes'].includes(answer.toLowerCase())) {
547
- info('Aborted.');
548
- return;
549
- }
550
- }
563
+ // Filter to tools that need changes
564
+ const pending = analyses.filter(a => a.analysis.status !== 'current');
551
565
 
552
- // Apply
553
- heading('Applying changes...');
554
- const results = [];
566
+ if (pending.length === 0) {
567
+ saveLensConfig(newConfig);
555
568
 
556
- for (const { tool, analysis } of pending) {
557
- try {
558
- // Backup malformed shared configs (e.g. ~/.claude/settings.json) before overwriting
559
- if (analysis.status === 'malformed' && tool.sharedConfig) {
560
- try { copyFileSync(tool.configPath, tool.configPath + '.bak'); } catch { /* file may be gone */ }
569
+ // Clean up legacy hook locations (safe: hooks are already current)
570
+ for (const { tool } of analyses) {
571
+ try {
572
+ for (const lr of cleanupLegacyHooks(tool)) {
573
+ success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
574
+ }
575
+ } catch (err) {
576
+ // non-fatal — hooks are current, legacy cleanup is best-effort
561
577
  }
562
- const existingConfig = analysis.config || null;
563
- const merged = buildMergedConfig(tool, existingConfig);
564
- writeHooksConfig(tool, merged);
565
- success(` ${tool.name}: done`);
566
- results.push({ tool: tool.name, ok: true });
567
- } catch (err) {
568
- error(` ${tool.name}: failed — ${err.message}`);
569
- results.push({ tool: tool.name, ok: false, error: err.message });
570
578
  }
571
- }
572
579
 
573
- // Persist config only after hooks are written — avoids inconsistency where
574
- // config has new serverUrl/projects but hooks still use old commands.
575
- saveLensConfig(newConfig);
580
+ success('Hooks are up-to-date.');
581
+ } else {
582
+ // Show plan
583
+ heading('Plan:');
584
+ for (const { tool, analysis } of pending) {
585
+ const plan = describePlan(tool, analysis);
586
+ if (!plan) continue;
587
+ info(` ${plan.description}`);
588
+ }
589
+ blank();
576
590
 
577
- // Verify hooks were written correctly
578
- heading('Verifying hooks...');
579
- let verifyFailed = false;
580
- for (const { tool } of pending) {
581
- const recheck = analyzeToolHooks(tool);
582
- if (recheck.status === 'current') {
583
- success(` ${tool.name}: hooks verified`);
584
- } else {
585
- error(` ${tool.name}: hooks not current (status: ${recheck.status})`);
586
- verifyFailed = true;
591
+ // Confirm
592
+ if (!auto) {
593
+ const answer = await ask('Proceed? [Y/n] ');
594
+ if (answer && !['y', 'yes'].includes(answer.toLowerCase())) {
595
+ info('Aborted.');
596
+ return;
597
+ }
587
598
  }
588
- }
589
599
 
590
- // Clean up legacy hook locations only AFTER new hooks are verified.
591
- // Without this guard, a failed write would delete working legacy hooks,
592
- // leaving the user with no hooks at all.
593
- if (!verifyFailed) {
594
- for (const { tool } of analyses) {
600
+ // Apply
601
+ heading('Applying changes...');
602
+ const results = [];
603
+
604
+ for (const { tool, analysis } of pending) {
595
605
  try {
596
- for (const lr of cleanupLegacyHooks(tool)) {
597
- success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
606
+ // Backup malformed shared configs (e.g. ~/.claude/settings.json) before overwriting
607
+ if (analysis.status === 'malformed' && tool.sharedConfig) {
608
+ try { copyFileSync(tool.configPath, tool.configPath + '.bak'); } catch { /* file may be gone */ }
598
609
  }
610
+ const existingConfig = analysis.config || null;
611
+ const merged = buildMergedConfig(tool, existingConfig);
612
+ writeHooksConfig(tool, merged);
613
+ success(` ${tool.name}: done`);
614
+ results.push({ tool: tool.name, ok: true });
599
615
  } catch (err) {
600
- // non-fatalhooks are verified, legacy cleanup is best-effort
616
+ error(` ${tool.name}: failed${err.message}`);
617
+ results.push({ tool: tool.name, ok: false, error: err.message });
601
618
  }
602
619
  }
603
- }
604
620
 
605
- // Summary
606
- heading('Summary');
607
- for (const r of results) {
608
- if (r.ok) {
609
- success(` ${r.tool}: configured`);
610
- } else {
611
- error(` ${r.tool}: failed (${r.error})`);
621
+ // Persist config only after hooks are written — avoids inconsistency where
622
+ // config has new serverUrl/projects but hooks still use old commands.
623
+ saveLensConfig(newConfig);
624
+
625
+ // Verify hooks were written correctly
626
+ heading('Verifying hooks...');
627
+ let verifyFailed = false;
628
+ for (const { tool } of pending) {
629
+ const recheck = analyzeToolHooks(tool);
630
+ if (recheck.status === 'current') {
631
+ success(` ${tool.name}: hooks verified`);
632
+ } else {
633
+ error(` ${tool.name}: hooks not current (status: ${recheck.status})`);
634
+ verifyFailed = true;
635
+ }
612
636
  }
613
- }
614
- if (results.some(r => !r.ok) || verifyFailed) {
615
- process.exitCode = 1;
616
- }
617
- }
618
637
 
619
- // Enable hooks if globally disabled (e.g. disableAllHooks in settings.local.json)
620
- for (const { tool } of analyses) {
621
- const disabled = checkHooksDisabled(tool);
622
- if (disabled.length > 0) {
623
- for (const d of disabled) {
624
- warn(` ${tool.name}: hooks disabled by disableAllHooks in ${d.filePath}`);
638
+ // Clean up legacy hook locations only AFTER new hooks are verified.
639
+ // Without this guard, a failed write would delete working legacy hooks,
640
+ // leaving the user with no hooks at all.
641
+ if (!verifyFailed) {
642
+ for (const { tool } of analyses) {
643
+ try {
644
+ for (const lr of cleanupLegacyHooks(tool)) {
645
+ success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
646
+ }
647
+ } catch (err) {
648
+ // non-fatal — hooks are verified, legacy cleanup is best-effort
649
+ }
650
+ }
625
651
  }
626
- let shouldFix;
627
- if (auto) {
628
- shouldFix = true;
629
- } else {
630
- const answer = await ask('Remove disableAllHooks to enable hooks? [Y/n] ');
631
- shouldFix = !answer || ['y', 'yes'].includes(answer.toLowerCase());
652
+
653
+ // Summary
654
+ heading('Summary');
655
+ for (const r of results) {
656
+ if (r.ok) {
657
+ success(` ${r.tool}: configured`);
658
+ } else {
659
+ error(` ${r.tool}: failed (${r.error})`);
660
+ }
632
661
  }
633
- if (shouldFix) {
662
+ if (results.some(r => !r.ok) || verifyFailed) {
663
+ process.exitCode = 1;
664
+ }
665
+ }
666
+
667
+ // Enable hooks if globally disabled (e.g. disableAllHooks in settings.local.json)
668
+ for (const { tool } of analyses) {
669
+ const disabled = checkHooksDisabled(tool);
670
+ if (disabled.length > 0) {
634
671
  for (const d of disabled) {
635
- try {
636
- enableHooks(d.filePath);
637
- success(` Removed disableAllHooks from ${d.filePath}`);
638
- } catch (err) {
639
- error(` Could not fix ${d.filePath}: ${err.message}`);
672
+ warn(` ${tool.name}: hooks disabled by disableAllHooks in ${d.filePath}`);
673
+ }
674
+ let shouldFix;
675
+ if (auto) {
676
+ shouldFix = true;
677
+ } else {
678
+ const answer = await ask('Remove disableAllHooks to enable hooks? [Y/n] ');
679
+ shouldFix = !answer || ['y', 'yes'].includes(answer.toLowerCase());
680
+ }
681
+ if (shouldFix) {
682
+ for (const d of disabled) {
683
+ try {
684
+ enableHooks(d.filePath);
685
+ success(` Removed disableAllHooks from ${d.filePath}`);
686
+ } catch (err) {
687
+ error(` Could not fix ${d.filePath}: ${err.message}`);
688
+ }
640
689
  }
690
+ } else {
691
+ warn(' Hooks will remain disabled. AI Lens cannot collect events.');
641
692
  }
642
- } else {
643
- warn(' Hooks will remain disabled. AI Lens cannot collect events.');
693
+ blank();
644
694
  }
645
- blank();
646
695
  }
647
696
  }
648
697
 
@@ -680,81 +729,79 @@ export default async function init() {
680
729
  }
681
730
 
682
731
  // MCP setup (HTTP transport — auth via OAuth in browser, no token needed)
683
- const mcpUrl = `${serverUrl}/mcp`;
684
- const setupMcp = !flags.noMcp;
685
-
686
- // Claude Code MCP
687
- const claudeDir = join(homedir(), '.claude');
688
- const hasClaudeDir = existsSync(claudeDir);
689
- let hasClaudeCli = false;
690
- try { execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
691
-
692
- if (hasClaudeDir && hasClaudeCli) {
693
- heading('MCP Server — Claude Code');
694
- let doSetup;
695
- if (auto || flags.noMcp !== undefined) {
696
- doSetup = setupMcp;
697
- } else {
698
- const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
699
- doSetup = !mcpAnswer || mcpAnswer.toLowerCase() === 'y';
700
- }
701
-
702
- if (doSetup) {
703
- let scope;
704
- if (flags.mcpScope && ['local', 'project', 'user'].includes(flags.mcpScope)) {
705
- scope = flags.mcpScope;
706
- } else if (auto) {
707
- scope = 'user';
732
+ if (!flags.noMcp) {
733
+ const mcpUrl = `${serverUrl}/mcp`;
734
+
735
+ // Claude Code MCP
736
+ const claudeDir = join(homedir(), '.claude');
737
+ const hasClaudeDir = existsSync(claudeDir);
738
+ let hasClaudeCli = false;
739
+ try { execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
740
+
741
+ if (hasClaudeDir && hasClaudeCli) {
742
+ heading('MCP Server — Claude Code');
743
+ let doSetup;
744
+ if (auto) {
745
+ doSetup = true;
708
746
  } else {
709
- const scopeInput = await ask(' Scope user, local, or project? (Enter = user): ');
710
- scope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
747
+ const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
748
+ doSetup = !mcpAnswer || mcpAnswer.toLowerCase() === 'y';
711
749
  }
712
- try {
713
- // Remove old stdio-based MCP from all scopes, then add HTTP-based
714
- try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore', shell: true }); } catch {}
715
- try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore', shell: true }); } catch {}
716
- try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore', shell: true }); } catch {}
717
- cleanupEmptyMcpJson();
718
- // Shell-escape mcpUrl to prevent command injection (value is user-supplied via --server).
719
- // cmd.exe escapes quotes by doubling (""); Unix shells use backslash-single-quote.
720
- const escapedMcpUrl = process.platform === 'win32'
721
- ? `"${mcpUrl.replace(/"/g, '""')}"`
722
- : `'${mcpUrl.replace(/'/g, "'\\''")}'`;
723
- execSync(
724
- `claude mcp add --transport http ai-lens -s ${scope} ${escapedMcpUrl}`,
725
- { stdio: 'inherit', shell: true },
726
- );
727
- success(` MCP server registered in Claude Code (${scope})`);
728
- } catch (err) {
729
- error(` Failed to register MCP server: ${err.message}`);
750
+
751
+ if (doSetup) {
752
+ let scope;
753
+ if (flags.mcpScope && ['local', 'project', 'user'].includes(flags.mcpScope)) {
754
+ scope = flags.mcpScope;
755
+ } else if (auto) {
756
+ scope = 'user';
757
+ } else {
758
+ const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
759
+ scope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
760
+ }
761
+ try {
762
+ try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore', shell: true }); } catch {}
763
+ try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore', shell: true }); } catch {}
764
+ try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore', shell: true }); } catch {}
765
+ cleanupEmptyMcpJson();
766
+ const escapedMcpUrl = process.platform === 'win32'
767
+ ? `"${mcpUrl.replace(/"/g, '""')}"`
768
+ : `'${mcpUrl.replace(/'/g, "'\\''")}'`;
769
+ execSync(
770
+ `claude mcp add --transport http ai-lens -s ${scope} ${escapedMcpUrl}`,
771
+ { stdio: 'inherit', shell: true },
772
+ );
773
+ success(` MCP server registered in Claude Code (${scope})`);
774
+ } catch (err) {
775
+ error(` Failed to register MCP server: ${err.message}`);
776
+ }
777
+ } else {
778
+ info(' Skipped');
730
779
  }
731
- } else {
732
- info(' Skipped');
733
780
  }
734
- }
735
781
 
736
- // Cursor MCP
737
- const cursorDir = join(homedir(), '.cursor');
738
- if (existsSync(cursorDir)) {
739
- heading('MCP Server — Cursor');
740
- let doSetup;
741
- if (auto || flags.noMcp !== undefined) {
742
- doSetup = setupMcp;
743
- } else {
744
- const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
745
- doSetup = !cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y';
746
- }
782
+ // Cursor MCP
783
+ const cursorDir = join(homedir(), '.cursor');
784
+ if (existsSync(cursorDir)) {
785
+ heading('MCP Server — Cursor');
786
+ let doSetup;
787
+ if (auto) {
788
+ doSetup = true;
789
+ } else {
790
+ const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
791
+ doSetup = !cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y';
792
+ }
747
793
 
748
- if (doSetup) {
749
- try {
750
- removeCursorMcp();
751
- addCursorMcp(mcpUrl);
752
- success(' MCP server registered in Cursor (~/.cursor/mcp.json)');
753
- } catch (err) {
754
- error(` Failed to register MCP server: ${err.message}`);
794
+ if (doSetup) {
795
+ try {
796
+ removeCursorMcp();
797
+ addCursorMcp(mcpUrl);
798
+ success(' MCP server registered in Cursor (~/.cursor/mcp.json)');
799
+ } catch (err) {
800
+ error(` Failed to register MCP server: ${err.message}`);
801
+ }
802
+ } else {
803
+ info(' Skipped');
755
804
  }
756
- } else {
757
- info(' Skipped');
758
805
  }
759
806
  }
760
807
 
package/cli/remove.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  heading, detail, blank, getLogPath,
9
9
  } from './logger.js';
10
10
  import {
11
- detectInstalledTools, getCursorToolConfig, analyzeToolHooks,
11
+ detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, analyzeToolHooks,
12
12
  buildStrippedConfig, writeHooksConfig, removeClientFiles, getVersionInfo,
13
13
  cleanupLegacyHooks, cleanupEmptyMcpJson, removeCursorMcp,
14
14
  } from './hooks.js';
@@ -31,14 +31,20 @@ export default async function remove() {
31
31
  heading(`AI Lens — Remove v${version} (${commit})`);
32
32
  blank();
33
33
 
34
- // Detect tools with AI Lens hooks (global + project .cursor/hooks.json if present)
34
+ // Detect tools with AI Lens hooks (global + project .cursor/ and .claude/ if present)
35
35
  heading('Scanning for AI Lens hooks...');
36
36
  let tools = detectInstalledTools();
37
- const projectCursor = getCursorToolConfig(resolve(process.cwd()), 'Cursor (project)');
37
+ const projectRoot = resolve(process.cwd());
38
+ const projectCursor = getCursorToolConfig(projectRoot, 'Cursor (project)');
38
39
  if (projectCursor && existsSync(projectCursor.configPath)) {
39
40
  tools = tools.filter(t => t.name !== 'Cursor' || t.configPath !== projectCursor.configPath);
40
41
  tools.push(projectCursor);
41
42
  }
43
+ const projectClaude = getClaudeCodeToolConfig(projectRoot, 'Claude Code (project)');
44
+ if (projectClaude && existsSync(projectClaude.configPath)) {
45
+ tools = tools.filter(t => t.name !== 'Claude Code' || t.configPath !== projectClaude.configPath);
46
+ tools.push(projectClaude);
47
+ }
42
48
  const withHooks = [];
43
49
 
44
50
  for (const tool of tools) {
package/cli/status.js CHANGED
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { homedir, release as osRelease, arch as osArch } from 'node:os';
5
5
  import { randomUUID } from 'node:crypto';
6
6
 
7
- import { getVersionInfo, readLensConfig, detectInstalledTools, analyzeToolHooks, checkHooksDisabled, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
7
+ import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, analyzeToolHooks, checkHooksDisabled, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
8
8
  import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity } from '../client/config.js';
9
9
  import { isLockStale } from '../client/sender.js';
10
10
  import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
@@ -82,16 +82,25 @@ function extractHookCommand(tool) {
82
82
  return null;
83
83
  }
84
84
 
85
+ function expandTilde(pathStr) {
86
+ if (!pathStr || typeof pathStr !== 'string') return pathStr;
87
+ const home = homedir();
88
+ if (pathStr === '~') return home;
89
+ if (pathStr.startsWith('~/')) return join(home, pathStr.slice(2));
90
+ if (pathStr.startsWith('~\\')) return join(home, pathStr.slice(2));
91
+ return pathStr;
92
+ }
93
+
85
94
  function validateHookCommandPaths(tool) {
86
95
  const command = extractHookCommand(tool);
87
96
  if (!command) return null;
88
97
 
89
98
  const issues = [];
90
99
 
91
- // Extract capture.js path using 'capture.js' as anchor
100
+ // Extract capture.js path using 'capture.js' as anchor (expand ~ so existsSync works)
92
101
  const captureMatch = command.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
93
102
  if (captureMatch) {
94
- const capturePath = captureMatch[1] || captureMatch[2];
103
+ const capturePath = expandTilde(captureMatch[1] || captureMatch[2]);
95
104
  if (!existsSync(capturePath)) {
96
105
  issues.push(`capture.js not found at: ${capturePath}`);
97
106
  }
@@ -103,7 +112,7 @@ function validateHookCommandPaths(tool) {
103
112
  if (!cmdForNode.startsWith('/usr/bin/env node')) {
104
113
  const nodeMatch = cmdForNode.match(/^["']([^"']+)["']|^(\S+)/);
105
114
  if (nodeMatch) {
106
- const nodePath = nodeMatch[1] || nodeMatch[2];
115
+ const nodePath = expandTilde(nodeMatch[1] || nodeMatch[2]);
107
116
  if (nodePath !== 'node' && !existsSync(nodePath)) {
108
117
  issues.push(`node not found at: ${nodePath}`);
109
118
  }
@@ -113,6 +122,21 @@ function validateHookCommandPaths(tool) {
113
122
  return issues.length > 0 ? issues : null;
114
123
  }
115
124
 
125
+ /**
126
+ * Global tools (detectInstalledTools) plus project-level tools when cwd has .cursor/hooks.json
127
+ * or .claude/settings.json. Status is assumed to be run from the project root where hooks live.
128
+ */
129
+ function getToolsForCaptureTest() {
130
+ const global = detectInstalledTools();
131
+ const projectRoot = process.cwd();
132
+ const projectTools = [];
133
+ const cursorProject = getCursorToolConfig(projectRoot);
134
+ if (cursorProject && existsSync(cursorProject.configPath)) projectTools.push(cursorProject);
135
+ const claudeProject = getClaudeCodeToolConfig(projectRoot);
136
+ if (claudeProject && existsSync(claudeProject.configPath)) projectTools.push(claudeProject);
137
+ return [...global, ...projectTools];
138
+ }
139
+
116
140
  function checkCaptureRun(installedTools) {
117
141
  // Collect commands from ALL installed tools — not just the first found (Issues 8-9).
118
142
  // If Claude Code works but Cursor is broken, both must be tested to surface the failure.
@@ -126,8 +150,22 @@ function checkCaptureRun(installedTools) {
126
150
  return { ok: null, summary: 'no hook command found', detail: 'Could not find an AI Lens hook command in any tool config' };
127
151
  }
128
152
 
153
+ const defaultCaptureScript = join(homedir(), '.ai-lens', 'client', 'capture.js');
154
+ const repoCaptureScript = join(process.cwd(), 'internal', 'analytics', 'ai-lens', 'client', 'capture.js');
129
155
  const toolResults = [];
130
156
  for (const { name, command } of toolCommands) {
157
+ // Prefer capture.js path from this tool's command when it exists; else default; else repo path (status run from workspace root).
158
+ const captureMatch = command.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
159
+ const rawPath = captureMatch ? (captureMatch[1] || captureMatch[2]) : null;
160
+ const fromCommand = rawPath ? expandTilde(rawPath) : null;
161
+ const captureScript = (fromCommand && existsSync(fromCommand))
162
+ ? fromCommand
163
+ : existsSync(defaultCaptureScript)
164
+ ? defaultCaptureScript
165
+ : existsSync(repoCaptureScript)
166
+ ? repoCaptureScript
167
+ : defaultCaptureScript;
168
+
131
169
  // Unique session_id per tool run so capture.log entries can be matched precisely (Issue 10).
132
170
  const testSessionId = 'status-check-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7);
133
171
  const testEvent = JSON.stringify({
@@ -136,9 +174,6 @@ function checkCaptureRun(installedTools) {
136
174
  stop_reason: 'test',
137
175
  cwd: join(homedir(), '.ai-lens-status-check-cwd'),
138
176
  });
139
- // Use spawnSync with stdin piped directly — avoids shell echo pipe syntax
140
- // that fails on Windows (no /usr/bin/env, single-quote handling differs).
141
- const captureScript = join(homedir(), '.ai-lens', 'client', 'capture.js');
142
177
  const testCmd = `${process.execPath} ${captureScript} < (test event)`;
143
178
  let exitOk = false;
144
179
  let exitDetail = '';
@@ -191,23 +226,35 @@ function checkCaptureRun(installedTools) {
191
226
  try {
192
227
  // On Windows, Cursor runs hooks via PowerShell (needs `& "..."` call-operator),
193
228
  // but Claude Code runs hooks via bash/cmd (no `&` prefix).
194
- // Test each tool with the shell it actually uses.
229
+ // On non-Windows, strip leading "& " if present (e.g. config copied from Windows) so bash doesn't background the process.
195
230
  const isWin = process.platform === 'win32';
196
231
  const isCursor = name === 'Cursor';
197
232
  const usePS = isWin && isCursor;
233
+ let shellCommand = !isWin && command.startsWith('& ') ? command.slice(2).trim() : command;
234
+ // Expand ~ in the command: bash/zsh won't expand ~ inside single quotes,
235
+ // so replace '~/...' and bare ~/... with the absolute home path.
236
+ if (!isWin) {
237
+ const home = homedir();
238
+ shellCommand = shellCommand
239
+ .replace(/'~\//g, `'${home}/`)
240
+ .replace(/"~\//g, `"${home}/`)
241
+ .replace(/(^|[ =])~\//g, `$1${home}/`);
242
+ }
243
+ // If the command's capture.js path doesn't exist, substitute the working path so the shell test can pass (e.g. only repo path exists).
244
+ if (rawPath && !existsSync(expandTilde(rawPath)) && captureScript !== expandTilde(rawPath)) {
245
+ const replacement = captureScript.includes(' ') ? `"${captureScript.replace(/"/g, '\\"')}"` : captureScript;
246
+ shellCommand = shellCommand.replace(rawPath, replacement);
247
+ }
198
248
  const shellResult = spawnSync(
199
- usePS ? 'powershell.exe' : isWin ? 'cmd.exe' : command,
249
+ usePS ? 'powershell.exe' : isWin ? 'cmd.exe' : '/bin/sh',
200
250
  usePS ? ['-NoProfile', '-Command', command]
201
251
  : isWin ? ['/c', command]
202
- : [],
252
+ : ['-c', shellCommand],
203
253
  {
204
- // Prepend UTF-8 BOM on Windows — PowerShell adds it when piping stdin,
205
- // so we must test that capture.js handles it correctly.
206
254
  input: isWin ? '\uFEFF' + shellEvent : shellEvent,
207
- shell: !isWin,
208
255
  encoding: 'utf-8',
209
256
  timeout: 10_000,
210
- env: { ...process.env, AI_LENS_PROJECTS: join(homedir(), '.ai-lens-status-check-nonexistent') },
257
+ env: { ...process.env, AI_LENS_PROJECTS: join(homedir(), '.ai-lens-status-check-nonexistent'), AI_LENS_STATUS_CHECK: '1' },
211
258
  windowsHide: true,
212
259
  },
213
260
  );
@@ -291,11 +338,10 @@ function checkToolVersion(appName) {
291
338
  const version = execSafe(`defaults read "${plistPath}" CFBundleShortVersionString 2>/dev/null`);
292
339
  if (version) return { ok: true, summary: `v${version}`, detail: `${appName}: v${version}` };
293
340
  }
294
- // Cross-platform fallback: use spawnSync with shell:true for reliable PATH resolution
295
- // on Windows (handles .exe extensions and PATH lookup automatically).
341
+ // Cross-platform fallback: spawn without shell to avoid DEP0190.
296
342
  const cliName = appName.toLowerCase().replace(/\s+/g, '');
297
343
  try {
298
- const result = spawnSync(cliName, ['--version'], { encoding: 'utf-8', timeout: 5000, shell: true });
344
+ const result = spawnSync(cliName, ['--version'], { encoding: 'utf-8', timeout: 5000 });
299
345
  const version = (result.stdout || result.stderr || '').trim();
300
346
  if (result.status === 0 && version) return { ok: true, summary: version, detail: `${appName}: ${version}` };
301
347
  } catch {}
@@ -979,25 +1025,32 @@ export default async function status() {
979
1025
  const configResult = checkConfig();
980
1026
  printLine('Config', configResult);
981
1027
 
982
- // 6. Hooks per installed tool
1028
+ // 6. Hooks: global + project (Cursor then Claude Code; within each: global then project)
983
1029
  const installedTools = detectInstalledTools();
984
- for (const tool of installedTools) {
985
- printLine(tool.name, checkHooks(tool));
1030
+ const toolsWithProject = getToolsForCaptureTest();
1031
+ const toolLabel = (tool) => (TOOL_CONFIGS.includes(tool) ? `${tool.name} (global)` : tool.name);
1032
+ const hooksOrder = (a, b) => {
1033
+ const nameA = toolLabel(a), nameB = toolLabel(b);
1034
+ const cursorFirst = (n) => (n.startsWith('Cursor') ? 0 : 1);
1035
+ const globalFirst = (n) => (n.includes('(global)') ? 0 : 1);
1036
+ return cursorFirst(nameA) - cursorFirst(nameB) || globalFirst(nameA) - globalFirst(nameB);
1037
+ };
1038
+ for (const tool of toolsWithProject.slice().sort(hooksOrder)) {
1039
+ printLine(toolLabel(tool), checkHooks(tool));
986
1040
  }
987
- // Also report tools from TOOL_CONFIGS that aren't installed
988
- for (const tool of TOOL_CONFIGS) {
989
- if (!installedTools.includes(tool)) {
990
- const r = { ok: null, summary: 'not installed', detail: `${tool.name} directory not found at ${tool.dirPath}` };
991
- printLine(tool.name, r);
992
- }
1041
+ // Global tools not installed (no ~/.cursor or ~/.claude) — same order: Cursor then Claude Code
1042
+ const notInstalled = TOOL_CONFIGS.filter(t => !installedTools.includes(t));
1043
+ for (const tool of notInstalled.sort((a, b) => (a.name === 'Cursor' ? -1 : b.name === 'Cursor' ? 1 : 0))) {
1044
+ const r = { ok: null, summary: 'not installed', detail: `${tool.name} directory not found at ${tool.dirPath}` };
1045
+ printLine(toolLabel(tool), r);
993
1046
  }
994
1047
 
995
1048
  // 7. Queue (before capture test so test event doesn't show as pending)
996
1049
  const queueResult = checkQueue();
997
1050
  printLine('Queue', queueResult);
998
1051
 
999
- // 8. Smoke-test the hook command
1000
- printLine('Capture test', checkCaptureRun(installedTools));
1052
+ // 8. Smoke-test the hook command (global + project)
1053
+ printLine('Capture test', checkCaptureRun(toolsWithProject));
1001
1054
 
1002
1055
  // 9. Sender log
1003
1056
  printLine('Sender log', checkSenderLog());
package/client/capture.js CHANGED
@@ -585,6 +585,11 @@ function trySpawnSender() {
585
585
  // =============================================================================
586
586
 
587
587
  async function main() {
588
+ // Skip capture when called from subprocess (e.g. analyze-person-dossier.py)
589
+ if (process.env.AI_LENS_SKIP === '1') {
590
+ process.exit(0);
591
+ }
592
+
588
593
  // Check server is configured
589
594
  const serverUrl = getServerUrl();
590
595
  if (!serverUrl) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.35",
3
+ "version": "0.8.37",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {
@@ -15,6 +15,9 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "prepare": "git rev-parse --short HEAD > .commithash 2>/dev/null || true",
18
+ "init": "node bin/ai-lens.js init",
19
+ "remove": "node bin/ai-lens.js remove",
20
+ "status": "node bin/ai-lens.js status",
18
21
  "prestart": "npm install --prefix server",
19
22
  "start": "node server/index.js",
20
23
  "test": "DATABASE_URL=postgresql://ailens:ailens@localhost:5432/ailens_test vitest run",