ai-lens 0.4.0 → 0.6.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 CHANGED
@@ -1 +1 @@
1
- b46a47e
1
+ 358f236
package/bin/ai-lens.js CHANGED
@@ -30,28 +30,12 @@ switch (command) {
30
30
  console.log(`ai-lens v${version} (${commit})`);
31
31
  break;
32
32
  }
33
- case 'admin': {
34
- const sub = process.argv[3];
35
- const { createInvite, listInvites } = await import('../cli/admin.js');
36
- if (sub === 'create-invite') await createInvite();
37
- else if (sub === 'list-invites') await listInvites();
38
- else {
39
- console.log('Usage: ai-lens admin <command>');
40
- console.log('');
41
- console.log('Commands:');
42
- console.log(' create-invite Create a team invite token');
43
- console.log(' list-invites List invite tokens');
44
- process.exit(1);
45
- }
46
- break;
47
- }
48
33
  default:
49
34
  console.log('Usage: ai-lens <command>');
50
35
  console.log('');
51
36
  console.log('Commands:');
52
37
  console.log(' init Configure AI tool hooks for event capture');
53
38
  console.log(' remove Remove AI Lens hooks and client files');
54
- console.log(' admin Admin commands (create-invite, list-invites)');
55
39
  console.log(' mcp Start the MCP server (stdio transport)');
56
40
  console.log(' version Show package version and commit hash');
57
41
  process.exit(command ? 1 : 0);
package/cli/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync } 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';
@@ -150,6 +150,8 @@ export const TOOL_CONFIGS = [
150
150
  hookDefs: CLAUDE_CODE_HOOKS,
151
151
  topLevelFields: {},
152
152
  sharedConfig: true,
153
+ // Older init versions wrote hooks here — clean up on init/remove
154
+ legacyConfigPaths: [join(homedir(), '.claude', 'hooks.json')],
153
155
  },
154
156
  {
155
157
  name: 'Cursor',
@@ -350,6 +352,123 @@ export function writeHooksConfig(tool, config) {
350
352
  // Describe what will happen (for plan display)
351
353
  // ---------------------------------------------------------------------------
352
354
 
355
+ /**
356
+ * Clean up AI Lens hooks from legacy config paths (e.g. ~/.claude/hooks.json).
357
+ * Returns array of { path, action } describing what was done.
358
+ */
359
+ export function cleanupLegacyHooks(tool) {
360
+ const results = [];
361
+ if (!Array.isArray(tool.legacyConfigPaths)) return results;
362
+
363
+ for (const legacyPath of tool.legacyConfigPaths) {
364
+ if (legacyPath === tool.configPath) continue;
365
+ if (!existsSync(legacyPath)) continue;
366
+
367
+ let raw;
368
+ try {
369
+ raw = readFileSync(legacyPath, 'utf-8');
370
+ } catch {
371
+ continue;
372
+ }
373
+
374
+ let config;
375
+ try {
376
+ config = JSON.parse(raw);
377
+ } catch {
378
+ continue;
379
+ }
380
+
381
+ // Check if any AI Lens hooks exist in this file
382
+ const hooks = config.hooks;
383
+ if (!hooks || typeof hooks !== 'object') continue;
384
+
385
+ let hasAiLens = false;
386
+ for (const entries of Object.values(hooks)) {
387
+ if (Array.isArray(entries) && entries.some(e => isAiLensHook(e))) {
388
+ hasAiLens = true;
389
+ break;
390
+ }
391
+ }
392
+ if (!hasAiLens) continue;
393
+
394
+ // Strip AI Lens hooks
395
+ const stripped = buildStrippedConfig(tool, config);
396
+ if (stripped) {
397
+ writeFileSync(legacyPath, JSON.stringify(stripped, null, 2) + '\n');
398
+ results.push({ path: legacyPath, action: 'cleaned' });
399
+ } else {
400
+ unlinkSync(legacyPath);
401
+ results.push({ path: legacyPath, action: 'deleted' });
402
+ }
403
+ }
404
+
405
+ return results;
406
+ }
407
+
408
+ /**
409
+ * Delete .mcp.json in cwd if it was left with empty mcpServers by `claude mcp remove`.
410
+ */
411
+ export function cleanupEmptyMcpJson() {
412
+ const mcpJsonPath = join(process.cwd(), '.mcp.json');
413
+ try {
414
+ const content = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
415
+ if (content.mcpServers
416
+ && Object.keys(content.mcpServers).length === 0
417
+ && Object.keys(content).length === 1) {
418
+ unlinkSync(mcpJsonPath);
419
+ }
420
+ } catch {
421
+ // file doesn't exist or isn't valid JSON — nothing to clean
422
+ }
423
+ }
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // Cursor MCP helpers (no CLI — direct JSON editing)
427
+ // ---------------------------------------------------------------------------
428
+
429
+ const CURSOR_MCP_GLOBAL = join(homedir(), '.cursor', 'mcp.json');
430
+
431
+ function cursorMcpEntry(mcpUrl) {
432
+ return { url: mcpUrl };
433
+ }
434
+
435
+ function readJsonSafe(path) {
436
+ try {
437
+ return JSON.parse(readFileSync(path, 'utf-8'));
438
+ } catch {
439
+ return null;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Add or update ai-lens MCP server in Cursor's mcp.json.
445
+ */
446
+ export function addCursorMcp(mcpUrl) {
447
+ const config = readJsonSafe(CURSOR_MCP_GLOBAL) || { mcpServers: {} };
448
+ if (!config.mcpServers) config.mcpServers = {};
449
+ config.mcpServers['ai-lens'] = cursorMcpEntry(mcpUrl);
450
+ mkdirSync(dirname(CURSOR_MCP_GLOBAL), { recursive: true });
451
+ writeFileSync(CURSOR_MCP_GLOBAL, JSON.stringify(config, null, 2) + '\n');
452
+ }
453
+
454
+ /**
455
+ * Remove ai-lens MCP server from Cursor's mcp.json.
456
+ * Also checks .cursor/mcp.json in cwd (project scope).
457
+ */
458
+ export function removeCursorMcp() {
459
+ const paths = [CURSOR_MCP_GLOBAL, join(process.cwd(), '.cursor', 'mcp.json')];
460
+ for (const p of paths) {
461
+ const config = readJsonSafe(p);
462
+ if (!config?.mcpServers?.['ai-lens']) continue;
463
+ delete config.mcpServers['ai-lens'];
464
+ if (Object.keys(config.mcpServers).length === 0 && Object.keys(config).length === 1) {
465
+ unlinkSync(p);
466
+ } else {
467
+ writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
468
+ }
469
+ }
470
+ }
471
+
353
472
  export function describePlan(tool, analysis) {
354
473
  const hookNames = Object.keys(tool.hookDefs);
355
474
 
package/cli/init.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  CAPTURE_PATH, detectInstalledTools,
14
14
  analyzeToolHooks, buildMergedConfig, writeHooksConfig, describePlan,
15
15
  installClientFiles, readLensConfig, saveLensConfig, getVersionInfo,
16
+ cleanupLegacyHooks, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
16
17
  } from './hooks.js';
17
18
 
18
19
  function ask(question) {
@@ -25,8 +26,40 @@ function ask(question) {
25
26
  });
26
27
  }
27
28
 
28
- // Default transport credentials (same as client/config.js DEFAULT_AUTH_TOKEN)
29
- const TRANSPORT_AUTH = 'collector:secret-collector-token-2026-ai-lens';
29
+ function getJson(url) {
30
+ return new Promise((resolve, reject) => {
31
+ const parsed = new URL(url);
32
+ const isHttps = parsed.protocol === 'https:';
33
+ const requestFn = isHttps ? httpsRequest : httpRequest;
34
+ const options = {
35
+ hostname: parsed.hostname,
36
+ port: parsed.port || (isHttps ? 443 : 80),
37
+ path: parsed.pathname + (parsed.search || ''),
38
+ method: 'GET',
39
+ headers: { 'Accept': 'application/json' },
40
+ timeout: 15_000,
41
+ };
42
+ const req = requestFn(options, (res) => {
43
+ let buf = '';
44
+ res.on('data', (chunk) => { buf += chunk; });
45
+ res.on('end', () => {
46
+ try {
47
+ const json = JSON.parse(buf);
48
+ if (res.statusCode >= 200 && res.statusCode < 300) {
49
+ resolve(json);
50
+ } else {
51
+ reject(new Error(json.error || `Server responded ${res.statusCode}`));
52
+ }
53
+ } catch {
54
+ reject(new Error(`Server responded ${res.statusCode}: ${buf}`));
55
+ }
56
+ });
57
+ });
58
+ req.on('error', reject);
59
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
60
+ req.end();
61
+ });
62
+ }
30
63
 
31
64
  function postJson(url, body) {
32
65
  return new Promise((resolve, reject) => {
@@ -42,7 +75,6 @@ function postJson(url, body) {
42
75
  headers: {
43
76
  'Content-Type': 'application/json',
44
77
  'Content-Length': Buffer.byteLength(data),
45
- 'Authorization': 'Basic ' + Buffer.from(TRANSPORT_AUTH).toString('base64'),
46
78
  },
47
79
  timeout: 15_000,
48
80
  };
@@ -69,6 +101,131 @@ function postJson(url, body) {
69
101
  });
70
102
  }
71
103
 
104
+ function postForm(url, params) {
105
+ return new Promise((resolve, reject) => {
106
+ const parsed = new URL(url);
107
+ const isHttps = parsed.protocol === 'https:';
108
+ const requestFn = isHttps ? httpsRequest : httpRequest;
109
+ const data = new URLSearchParams(params).toString();
110
+ const options = {
111
+ hostname: parsed.hostname,
112
+ port: parsed.port || (isHttps ? 443 : 80),
113
+ path: parsed.pathname,
114
+ method: 'POST',
115
+ headers: {
116
+ 'Content-Type': 'application/x-www-form-urlencoded',
117
+ 'Content-Length': Buffer.byteLength(data),
118
+ },
119
+ timeout: 15_000,
120
+ };
121
+ const req = requestFn(options, (res) => {
122
+ let buf = '';
123
+ res.on('data', (chunk) => { buf += chunk; });
124
+ res.on('end', () => {
125
+ try {
126
+ resolve({ status: res.statusCode, data: JSON.parse(buf) });
127
+ } catch {
128
+ reject(new Error(`Server responded ${res.statusCode}: ${buf}`));
129
+ }
130
+ });
131
+ });
132
+ req.on('error', reject);
133
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
134
+ req.write(data);
135
+ req.end();
136
+ });
137
+ }
138
+
139
+ function sleep(ms) {
140
+ return new Promise(resolve => setTimeout(resolve, ms));
141
+ }
142
+
143
+ async function deviceCodeAuth(serverUrl) {
144
+ // 1. Fetch Auth0 config from server
145
+ let config;
146
+ try {
147
+ config = await getJson(`${serverUrl}/api/auth/config`);
148
+ } catch (err) {
149
+ throw new Error(`Could not fetch auth config from server: ${err.message}`);
150
+ }
151
+
152
+ if (!config.enabled || !config.domain || !config.cliClientId) {
153
+ throw new Error('Auth0 device code flow not configured on server (missing AUTH0_CLI_CLIENT_ID)');
154
+ }
155
+
156
+ const { domain, cliClientId } = config;
157
+
158
+ // 2. Request device code
159
+ const codeParams = {
160
+ client_id: cliClientId,
161
+ scope: 'openid profile email',
162
+ };
163
+
164
+ const codeResp = await postForm(`https://${domain}/oauth/device/code`, codeParams);
165
+ if (codeResp.status !== 200) {
166
+ throw new Error(`Device code request failed: ${JSON.stringify(codeResp.data)}`);
167
+ }
168
+
169
+ const {
170
+ device_code,
171
+ user_code,
172
+ verification_uri_complete,
173
+ verification_uri,
174
+ interval: pollInterval = 5,
175
+ expires_in,
176
+ } = codeResp.data;
177
+
178
+ // 3. Show URL + code
179
+ blank();
180
+ info(' Open this URL in your browser to authenticate:');
181
+ blank();
182
+ info(` ${verification_uri_complete || verification_uri}`);
183
+ blank();
184
+ if (user_code) {
185
+ info(` Code: ${user_code}`);
186
+ blank();
187
+ }
188
+ info(` Waiting for browser login (expires in ${Math.floor(expires_in / 60)} min)...`);
189
+
190
+ // 4. Poll for token
191
+ let interval = pollInterval;
192
+ const deadline = Date.now() + expires_in * 1000;
193
+
194
+ while (Date.now() < deadline) {
195
+ await sleep(interval * 1000);
196
+
197
+ const tokenResp = await postForm(`https://${domain}/oauth/token`, {
198
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
199
+ client_id: cliClientId,
200
+ device_code,
201
+ });
202
+
203
+ if (tokenResp.status === 200 && tokenResp.data.id_token) {
204
+ // 5. Exchange JWT for personal token
205
+ const result = await postJson(`${serverUrl}/api/auth/device-token`, {
206
+ jwt: tokenResp.data.id_token,
207
+ });
208
+ return result;
209
+ }
210
+
211
+ const err = tokenResp.data.error;
212
+ if (err === 'authorization_pending') {
213
+ continue;
214
+ } else if (err === 'slow_down') {
215
+ interval += 5;
216
+ continue;
217
+ } else if (err === 'expired_token') {
218
+ throw new Error('Device code expired. Please try again.');
219
+ } else if (err === 'access_denied') {
220
+ throw new Error('Authentication was denied.');
221
+ } else {
222
+ throw new Error(`Unexpected token response: ${JSON.stringify(tokenResp.data)}`);
223
+ }
224
+ }
225
+
226
+ throw new Error('Device code expired. Please try again.');
227
+ }
228
+
72
229
  export default async function init() {
73
230
  const { version, commit } = getVersionInfo();
74
231
  initLogger(`v${version} (${commit})`);
@@ -103,7 +260,7 @@ export default async function init() {
103
260
  const serverInput = await ask(
104
261
  `Server URL (Enter = ${currentServer}): `,
105
262
  );
106
- const serverUrl = serverInput || currentServer;
263
+ const serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
107
264
  info(` Server: ${serverUrl}`);
108
265
 
109
266
  // Project filter
@@ -126,41 +283,17 @@ export default async function init() {
126
283
  }
127
284
  blank();
128
285
 
129
- // Authentication (required)
286
+ // Authentication
130
287
  heading('Authentication');
131
288
  if (!currentConfig.authToken) {
132
- info('Invite token is required. Get one from your team lead.');
133
- // eslint-disable-next-line no-constant-condition
134
- while (true) {
135
- const inviteInput = await ask('Invite token: ');
136
- if (!inviteInput) {
137
- warn(' Invite token is required to continue.');
138
- continue;
139
- }
140
- if (!inviteInput.startsWith('ailens_inv_')) {
141
- warn(' Token must start with ailens_inv_. Try again.');
142
- continue;
143
- }
144
- try {
145
- let email, gitName;
146
- try {
147
- email = execSync('git config user.email', { encoding: 'utf-8' }).trim();
148
- gitName = execSync('git config user.name', { encoding: 'utf-8' }).trim();
149
- } catch {
150
- error(' Could not read git identity. Set git config user.email and user.name first.');
151
- return;
152
- }
153
- const result = await postJson(serverUrl + '/api/auth/accept-invite', {
154
- token: inviteInput, email, name: gitName,
155
- });
156
- newConfig.authToken = result.token;
157
- saveLensConfig(newConfig);
158
- success(` Authenticated! Team: ${result.team_id}`);
159
- break;
160
- } catch (err) {
161
- error(` Authentication failed: ${err.message}`);
162
- info(' Try again.');
163
- }
289
+ try {
290
+ const result = await deviceCodeAuth(serverUrl);
291
+ newConfig.authToken = result.token;
292
+ saveLensConfig(newConfig);
293
+ success(` Authenticated as ${result.name} (${result.email})`);
294
+ } catch (err) {
295
+ error(` Authentication failed: ${err.message}`);
296
+ return;
164
297
  }
165
298
  } else {
166
299
  success(' Already authenticated (token present)');
@@ -196,6 +329,13 @@ export default async function init() {
196
329
  // Filter to tools that need changes
197
330
  const pending = analyses.filter(a => a.analysis.status !== 'current');
198
331
 
332
+ // Clean up legacy hook locations (always, even if current hooks are up-to-date)
333
+ for (const { tool } of analyses) {
334
+ for (const lr of cleanupLegacyHooks(tool)) {
335
+ success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
336
+ }
337
+ }
338
+
199
339
  if (pending.length === 0) {
200
340
  success('Everything is up-to-date. Nothing to do.');
201
341
  } else {
@@ -258,35 +398,56 @@ export default async function init() {
258
398
  blank();
259
399
  }
260
400
 
261
- // MCP setup (only if Claude Code is installed)
401
+ // MCP setup (HTTP transport auth via OAuth in browser, no token needed)
402
+ const mcpUrl = `${serverUrl}/mcp`;
403
+
404
+ // Claude Code MCP
262
405
  const claudeDir = join(homedir(), '.claude');
263
406
  const hasClaudeDir = existsSync(claudeDir);
264
407
  let hasClaudeCli = false;
265
- try {
266
- execSync('which claude', { stdio: 'ignore' });
267
- hasClaudeCli = true;
268
- } catch {}
408
+ try { execSync('which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
269
409
 
270
410
  if (hasClaudeDir && hasClaudeCli) {
271
- heading('MCP Server');
411
+ heading('MCP Server — Claude Code');
272
412
  const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
273
413
  if (!mcpAnswer || mcpAnswer.toLowerCase() === 'y') {
274
- const mcpToken = newConfig.authToken;
275
- if (!mcpToken) {
276
- warn(' No auth token available — authenticate first.');
277
- } else {
278
- try {
279
- execSync(
280
- `claude mcp add ai-lens -e AI_LENS_SERVER_URL=${serverUrl} -e AI_LENS_AUTH_TOKEN=${mcpToken} -- npx -y ai-lens mcp`,
281
- { stdio: 'inherit' },
282
- );
283
- success(' MCP server registered in Claude Code');
284
- } catch (err) {
285
- error(` Failed to register MCP server: ${err.message}`);
286
- }
414
+ const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
415
+ const scope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
416
+ try {
417
+ // Remove old stdio-based MCP from all scopes, then add HTTP-based
418
+ try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore' }); } catch {}
419
+ try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore' }); } catch {}
420
+ try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore' }); } catch {}
421
+ cleanupEmptyMcpJson();
422
+ execSync(
423
+ `claude mcp add --transport http ai-lens -s ${scope} ${mcpUrl}`,
424
+ { stdio: 'inherit' },
425
+ );
426
+ success(` MCP server registered in Claude Code (${scope})`);
427
+ } catch (err) {
428
+ error(` Failed to register MCP server: ${err.message}`);
429
+ }
430
+ } else {
431
+ info(' Skipped');
432
+ }
433
+ blank();
434
+ }
435
+
436
+ // Cursor MCP
437
+ const cursorDir = join(homedir(), '.cursor');
438
+ if (existsSync(cursorDir)) {
439
+ heading('MCP Server — Cursor');
440
+ const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
441
+ if (!cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y') {
442
+ try {
443
+ removeCursorMcp();
444
+ addCursorMcp(mcpUrl);
445
+ success(' MCP server registered in Cursor (~/.cursor/mcp.json)');
446
+ } catch (err) {
447
+ error(` Failed to register MCP server: ${err.message}`);
287
448
  }
288
449
  } else {
289
- info(' Skipped MCP setup');
450
+ info(' Skipped');
290
451
  }
291
452
  blank();
292
453
  }
package/cli/remove.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { unlinkSync } from 'node:fs';
2
+ import { unlinkSync, existsSync } from 'node:fs';
3
+ import { execSync } from 'node:child_process';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
3
6
  import {
4
7
  initLogger, info, success, warn, error,
5
8
  heading, detail, blank, getLogPath,
@@ -7,6 +10,7 @@ import {
7
10
  import {
8
11
  detectInstalledTools, analyzeToolHooks,
9
12
  buildStrippedConfig, writeHooksConfig, removeClientFiles, getVersionInfo,
13
+ cleanupLegacyHooks, cleanupEmptyMcpJson, removeCursorMcp,
10
14
  } from './hooks.js';
11
15
 
12
16
  function ask(question) {
@@ -88,6 +92,46 @@ export default async function remove() {
88
92
  }
89
93
  blank();
90
94
 
95
+ // Clean up legacy hook locations
96
+ for (const tool of tools) {
97
+ for (const lr of cleanupLegacyHooks(tool)) {
98
+ success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
99
+ }
100
+ }
101
+ blank();
102
+
103
+ // Remove MCP servers
104
+ heading('Removing MCP servers...');
105
+
106
+ // Claude Code
107
+ const claudeDir = join(homedir(), '.claude');
108
+ let hasClaudeCli = false;
109
+ try { execSync('which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
110
+
111
+ if (existsSync(claudeDir) && hasClaudeCli) {
112
+ try {
113
+ try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore' }); } catch {}
114
+ try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore' }); } catch {}
115
+ try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore' }); } catch {}
116
+ cleanupEmptyMcpJson();
117
+ success(' Claude Code: MCP server removed');
118
+ } catch (err) {
119
+ error(` Claude Code: failed — ${err.message}`);
120
+ }
121
+ }
122
+
123
+ // Cursor
124
+ const cursorDir = join(homedir(), '.cursor');
125
+ if (existsSync(cursorDir)) {
126
+ try {
127
+ removeCursorMcp();
128
+ success(' Cursor: MCP server removed');
129
+ } catch (err) {
130
+ error(` Cursor: failed — ${err.message}`);
131
+ }
132
+ }
133
+ blank();
134
+
91
135
  // Remove client files
92
136
  heading('Removing client files...');
93
137
  try {
@@ -181,6 +181,27 @@ server.tool(
181
181
  }
182
182
  );
183
183
 
184
+ // 10. search
185
+ server.tool(
186
+ "search",
187
+ "Search across all sessions, tasks, and projects using natural language. Returns matching chains grouped by relevance with snippets. Use chain_id from results with get_chain or get_chain_analysis for full details.",
188
+ {
189
+ query: z.string().describe("Search query (e.g., 'auth0 device flow', 'migration bug', 'token storage')"),
190
+ days: z.number().optional().describe("Time window in days (default: 90)"),
191
+ developer_id: z.string().optional().describe("Filter to specific developer (UUID)"),
192
+ project: z.string().optional().describe("Filter by project path substring"),
193
+ limit: z.number().optional().describe("Max results (default: 20, max: 50)"),
194
+ },
195
+ async ({ query, days, developer_id, project, limit }) => {
196
+ const params = new URLSearchParams({ q: query });
197
+ if (days) params.set('days', String(days));
198
+ if (developer_id) params.set('developer_id', developer_id);
199
+ if (project) params.set('project', project);
200
+ if (limit) params.set('limit', String(limit));
201
+ return textResult(await apiCall(`/api/dashboard/search?${params}`));
202
+ }
203
+ );
204
+
184
205
  const transport = new StdioServerTransport();
185
206
  await server.connect(transport);
186
207
  console.error("AI Lens MCP server running");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {
package/cli/admin.js DELETED
@@ -1,120 +0,0 @@
1
- import { createInterface } from 'node:readline';
2
- import { request as httpRequest } from 'node:http';
3
- import { request as httpsRequest } from 'node:https';
4
- import { readLensConfig } from './hooks.js';
5
-
6
- function ask(question) {
7
- const rl = createInterface({ input: process.stdin, output: process.stdout });
8
- return new Promise(resolve => {
9
- rl.question(question, answer => {
10
- rl.close();
11
- resolve(answer.trim());
12
- });
13
- });
14
- }
15
-
16
- function adminRequest(method, url, body) {
17
- const secret = process.env.AI_LENS_ADMIN_SECRET;
18
- if (!secret) {
19
- throw new Error('AI_LENS_ADMIN_SECRET env var is required');
20
- }
21
- return new Promise((resolve, reject) => {
22
- const parsed = new URL(url);
23
- const isHttps = parsed.protocol === 'https:';
24
- const requestFn = isHttps ? httpsRequest : httpRequest;
25
- const data = body ? JSON.stringify(body) : null;
26
- const options = {
27
- hostname: parsed.hostname,
28
- port: parsed.port || (isHttps ? 443 : 80),
29
- path: parsed.pathname + (parsed.search || ''),
30
- method,
31
- headers: {
32
- 'X-Admin-Secret': secret,
33
- ...(data ? {
34
- 'Content-Type': 'application/json',
35
- 'Content-Length': Buffer.byteLength(data),
36
- } : {}),
37
- },
38
- timeout: 15_000,
39
- };
40
- const req = requestFn(options, (res) => {
41
- let buf = '';
42
- res.on('data', (chunk) => { buf += chunk; });
43
- res.on('end', () => {
44
- try {
45
- const json = JSON.parse(buf);
46
- if (res.statusCode >= 200 && res.statusCode < 300) {
47
- resolve(json);
48
- } else {
49
- reject(new Error(json.error || `Server responded ${res.statusCode}`));
50
- }
51
- } catch {
52
- reject(new Error(`Server responded ${res.statusCode}: ${buf}`));
53
- }
54
- });
55
- });
56
- req.on('error', reject);
57
- req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
58
- if (data) req.write(data);
59
- req.end();
60
- });
61
- }
62
-
63
- function getServerUrl() {
64
- const config = readLensConfig();
65
- return config.serverUrl || process.env.AI_LENS_SERVER_URL || 'http://localhost:3000';
66
- }
67
-
68
- export async function createInvite() {
69
- const serverUrl = getServerUrl();
70
- const teamId = await ask('Team ID: ');
71
- if (!teamId) {
72
- console.error('Team ID is required');
73
- process.exit(1);
74
- }
75
- const label = await ask('Label (optional): ');
76
- const maxUsesStr = await ask('Max uses (optional, Enter = unlimited): ');
77
- const maxUses = maxUsesStr ? parseInt(maxUsesStr, 10) : undefined;
78
-
79
- try {
80
- const result = await adminRequest('POST', `${serverUrl}/api/auth/admin/invite-tokens`, {
81
- team_id: teamId,
82
- label: label || undefined,
83
- max_uses: maxUses,
84
- });
85
- console.log('\nInvite token created:');
86
- console.log(` Token: ${result.token}`);
87
- console.log(` Team: ${result.team_id}`);
88
- if (result.label) console.log(` Label: ${result.label}`);
89
- if (result.max_uses) console.log(` Max uses: ${result.max_uses}`);
90
- console.log('\nShare this token with developers. They can use it during: npx ai-lens init');
91
- } catch (err) {
92
- console.error(`Error: ${err.message}`);
93
- process.exit(1);
94
- }
95
- }
96
-
97
- export async function listInvites() {
98
- const serverUrl = getServerUrl();
99
- const teamId = await ask('Team ID (optional, Enter = all): ');
100
- const query = teamId ? `?team_id=${encodeURIComponent(teamId)}` : '';
101
-
102
- try {
103
- const tokens = await adminRequest('GET', `${serverUrl}/api/auth/admin/invite-tokens${query}`);
104
- if (tokens.length === 0) {
105
- console.log('No invite tokens found.');
106
- return;
107
- }
108
- console.log(`\n${'ID'.padEnd(38)}${'Team'.padEnd(20)}${'Label'.padEnd(25)}${'Uses'.padEnd(10)}${'Created'}`);
109
- console.log('-'.repeat(110));
110
- for (const t of tokens) {
111
- const uses = t.max_uses ? `${t.uses}/${t.max_uses}` : `${t.uses}`;
112
- console.log(
113
- `${t.id.padEnd(38)}${(t.team_id || '').padEnd(20)}${(t.label || '').padEnd(25)}${uses.padEnd(10)}${t.created_at}`,
114
- );
115
- }
116
- } catch (err) {
117
- console.error(`Error: ${err.message}`);
118
- process.exit(1);
119
- }
120
- }