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 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
+ }