ai-lens 0.8.107 → 0.8.108
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/CHANGELOG.md +4 -0
- package/bin/ai-lens.js +3 -2
- package/cli/hooks.js +59 -6
- package/cli/init.js +232 -104
- package/cli/scan.js +82 -24
- package/cli/status.js +27 -6
- package/client/capture.js +88 -0
- package/client/config.js +22 -0
- package/client/sender.js +25 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
3b3e261
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
|
|
4
4
|
|
|
5
|
+
## 0.8.108 — 2026-06-22
|
|
6
|
+
- feat: `ai-lens init --mcp-only` registers just the MCP server in Claude Code and Cursor — for when you only need to read sessions from Claude, not capture hooks. It skips hook setup, authentication, and history import. The MCP scope now mirrors how AI Lens is installed: a local/project install registers the MCP at project scope, a global install at user scope (local wins if both are present); pass `--mcp-scope` to override.
|
|
7
|
+
- change: `init --project-hooks` now also registers the MCP at project scope (Claude `local` + the project `.cursor/mcp.json`) to match where it writes hooks, instead of always registering globally. Pass `--mcp-scope` to override.
|
|
8
|
+
|
|
5
9
|
## 0.8.107 — 2026-06-19
|
|
6
10
|
- fix: Cyrillic / non-ASCII in Windows hook payloads is no longer corrupted (it was being dropped as malformed JSON). The windowless launcher now reads the payload as raw bytes straight off the process's stdin and never references PowerShell's `$input` — merely touching `$input` both pre-drains stdin and decodes it through the console's OEM codepage, mangling the UTF-8 BOM and every Cyrillic byte before forwarding. Byte-exact passthrough now; the 0.8.105 read still went through `$input` first. Cursor and Claude Code.
|
|
7
11
|
- fix: the Windows hidden-sender launch now runs detached, so it isn't killed together with the hook's process tree before it finishes starting the sender — closing a rare window where an event could be dropped. The launcher is GUI-subsystem, so detached still adds no console window (no flash).
|
package/bin/ai-lens.js
CHANGED
|
@@ -59,9 +59,10 @@ switch (command) {
|
|
|
59
59
|
console.log(' --server URL Server URL (default: saved or http://localhost:3000)');
|
|
60
60
|
console.log(' --yes, -y Non-interactive: accept all defaults, no prompts');
|
|
61
61
|
console.log(' --projects LIST Comma-separated project paths to track');
|
|
62
|
-
console.log(' --no-hooks Skip writing hooks
|
|
62
|
+
console.log(' --no-hooks Skip writing hooks (config + auth + MCP only)');
|
|
63
63
|
console.log(' --no-mcp Skip MCP server registration');
|
|
64
|
-
console.log(' --mcp-
|
|
64
|
+
console.log(' --mcp-only Register only the MCP server, mirroring your install scope (skip hooks/auth/import)');
|
|
65
|
+
console.log(' --mcp-scope S MCP scope: user, local, or project (default: auto-detected from install scope, else user)');
|
|
65
66
|
console.log(' --project-hooks Write Cursor/Claude hooks to project .cursor/ and .claude/ (not ~/.cursor)');
|
|
66
67
|
console.log(' --use-repo-path Run capture.js from this package; skip copy to ~/.ai-lens/client/');
|
|
67
68
|
console.log(' remove Remove AI Lens hooks and client files');
|
package/cli/hooks.js
CHANGED
|
@@ -1190,6 +1190,50 @@ export function detectInstalledTools(ctx = null) {
|
|
|
1190
1190
|
return tools.filter(t => existsSync(t.dirPath));
|
|
1191
1191
|
}
|
|
1192
1192
|
|
|
1193
|
+
/**
|
|
1194
|
+
* Detect whether AI Lens is installed at the PROJECT (local) or GLOBAL (user)
|
|
1195
|
+
* level, by looking at where its hooks actually live. Used to mirror the install
|
|
1196
|
+
* scope when registering the MCP server (`init --mcp-only`).
|
|
1197
|
+
*
|
|
1198
|
+
* "present" = a tool config whose hooks include an AI Lens entry, i.e.
|
|
1199
|
+
* analyzeToolHooks(...).status is 'current' or 'outdated' (NOT 'fresh'/'absent').
|
|
1200
|
+
*
|
|
1201
|
+
* Project (local) takes priority over global when both are present.
|
|
1202
|
+
*
|
|
1203
|
+
* @param {string} projectRoot - Absolute path to inspect (e.g. process.cwd()).
|
|
1204
|
+
* @returns {'project' | 'user' | null}
|
|
1205
|
+
*/
|
|
1206
|
+
export function detectAiLensInstallScope(projectRoot) {
|
|
1207
|
+
// noBackup: this is read-only scope detection — never rename/back up a malformed
|
|
1208
|
+
// file just to sniff its status (the project configs are sharedConfig:false, which
|
|
1209
|
+
// would otherwise trigger a .bak rename of the user's settings.local.json).
|
|
1210
|
+
const isPresent = (tool) => {
|
|
1211
|
+
if (!tool) return false;
|
|
1212
|
+
const status = analyzeToolHooks(tool, { noBackup: true }).status;
|
|
1213
|
+
return status === 'current' || status === 'outdated';
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
// Project-level configs. Claude project hooks land in settings.local.json
|
|
1217
|
+
// (per-machine, gitignored); the committed `--use-repo-path` form lands in
|
|
1218
|
+
// settings.json — check BOTH so neither install style is missed.
|
|
1219
|
+
const claudeProject = getClaudeCodeToolConfig(projectRoot);
|
|
1220
|
+
const claudeProjectLocal = claudeProject
|
|
1221
|
+
? { ...claudeProject, configPath: claudeLocalSettingsPath(claudeProject) }
|
|
1222
|
+
: null;
|
|
1223
|
+
const projectTools = [
|
|
1224
|
+
claudeProject,
|
|
1225
|
+
claudeProjectLocal,
|
|
1226
|
+
getCursorToolConfig(projectRoot),
|
|
1227
|
+
getCodexToolConfig(projectRoot),
|
|
1228
|
+
];
|
|
1229
|
+
if (projectTools.some(isPresent)) return 'project';
|
|
1230
|
+
|
|
1231
|
+
// Global (user-level) configs — ~/.claude, ~/.cursor, ~/.codex.
|
|
1232
|
+
if (detectInstalledTools().some(isPresent)) return 'user';
|
|
1233
|
+
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1193
1237
|
// ---------------------------------------------------------------------------
|
|
1194
1238
|
// Analysis
|
|
1195
1239
|
// ---------------------------------------------------------------------------
|
|
@@ -1215,8 +1259,10 @@ export function analyzeToolHooks(tool, opts = {}) {
|
|
|
1215
1259
|
try {
|
|
1216
1260
|
config = JSON.parse(raw);
|
|
1217
1261
|
} catch (err) {
|
|
1218
|
-
// For shared config files (settings.json), don't backup/rename — other tools depend on it
|
|
1219
|
-
|
|
1262
|
+
// For shared config files (settings.json), don't backup/rename — other tools depend on it.
|
|
1263
|
+
// opts.noBackup lets read-only callers (e.g. install-scope detection) inspect status
|
|
1264
|
+
// without mutating the user's file.
|
|
1265
|
+
if (tool.sharedConfig || opts.noBackup) {
|
|
1220
1266
|
return { status: 'malformed', error: err.message, disableAllHooks: false };
|
|
1221
1267
|
}
|
|
1222
1268
|
const bakPath = tool.configPath + '.bak';
|
|
@@ -1807,13 +1853,20 @@ function readJsonSafe(path) {
|
|
|
1807
1853
|
|
|
1808
1854
|
/**
|
|
1809
1855
|
* Add or update ai-lens MCP server in Cursor's mcp.json.
|
|
1856
|
+
* @param {string} mcpUrl
|
|
1857
|
+
* @param {object} [opts]
|
|
1858
|
+
* @param {string} [opts.projectRoot] - When set, write the project-level
|
|
1859
|
+
* <projectRoot>/.cursor/mcp.json instead of the global ~/.cursor/mcp.json.
|
|
1810
1860
|
*/
|
|
1811
|
-
export function addCursorMcp(mcpUrl) {
|
|
1812
|
-
const
|
|
1861
|
+
export function addCursorMcp(mcpUrl, { projectRoot } = {}) {
|
|
1862
|
+
const target = projectRoot
|
|
1863
|
+
? join(projectRoot, '.cursor', 'mcp.json')
|
|
1864
|
+
: CURSOR_MCP_GLOBAL;
|
|
1865
|
+
const config = readJsonSafe(target) || { mcpServers: {} };
|
|
1813
1866
|
if (!config.mcpServers) config.mcpServers = {};
|
|
1814
1867
|
config.mcpServers['ai-lens'] = cursorMcpEntry(mcpUrl);
|
|
1815
|
-
mkdirSync(dirname(
|
|
1816
|
-
writeFileSync(
|
|
1868
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1869
|
+
writeFileSync(target, JSON.stringify(config, null, 2) + '\n');
|
|
1817
1870
|
}
|
|
1818
1871
|
|
|
1819
1872
|
/**
|
package/cli/init.js
CHANGED
|
@@ -20,9 +20,9 @@ import {
|
|
|
20
20
|
getClaudeCodeHookDefsWithPath, getClaudeCodeHookDefsWithProjectDir, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
|
|
21
21
|
cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
|
|
22
22
|
checkHooksDisabled, enableHooks,
|
|
23
|
-
findStableNodePath,
|
|
23
|
+
findStableNodePath, detectAiLensInstallScope,
|
|
24
24
|
} from './hooks.js';
|
|
25
|
-
import {
|
|
25
|
+
import { scanNestedProjects, summarizeNestedProjects } from './scan.js';
|
|
26
26
|
|
|
27
27
|
function ask(question) {
|
|
28
28
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -193,6 +193,14 @@ function makeNestedClaudeTool(projectDir, capturePathInHooks, ctx = null) {
|
|
|
193
193
|
return tool;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
function makeNestedCodexTool(projectDir, capturePathInHooks, ctx = null) {
|
|
197
|
+
const tool = getCodexToolConfig(projectDir, `Codex (${projectDir})`, ctx);
|
|
198
|
+
if (capturePathInHooks) {
|
|
199
|
+
tool.hookDefs = getCodexHookDefsWithPath(capturePathInHooks, ctx);
|
|
200
|
+
}
|
|
201
|
+
return tool;
|
|
202
|
+
}
|
|
203
|
+
|
|
196
204
|
async function deviceCodeAuth(serverUrl) {
|
|
197
205
|
// 1. Fetch Auth0 config from server
|
|
198
206
|
let config;
|
|
@@ -353,6 +361,9 @@ function getInitArgs() {
|
|
|
353
361
|
case '--no-mcp':
|
|
354
362
|
flags.noMcp = true;
|
|
355
363
|
break;
|
|
364
|
+
case '--mcp-only':
|
|
365
|
+
flags.mcpOnly = true;
|
|
366
|
+
break;
|
|
356
367
|
case '--no-hooks':
|
|
357
368
|
flags.noHooks = true;
|
|
358
369
|
break;
|
|
@@ -379,7 +390,7 @@ function getInitArgs() {
|
|
|
379
390
|
const a = args[i];
|
|
380
391
|
if (a.startsWith('-')) {
|
|
381
392
|
process.stderr.write(`Warning: unknown flag "${a}" — did you mean --${a.replace(/^-+/, '')}?\n`);
|
|
382
|
-
} else if (['server', 'projects', 'yes', 'no-mcp', 'no-hooks', 'project-hooks', 'use-repo-path', 'install-launcher', 'mcp-scope'].includes(a)) {
|
|
393
|
+
} else if (['server', 'projects', 'yes', 'no-mcp', 'mcp-only', 'no-hooks', 'project-hooks', 'use-repo-path', 'install-launcher', 'mcp-scope'].includes(a)) {
|
|
383
394
|
process.stderr.write(`Warning: unexpected argument "${a}" — did you mean --${a}?\n`);
|
|
384
395
|
}
|
|
385
396
|
}
|
|
@@ -389,6 +400,138 @@ function getInitArgs() {
|
|
|
389
400
|
return flags;
|
|
390
401
|
}
|
|
391
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Resolve the server URL from --server flag / saved config / interactive prompt.
|
|
405
|
+
* Normalizes scheme + trailing slash and validates. Shared by the full init flow
|
|
406
|
+
* and the --mcp-only path.
|
|
407
|
+
*/
|
|
408
|
+
async function resolveServerUrl(flags, auto, currentConfig) {
|
|
409
|
+
const currentServer = currentConfig.serverUrl || 'http://localhost:3000';
|
|
410
|
+
let serverUrl;
|
|
411
|
+
if (flags.server) {
|
|
412
|
+
serverUrl = flags.server.replace(/\/+$/, '');
|
|
413
|
+
} else if (auto) {
|
|
414
|
+
serverUrl = currentServer;
|
|
415
|
+
} else {
|
|
416
|
+
const serverInput = await ask(`Server URL (Enter = ${currentServer}): `);
|
|
417
|
+
serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
|
|
418
|
+
}
|
|
419
|
+
if (!/^https?:\/\//i.test(serverUrl)) serverUrl = `http://${serverUrl}`;
|
|
420
|
+
try { new URL(serverUrl); } catch { error(`Invalid server URL: ${serverUrl}`); process.exit(1); }
|
|
421
|
+
return serverUrl;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Register the AI Lens MCP server in Claude Code and/or Cursor (HTTP transport —
|
|
426
|
+
* auth via OAuth in browser, no token needed). Shared by the full init flow and
|
|
427
|
+
* the --mcp-only path.
|
|
428
|
+
*
|
|
429
|
+
* @param {string} serverUrl
|
|
430
|
+
* @param {object} opts
|
|
431
|
+
* @param {boolean} opts.auto - non-interactive (--yes)
|
|
432
|
+
* @param {string} [opts.mcpScope] - explicit --mcp-scope (user|local|project)
|
|
433
|
+
* @param {string} [opts.forcedScope] - pre-resolved Claude scope; skips the prompt
|
|
434
|
+
* @param {string} [opts.projectRoot] - project dir to use when the scope is local/project
|
|
435
|
+
*/
|
|
436
|
+
async function setupMcpServers(serverUrl, { auto, mcpScope, forcedScope = null, projectRoot = null }) {
|
|
437
|
+
const mcpUrl = `${serverUrl}/mcp`;
|
|
438
|
+
|
|
439
|
+
const claudeDir = join(homedir(), '.claude');
|
|
440
|
+
const hasClaudeDir = existsSync(claudeDir);
|
|
441
|
+
let hasClaudeCli = false;
|
|
442
|
+
try { execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
|
|
443
|
+
|
|
444
|
+
const cursorDir = join(homedir(), '.cursor');
|
|
445
|
+
const hasCursorDir = existsSync(cursorDir);
|
|
446
|
+
|
|
447
|
+
// A local/project scope targets the project .cursor/mcp.json; a user scope (or no
|
|
448
|
+
// project context) targets the global ~/.cursor/mcp.json. Owned here so the full
|
|
449
|
+
// init flow and --mcp-only map scope → Cursor target the same way.
|
|
450
|
+
const cursorRootFor = (scope) => ((scope === 'local' || scope === 'project') ? projectRoot : null);
|
|
451
|
+
|
|
452
|
+
// Resolve the Claude scope when it's known without prompting. null ⇒ ask
|
|
453
|
+
// interactively in the Claude block below, and the Cursor target then follows it.
|
|
454
|
+
let resolvedScope = forcedScope
|
|
455
|
+
|| (mcpScope && ['local', 'project', 'user'].includes(mcpScope) ? mcpScope : null)
|
|
456
|
+
|| (auto ? 'user' : null);
|
|
457
|
+
|
|
458
|
+
if (!hasClaudeDir && !hasCursorDir && !cursorRootFor(resolvedScope)) {
|
|
459
|
+
warn(' No MCP-capable tool found (~/.claude or ~/.cursor) — nothing to register.');
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (hasClaudeDir && !hasClaudeCli) {
|
|
463
|
+
warn(' Claude Code detected but the `claude` CLI is not on PATH — skipping Claude MCP registration.');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Claude Code MCP
|
|
467
|
+
if (hasClaudeDir && hasClaudeCli) {
|
|
468
|
+
heading('MCP Server — Claude Code');
|
|
469
|
+
let doSetup;
|
|
470
|
+
if (auto) {
|
|
471
|
+
doSetup = true;
|
|
472
|
+
} else {
|
|
473
|
+
const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
|
|
474
|
+
doSetup = !mcpAnswer || mcpAnswer.toLowerCase() === 'y';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (doSetup) {
|
|
478
|
+
if (!resolvedScope) {
|
|
479
|
+
const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
|
|
480
|
+
resolvedScope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
|
|
481
|
+
}
|
|
482
|
+
const scope = resolvedScope;
|
|
483
|
+
try {
|
|
484
|
+
try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore', shell: true }); } catch {}
|
|
485
|
+
try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore', shell: true }); } catch {}
|
|
486
|
+
try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore', shell: true }); } catch {}
|
|
487
|
+
cleanupEmptyMcpJson();
|
|
488
|
+
const escapedMcpUrl = process.platform === 'win32'
|
|
489
|
+
? `"${mcpUrl.replace(/"/g, '""')}"`
|
|
490
|
+
: `'${mcpUrl.replace(/'/g, "'\\''")}'`;
|
|
491
|
+
execSync(
|
|
492
|
+
`claude mcp add --transport http ai-lens -s ${scope} ${escapedMcpUrl}`,
|
|
493
|
+
{ stdio: 'inherit', shell: true },
|
|
494
|
+
);
|
|
495
|
+
success(` MCP server registered in Claude Code (${scope})`);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
error(` Failed to register MCP server: ${err.message}`);
|
|
498
|
+
}
|
|
499
|
+
} else {
|
|
500
|
+
info(' Skipped');
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Cursor MCP. Mirror the resolved scope: project file for local/project, else global.
|
|
505
|
+
// Gate on a project target too, so a project-scoped install still registers even when
|
|
506
|
+
// the global ~/.cursor doesn't exist (fresh machine / CI / Cursor-only-in-project).
|
|
507
|
+
const cursorProjectRoot = cursorRootFor(resolvedScope);
|
|
508
|
+
if (hasCursorDir || cursorProjectRoot) {
|
|
509
|
+
heading('MCP Server — Cursor');
|
|
510
|
+
let doSetup;
|
|
511
|
+
if (auto) {
|
|
512
|
+
doSetup = true;
|
|
513
|
+
} else {
|
|
514
|
+
const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
|
|
515
|
+
doSetup = !cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y';
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (doSetup) {
|
|
519
|
+
try {
|
|
520
|
+
removeCursorMcp();
|
|
521
|
+
addCursorMcp(mcpUrl, { projectRoot: cursorProjectRoot });
|
|
522
|
+
const where = cursorProjectRoot
|
|
523
|
+
? join(cursorProjectRoot, '.cursor', 'mcp.json')
|
|
524
|
+
: '~/.cursor/mcp.json';
|
|
525
|
+
success(` MCP server registered in Cursor (${where})`);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
error(` Failed to register MCP server: ${err.message}`);
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
info(' Skipped');
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
392
535
|
export default async function init() {
|
|
393
536
|
const flags = getInitArgs();
|
|
394
537
|
const auto = flags.yes || false;
|
|
@@ -400,6 +543,50 @@ export default async function init() {
|
|
|
400
543
|
heading(`AI Lens — Init v${version} (${commit})`);
|
|
401
544
|
detail(`capture.js: ${CAPTURE_PATH}`);
|
|
402
545
|
|
|
546
|
+
// --mcp-only: register ONLY the MCP server, mirroring the existing AI Lens
|
|
547
|
+
// install scope (local/project vs global/user). Skips hooks, auth, and import.
|
|
548
|
+
if (flags.mcpOnly) {
|
|
549
|
+
if (flags.noMcp) {
|
|
550
|
+
error('--mcp-only cannot be combined with --no-mcp.');
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
heading('Configuration');
|
|
554
|
+
const currentConfig = readLensConfig();
|
|
555
|
+
const serverUrl = await resolveServerUrl(flags, auto, currentConfig);
|
|
556
|
+
info(` Server: ${serverUrl}`);
|
|
557
|
+
|
|
558
|
+
// Soft health check — don't block registration (the server may be briefly down),
|
|
559
|
+
// but warn so a typo'd URL doesn't silently register a dead MCP (verification is skipped).
|
|
560
|
+
try {
|
|
561
|
+
const health = await getJson(`${serverUrl}/api/health`);
|
|
562
|
+
if (health.status !== 'ok') warn(` Server responded with unexpected status: ${JSON.stringify(health)}`);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
warn(` Server unreachable (${err.message}) — registering MCP anyway; double-check the URL.`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Persist the server URL so it sticks for later runs.
|
|
568
|
+
saveLensConfig({ ...currentConfig, serverUrl });
|
|
569
|
+
|
|
570
|
+
// Scope precedence: explicit --mcp-scope > --project-hooks > detected footprint > user.
|
|
571
|
+
const cwd = resolve(process.cwd());
|
|
572
|
+
let forcedScope;
|
|
573
|
+
if (flags.mcpScope && ['user', 'local', 'project'].includes(flags.mcpScope)) {
|
|
574
|
+
forcedScope = flags.mcpScope;
|
|
575
|
+
} else if (flags.projectHooks) {
|
|
576
|
+
forcedScope = 'local';
|
|
577
|
+
} else {
|
|
578
|
+
const detected = detectAiLensInstallScope(cwd); // 'project' | 'user' | null
|
|
579
|
+
forcedScope = detected === 'project' ? 'local' : 'user';
|
|
580
|
+
}
|
|
581
|
+
info(` Install scope: ${forcedScope === 'user' ? 'global (user)' : 'local (project)'}`);
|
|
582
|
+
|
|
583
|
+
await setupMcpServers(serverUrl, { auto, forcedScope, projectRoot: cwd });
|
|
584
|
+
|
|
585
|
+
heading('Done');
|
|
586
|
+
info(' MCP-only setup complete — hooks, auth, and import were skipped.');
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
403
590
|
// Resolve a stable node binary path up-front. The same resolution is baked into
|
|
404
591
|
// the per-machine launcher (run.sh / run.cmd) and into hook commands. We only
|
|
405
592
|
// need it when hooks will actually be written; --no-hooks skips it so users
|
|
@@ -546,20 +733,7 @@ export default async function init() {
|
|
|
546
733
|
const currentConfig = readLensConfig();
|
|
547
734
|
|
|
548
735
|
// Server URL
|
|
549
|
-
const
|
|
550
|
-
let serverUrl;
|
|
551
|
-
if (flags.server) {
|
|
552
|
-
serverUrl = flags.server.replace(/\/+$/, '');
|
|
553
|
-
} else if (auto) {
|
|
554
|
-
serverUrl = currentServer;
|
|
555
|
-
} else {
|
|
556
|
-
const serverInput = await ask(
|
|
557
|
-
`Server URL (Enter = ${currentServer}): `,
|
|
558
|
-
);
|
|
559
|
-
serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
|
|
560
|
-
}
|
|
561
|
-
if (!/^https?:\/\//i.test(serverUrl)) serverUrl = `http://${serverUrl}`;
|
|
562
|
-
try { new URL(serverUrl); } catch { error(`Invalid server URL: ${serverUrl}`); process.exit(1); }
|
|
736
|
+
const serverUrl = await resolveServerUrl(flags, auto, currentConfig);
|
|
563
737
|
info(` Server: ${serverUrl}`);
|
|
564
738
|
|
|
565
739
|
// Project filter
|
|
@@ -756,34 +930,50 @@ export default async function init() {
|
|
|
756
930
|
|
|
757
931
|
let nestedScan = [];
|
|
758
932
|
let nestedPending = [];
|
|
933
|
+
let nestedCodexTrust = [];
|
|
759
934
|
if (flags.projectHooks) {
|
|
760
935
|
const trackedRoots = getTrackedRoots(newConfig.projects, resolve(process.cwd()));
|
|
761
|
-
nestedScan =
|
|
936
|
+
nestedScan = scanNestedProjects(trackedRoots);
|
|
762
937
|
|
|
763
938
|
if (nestedScan.length > 0) {
|
|
764
|
-
heading('Nested
|
|
939
|
+
heading('Nested projects');
|
|
765
940
|
for (const result of nestedScan) {
|
|
766
941
|
const installNote = result.installTarget ? ` -> install in ${result.installTarget}` : '';
|
|
767
|
-
info(` ${result.relativePath}: ${result.status}${installNote}`);
|
|
942
|
+
info(` ${result.relativePath} (${result.tool}): ${result.status}${installNote}`);
|
|
768
943
|
}
|
|
769
944
|
const summary = summarizeNestedProjects(nestedScan);
|
|
770
|
-
detail(`Found ${summary.total} nested
|
|
945
|
+
detail(`Found ${summary.total} nested hook target(s), ${summary.unhooked} need attention.`);
|
|
771
946
|
blank();
|
|
772
947
|
}
|
|
773
948
|
|
|
774
949
|
const nestedActionable = nestedScan.filter(result => result.installTarget);
|
|
950
|
+
nestedCodexTrust = nestedScan
|
|
951
|
+
.filter(result => result.tool === 'codex' && !result.installTarget)
|
|
952
|
+
.map(result => ({ tool: makeNestedCodexTool(result.projectDir, null, ctx) }));
|
|
775
953
|
let shouldInstallNested = auto && nestedActionable.length > 0;
|
|
776
954
|
if (!auto && nestedActionable.length > 0) {
|
|
777
|
-
const answer = await ask(`Install hooks in ${nestedActionable.length} nested
|
|
955
|
+
const answer = await ask(`Install hooks in ${nestedActionable.length} nested hook target(s)? [Y/n] `);
|
|
778
956
|
shouldInstallNested = !answer || ['y', 'yes'].includes(answer.toLowerCase());
|
|
779
957
|
}
|
|
780
958
|
|
|
781
959
|
if (shouldInstallNested) {
|
|
782
960
|
nestedPending = nestedActionable.map(result => {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
961
|
+
let tool;
|
|
962
|
+
if (result.tool === 'codex') {
|
|
963
|
+
const capturePathInHooks = flags.useRepoPath
|
|
964
|
+
? (() => {
|
|
965
|
+
const abs = resolve(REPO_CAPTURE_PATH);
|
|
966
|
+
const userHome = homedir();
|
|
967
|
+
return abs.startsWith(userHome) ? `~${abs.slice(userHome.length).replace(/\\/g, '/')}` : abs.replace(/\\/g, '/');
|
|
968
|
+
})()
|
|
969
|
+
: null;
|
|
970
|
+
tool = makeNestedCodexTool(result.projectDir, capturePathInHooks, ctx);
|
|
971
|
+
} else {
|
|
972
|
+
const capturePathInHooks = flags.useRepoPath
|
|
973
|
+
? relative(result.projectDir, resolve(REPO_CAPTURE_PATH)).replace(/\\/g, '/')
|
|
974
|
+
: null;
|
|
975
|
+
tool = makeNestedClaudeTool(result.projectDir, capturePathInHooks, ctx);
|
|
976
|
+
}
|
|
787
977
|
tool.configPath = join(tool.dirPath, result.installTarget);
|
|
788
978
|
return {
|
|
789
979
|
tool,
|
|
@@ -822,7 +1012,7 @@ export default async function init() {
|
|
|
822
1012
|
// Idempotent: re-run hook trust enrollment even when no hooks needed
|
|
823
1013
|
// rewriting. Without this, a previously-installed-but-untrusted Codex
|
|
824
1014
|
// hook stays inert until the next time hooks themselves change.
|
|
825
|
-
for (const { tool } of analyses) {
|
|
1015
|
+
for (const { tool } of [...analyses, ...nestedCodexTrust]) {
|
|
826
1016
|
if (tool.name !== 'Codex' && !tool.name.startsWith('Codex')) continue;
|
|
827
1017
|
try {
|
|
828
1018
|
const r = enableCodexHookTrust(tool.configPath);
|
|
@@ -861,8 +1051,10 @@ export default async function init() {
|
|
|
861
1051
|
|
|
862
1052
|
for (const { tool, analysis } of [...pending, ...nestedPending]) {
|
|
863
1053
|
try {
|
|
864
|
-
// Backup malformed
|
|
865
|
-
|
|
1054
|
+
// Backup malformed configs before overwriting. analyzeToolHooks already
|
|
1055
|
+
// renames non-shared malformed files; nested scan is read-only, so it
|
|
1056
|
+
// reaches this path and needs the backup here.
|
|
1057
|
+
if (analysis.status === 'malformed' && existsSync(tool.configPath)) {
|
|
866
1058
|
try { copyFileSync(tool.configPath, tool.configPath + '.bak'); } catch { /* file may be gone */ }
|
|
867
1059
|
}
|
|
868
1060
|
const existingConfig = analysis.config || null;
|
|
@@ -882,7 +1074,7 @@ export default async function init() {
|
|
|
882
1074
|
// key, no `file:` prefix) — the same state the TUI "Trust all" flow writes,
|
|
883
1075
|
// but driven by AI Lens at install time. With it, `codex exec` fires hooks
|
|
884
1076
|
// with no interactive step.
|
|
885
|
-
for (const { tool } of [...pending, ...nestedPending]) {
|
|
1077
|
+
for (const { tool } of [...pending, ...nestedPending, ...nestedCodexTrust]) {
|
|
886
1078
|
if (tool.name !== 'Codex' && !tool.name.startsWith('Codex')) continue;
|
|
887
1079
|
try {
|
|
888
1080
|
const r = enableCodexHookTrust(tool.configPath);
|
|
@@ -1074,81 +1266,17 @@ export default async function init() {
|
|
|
1074
1266
|
} catch {}
|
|
1075
1267
|
}
|
|
1076
1268
|
|
|
1077
|
-
// MCP setup (HTTP transport — auth via OAuth in browser, no token needed)
|
|
1269
|
+
// MCP setup (HTTP transport — auth via OAuth in browser, no token needed).
|
|
1270
|
+
// --project-hooks installs hooks at project scope, so mirror that for the MCP:
|
|
1271
|
+
// default the scope to local and target the project .cursor/mcp.json (an explicit
|
|
1272
|
+
// --mcp-scope still wins; setupMcpServers maps scope → Cursor target).
|
|
1078
1273
|
if (!flags.noMcp) {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
try { execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
|
|
1086
|
-
|
|
1087
|
-
if (hasClaudeDir && hasClaudeCli) {
|
|
1088
|
-
heading('MCP Server — Claude Code');
|
|
1089
|
-
let doSetup;
|
|
1090
|
-
if (auto) {
|
|
1091
|
-
doSetup = true;
|
|
1092
|
-
} else {
|
|
1093
|
-
const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
|
|
1094
|
-
doSetup = !mcpAnswer || mcpAnswer.toLowerCase() === 'y';
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
if (doSetup) {
|
|
1098
|
-
let scope;
|
|
1099
|
-
if (flags.mcpScope && ['local', 'project', 'user'].includes(flags.mcpScope)) {
|
|
1100
|
-
scope = flags.mcpScope;
|
|
1101
|
-
} else if (auto) {
|
|
1102
|
-
scope = 'user';
|
|
1103
|
-
} else {
|
|
1104
|
-
const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
|
|
1105
|
-
scope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
|
|
1106
|
-
}
|
|
1107
|
-
try {
|
|
1108
|
-
try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore', shell: true }); } catch {}
|
|
1109
|
-
try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore', shell: true }); } catch {}
|
|
1110
|
-
try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore', shell: true }); } catch {}
|
|
1111
|
-
cleanupEmptyMcpJson();
|
|
1112
|
-
const escapedMcpUrl = process.platform === 'win32'
|
|
1113
|
-
? `"${mcpUrl.replace(/"/g, '""')}"`
|
|
1114
|
-
: `'${mcpUrl.replace(/'/g, "'\\''")}'`;
|
|
1115
|
-
execSync(
|
|
1116
|
-
`claude mcp add --transport http ai-lens -s ${scope} ${escapedMcpUrl}`,
|
|
1117
|
-
{ stdio: 'inherit', shell: true },
|
|
1118
|
-
);
|
|
1119
|
-
success(` MCP server registered in Claude Code (${scope})`);
|
|
1120
|
-
} catch (err) {
|
|
1121
|
-
error(` Failed to register MCP server: ${err.message}`);
|
|
1122
|
-
}
|
|
1123
|
-
} else {
|
|
1124
|
-
info(' Skipped');
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
// Cursor MCP
|
|
1129
|
-
const cursorDir = join(homedir(), '.cursor');
|
|
1130
|
-
if (existsSync(cursorDir)) {
|
|
1131
|
-
heading('MCP Server — Cursor');
|
|
1132
|
-
let doSetup;
|
|
1133
|
-
if (auto) {
|
|
1134
|
-
doSetup = true;
|
|
1135
|
-
} else {
|
|
1136
|
-
const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
|
|
1137
|
-
doSetup = !cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y';
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
if (doSetup) {
|
|
1141
|
-
try {
|
|
1142
|
-
removeCursorMcp();
|
|
1143
|
-
addCursorMcp(mcpUrl);
|
|
1144
|
-
success(' MCP server registered in Cursor (~/.cursor/mcp.json)');
|
|
1145
|
-
} catch (err) {
|
|
1146
|
-
error(` Failed to register MCP server: ${err.message}`);
|
|
1147
|
-
}
|
|
1148
|
-
} else {
|
|
1149
|
-
info(' Skipped');
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1274
|
+
await setupMcpServers(serverUrl, {
|
|
1275
|
+
auto,
|
|
1276
|
+
mcpScope: flags.mcpScope,
|
|
1277
|
+
forcedScope: (flags.projectHooks && !flags.mcpScope) ? 'local' : null,
|
|
1278
|
+
projectRoot: flags.projectHooks ? resolve(process.cwd()) : null,
|
|
1279
|
+
});
|
|
1152
1280
|
}
|
|
1153
1281
|
|
|
1154
1282
|
// Quick verification
|
package/cli/scan.js
CHANGED
|
@@ -78,40 +78,68 @@ function hasAnyNonAiLensHooks(config) {
|
|
|
78
78
|
return collectHookEntries(config).some(entry => !isAiLensHook(entry));
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
const settingsPath = join(claudeDir, 'settings.json');
|
|
84
|
-
const settingsLocalPath = join(claudeDir, 'settings.local.json');
|
|
85
|
-
const settings = readJsonSafe(settingsPath);
|
|
86
|
-
const settingsLocal = readJsonSafe(settingsLocalPath);
|
|
87
|
-
|
|
88
|
-
if (settings === 'MALFORMED' || settingsLocal === 'MALFORMED') {
|
|
81
|
+
function classifyHookProject({ tool, configs, installTarget, existingConfig, malformedInstallTarget = null }) {
|
|
82
|
+
if (configs.some(config => config === 'MALFORMED') || existingConfig === 'MALFORMED') {
|
|
89
83
|
return {
|
|
84
|
+
tool,
|
|
90
85
|
status: 'malformed',
|
|
91
|
-
installTarget:
|
|
86
|
+
installTarget: malformedInstallTarget,
|
|
92
87
|
existingConfig: null,
|
|
93
88
|
};
|
|
94
89
|
}
|
|
95
90
|
|
|
96
|
-
if (hasAnyAiLensHook
|
|
91
|
+
if (configs.some(hasAnyAiLensHook) || hasAnyAiLensHook(existingConfig)) {
|
|
97
92
|
return {
|
|
93
|
+
tool,
|
|
98
94
|
status: 'installed',
|
|
99
95
|
installTarget: null,
|
|
100
96
|
existingConfig: null,
|
|
101
97
|
};
|
|
102
98
|
}
|
|
103
99
|
|
|
100
|
+
return {
|
|
101
|
+
tool,
|
|
102
|
+
status: configs.some(hasAnyNonAiLensHooks) || hasAnyNonAiLensHooks(existingConfig)
|
|
103
|
+
? 'has non-ai-lens hooks'
|
|
104
|
+
: 'missing',
|
|
105
|
+
installTarget,
|
|
106
|
+
existingConfig,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function classifyClaudeProject(projectDir) {
|
|
111
|
+
const claudeDir = join(projectDir, '.claude');
|
|
112
|
+
const settingsPath = join(claudeDir, 'settings.json');
|
|
113
|
+
const settingsLocalPath = join(claudeDir, 'settings.local.json');
|
|
114
|
+
const settings = readJsonSafe(settingsPath);
|
|
115
|
+
const settingsLocal = readJsonSafe(settingsLocalPath);
|
|
116
|
+
|
|
104
117
|
// AI Lens Claude hooks are per-machine / OS-specific (ADR 0003 — absolute node path,
|
|
105
118
|
// conhost.exe on Windows). They must NEVER be written into a COMMITTED settings.json
|
|
106
119
|
// (that re-introduces the cross-OS / cross-machine breakage MR !298 removed). Always
|
|
107
120
|
// install into the gitignored, per-machine settings.local.json, which Claude Code
|
|
108
121
|
// merges with settings.json at runtime — so a project's own non-ai-lens hooks in
|
|
109
122
|
// settings.json keep working alongside.
|
|
110
|
-
return {
|
|
111
|
-
|
|
123
|
+
return classifyHookProject({
|
|
124
|
+
tool: 'claude',
|
|
125
|
+
configs: [settings],
|
|
112
126
|
installTarget: 'settings.local.json',
|
|
113
127
|
existingConfig: settingsLocal,
|
|
114
|
-
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function classifyCodexProject(projectDir) {
|
|
132
|
+
const codexDir = join(projectDir, '.codex');
|
|
133
|
+
const hooksPath = join(codexDir, 'hooks.json');
|
|
134
|
+
const hooks = readJsonSafe(hooksPath);
|
|
135
|
+
|
|
136
|
+
return classifyHookProject({
|
|
137
|
+
tool: 'codex',
|
|
138
|
+
configs: [],
|
|
139
|
+
installTarget: 'hooks.json',
|
|
140
|
+
malformedInstallTarget: 'hooks.json',
|
|
141
|
+
existingConfig: hooks,
|
|
142
|
+
});
|
|
115
143
|
}
|
|
116
144
|
|
|
117
145
|
function scanRoot(rootPath, options, results, seen) {
|
|
@@ -133,18 +161,40 @@ function scanRoot(rootPath, options, results, seen) {
|
|
|
133
161
|
const childPath = join(currentDir, child.name);
|
|
134
162
|
if (isIgnoredByGitignore(rules, childPath)) continue;
|
|
135
163
|
|
|
136
|
-
if (child.name === '.git' || child.name === '.claude') {
|
|
164
|
+
if (child.name === '.git' || child.name === '.claude' || child.name === '.codex') {
|
|
137
165
|
const projectDir = resolve(currentDir);
|
|
138
166
|
if (projectDir !== rootPath) {
|
|
139
167
|
const depth = countDepth(rootPath, projectDir);
|
|
140
|
-
if (depth <= maxDepth
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
168
|
+
if (depth <= maxDepth) {
|
|
169
|
+
const projectKey = `${rootPath}:${projectDir}`;
|
|
170
|
+
if (!seen.has(projectKey)) {
|
|
171
|
+
seen.add(projectKey);
|
|
172
|
+
const base = {
|
|
173
|
+
projectDir,
|
|
174
|
+
relativePath: relative(rootPath, projectDir).replace(/\\/g, '/'),
|
|
175
|
+
marker: child.name,
|
|
176
|
+
};
|
|
177
|
+
// Classify by which markers ACTUALLY exist in the dir, not by which
|
|
178
|
+
// child readdir happened to yield first. readdirSync order is
|
|
179
|
+
// filesystem-dependent (unsorted), so gating Claude on
|
|
180
|
+
// `child.name !== '.codex'` dropped the Claude target whenever
|
|
181
|
+
// `.codex` was enumerated before `.git`/`.claude` in the same repo
|
|
182
|
+
// (latent on Linux; ANL-1126 review). Codex is offered for every
|
|
183
|
+
// project dir; Claude only for git/.claude repos (a .codex-only dir
|
|
184
|
+
// is not a Claude project).
|
|
185
|
+
const isClaudeProject = existsSync(join(projectDir, '.git'))
|
|
186
|
+
|| existsSync(join(projectDir, '.claude'));
|
|
187
|
+
if (isClaudeProject) {
|
|
188
|
+
results.push({
|
|
189
|
+
...base,
|
|
190
|
+
...classifyClaudeProject(projectDir),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
results.push({
|
|
194
|
+
...base,
|
|
195
|
+
...classifyCodexProject(projectDir),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
148
198
|
}
|
|
149
199
|
}
|
|
150
200
|
continue;
|
|
@@ -160,7 +210,7 @@ function scanRoot(rootPath, options, results, seen) {
|
|
|
160
210
|
visit(rootPath);
|
|
161
211
|
}
|
|
162
212
|
|
|
163
|
-
export function
|
|
213
|
+
export function scanNestedProjects(projectRoots, options = {}) {
|
|
164
214
|
const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : DEFAULT_MAX_DEPTH;
|
|
165
215
|
const roots = (projectRoots || []).map(path => resolve(path));
|
|
166
216
|
const results = [];
|
|
@@ -171,7 +221,15 @@ export function scanNestedClaudeProjects(projectRoots, options = {}) {
|
|
|
171
221
|
scanRoot(rootPath, { maxDepth }, results, seen);
|
|
172
222
|
}
|
|
173
223
|
|
|
174
|
-
return results.sort((a, b) =>
|
|
224
|
+
return results.sort((a, b) => (
|
|
225
|
+
a.relativePath.localeCompare(b.relativePath) || a.tool.localeCompare(b.tool)
|
|
226
|
+
));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function scanNestedClaudeProjects(projectRoots, options = {}) {
|
|
230
|
+
return scanNestedProjects(projectRoots, options)
|
|
231
|
+
.filter(result => result.tool === 'claude')
|
|
232
|
+
.map(({ tool, ...result }) => result);
|
|
175
233
|
}
|
|
176
234
|
|
|
177
235
|
export function summarizeNestedProjects(results) {
|
package/cli/status.js
CHANGED
|
@@ -11,7 +11,7 @@ import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConf
|
|
|
11
11
|
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';
|
|
12
12
|
import { isLockStale } from '../client/sender.js';
|
|
13
13
|
import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
|
|
14
|
-
import {
|
|
14
|
+
import { scanNestedProjects, summarizeNestedProjects } from './scan.js';
|
|
15
15
|
|
|
16
16
|
const INIT_LOG_PATH = join(DATA_DIR, 'init.log');
|
|
17
17
|
|
|
@@ -510,6 +510,16 @@ function checkGitIdentity() {
|
|
|
510
510
|
return { ok: false, summary: 'not configured', detail: 'Git identity not configured (git config user.email)' };
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
+
// Server-driven update nudge ([0093]): the sender caches the server's verdict to
|
|
514
|
+
// ~/.ai-lens/messages.json. Read-only here (no network); returns the outdated
|
|
515
|
+
// message if the server says this client is behind the latest published version.
|
|
516
|
+
function readOutdatedNudge() {
|
|
517
|
+
try {
|
|
518
|
+
const cache = JSON.parse(readFileSync(join(homedir(), '.ai-lens', 'messages.json'), 'utf-8'));
|
|
519
|
+
return (cache.messages || []).find(m => m && typeof m.id === 'string' && m.id.startsWith('outdated:')) || null;
|
|
520
|
+
} catch { return null; }
|
|
521
|
+
}
|
|
522
|
+
|
|
513
523
|
export function checkClientFiles(tools = []) {
|
|
514
524
|
// In repo-path mode the hooks run capture.js straight from the repo, so the
|
|
515
525
|
// ~/.ai-lens/client/ copy (if any) is an UNUSED leftover from a prior copy-mode
|
|
@@ -529,6 +539,10 @@ export function checkClientFiles(tools = []) {
|
|
|
529
539
|
if (copyExists) {
|
|
530
540
|
detail += `\n Note: ~/.ai-lens/client/ exists but is UNUSED in repo-path mode (stale leftover from an old copy-install; safe to ignore).`;
|
|
531
541
|
}
|
|
542
|
+
// Server nudge (if behind latest published): surface as an info line, but keep
|
|
543
|
+
// ok=true — a stale repo checkout is fixed by /sync, not a false ✗ here (Y4).
|
|
544
|
+
const repoNudge = readOutdatedNudge();
|
|
545
|
+
if (repoNudge) detail += `\n ! ${repoNudge.text.split('\n').join('\n ')}`;
|
|
532
546
|
// Healthy as long as the hook-source version is readable. Don't compare against
|
|
533
547
|
// the CLI: `ai-lens status` may run from a global npx CLI while the hooks point
|
|
534
548
|
// at a checkout — that mismatch is expected, not an error. A stale repo checkout
|
|
@@ -557,9 +571,16 @@ export function checkClientFiles(tools = []) {
|
|
|
557
571
|
const clientCommit = versionJson.commit || 'unknown';
|
|
558
572
|
outdated = clientVersion !== cliVersion || clientCommit !== cliCommit;
|
|
559
573
|
versionDetail = `\n Client version: ${clientVersion} (${clientCommit})`;
|
|
574
|
+
const nudge = readOutdatedNudge();
|
|
560
575
|
if (outdated) {
|
|
561
576
|
versionDetail += `\n CLI version: ${cliVersion} (${cliCommit})`;
|
|
562
|
-
versionDetail += `\n ! Client is outdated — run: npx -y ai-lens init --yes`;
|
|
577
|
+
if (!nudge) versionDetail += `\n ! Client is outdated — run: npx -y ai-lens init --yes`;
|
|
578
|
+
}
|
|
579
|
+
// Server says behind latest published → copy-mode must re-init. Prefer the
|
|
580
|
+
// server's line (it carries the exact latest version + command).
|
|
581
|
+
if (nudge) {
|
|
582
|
+
versionDetail += `\n ! ${nudge.text.split('\n').join('\n ')}`;
|
|
583
|
+
outdated = true;
|
|
563
584
|
}
|
|
564
585
|
} catch {
|
|
565
586
|
versionDetail = `\n Client version: unknown (version.json not found — run: npx -y ai-lens init)`;
|
|
@@ -1564,7 +1585,7 @@ export default async function status({ report = false } = {}) {
|
|
|
1564
1585
|
|
|
1565
1586
|
if (hasProjectHooks) {
|
|
1566
1587
|
const nestedRoots = getMonitoredProjects() || [process.cwd()];
|
|
1567
|
-
const nestedProjects =
|
|
1588
|
+
const nestedProjects = scanNestedProjects(nestedRoots);
|
|
1568
1589
|
const nestedSummary = summarizeNestedProjects(nestedProjects);
|
|
1569
1590
|
const nestedUnhooked = nestedProjects.filter(result => result.status !== 'installed');
|
|
1570
1591
|
printLine('Nested projects', {
|
|
@@ -1572,10 +1593,10 @@ export default async function status({ report = false } = {}) {
|
|
|
1572
1593
|
summary: nestedSummary.total === 0
|
|
1573
1594
|
? 'none found'
|
|
1574
1595
|
: nestedSummary.unhooked === 0
|
|
1575
|
-
? `${nestedSummary.total} nested
|
|
1576
|
-
: `${nestedSummary.unhooked} unhooked / ${nestedSummary.total} nested
|
|
1596
|
+
? `${nestedSummary.total} nested hook target(s), all hooked`
|
|
1597
|
+
: `${nestedSummary.unhooked} unhooked / ${nestedSummary.total} nested hook target(s)`,
|
|
1577
1598
|
detail: nestedUnhooked.length > 0
|
|
1578
|
-
? `nested_unhooked_projects:\n${nestedUnhooked.map(result => `- ${result.projectDir} (${result.status})`).join('\n')}`
|
|
1599
|
+
? `nested_unhooked_projects:\n${nestedUnhooked.map(result => `- ${result.projectDir} (${result.tool}: ${result.status})`).join('\n')}`
|
|
1579
1600
|
: 'nested_unhooked_projects: none',
|
|
1580
1601
|
});
|
|
1581
1602
|
}
|
package/client/capture.js
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
getGitIdentity,
|
|
30
30
|
getGitMetadata,
|
|
31
31
|
getMonitoredProjects,
|
|
32
|
+
MESSAGES_PATH,
|
|
33
|
+
MESSAGES_SHOWN_PATH,
|
|
32
34
|
} from './config.js';
|
|
33
35
|
import { isLockStale, isSenderBackoffActive } from './sender.js';
|
|
34
36
|
import { toNumberOrNull, buildTokenUsageRaw } from './token-usage.js';
|
|
@@ -1379,6 +1381,86 @@ export function trySpawnSender() {
|
|
|
1379
1381
|
}
|
|
1380
1382
|
|
|
1381
1383
|
|
|
1384
|
+
// =============================================================================
|
|
1385
|
+
// Server-driven client messages ([0093]) — render on SessionStart
|
|
1386
|
+
// =============================================================================
|
|
1387
|
+
|
|
1388
|
+
const MESSAGE_THROTTLE_MS = 24 * 60 * 60 * 1000; // show a given message ≤ once/day
|
|
1389
|
+
const MESSAGE_MAX_LEN = 2000;
|
|
1390
|
+
// Appended only for Cursor (additional_context → agent context) to raise the odds
|
|
1391
|
+
// the agent relays the nudge to the human. NOT added for Claude (direct banner).
|
|
1392
|
+
const CURSOR_RELAY_SUFFIX = '\n(Сообщи это пользователю в начале своего ответа.)';
|
|
1393
|
+
|
|
1394
|
+
function readJsonFileSafe(path) {
|
|
1395
|
+
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function sanitizeMessageText(s) {
|
|
1399
|
+
if (typeof s !== 'string') return '';
|
|
1400
|
+
// Strip control chars except newline/tab; cap length (defense-in-depth — the
|
|
1401
|
+
// text is server-controlled and enters the tool's context).
|
|
1402
|
+
let out = '';
|
|
1403
|
+
for (const ch of s) {
|
|
1404
|
+
const c = ch.codePointAt(0);
|
|
1405
|
+
if (c === 9 || c === 10 || (c >= 32 && c !== 127)) out += ch;
|
|
1406
|
+
if (out.length >= MESSAGE_MAX_LEN) break;
|
|
1407
|
+
}
|
|
1408
|
+
return out.slice(0, MESSAGE_MAX_LEN);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** First cache message whose id hasn't been shown within the throttle window. */
|
|
1412
|
+
function pickUnthrottledMessage(messages, shownMap, nowMs) {
|
|
1413
|
+
for (const m of messages) {
|
|
1414
|
+
if (!m || !m.id || typeof m.text !== 'string') continue;
|
|
1415
|
+
const last = shownMap[m.id] ? Date.parse(shownMap[m.id]) : 0;
|
|
1416
|
+
if (!Number.isFinite(last) || nowMs - last >= MESSAGE_THROTTLE_MS) return m;
|
|
1417
|
+
}
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** Atomically record `id` as shown now, pruning ids no longer in the cache. */
|
|
1422
|
+
function markShown(id, validIds, nowIso) {
|
|
1423
|
+
try {
|
|
1424
|
+
const map = readJsonFileSafe(MESSAGES_SHOWN_PATH) || {};
|
|
1425
|
+
const next = {};
|
|
1426
|
+
for (const k of validIds) if (map[k]) next[k] = map[k];
|
|
1427
|
+
next[id] = nowIso;
|
|
1428
|
+
const tmp = MESSAGES_SHOWN_PATH + '.tmp.' + process.pid;
|
|
1429
|
+
writeFileSync(tmp, JSON.stringify(next));
|
|
1430
|
+
renameSync(tmp, MESSAGES_SHOWN_PATH);
|
|
1431
|
+
} catch { /* best-effort */ }
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* On SessionStart, render a cached server message into the tool's context via the
|
|
1436
|
+
* one stdout write capture.js ever makes. Claude → `systemMessage` (banner);
|
|
1437
|
+
* Cursor → `additional_context` (+ relay suffix). Codex/unknown → nothing.
|
|
1438
|
+
* Identity-independent (reads only the local cache), throttled per id, and fully
|
|
1439
|
+
* isolated: any failure is swallowed so it can never flip capture to exit≠0.
|
|
1440
|
+
*/
|
|
1441
|
+
export function maybeEmitSessionStartMessage(primary, { now = Date.now() } = {}) {
|
|
1442
|
+
try {
|
|
1443
|
+
if (!primary || primary.type !== 'SessionStart') return;
|
|
1444
|
+
const source = primary.source;
|
|
1445
|
+
if (source !== 'claude_code' && source !== 'cursor') return; // codex/unknown deferred
|
|
1446
|
+
const cache = readJsonFileSafe(MESSAGES_PATH);
|
|
1447
|
+
const messages = cache && Array.isArray(cache.messages) ? cache.messages : null;
|
|
1448
|
+
if (!messages || messages.length === 0) return;
|
|
1449
|
+
const shownMap = readJsonFileSafe(MESSAGES_SHOWN_PATH) || {};
|
|
1450
|
+
const msg = pickUnthrottledMessage(messages, shownMap, now);
|
|
1451
|
+
if (!msg) return;
|
|
1452
|
+
|
|
1453
|
+
const text = sanitizeMessageText(msg.text);
|
|
1454
|
+
if (!text) return;
|
|
1455
|
+
const out = source === 'claude_code'
|
|
1456
|
+
? { systemMessage: text }
|
|
1457
|
+
: { additional_context: text + CURSOR_RELAY_SUFFIX };
|
|
1458
|
+
|
|
1459
|
+
process.stdout.write(JSON.stringify(out));
|
|
1460
|
+
markShown(msg.id, messages.map(m => m && m.id).filter(Boolean), new Date(now).toISOString());
|
|
1461
|
+
} catch { /* never break capture */ }
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1382
1464
|
// =============================================================================
|
|
1383
1465
|
// Main
|
|
1384
1466
|
// =============================================================================
|
|
@@ -1435,6 +1517,12 @@ async function main() {
|
|
|
1435
1517
|
// hook invocation on the same machine.
|
|
1436
1518
|
const primary = events[0];
|
|
1437
1519
|
|
|
1520
|
+
// Render a server-driven update nudge ([0093]) on SessionStart — BEFORE the
|
|
1521
|
+
// project_filter / no_email / dedup gates below (those drop exactly the
|
|
1522
|
+
// stale/unknown devs we want to reach). Identity-independent, reads only the
|
|
1523
|
+
// local cache, never throws → can't flip this process to a non-zero exit.
|
|
1524
|
+
maybeEmitSessionStartMessage(primary);
|
|
1525
|
+
|
|
1438
1526
|
// Filter by monitored projects (if configured) — based on the primary event.
|
|
1439
1527
|
// If the primary is filtered out, drop the entire batch (the per-call events
|
|
1440
1528
|
// share the same project_path).
|
package/client/config.js
CHANGED
|
@@ -24,6 +24,10 @@ export const LOG_PATH = join(DATA_DIR, 'sender.log');
|
|
|
24
24
|
export const CAPTURE_LOG_PATH = join(DATA_DIR, 'capture.log');
|
|
25
25
|
export const SENDER_BACKOFF_PATH = join(DATA_DIR, 'sender-backoff.json');
|
|
26
26
|
export const LAST_STATUS_REPORT_PATH = join(DATA_DIR, 'last-status-report');
|
|
27
|
+
// Server-driven client messages ([0093]): sender caches them here; capture.js
|
|
28
|
+
// renders on SessionStart. `messages-shown` is the per-id throttle ledger.
|
|
29
|
+
export const MESSAGES_PATH = join(DATA_DIR, 'messages.json');
|
|
30
|
+
export const MESSAGES_SHOWN_PATH = join(DATA_DIR, 'messages-shown.json');
|
|
27
31
|
export const LOG_MAX_AGE_DAYS = 30;
|
|
28
32
|
const GIT_ROOT_CACHE = new Map();
|
|
29
33
|
// Pipe stderr (instead of inheriting it) so that "fatal: not a git repository"
|
|
@@ -154,6 +158,24 @@ export function getClientVersion() {
|
|
|
154
158
|
return { version: 'unknown', commit: 'unknown' };
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Install mode of the RUNNING client — mirrors getClientVersion()'s three-way
|
|
163
|
+
* resolution so mode and version never disagree:
|
|
164
|
+
* 'copy' — sibling version.json (copied into ~/.ai-lens/client/ by init)
|
|
165
|
+
* 'repo' — running from the ai-lens package checkout (--use-repo-path / npx)
|
|
166
|
+
* 'unknown' — neither resolves
|
|
167
|
+
* Sent as X-Client-Mode so the server can return a mode-aware update nudge ([0093]).
|
|
168
|
+
*/
|
|
169
|
+
export function getClientMode() {
|
|
170
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
171
|
+
try { readFileSync(join(here, 'version.json'), 'utf-8'); return 'copy'; } catch { /* not a copy */ }
|
|
172
|
+
try {
|
|
173
|
+
const pkg = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf-8'));
|
|
174
|
+
if (pkg?.name === 'ai-lens' && pkg.version) return 'repo';
|
|
175
|
+
} catch { /* not a checkout */ }
|
|
176
|
+
return 'unknown';
|
|
177
|
+
}
|
|
178
|
+
|
|
157
179
|
export function getGitIdentity(cwd) {
|
|
158
180
|
let email = null;
|
|
159
181
|
let name = null;
|
package/client/sender.js
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
getServerUrl,
|
|
42
42
|
getAuthToken,
|
|
43
43
|
getClientVersion,
|
|
44
|
+
getClientMode,
|
|
45
|
+
MESSAGES_PATH,
|
|
44
46
|
DEFAULT_SERVER_URL,
|
|
45
47
|
log,
|
|
46
48
|
} from './config.js';
|
|
@@ -627,12 +629,32 @@ export function isTransientFetchError(err) {
|
|
|
627
629
|
return false;
|
|
628
630
|
}
|
|
629
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Cache server-driven client messages ([0093]) for capture.js to render on
|
|
634
|
+
* SessionStart. `undefined` = no response parsed this run → leave cache as-is;
|
|
635
|
+
* `[]` = client is current → remove the cache (so a fixed client stops nudging);
|
|
636
|
+
* non-empty → atomic tmp+rename write. Best-effort: never break the flush.
|
|
637
|
+
*/
|
|
638
|
+
function cacheClientMessages(messages) {
|
|
639
|
+
if (!Array.isArray(messages)) return;
|
|
640
|
+
try {
|
|
641
|
+
if (messages.length === 0) {
|
|
642
|
+
try { unlinkSync(MESSAGES_PATH); } catch { /* already absent */ }
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const tmp = MESSAGES_PATH + '.tmp.' + process.pid;
|
|
646
|
+
writeFileSync(tmp, JSON.stringify({ updatedAt: new Date().toISOString(), messages }));
|
|
647
|
+
renameSync(tmp, MESSAGES_PATH);
|
|
648
|
+
} catch { /* best-effort */ }
|
|
649
|
+
}
|
|
650
|
+
|
|
630
651
|
function buildHeaders(identity, authToken) {
|
|
631
652
|
const { version: clientVersion, commit: clientCommit } = getClientVersion();
|
|
632
653
|
const headers = {
|
|
633
654
|
'Content-Type': 'application/json',
|
|
634
655
|
'Connection': 'close',
|
|
635
656
|
'X-Client-Version': `${clientVersion}+${clientCommit}`,
|
|
657
|
+
'X-Client-Mode': getClientMode(),
|
|
636
658
|
};
|
|
637
659
|
if (identity.email) headers['X-Developer-Git-Email'] = identity.email;
|
|
638
660
|
if (identity.name) headers['X-Developer-Name'] = encodeURIComponent(identity.name);
|
|
@@ -849,6 +871,7 @@ async function main() {
|
|
|
849
871
|
}
|
|
850
872
|
|
|
851
873
|
const sentEventIds = new Set();
|
|
874
|
+
let lastMessages; // server-driven client messages ([0093]) from the last ok POST
|
|
852
875
|
|
|
853
876
|
try {
|
|
854
877
|
for (const { identity, events: batch } of byDeveloper.values()) {
|
|
@@ -857,6 +880,7 @@ async function main() {
|
|
|
857
880
|
for (const chunk of chunks) {
|
|
858
881
|
const result = await postEvents(serverUrl, chunk, identity, hasAuthToken ? authToken : null, { lockPath });
|
|
859
882
|
refreshLock(lockPath);
|
|
883
|
+
if (Array.isArray(result?.messages)) lastMessages = result.messages;
|
|
860
884
|
totalReceived += (result?.received ?? 0);
|
|
861
885
|
if ((result?.skipped ?? 0) > 0) {
|
|
862
886
|
log({ msg: 'server-skipped', skipped: result.skipped, chunk_size: chunk.length, developer: identity.email });
|
|
@@ -869,6 +893,7 @@ async function main() {
|
|
|
869
893
|
log({ msg: 'sent', events: totalReceived, chunks: chunks.length, developer: identity.email, projects, server: serverUrl });
|
|
870
894
|
}
|
|
871
895
|
clearSenderBackoff();
|
|
896
|
+
cacheClientMessages(lastMessages);
|
|
872
897
|
commitQueue(sendingDir, acquiredFiles);
|
|
873
898
|
trySpawnStatusReport();
|
|
874
899
|
} catch (err) {
|