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 +1 -1
- package/bin/ai-lens.js +0 -16
- package/cli/hooks.js +120 -1
- package/cli/init.js +218 -57
- package/cli/remove.js +45 -1
- package/mcp-server/index.js +21 -0
- package/package.json +1 -1
- package/cli/admin.js +0 -120
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
286
|
+
// Authentication
|
|
130
287
|
heading('Authentication');
|
|
131
288
|
if (!currentConfig.authToken) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 (
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
try {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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 {
|
package/mcp-server/index.js
CHANGED
|
@@ -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
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
|
-
}
|