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 +1 -1
- package/README.md +12 -0
- package/bin/ai-lens.js +1 -0
- package/cli/hooks.js +151 -50
- package/cli/init.js +257 -210
- package/cli/remove.js +9 -3
- package/cli/status.js +81 -28
- package/client/capture.js +5 -0
- package/package.json +4 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 ${
|
|
111
|
-
: `${shellEscape(nodePath.replace(/\\/g, '/'))} ${
|
|
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
|
-
|
|
118
|
-
|
|
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 (
|
|
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 =>
|
|
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
|
-
//
|
|
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 (
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
//
|
|
535
|
-
heading('
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
//
|
|
544
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
const results = [];
|
|
566
|
+
if (pending.length === 0) {
|
|
567
|
+
saveLensConfig(newConfig);
|
|
555
568
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
for (const { tool } of
|
|
600
|
+
// Apply
|
|
601
|
+
heading('Applying changes...');
|
|
602
|
+
const results = [];
|
|
603
|
+
|
|
604
|
+
for (const { tool, analysis } of pending) {
|
|
595
605
|
try {
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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 (
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
710
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
{ stdio: '
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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/
|
|
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
|
|
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
|
-
//
|
|
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' :
|
|
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:
|
|
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
|
|
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
|
|
1028
|
+
// 6. Hooks: global + project (Cursor then Claude Code; within each: global then project)
|
|
983
1029
|
const installedTools = detectInstalledTools();
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
//
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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(
|
|
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.
|
|
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",
|