ai-lens 0.8.51 → 0.8.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.commithash CHANGED
@@ -1 +1 @@
1
- 7c4d056
1
+ c830ed5
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # AI Lens
2
2
 
3
- Analytics for AI coding sessions. Captures hook events from Claude Code and Cursor, and near-real-time session events from Codex, normalizes them to a unified format, queues locally, and ships to a centralized server with a web dashboard.
3
+ Analytics for AI coding sessions. Captures hook events from Claude Code, Cursor,
4
+ and Codex, normalizes them to a unified format, queues locally, and ships to a
5
+ centralized server with a web dashboard and MCP integration.
4
6
 
5
7
  ```
6
8
  Hook fires → capture.js → normalize → queue.jsonl → sender.js → POST /api/events → server → dashboard
@@ -19,6 +21,7 @@ This will:
19
21
  2. Copy client files to `~/.ai-lens/client/`
20
22
  3. Configure hooks in `~/.claude/settings.json` and/or `~/.cursor/hooks.json`
21
23
  4. Start the Codex watcher for user-level and project-local Codex sessions
24
+ 5. Register the MCP server for in-editor analytics (optional)
22
25
 
23
26
  Re-running is safe — it updates outdated hooks and skips current ones.
24
27
 
@@ -27,19 +30,38 @@ Re-running is safe — it updates outdated hooks and skips current ones.
27
30
  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:
28
31
 
29
32
  ```bash
30
- 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
33
+ npx -y ai-lens init --server https://ai-lens.rantsports.com --no-mcp --project-hooks
31
34
  ```
32
35
 
33
36
  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.
34
37
 
35
- 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.
38
+ ### CLI commands
36
39
 
37
- Configure the server URL and optionally filter projects:
40
+ ```bash
41
+ npx ai-lens init # Setup wizard — detect tools, install hooks, configure MCP
42
+ npx ai-lens status # Run health checks and generate a diagnostic report
43
+ npx ai-lens remove # Remove hooks, client files, and MCP config
44
+ npx ai-lens version # Show installed version
45
+ ```
46
+
47
+ ### CLI options
48
+
49
+ | Flag | Description |
50
+ |------|-------------|
51
+ | `--server URL` | Server URL (default: `http://localhost:3000`) |
52
+ | `--yes`, `-y` | Non-interactive mode, accept all defaults |
53
+ | `--no-mcp` | Skip MCP server registration |
54
+ | `--mcp-scope SCOPE` | MCP scope: `user` (default), `local`, or `project` |
55
+ | `--projects LIST` | Comma-separated project paths to monitor (default: all) |
56
+ | `--project-hooks` | Write hooks into project directory instead of global config |
57
+ | `--use-repo-path` | Run capture.js from package path instead of copying to `~/.ai-lens/client/` |
58
+
59
+ ### Environment variables (client)
38
60
 
39
61
  ```bash
40
62
  # In your shell profile (~/.zshrc, ~/.bashrc)
41
- export AI_LENS_SERVER_URL=http://your-server:13300
42
- export AI_LENS_PROJECTS="~/work/, ~/projects/" # optional, default: all; nested repos under these roots are included
63
+ export AI_LENS_SERVER_URL=https://ai-lens.rantsports.com
64
+ export AI_LENS_PROJECTS="~/meta/, ~/meta-cursor/" # optional, default: all
43
65
  ```
44
66
 
45
67
  <details>
@@ -96,47 +118,94 @@ docker compose up -d
96
118
  ```
97
119
 
98
120
  Starts three containers:
99
- - **nginx** — reverse proxy with basic auth, port `13300`
100
- - **app** — Node.js Express server with dashboard
101
- - **postgres** — PostgreSQL 16 database
102
121
 
103
- Dashboard: `http://your-server:13300`
122
+ | Container | Image | Purpose |
123
+ |-----------|-------|---------|
124
+ | **app** | `ai-lens/app` | API server + web dashboard (port 3000) |
125
+ | **postgres** | `postgres:16-alpine` | PostgreSQL 16 database |
126
+ | **analyzer** | `ai-lens/analyzer` | Background session analyzer (needs `claude login`) |
127
+
128
+ Dashboard: https://ai-lens.rantsports.com
104
129
 
105
- Default credentials:
130
+ Images are stored in ECR (`267996409571.dkr.ecr.eu-north-1.amazonaws.com/ai-lens/`) and mirrored to GHCR (`ghcr.io/r-ms/ai-lens/`) on every push to `main`.
106
131
 
107
- | User | Password | Purpose |
108
- |------|----------|---------|
109
- | `collector` | `secret-collector-token-2026-ai-lens` | Client sender (automatic via `AI_LENS_AUTH_TOKEN`) |
110
- | `meta` | `meta` | Browser / dashboard access |
132
+ ### Environment variables (server)
133
+
134
+ | Variable | Default | Description |
135
+ |----------|---------|-------------|
136
+ | `PORT` | `3000` | Server port |
137
+ | `DATABASE_URL` | _(required)_ | PostgreSQL connection string |
138
+ | `POSTGRES_PASSWORD` | `ailens` | PostgreSQL password (docker compose) |
139
+ | `ANALYSIS_INTERVAL` | `3600` | Seconds between analysis runs |
140
+ | `AI_LENS_ADMIN_SECRET` | _(none)_ | Admin secret for auth token management |
141
+ | `OPENAI_API_KEY` | _(none)_ | OpenAI API key for FAISS vector search; text search works without it |
142
+ | `TEAMS_CONFIG` | _(none)_ | JSON config for team definitions |
143
+ | `CORS_ALLOWED_ORIGINS` | _(none)_ | Allowed CORS origins |
144
+
145
+ #### Auth0 SSO
146
+
147
+ | Variable | Description |
148
+ |----------|-------------|
149
+ | `AUTH0_DOMAIN` | Auth0 tenant domain |
150
+ | `AUTH0_CLIENT_ID` | Auth0 SPA client ID |
151
+ | `AUTH0_AUDIENCE` | Auth0 API audience identifier |
152
+ | `AUTH0_ALLOWED_DOMAIN` | Restrict login to a specific email domain |
153
+ | `AUTH0_CLI_CLIENT_ID` | Auth0 Native app client ID for device code flow |
154
+ | `MCP_SERVER_URL` | Public server URL for MCP OAuth callbacks |
155
+
156
+ Without Auth0, the server uses git email headers for identity (personal mode).
111
157
 
112
158
  ### Local development
113
159
 
114
160
  ```bash
115
161
  docker compose up postgres -d
116
- npm install --prefix server # Install server deps (auto-runs via prestart)
162
+ npm install
117
163
  DATABASE_URL=postgresql://ailens:ailens@localhost:5432/ailens npm start
118
164
  ```
119
165
 
120
- The server now requires PostgreSQL via `DATABASE_URL`. The old SQLite fallback is no longer supported.
121
-
122
166
  ## Dashboard
123
167
 
124
- React + TypeScript SPA with session timelines, tool breakdowns, adoption trends, and per-developer analytics.
168
+ React + TypeScript SPA with:
169
+ - Organization-wide KPIs and adoption trends
170
+ - Team and developer breakdowns
171
+ - Session timelines with tool usage
172
+ - AI-generated session and team analyses
173
+ - Token usage by model
174
+ - MCP server and skill distribution
175
+ - Knowledge base and recurring problems
125
176
 
126
177
  ```bash
127
178
  cd dashboard
128
179
  npm install
129
180
  npm run dev # Vite dev server with HMR (proxies API to localhost:3000)
130
- ```
131
-
132
- Production build (served by Express as static files):
133
-
134
- ```bash
135
- npm run build:dashboard
181
+ npm run build # Production build (served by Express as static files)
136
182
  ```
137
183
 
138
184
  Tech: Vite, Tailwind CSS, Nivo charts, TanStack Query, react-router-dom.
139
185
 
186
+ ## MCP Tools
187
+
188
+ When MCP is enabled during `npx ai-lens init`, these tools become available inside Claude Code and Cursor:
189
+
190
+ | Tool | Description |
191
+ |------|-------------|
192
+ | `who_am_i` | Identify yourself by git email — returns your developer profile and team(s) |
193
+ | `get_overview` | Organization-wide KPIs: active developers, adoption rate, AI hours, MCP and skill distribution |
194
+ | `list_teams` | List all teams with member counts, adoption rate, and AI hours |
195
+ | `get_team` | Team detail: KPIs, members, tasks, activity trend, MCP and skill distribution |
196
+ | `get_team_analysis` | AI-generated team analysis: achievements, recurring problems, recommendations |
197
+ | `get_developer` | Developer profile: sessions, AI hours, tasks, MCP and skill usage, team comparison |
198
+ | `get_mcp_distribution` | MCP server usage across the organization |
199
+ | `get_chain` | Session chain with compact event timeline, plan mode segments, and timing |
200
+ | `get_events` | Full event data for specific event IDs |
201
+ | `get_chain_analysis` | AI-generated chain analysis: tasks, problems, tool errors, unanswered questions |
202
+ | `request_analysis` | Manually trigger analysis for a specific session chain |
203
+ | `get_token_usage` | Token usage statistics grouped by model (input/output/cache tokens) |
204
+ | `knowhow_search` | Search the team knowledge base built from session analyses |
205
+ | `knowhow_update` | Add or update a knowledge base entry |
206
+ | `export_developer_tips` | Export personalized tips as a Markdown document |
207
+ | `search` | Natural language search across sessions, tasks, and projects |
208
+
140
209
  ## API
141
210
 
142
211
  ### `POST /api/events`
@@ -144,7 +213,7 @@ Tech: Vite, Tailwind CSS, Nivo charts, TanStack Query, react-router-dom.
144
213
  Batch insert events. Deduplicates by `event_id` (ON CONFLICT DO NOTHING) — safe to re-send.
145
214
 
146
215
  ```
147
- Headers: X-Developer-Git-Email, X-Developer-Name, Authorization: Basic <base64>
216
+ Headers: X-Developer-Git-Email, X-Developer-Name, X-Auth-Token
148
217
  Body: [{ source, session_id, type, project_path, timestamp, data, raw, event_id }]
149
218
  Response: { received, skipped, deduplicated }
150
219
  ```
@@ -163,38 +232,36 @@ List all developers.
163
232
 
164
233
  ### `GET /api/dashboard/*`
165
234
 
166
- Aggregate endpoints for dashboard charts (stats, trends, tool usage, etc.).
235
+ Aggregate endpoints for dashboard charts: overview, teams, developers, tokens, MCP distribution, developer activity, knowledge base, problems, company/team/developer analyses.
167
236
 
168
237
  ## Event Types
169
238
 
170
239
  | Type | Source | Description |
171
240
  |------|--------|-------------|
172
- | `SessionStart` | Both | Session opened |
173
- | `SessionEnd` | Both | Session closed |
174
- | `UserPromptSubmit` | Both | User sent a prompt |
175
- | `PostToolUse` | Both | Tool execution completed |
176
- | `PostToolUseFailure` | Both | Tool execution failed |
177
- | `Stop` | Both | Agent stopped |
178
- | `PreCompact` | Both | Context compaction triggered |
241
+ | `SessionStart` | All | Session opened |
242
+ | `SessionEnd` | All | Session closed |
243
+ | `UserPromptSubmit` | All | User sent a prompt |
244
+ | `PostToolUse` | All | Tool execution completed |
245
+ | `PostToolUseFailure` | All | Tool execution failed |
246
+ | `Stop` | All | Agent stopped |
247
+ | `PreCompact` | All | Context compaction triggered |
179
248
  | `PlanModeStart` | Claude Code | Entered plan mode |
180
249
  | `PlanModeEnd` | Claude Code | Exited plan mode (plan content in raw payload) |
181
- | `SubagentStart` | Both | Subagent spawned |
182
- | `SubagentStop` | Both | Subagent finished |
250
+ | `SubagentStart` | All | Subagent spawned |
251
+ | `SubagentStop` | All | Subagent finished |
183
252
  | `FileEdit` | Cursor | File edited |
184
253
  | `ShellExecution` | Cursor | Shell command executed |
185
254
  | `MCPExecution` | Cursor | MCP tool executed |
186
- | `AgentResponse` | Cursor | Agent response |
255
+ | `AgentResponse` | Cursor | Agent response (includes token usage in raw payload) |
187
256
  | `AgentThought` | Cursor | Agent reasoning |
188
257
 
189
- ## Environment Variables
258
+ ## Supported Tools
190
259
 
191
- | Variable | Default | Description |
192
- |----------|---------|-------------|
193
- | `PORT` | `3000` (local), `13300` (Docker) | Server port |
194
- | `DATABASE_URL` | _(required)_ | PostgreSQL connection string |
195
- | `AI_LENS_SERVER_URL` | `http://localhost:3000` | Client server endpoint |
196
- | `AI_LENS_AUTH_TOKEN` | `collector:secret-collector-token-2026-ai-lens` | Client auth (`user:password`) |
197
- | `AI_LENS_PROJECTS` | _(all)_ | Comma-separated project paths to monitor (`~` supported) |
260
+ | Tool | Hook mechanism |
261
+ |------|---------------|
262
+ | **Claude Code** | Hooks via `~/.claude/settings.json` |
263
+ | **Cursor** | Hooks via `~/.cursor/hooks.json` |
264
+ | **Codex** | File watcher on `~/.codex` and project-local `.codex` directories |
198
265
 
199
266
  ## Client Data
200
267
 
@@ -203,41 +270,35 @@ Stored in `~/.ai-lens/`:
203
270
  | File | Purpose |
204
271
  |------|---------|
205
272
  | `client/` | Installed client files (capture.js, sender.js, config.js) |
273
+ | `config.json` | Server URL, auth token, project list |
206
274
  | `queue.jsonl` | Pending events |
207
275
  | `queue.sending.jsonl` | Events being sent (atomic rename as mutex) |
208
276
  | `sender.log` | Sender activity log |
277
+ | `capture.log` | Capture drop log (normalization failures, write errors) |
209
278
  | `session-paths.json` | Session-to-project path cache |
210
279
 
211
280
  ## Development
212
281
 
213
282
  ```bash
214
- npm test # Run all tests (vitest, 204 tests)
283
+ npm test # Run all tests (vitest, 683 tests)
215
284
  npm run test:watch # Watch mode
216
285
  npm run dev:dashboard # Dashboard dev server
217
286
  ```
218
287
 
219
- Tests use in-memory SQLite via `initTestDb()`.
288
+ Tests require PostgreSQL — set `DATABASE_URL` or use `docker compose up postgres -d` (test DB `ailens_test` is created automatically).
220
289
 
221
290
  ## Deployment
222
291
 
223
292
  GitLab CI (`.gitlab-ci.yml`) on push to `main`:
224
293
 
225
- 1. `rsync` to deploy host
226
- 2. `docker compose down && docker compose up -d --build`
227
- 3. Health check
228
-
229
- ## Data Migration
230
-
231
- Sync local SQLite data to a remote PostgreSQL server:
232
-
233
- ```bash
234
- node scripts/sync-to-remote.js # Default remote
235
- node scripts/sync-to-remote.js http://custom:13300 # Custom URL
236
- ```
294
+ 1. **build-app** — builds `ai-lens/app` Docker image, pushes to ECR + GHCR
295
+ 2. **build-analyzer** — builds `ai-lens/analyzer` Docker image, pushes to ECR + GHCR
296
+ 3. **deploy** — zero-downtime rolling deploy to production (scale up new container, health check, remove old)
237
297
 
238
- Safe to re-run deduplicates by `event_id`.
298
+ Build jobs trigger only when relevant files change (Dockerfile, server/**, dashboard/**, etc.).
239
299
 
240
300
  ## Requirements
241
301
 
242
302
  - Node.js 20+
243
303
  - Docker + Docker Compose (for production deployment)
304
+ - PostgreSQL 16 (for local development without Docker)
package/bin/ai-lens.js CHANGED
@@ -15,7 +15,7 @@ switch (command) {
15
15
  }
16
16
  case 'status': {
17
17
  const { default: status } = await import('../cli/status.js');
18
- await status();
18
+ await status({ report: process.argv.includes('--report') });
19
19
  break;
20
20
  }
21
21
  case 'version':
package/cli/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync, chmodSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
@@ -145,7 +145,16 @@ export function cursorCaptureCommand(useTilde = false, customPath = null) {
145
145
  // Client file installation
146
146
  // ---------------------------------------------------------------------------
147
147
 
148
- const CLIENT_FILES = ['capture.js', 'sender.js', 'config.js', 'redact.js', 'codex.js', 'codex-watcher.js'];
148
+ /**
149
+ * Enumerate every .js file that ships with the package's client/ directory.
150
+ * Dynamic discovery (rather than a hardcoded CLIENT_FILES list) guarantees
151
+ * that any new client/*.js file added to the repo is automatically installed,
152
+ * so a forgotten list entry can never produce a broken install that crashes
153
+ * every hook with ERR_MODULE_NOT_FOUND on a missing sibling import.
154
+ */
155
+ export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
156
+ return readdirSync(sourceDir).filter(f => f.endsWith('.js')).sort();
157
+ }
149
158
 
150
159
  /**
151
160
  * Copy client/ files from the package source to ~/.ai-lens/client/.
@@ -155,7 +164,7 @@ export function installClientFiles() {
155
164
  const sourceDir = join(__dirname, '..', 'client');
156
165
  mkdirSync(CLIENT_INSTALL_DIR, { recursive: true });
157
166
 
158
- for (const file of CLIENT_FILES) {
167
+ for (const file of listClientFiles(sourceDir)) {
159
168
  copyFileSync(join(sourceDir, file), join(CLIENT_INSTALL_DIR, file));
160
169
  }
161
170
 
@@ -165,11 +174,12 @@ export function installClientFiles() {
165
174
  '{"type":"module"}\n',
166
175
  );
167
176
 
168
- // Write version.json so installed capture.js can identify itself
177
+ // Write version.json so installed capture.js can identify itself.
178
+ // packageRoot lets sender.js find bin/ai-lens.js for background status reports.
169
179
  const { version, commit } = getVersionInfo();
170
180
  writeFileSync(
171
181
  join(CLIENT_INSTALL_DIR, 'version.json'),
172
- JSON.stringify({ version, commit }) + '\n',
182
+ JSON.stringify({ version, commit, packageRoot: PKG_ROOT }) + '\n',
173
183
  );
174
184
  }
175
185
 
package/cli/init.js CHANGED
@@ -67,7 +67,7 @@ function getJson(url) {
67
67
  });
68
68
  }
69
69
 
70
- function postJson(url, body) {
70
+ function postJson(url, body, timeoutMs = 15_000) {
71
71
  return new Promise((resolve, reject) => {
72
72
  const parsed = new URL(url);
73
73
  const isHttps = parsed.protocol === 'https:';
@@ -82,7 +82,7 @@ function postJson(url, body) {
82
82
  'Content-Type': 'application/json',
83
83
  'Content-Length': Buffer.byteLength(data),
84
84
  },
85
- timeout: 15_000,
85
+ timeout: timeoutMs,
86
86
  };
87
87
  const req = requestFn(options, (res) => {
88
88
  let buf = '';
@@ -107,7 +107,7 @@ function postJson(url, body) {
107
107
  });
108
108
  }
109
109
 
110
- function postForm(url, params) {
110
+ function postForm(url, params, timeoutMs = 15_000) {
111
111
  return new Promise((resolve, reject) => {
112
112
  const parsed = new URL(url);
113
113
  const isHttps = parsed.protocol === 'https:';
@@ -122,7 +122,7 @@ function postForm(url, params) {
122
122
  'Content-Type': 'application/x-www-form-urlencoded',
123
123
  'Content-Length': Buffer.byteLength(data),
124
124
  },
125
- timeout: 15_000,
125
+ timeout: timeoutMs,
126
126
  };
127
127
  const req = requestFn(options, (res) => {
128
128
  let buf = '';
@@ -146,6 +146,26 @@ function sleep(ms) {
146
146
  return new Promise(resolve => setTimeout(resolve, ms));
147
147
  }
148
148
 
149
+ function isTransientNetError(err) {
150
+ const msg = `${err && err.code ? err.code : ''} ${err && err.message ? err.message : err}`;
151
+ return /ECONNRESET|EPIPE|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|socket hang up/i.test(String(msg));
152
+ }
153
+
154
+ /** Retry fn on flaky TLS/proxy resets (common during long Auth0 device polls). */
155
+ async function withRetries(fn, { attempts = 5, baseDelayMs = 2000 } = {}) {
156
+ let lastErr;
157
+ for (let i = 1; i <= attempts; i++) {
158
+ try {
159
+ return await fn();
160
+ } catch (err) {
161
+ lastErr = err;
162
+ if (!isTransientNetError(err) || i === attempts) throw err;
163
+ await sleep(baseDelayMs * i);
164
+ }
165
+ }
166
+ throw lastErr;
167
+ }
168
+
149
169
  function startCodexWatcher(watcherPath) {
150
170
  if (!existsSync(watcherPath)) return false;
151
171
  if (process.env.AI_LENS_TEST_NO_DETACHED_SPAWN === '1') return true;
@@ -219,17 +239,24 @@ async function deviceCodeAuth(serverUrl) {
219
239
  while (Date.now() < deadline) {
220
240
  await sleep(interval * 1000);
221
241
 
222
- const tokenResp = await postForm(`https://${domain}/oauth/token`, {
223
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
224
- client_id: cliClientId,
225
- device_code,
226
- });
242
+ // Auth0 may keep this request open longer than a typical HTTP call; 15s caused false "Request timed out".
243
+ const tokenResp = await withRetries(
244
+ () => postForm(`https://${domain}/oauth/token`, {
245
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
246
+ client_id: cliClientId,
247
+ device_code,
248
+ }, 120_000),
249
+ { attempts: 6, baseDelayMs: 2000 },
250
+ );
227
251
 
228
252
  if (tokenResp.status === 200 && tokenResp.data.id_token) {
229
253
  // 5. Exchange JWT for personal token
230
- const result = await postJson(`${serverUrl}/api/auth/device-token`, {
231
- jwt: tokenResp.data.id_token,
232
- });
254
+ const result = await withRetries(
255
+ () => postJson(`${serverUrl}/api/auth/device-token`, {
256
+ jwt: tokenResp.data.id_token,
257
+ }, 120_000),
258
+ { attempts: 5, baseDelayMs: 2000 },
259
+ );
233
260
  if (!result?.token) throw new Error('Server returned no token — contact your admin');
234
261
  return result;
235
262
  }
@@ -510,10 +537,11 @@ export default async function init() {
510
537
  try {
511
538
  authResult = await deviceCodeAuth(serverUrl);
512
539
  } catch (err) {
513
- if (err.message.includes('not configured')) {
540
+ const msg = (err && err.message) ? err.message : String(err);
541
+ if (msg.includes('not configured')) {
514
542
  warn(` Auth not configured on server — personal mode (events sent via git identity)`);
515
543
  } else {
516
- warn(` Authentication failed: ${err.message}`);
544
+ warn(` Authentication failed: ${msg}`);
517
545
  warn(` Run "npx -y ai-lens init" again later to authenticate`);
518
546
  }
519
547
  }
package/cli/status.js CHANGED
@@ -5,7 +5,7 @@ import { homedir, release as osRelease, arch as osArch } from 'node:os';
5
5
  import { randomUUID } from 'node:crypto';
6
6
 
7
7
  import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, analyzeToolHooks, checkHooksDisabled, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
8
- import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
8
+ import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, LAST_STATUS_REPORT_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
9
9
  import { isLockStale } from '../client/sender.js';
10
10
  import { readCodexWatcherLock, resolveWatchedCodexDirs } from '../client/codex-watcher.js';
11
11
  import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
@@ -92,6 +92,42 @@ function expandTilde(pathStr) {
92
92
  return pathStr;
93
93
  }
94
94
 
95
+ /**
96
+ * Detect install mode from the capture.js path in hook commands.
97
+ * Returns { ok, summary, detail } for the status output.
98
+ */
99
+ function detectInstallMode(tools) {
100
+ const copyDir = join(homedir(), '.ai-lens', 'client') + '/';
101
+ const paths = [];
102
+ for (const tool of tools) {
103
+ const cmd = extractHookCommand(tool);
104
+ if (!cmd) continue;
105
+ const m = cmd.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
106
+ if (m) paths.push({ tool: tool.name, raw: m[1] || m[2], resolved: expandTilde(m[1] || m[2]) });
107
+ }
108
+ if (paths.length === 0) {
109
+ return { ok: null, summary: 'unknown', detail: 'No hook commands found — cannot determine install mode' };
110
+ }
111
+ const modes = paths.map(p => {
112
+ if (p.resolved.startsWith(copyDir)) return 'copy';
113
+ return 'repo-path';
114
+ });
115
+ const unique = [...new Set(modes)];
116
+ const mode = unique.length === 1 ? unique[0] : 'mixed';
117
+ const detail = paths.map(p => {
118
+ const m = p.resolved.startsWith(copyDir) ? 'copy' : 'repo-path';
119
+ return ` ${p.tool}: ${m} (${p.raw})`;
120
+ }).join('\n');
121
+
122
+ if (mode === 'copy') {
123
+ return { ok: true, summary: 'copy (~/.ai-lens/client/)', detail: `Client files copied to ~/.ai-lens/client/\nUpdate: npx -y ai-lens init --yes\n${detail}` };
124
+ }
125
+ if (mode === 'repo-path') {
126
+ return { ok: true, summary: 'repo-path (auto-update on git pull)', detail: `Hooks point directly to repo/package source\nUpdate: git pull (or npx cache refresh)\n${detail}` };
127
+ }
128
+ return { ok: null, summary: `mixed (${unique.join(' + ')})`, detail: `Different tools use different install modes:\n${detail}` };
129
+ }
130
+
95
131
  function validateHookCommandPaths(tool) {
96
132
  const command = extractHookCommand(tool);
97
133
  if (!command) return null;
@@ -1059,43 +1095,83 @@ function buildReport(results, timestamp, warnings = [], allTools = TOOL_CONFIGS)
1059
1095
  return lines.join('\n');
1060
1096
  }
1061
1097
 
1098
+ // ---------------------------------------------------------------------------
1099
+ // Report mode: POST structured status to server
1100
+ // ---------------------------------------------------------------------------
1101
+
1102
+ async function sendStatusReport(results, warnings, clientVersion, clientCommit, serverUrl, authToken) {
1103
+ if (!serverUrl || !authToken) return;
1104
+
1105
+ const payload = {
1106
+ timestamp: new Date().toISOString(),
1107
+ client_version: clientVersion,
1108
+ client_commit: clientCommit,
1109
+ node_version: process.version,
1110
+ os: `${process.platform} ${osRelease()} ${osArch()}`,
1111
+ checks: results.map(({ label, ok, summary, detail }) => ({ label, ok, summary, detail })),
1112
+ warnings: warnings.map(({ msg, action }) => ({ msg, action })),
1113
+ };
1114
+
1115
+ try {
1116
+ const res = await fetch(`${serverUrl}/api/client-reports`, {
1117
+ method: 'POST',
1118
+ headers: {
1119
+ 'Content-Type': 'application/json',
1120
+ 'X-Auth-Token': authToken,
1121
+ 'X-Client-Version': `${clientVersion}+${clientCommit}`,
1122
+ },
1123
+ body: JSON.stringify(payload),
1124
+ signal: AbortSignal.timeout(15_000),
1125
+ });
1126
+ if (res.ok) {
1127
+ try { writeFileSync(LAST_STATUS_REPORT_PATH, new Date().toISOString()); } catch {}
1128
+ }
1129
+ } catch {
1130
+ // Silent — report is best-effort
1131
+ }
1132
+ }
1133
+
1062
1134
  // ---------------------------------------------------------------------------
1063
1135
  // Main
1064
1136
  // ---------------------------------------------------------------------------
1065
1137
 
1066
- export default async function status() {
1138
+ export default async function status({ report = false } = {}) {
1067
1139
  const versionResult = checkVersion();
1068
- initLogger(versionResult.summary);
1140
+ if (!report) initLogger(versionResult.summary);
1069
1141
 
1070
1142
  const { version, commit } = getVersionInfo();
1071
- blank();
1072
- info(`${BOLD}AI Lens Status v${version} (${commit})${RESET}`);
1073
- info('='.repeat(40));
1074
- blank();
1143
+ if (!report) {
1144
+ blank();
1145
+ info(`${BOLD}AI Lens Status v${version} (${commit})${RESET}`);
1146
+ info('='.repeat(40));
1147
+ blank();
1148
+ }
1075
1149
 
1076
1150
  const results = [];
1077
1151
 
1078
1152
  function printLine(label, result) {
1079
- const icon = result.ok === true ? CHECK : result.ok === false ? CROSS : `${DIM}-${RESET}`;
1080
- const pad = ' '.repeat(Math.max(1, 17 - label.length));
1081
- info(`${label}:${pad}${icon} ${result.summary}`);
1153
+ if (!report) {
1154
+ const icon = result.ok === true ? CHECK : result.ok === false ? CROSS : `${DIM}-${RESET}`;
1155
+ const pad = ' '.repeat(Math.max(1, 17 - label.length));
1156
+ info(`${label}:${pad}${icon} ${result.summary}`);
1157
+ }
1082
1158
  results.push({ label, ...result });
1083
1159
  }
1084
1160
 
1085
1161
  // 1. System info
1086
1162
  const sys = checkSystem();
1087
- info(`${'System:'} ${sys.summary}`);
1163
+ if (!report) info(`${'System:'} ${sys.summary}`);
1088
1164
  results.push({ label: 'System', ...sys });
1089
1165
 
1090
1166
  // 2. Tool versions
1091
1167
  const claude = checkToolVersion('Claude');
1092
1168
  const cursor = checkToolVersion('Cursor');
1093
1169
  if (claude.ok !== null) {
1094
- info(`${'Claude Code:'} ${claude.summary}`);
1170
+ if (!report) info(`${'Claude Code:'} ${claude.summary}`);
1095
1171
  results.push({ label: 'Claude Code version', ...claude });
1096
1172
  }
1097
1173
  if (cursor.ok !== null) {
1098
- info(`${'Cursor:'} ${cursor.summary}`);
1174
+ if (!report) info(`${'Cursor:'} ${cursor.summary}`);
1099
1175
  results.push({ label: 'Cursor version', ...cursor });
1100
1176
  }
1101
1177
 
@@ -1155,6 +1231,10 @@ export default async function status() {
1155
1231
  detail: `Global hooks active: ${hasGlobalHooks}\nProject hooks active: ${hasProjectHooks}${hasGlobalHooks && hasProjectHooks ? '\nWarning: both global and project hooks are active — events may be captured twice. Run init with --project-hooks to consolidate.' : ''}`,
1156
1232
  });
1157
1233
 
1234
+ // 6c. Install mode: detect how capture.js is referenced in hooks
1235
+ const installMode = detectInstallMode(toolsWithProject);
1236
+ printLine('Install mode', installMode);
1237
+
1158
1238
  // 7. Queue (before capture test so test event doesn't show as pending)
1159
1239
  const queueResult = checkQueue();
1160
1240
  printLine('Queue', queueResult);
@@ -1200,7 +1280,7 @@ export default async function status() {
1200
1280
  serverReachable: serverResult.ok === true,
1201
1281
  });
1202
1282
 
1203
- if (warnings.length > 0) {
1283
+ if (!report && warnings.length > 0) {
1204
1284
  blank();
1205
1285
  info('='.repeat(40));
1206
1286
  info(`${BOLD}Warnings${RESET}`);
@@ -1211,19 +1291,23 @@ export default async function status() {
1211
1291
  }
1212
1292
  }
1213
1293
 
1214
- // Write report file
1215
- const timestamp = new Date().toISOString();
1216
- // Merge global TOOL_CONFIGS (always listed, even if not installed) with project tools
1217
- const allToolsForReport = [...TOOL_CONFIGS, ...toolsWithProject.filter(t => !TOOL_CONFIGS.includes(t))];
1218
- const report = buildReport(results, timestamp, warnings, allToolsForReport);
1219
- try {
1220
- writeFileSync(REPORT_PATH, report);
1221
- blank();
1222
- info(`${DIM}Full report \u2192 ${REPORT_PATH}${RESET}`);
1223
- } catch (err) {
1294
+ if (report) {
1295
+ // --report mode: POST structured JSON to server, update marker file
1296
+ await sendStatusReport(results, warnings, version, commit, serverUrl, authToken);
1297
+ } else {
1298
+ // Normal mode: write text report file
1299
+ const timestamp = new Date().toISOString();
1300
+ // Merge global TOOL_CONFIGS (always listed, even if not installed) with project tools
1301
+ const allToolsForReport = [...TOOL_CONFIGS, ...toolsWithProject.filter(t => !TOOL_CONFIGS.includes(t))];
1302
+ const reportText = buildReport(results, timestamp, warnings, allToolsForReport);
1303
+ try {
1304
+ writeFileSync(REPORT_PATH, reportText);
1305
+ blank();
1306
+ info(`${DIM}Full report \u2192 ${REPORT_PATH}${RESET}`);
1307
+ } catch (err) {
1308
+ blank();
1309
+ error(`Could not write report: ${err.message}`);
1310
+ }
1224
1311
  blank();
1225
- error(`Could not write report: ${err.message}`);
1226
1312
  }
1227
-
1228
- blank();
1229
1313
  }