ai-lens 0.1.0
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 -0
- package/README.md +221 -0
- package/bin/ai-lens.js +28 -0
- package/cli/hooks.js +365 -0
- package/cli/init.js +216 -0
- package/cli/logger.js +76 -0
- package/cli/remove.js +103 -0
- package/client/capture.js +487 -0
- package/client/config.js +59 -0
- package/client/redact.js +68 -0
- package/client/sender.js +249 -0
- package/mcp-server/index.js +159 -0
- package/package.json +34 -0
package/.commithash
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
563282f
|
package/README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# AI Lens
|
|
2
|
+
|
|
3
|
+
Hook-based analytics for AI coding sessions. Captures events from Claude Code and Cursor, normalizes them to a unified format, queues locally, and ships to a centralized server with a web dashboard.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Hook fires → capture.js → normalize → queue.jsonl → sender.js → POST /api/events → server → dashboard
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Developer Setup (client)
|
|
10
|
+
|
|
11
|
+
Run the init command on each developer machine:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx ai-lens init
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
This will:
|
|
18
|
+
1. Detect installed AI tools (Claude Code, Cursor)
|
|
19
|
+
2. Copy client files to `~/.ai-lens/client/`
|
|
20
|
+
3. Configure hooks in `~/.claude/settings.json` and/or `~/.cursor/hooks.json`
|
|
21
|
+
|
|
22
|
+
Re-running is safe — it updates outdated hooks and skips current ones.
|
|
23
|
+
|
|
24
|
+
Configure the server URL and optionally filter projects:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# In your shell profile (~/.zshrc, ~/.bashrc)
|
|
28
|
+
export AI_LENS_SERVER_URL=http://your-server:13300
|
|
29
|
+
export AI_LENS_PROJECTS="~/work/, ~/projects/" # optional, default: all
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
<details>
|
|
33
|
+
<summary>Manual hook setup</summary>
|
|
34
|
+
|
|
35
|
+
**Claude Code** — `~/.claude/settings.json` (hooks section):
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"hooks": {
|
|
40
|
+
"SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
41
|
+
"UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
42
|
+
"PreToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
43
|
+
"PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
44
|
+
"Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Cursor** — `~/.cursor/hooks.json`:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"version": 1,
|
|
54
|
+
"hooks": {
|
|
55
|
+
"sessionStart": [{ "command": "node ~/.ai-lens/client/capture.js" }],
|
|
56
|
+
"beforeSubmitPrompt": [{ "command": "node ~/.ai-lens/client/capture.js" }],
|
|
57
|
+
"postToolUse": [{ "command": "node ~/.ai-lens/client/capture.js" }],
|
|
58
|
+
"afterFileEdit": [{ "command": "node ~/.ai-lens/client/capture.js" }],
|
|
59
|
+
"afterShellExecution": [{ "command": "node ~/.ai-lens/client/capture.js" }],
|
|
60
|
+
"afterMCPExecution": [{ "command": "node ~/.ai-lens/client/capture.js" }],
|
|
61
|
+
"stop": [{ "command": "node ~/.ai-lens/client/capture.js" }],
|
|
62
|
+
"sessionEnd": [{ "command": "node ~/.ai-lens/client/capture.js" }]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
</details>
|
|
68
|
+
|
|
69
|
+
## Server Setup
|
|
70
|
+
|
|
71
|
+
### Docker (production)
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
docker compose up -d
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Starts three containers:
|
|
78
|
+
- **nginx** — reverse proxy with basic auth, port `13300`
|
|
79
|
+
- **app** — Node.js Express server with dashboard
|
|
80
|
+
- **postgres** — PostgreSQL 16 database
|
|
81
|
+
|
|
82
|
+
Dashboard: `http://your-server:13300`
|
|
83
|
+
|
|
84
|
+
Default credentials:
|
|
85
|
+
|
|
86
|
+
| User | Password | Purpose |
|
|
87
|
+
|------|----------|---------|
|
|
88
|
+
| `collector` | `secret-collector-token-2026-ai-lens` | Client sender (automatic via `AI_LENS_AUTH_TOKEN`) |
|
|
89
|
+
| `meta` | `meta` | Browser / dashboard access |
|
|
90
|
+
|
|
91
|
+
### Local development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npm install --prefix server # Install server deps (auto-runs via prestart)
|
|
95
|
+
npm start # Express on port 3000, SQLite at ~/.ai-lens-server/data.db
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
SQLite is used when `DATABASE_URL` is not set. PostgreSQL is used in Docker via `DATABASE_URL=postgresql://...`.
|
|
99
|
+
|
|
100
|
+
## Dashboard
|
|
101
|
+
|
|
102
|
+
React + TypeScript SPA with session timelines, tool breakdowns, adoption trends, and per-developer analytics.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
cd dashboard
|
|
106
|
+
npm install
|
|
107
|
+
npm run dev # Vite dev server with HMR (proxies API to localhost:3000)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Production build (served by Express as static files):
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm run build:dashboard
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Tech: Vite, Tailwind CSS, Nivo charts, TanStack Query, react-router-dom.
|
|
117
|
+
|
|
118
|
+
## API
|
|
119
|
+
|
|
120
|
+
### `POST /api/events`
|
|
121
|
+
|
|
122
|
+
Batch insert events. Deduplicates by `event_id` (ON CONFLICT DO NOTHING) — safe to re-send.
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
Headers: X-Developer-Git-Email, X-Developer-Name, Authorization: Basic <base64>
|
|
126
|
+
Body: [{ source, session_id, type, project_path, timestamp, data, raw, event_id }]
|
|
127
|
+
Response: { received, skipped, deduplicated }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `GET /api/sessions`
|
|
131
|
+
|
|
132
|
+
List sessions. Query params: `developer_id`, `source`, `days` (default 30).
|
|
133
|
+
|
|
134
|
+
### `GET /api/sessions/:id`
|
|
135
|
+
|
|
136
|
+
Session detail with all events, plan segments, and metadata.
|
|
137
|
+
|
|
138
|
+
### `GET /api/developers`
|
|
139
|
+
|
|
140
|
+
List all developers.
|
|
141
|
+
|
|
142
|
+
### `GET /api/dashboard/*`
|
|
143
|
+
|
|
144
|
+
Aggregate endpoints for dashboard charts (stats, trends, tool usage, etc.).
|
|
145
|
+
|
|
146
|
+
## Event Types
|
|
147
|
+
|
|
148
|
+
| Type | Source | Description |
|
|
149
|
+
|------|--------|-------------|
|
|
150
|
+
| `SessionStart` | Both | Session opened |
|
|
151
|
+
| `SessionEnd` | Both | Session closed |
|
|
152
|
+
| `UserPromptSubmit` | Both | User sent a prompt |
|
|
153
|
+
| `PostToolUse` | Both | Tool execution completed |
|
|
154
|
+
| `PostToolUseFailure` | Both | Tool execution failed |
|
|
155
|
+
| `Stop` | Both | Agent stopped |
|
|
156
|
+
| `PreCompact` | Both | Context compaction triggered |
|
|
157
|
+
| `PlanModeStart` | Claude Code | Entered plan mode |
|
|
158
|
+
| `PlanModeEnd` | Claude Code | Exited plan mode (plan content in raw payload) |
|
|
159
|
+
| `SubagentStart` | Both | Subagent spawned |
|
|
160
|
+
| `SubagentStop` | Both | Subagent finished |
|
|
161
|
+
| `FileEdit` | Cursor | File edited |
|
|
162
|
+
| `ShellExecution` | Cursor | Shell command executed |
|
|
163
|
+
| `MCPExecution` | Cursor | MCP tool executed |
|
|
164
|
+
| `AgentResponse` | Cursor | Agent response |
|
|
165
|
+
| `AgentThought` | Cursor | Agent reasoning |
|
|
166
|
+
|
|
167
|
+
## Environment Variables
|
|
168
|
+
|
|
169
|
+
| Variable | Default | Description |
|
|
170
|
+
|----------|---------|-------------|
|
|
171
|
+
| `PORT` | `3000` (local), `13300` (Docker) | Server port |
|
|
172
|
+
| `DATABASE_URL` | _(unset = SQLite)_ | PostgreSQL connection string |
|
|
173
|
+
| `AI_LENS_SERVER_URL` | `http://localhost:3000` | Client → server endpoint |
|
|
174
|
+
| `AI_LENS_AUTH_TOKEN` | `collector:secret-collector-token-2026-ai-lens` | Client auth (`user:password`) |
|
|
175
|
+
| `AI_LENS_PROJECTS` | _(all)_ | Comma-separated project paths to monitor (`~` supported) |
|
|
176
|
+
|
|
177
|
+
## Client Data
|
|
178
|
+
|
|
179
|
+
Stored in `~/.ai-lens/`:
|
|
180
|
+
|
|
181
|
+
| File | Purpose |
|
|
182
|
+
|------|---------|
|
|
183
|
+
| `client/` | Installed client files (capture.js, sender.js, config.js) |
|
|
184
|
+
| `queue.jsonl` | Pending events |
|
|
185
|
+
| `queue.sending.jsonl` | Events being sent (atomic rename as mutex) |
|
|
186
|
+
| `sender.log` | Sender activity log |
|
|
187
|
+
| `session-paths.json` | Session-to-project path cache |
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm test # Run all tests (vitest, 204 tests)
|
|
193
|
+
npm run test:watch # Watch mode
|
|
194
|
+
npm run dev:dashboard # Dashboard dev server
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Tests use in-memory SQLite via `initTestDb()`.
|
|
198
|
+
|
|
199
|
+
## Deployment
|
|
200
|
+
|
|
201
|
+
GitLab CI (`.gitlab-ci.yml`) on push to `main`:
|
|
202
|
+
|
|
203
|
+
1. `rsync` to deploy host
|
|
204
|
+
2. `docker compose down && docker compose up -d --build`
|
|
205
|
+
3. Health check
|
|
206
|
+
|
|
207
|
+
## Data Migration
|
|
208
|
+
|
|
209
|
+
Sync local SQLite data to a remote PostgreSQL server:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
node scripts/sync-to-remote.js # Default remote
|
|
213
|
+
node scripts/sync-to-remote.js http://custom:13300 # Custom URL
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Safe to re-run — deduplicates by `event_id`.
|
|
217
|
+
|
|
218
|
+
## Requirements
|
|
219
|
+
|
|
220
|
+
- Node.js 20+
|
|
221
|
+
- Docker + Docker Compose (for production deployment)
|
package/bin/ai-lens.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const command = process.argv[2];
|
|
4
|
+
|
|
5
|
+
switch (command) {
|
|
6
|
+
case 'init': {
|
|
7
|
+
const { default: init } = await import('../cli/init.js');
|
|
8
|
+
await init();
|
|
9
|
+
break;
|
|
10
|
+
}
|
|
11
|
+
case 'remove': {
|
|
12
|
+
const { default: remove } = await import('../cli/remove.js');
|
|
13
|
+
await remove();
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
case 'mcp': {
|
|
17
|
+
await import('../mcp-server/index.js');
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
default:
|
|
21
|
+
console.log('Usage: ai-lens <command>');
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log('Commands:');
|
|
24
|
+
console.log(' init Configure AI tool hooks for event capture');
|
|
25
|
+
console.log(' remove Remove AI Lens hooks and client files');
|
|
26
|
+
console.log(' mcp Start the MCP server (stdio transport)');
|
|
27
|
+
process.exit(command ? 1 : 0);
|
|
28
|
+
}
|
package/cli/hooks.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const PKG_ROOT = join(__dirname, '..');
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Version info
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export function getVersionInfo() {
|
|
14
|
+
let version = 'unknown';
|
|
15
|
+
let commit = 'unknown';
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8'));
|
|
18
|
+
version = pkg.version;
|
|
19
|
+
} catch { /* ignore */ }
|
|
20
|
+
try {
|
|
21
|
+
commit = readFileSync(join(PKG_ROOT, '.commithash'), 'utf-8').trim();
|
|
22
|
+
} catch { /* ignore */ }
|
|
23
|
+
return { version, commit };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Stable install location for client files
|
|
27
|
+
const CLIENT_INSTALL_DIR = join(homedir(), '.ai-lens', 'client');
|
|
28
|
+
const CONFIG_PATH = join(homedir(), '.ai-lens', 'config.json');
|
|
29
|
+
|
|
30
|
+
// Hooks always point to the installed copy at ~/.ai-lens/client/capture.js
|
|
31
|
+
export const CAPTURE_PATH = join(CLIENT_INSTALL_DIR, 'capture.js');
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// AI Lens config (~/.ai-lens/config.json)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export function readLensConfig() {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function saveLensConfig(config) {
|
|
46
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
47
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_SERVER_URL = 'http://localhost:3000';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Escape a string for safe embedding in a single-quoted shell context.
|
|
54
|
+
* Standard POSIX approach: replace each ' with '\'' (end quote, escaped quote, start quote).
|
|
55
|
+
*/
|
|
56
|
+
export function shellEscape(str) {
|
|
57
|
+
if (typeof str !== 'string') return "''";
|
|
58
|
+
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function captureCommand() {
|
|
62
|
+
const config = readLensConfig();
|
|
63
|
+
const envs = [];
|
|
64
|
+
if (config.serverUrl && config.serverUrl !== DEFAULT_SERVER_URL) {
|
|
65
|
+
envs.push(`AI_LENS_SERVER_URL=${shellEscape(config.serverUrl)}`);
|
|
66
|
+
}
|
|
67
|
+
if (config.projects) {
|
|
68
|
+
envs.push(`AI_LENS_PROJECTS=${shellEscape(config.projects)}`);
|
|
69
|
+
}
|
|
70
|
+
const base = `node ${CAPTURE_PATH}`;
|
|
71
|
+
return envs.length > 0 ? `${envs.join(' ')} ${base}` : base;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Client file installation
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
const CLIENT_FILES = ['capture.js', 'sender.js', 'config.js', 'redact.js'];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Copy client/ files from the package source to ~/.ai-lens/client/.
|
|
82
|
+
* Works from npx cache, global install, and local dev.
|
|
83
|
+
*/
|
|
84
|
+
export function installClientFiles() {
|
|
85
|
+
const sourceDir = join(__dirname, '..', 'client');
|
|
86
|
+
mkdirSync(CLIENT_INSTALL_DIR, { recursive: true });
|
|
87
|
+
|
|
88
|
+
for (const file of CLIENT_FILES) {
|
|
89
|
+
copyFileSync(join(sourceDir, file), join(CLIENT_INSTALL_DIR, file));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ESM needs package.json with "type": "module"
|
|
93
|
+
writeFileSync(
|
|
94
|
+
join(CLIENT_INSTALL_DIR, 'package.json'),
|
|
95
|
+
'{"type":"module"}\n',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove installed client files from ~/.ai-lens/client/.
|
|
101
|
+
*/
|
|
102
|
+
export function removeClientFiles() {
|
|
103
|
+
if (existsSync(CLIENT_INSTALL_DIR)) {
|
|
104
|
+
rmSync(CLIENT_INSTALL_DIR, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Hook definitions per tool
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
const CLAUDE_CODE_HOOKS = {
|
|
113
|
+
SessionStart: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
|
|
114
|
+
SessionEnd: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
|
|
115
|
+
UserPromptSubmit: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
|
|
116
|
+
PreToolUse: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
|
|
117
|
+
PostToolUse: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
|
|
118
|
+
PostToolUseFailure: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
|
|
119
|
+
Stop: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
|
|
120
|
+
PreCompact: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
|
|
121
|
+
SubagentStart: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
|
|
122
|
+
SubagentStop: () => ({ matcher: '', hooks: [{ type: 'command', command: captureCommand() }] }),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const CURSOR_HOOKS = {
|
|
126
|
+
sessionStart: () => ({ command: captureCommand() }),
|
|
127
|
+
beforeSubmitPrompt: () => ({ command: captureCommand() }),
|
|
128
|
+
postToolUse: () => ({ command: captureCommand() }),
|
|
129
|
+
postToolUseFailure: () => ({ command: captureCommand() }),
|
|
130
|
+
afterFileEdit: () => ({ command: captureCommand() }),
|
|
131
|
+
afterShellExecution: () => ({ command: captureCommand() }),
|
|
132
|
+
afterMCPExecution: () => ({ command: captureCommand() }),
|
|
133
|
+
subagentStart: () => ({ command: captureCommand() }),
|
|
134
|
+
subagentStop: () => ({ command: captureCommand() }),
|
|
135
|
+
preCompact: () => ({ command: captureCommand() }),
|
|
136
|
+
afterAgentResponse: () => ({ command: captureCommand() }),
|
|
137
|
+
afterAgentThought: () => ({ command: captureCommand() }),
|
|
138
|
+
stop: () => ({ command: captureCommand() }),
|
|
139
|
+
sessionEnd: () => ({ command: captureCommand() }),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const TOOL_CONFIGS = [
|
|
143
|
+
{
|
|
144
|
+
name: 'Claude Code',
|
|
145
|
+
dirPath: join(homedir(), '.claude'),
|
|
146
|
+
configPath: join(homedir(), '.claude', 'settings.json'),
|
|
147
|
+
hookDefs: CLAUDE_CODE_HOOKS,
|
|
148
|
+
topLevelFields: {},
|
|
149
|
+
sharedConfig: true,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'Cursor',
|
|
153
|
+
dirPath: join(homedir(), '.cursor'),
|
|
154
|
+
configPath: join(homedir(), '.cursor', 'hooks.json'),
|
|
155
|
+
hookDefs: CURSOR_HOOKS,
|
|
156
|
+
topLevelFields: { version: 1 },
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// AI Lens hook detection
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
export function isAiLensHook(entry) {
|
|
165
|
+
// Flat format (Cursor): { command: "..." }
|
|
166
|
+
const cmd = entry?.command || '';
|
|
167
|
+
if (cmd.includes('ai-lens') && cmd.includes('capture.js')) return true;
|
|
168
|
+
// Nested format (Claude Code): { matcher, hooks: [{ command: "..." }] }
|
|
169
|
+
if (Array.isArray(entry?.hooks)) {
|
|
170
|
+
return entry.hooks.some(h => {
|
|
171
|
+
const c = h?.command || '';
|
|
172
|
+
return c.includes('ai-lens') && c.includes('capture.js');
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isCurrentAiLensHook(entry) {
|
|
179
|
+
// Flat format (Cursor)
|
|
180
|
+
if (entry?.command === captureCommand()) return true;
|
|
181
|
+
// Nested format (Claude Code)
|
|
182
|
+
if (Array.isArray(entry?.hooks)) {
|
|
183
|
+
return entry.hooks.some(h => h?.command === captureCommand());
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Tool detection
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
export function detectInstalledTools() {
|
|
193
|
+
return TOOL_CONFIGS.filter(t => existsSync(t.dirPath));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Analysis
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Analyze a tool's hooks.json and return status.
|
|
202
|
+
* Returns { status, config?, error? }
|
|
203
|
+
* status: 'fresh' | 'current' | 'outdated' | 'absent' | 'malformed'
|
|
204
|
+
*/
|
|
205
|
+
export function analyzeToolHooks(tool) {
|
|
206
|
+
if (!existsSync(tool.configPath)) {
|
|
207
|
+
return { status: 'fresh' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let raw;
|
|
211
|
+
try {
|
|
212
|
+
raw = readFileSync(tool.configPath, 'utf-8');
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return { status: 'malformed', error: err.message };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let config;
|
|
218
|
+
try {
|
|
219
|
+
config = JSON.parse(raw);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// For shared config files (settings.json), don't backup/rename — other tools depend on it
|
|
222
|
+
if (tool.sharedConfig) {
|
|
223
|
+
return { status: 'malformed', error: err.message };
|
|
224
|
+
}
|
|
225
|
+
const bakPath = tool.configPath + '.bak';
|
|
226
|
+
try { renameSync(tool.configPath, bakPath); } catch { /* ignore */ }
|
|
227
|
+
return { status: 'malformed', backupPath: bakPath, error: err.message };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const hooks = config.hooks;
|
|
231
|
+
if (!hooks || typeof hooks !== 'object') {
|
|
232
|
+
return { status: 'absent', config };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Check if any AI Lens hooks exist
|
|
236
|
+
let hasAiLens = false;
|
|
237
|
+
let allCurrent = true;
|
|
238
|
+
|
|
239
|
+
for (const hookName of Object.keys(tool.hookDefs)) {
|
|
240
|
+
const entries = hooks[hookName];
|
|
241
|
+
if (!Array.isArray(entries)) continue;
|
|
242
|
+
for (const entry of entries) {
|
|
243
|
+
if (isAiLensHook(entry)) {
|
|
244
|
+
hasAiLens = true;
|
|
245
|
+
if (!isCurrentAiLensHook(entry)) {
|
|
246
|
+
allCurrent = false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!hasAiLens) {
|
|
253
|
+
return { status: 'absent', config };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Check all expected hooks are present
|
|
257
|
+
for (const hookName of Object.keys(tool.hookDefs)) {
|
|
258
|
+
const entries = hooks[hookName];
|
|
259
|
+
if (!Array.isArray(entries) || !entries.some(e => isAiLensHook(e))) {
|
|
260
|
+
allCurrent = false;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { status: allCurrent ? 'current' : 'outdated', config };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Merge logic
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Build a merged config: strip old AI Lens entries, add new ones,
|
|
274
|
+
* preserve everything else.
|
|
275
|
+
*/
|
|
276
|
+
export function buildMergedConfig(tool, existingConfig) {
|
|
277
|
+
const base = existingConfig ? structuredClone(existingConfig) : {};
|
|
278
|
+
|
|
279
|
+
// Preserve top-level fields (e.g. Cursor's version: 1)
|
|
280
|
+
for (const [k, v] of Object.entries(tool.topLevelFields)) {
|
|
281
|
+
if (base[k] === undefined) {
|
|
282
|
+
base[k] = v;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!base.hooks || typeof base.hooks !== 'object') {
|
|
287
|
+
base.hooks = {};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const [hookName, entryFactory] of Object.entries(tool.hookDefs)) {
|
|
291
|
+
const existing = Array.isArray(base.hooks[hookName]) ? base.hooks[hookName] : [];
|
|
292
|
+
// Remove old AI Lens entries
|
|
293
|
+
const preserved = existing.filter(e => !isAiLensHook(e));
|
|
294
|
+
// Append new AI Lens entry
|
|
295
|
+
preserved.push(entryFactory());
|
|
296
|
+
base.hooks[hookName] = preserved;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return base;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Build a config with all AI Lens entries removed.
|
|
304
|
+
* Returns null if the resulting hooks object is empty (file can be deleted).
|
|
305
|
+
*/
|
|
306
|
+
export function buildStrippedConfig(tool, existingConfig) {
|
|
307
|
+
const base = structuredClone(existingConfig);
|
|
308
|
+
|
|
309
|
+
if (!base.hooks || typeof base.hooks !== 'object') {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const hookName of Object.keys(tool.hookDefs)) {
|
|
314
|
+
const entries = base.hooks[hookName];
|
|
315
|
+
if (!Array.isArray(entries)) continue;
|
|
316
|
+
const preserved = entries.filter(e => !isAiLensHook(e));
|
|
317
|
+
if (preserved.length > 0) {
|
|
318
|
+
base.hooks[hookName] = preserved;
|
|
319
|
+
} else {
|
|
320
|
+
delete base.hooks[hookName];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// If no hooks remain, clean up
|
|
325
|
+
if (Object.keys(base.hooks).length === 0) {
|
|
326
|
+
delete base.hooks;
|
|
327
|
+
// If other settings remain (shared config like settings.json), keep them
|
|
328
|
+
if (Object.keys(base).length > 0) {
|
|
329
|
+
return base;
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return base;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Write config
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
export function writeHooksConfig(tool, config) {
|
|
342
|
+
mkdirSync(dirname(tool.configPath), { recursive: true });
|
|
343
|
+
writeFileSync(tool.configPath, JSON.stringify(config, null, 2) + '\n');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Describe what will happen (for plan display)
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
export function describePlan(tool, analysis) {
|
|
351
|
+
const hookNames = Object.keys(tool.hookDefs);
|
|
352
|
+
|
|
353
|
+
switch (analysis.status) {
|
|
354
|
+
case 'fresh':
|
|
355
|
+
return { action: 'create', description: `Create ${tool.configPath}`, hooks: hookNames };
|
|
356
|
+
case 'outdated':
|
|
357
|
+
return { action: 'update', description: `Update AI Lens hooks in ${tool.configPath}`, hooks: hookNames };
|
|
358
|
+
case 'absent':
|
|
359
|
+
return { action: 'add', description: `Add AI Lens hooks to ${tool.configPath}`, hooks: hookNames };
|
|
360
|
+
case 'malformed':
|
|
361
|
+
return { action: 'recreate', description: `Recreate ${tool.configPath} (backed up to .bak)`, hooks: hookNames };
|
|
362
|
+
default:
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|