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 +1 -1
- package/README.md +121 -60
- package/bin/ai-lens.js +1 -1
- package/cli/hooks.js +15 -5
- package/cli/init.js +42 -14
- package/cli/status.js +111 -27
- package/client/capture.js +448 -37
- package/client/codex.js +47 -0
- package/client/config.js +16 -2
- package/client/sender.js +47 -1
- package/client/token-usage.js +47 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
38
|
+
### CLI commands
|
|
36
39
|
|
|
37
|
-
|
|
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=
|
|
42
|
-
export AI_LENS_PROJECTS="~/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
|
110
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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` |
|
|
173
|
-
| `SessionEnd` |
|
|
174
|
-
| `UserPromptSubmit` |
|
|
175
|
-
| `PostToolUse` |
|
|
176
|
-
| `PostToolUseFailure` |
|
|
177
|
-
| `Stop` |
|
|
178
|
-
| `PreCompact` |
|
|
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` |
|
|
182
|
-
| `SubagentStop` |
|
|
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
|
-
##
|
|
258
|
+
## Supported Tools
|
|
190
259
|
|
|
191
|
-
|
|
|
192
|
-
|
|
193
|
-
|
|
|
194
|
-
|
|
|
195
|
-
|
|
|
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,
|
|
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
|
|
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. `
|
|
226
|
-
2. `
|
|
227
|
-
3.
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
231
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
}
|