ai-lens 0.8.52 → 0.8.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.commithash CHANGED
@@ -1 +1 @@
1
- 8553371
1
+ c830ed5
package/bin/ai-lens.js CHANGED
@@ -15,7 +15,7 @@ switch (command) {
15
15
  }
16
16
  case 'status': {
17
17
  const { default: status } = await import('../cli/status.js');
18
- await status();
18
+ await status({ report: process.argv.includes('--report') });
19
19
  break;
20
20
  }
21
21
  case 'version':
package/cli/hooks.js CHANGED
@@ -174,11 +174,12 @@ export function installClientFiles() {
174
174
  '{"type":"module"}\n',
175
175
  );
176
176
 
177
- // Write version.json so installed capture.js can identify itself
177
+ // Write version.json so installed capture.js can identify itself.
178
+ // packageRoot lets sender.js find bin/ai-lens.js for background status reports.
178
179
  const { version, commit } = getVersionInfo();
179
180
  writeFileSync(
180
181
  join(CLIENT_INSTALL_DIR, 'version.json'),
181
- JSON.stringify({ version, commit }) + '\n',
182
+ JSON.stringify({ version, commit, packageRoot: PKG_ROOT }) + '\n',
182
183
  );
183
184
  }
184
185
 
package/cli/status.js CHANGED
@@ -5,7 +5,7 @@ import { homedir, release as osRelease, arch as osArch } from 'node:os';
5
5
  import { randomUUID } from 'node:crypto';
6
6
 
7
7
  import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, analyzeToolHooks, checkHooksDisabled, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
8
- import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
8
+ import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, LAST_STATUS_REPORT_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
9
9
  import { isLockStale } from '../client/sender.js';
10
10
  import { readCodexWatcherLock, resolveWatchedCodexDirs } from '../client/codex-watcher.js';
11
11
  import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
@@ -92,6 +92,42 @@ function expandTilde(pathStr) {
92
92
  return pathStr;
93
93
  }
94
94
 
95
+ /**
96
+ * Detect install mode from the capture.js path in hook commands.
97
+ * Returns { ok, summary, detail } for the status output.
98
+ */
99
+ function detectInstallMode(tools) {
100
+ const copyDir = join(homedir(), '.ai-lens', 'client') + '/';
101
+ const paths = [];
102
+ for (const tool of tools) {
103
+ const cmd = extractHookCommand(tool);
104
+ if (!cmd) continue;
105
+ const m = cmd.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
106
+ if (m) paths.push({ tool: tool.name, raw: m[1] || m[2], resolved: expandTilde(m[1] || m[2]) });
107
+ }
108
+ if (paths.length === 0) {
109
+ return { ok: null, summary: 'unknown', detail: 'No hook commands found — cannot determine install mode' };
110
+ }
111
+ const modes = paths.map(p => {
112
+ if (p.resolved.startsWith(copyDir)) return 'copy';
113
+ return 'repo-path';
114
+ });
115
+ const unique = [...new Set(modes)];
116
+ const mode = unique.length === 1 ? unique[0] : 'mixed';
117
+ const detail = paths.map(p => {
118
+ const m = p.resolved.startsWith(copyDir) ? 'copy' : 'repo-path';
119
+ return ` ${p.tool}: ${m} (${p.raw})`;
120
+ }).join('\n');
121
+
122
+ if (mode === 'copy') {
123
+ return { ok: true, summary: 'copy (~/.ai-lens/client/)', detail: `Client files copied to ~/.ai-lens/client/\nUpdate: npx -y ai-lens init --yes\n${detail}` };
124
+ }
125
+ if (mode === 'repo-path') {
126
+ return { ok: true, summary: 'repo-path (auto-update on git pull)', detail: `Hooks point directly to repo/package source\nUpdate: git pull (or npx cache refresh)\n${detail}` };
127
+ }
128
+ return { ok: null, summary: `mixed (${unique.join(' + ')})`, detail: `Different tools use different install modes:\n${detail}` };
129
+ }
130
+
95
131
  function validateHookCommandPaths(tool) {
96
132
  const command = extractHookCommand(tool);
97
133
  if (!command) return null;
@@ -1059,43 +1095,83 @@ function buildReport(results, timestamp, warnings = [], allTools = TOOL_CONFIGS)
1059
1095
  return lines.join('\n');
1060
1096
  }
1061
1097
 
1098
+ // ---------------------------------------------------------------------------
1099
+ // Report mode: POST structured status to server
1100
+ // ---------------------------------------------------------------------------
1101
+
1102
+ async function sendStatusReport(results, warnings, clientVersion, clientCommit, serverUrl, authToken) {
1103
+ if (!serverUrl || !authToken) return;
1104
+
1105
+ const payload = {
1106
+ timestamp: new Date().toISOString(),
1107
+ client_version: clientVersion,
1108
+ client_commit: clientCommit,
1109
+ node_version: process.version,
1110
+ os: `${process.platform} ${osRelease()} ${osArch()}`,
1111
+ checks: results.map(({ label, ok, summary, detail }) => ({ label, ok, summary, detail })),
1112
+ warnings: warnings.map(({ msg, action }) => ({ msg, action })),
1113
+ };
1114
+
1115
+ try {
1116
+ const res = await fetch(`${serverUrl}/api/client-reports`, {
1117
+ method: 'POST',
1118
+ headers: {
1119
+ 'Content-Type': 'application/json',
1120
+ 'X-Auth-Token': authToken,
1121
+ 'X-Client-Version': `${clientVersion}+${clientCommit}`,
1122
+ },
1123
+ body: JSON.stringify(payload),
1124
+ signal: AbortSignal.timeout(15_000),
1125
+ });
1126
+ if (res.ok) {
1127
+ try { writeFileSync(LAST_STATUS_REPORT_PATH, new Date().toISOString()); } catch {}
1128
+ }
1129
+ } catch {
1130
+ // Silent — report is best-effort
1131
+ }
1132
+ }
1133
+
1062
1134
  // ---------------------------------------------------------------------------
1063
1135
  // Main
1064
1136
  // ---------------------------------------------------------------------------
1065
1137
 
1066
- export default async function status() {
1138
+ export default async function status({ report = false } = {}) {
1067
1139
  const versionResult = checkVersion();
1068
- initLogger(versionResult.summary);
1140
+ if (!report) initLogger(versionResult.summary);
1069
1141
 
1070
1142
  const { version, commit } = getVersionInfo();
1071
- blank();
1072
- info(`${BOLD}AI Lens Status v${version} (${commit})${RESET}`);
1073
- info('='.repeat(40));
1074
- blank();
1143
+ if (!report) {
1144
+ blank();
1145
+ info(`${BOLD}AI Lens Status v${version} (${commit})${RESET}`);
1146
+ info('='.repeat(40));
1147
+ blank();
1148
+ }
1075
1149
 
1076
1150
  const results = [];
1077
1151
 
1078
1152
  function printLine(label, result) {
1079
- const icon = result.ok === true ? CHECK : result.ok === false ? CROSS : `${DIM}-${RESET}`;
1080
- const pad = ' '.repeat(Math.max(1, 17 - label.length));
1081
- info(`${label}:${pad}${icon} ${result.summary}`);
1153
+ if (!report) {
1154
+ const icon = result.ok === true ? CHECK : result.ok === false ? CROSS : `${DIM}-${RESET}`;
1155
+ const pad = ' '.repeat(Math.max(1, 17 - label.length));
1156
+ info(`${label}:${pad}${icon} ${result.summary}`);
1157
+ }
1082
1158
  results.push({ label, ...result });
1083
1159
  }
1084
1160
 
1085
1161
  // 1. System info
1086
1162
  const sys = checkSystem();
1087
- info(`${'System:'} ${sys.summary}`);
1163
+ if (!report) info(`${'System:'} ${sys.summary}`);
1088
1164
  results.push({ label: 'System', ...sys });
1089
1165
 
1090
1166
  // 2. Tool versions
1091
1167
  const claude = checkToolVersion('Claude');
1092
1168
  const cursor = checkToolVersion('Cursor');
1093
1169
  if (claude.ok !== null) {
1094
- info(`${'Claude Code:'} ${claude.summary}`);
1170
+ if (!report) info(`${'Claude Code:'} ${claude.summary}`);
1095
1171
  results.push({ label: 'Claude Code version', ...claude });
1096
1172
  }
1097
1173
  if (cursor.ok !== null) {
1098
- info(`${'Cursor:'} ${cursor.summary}`);
1174
+ if (!report) info(`${'Cursor:'} ${cursor.summary}`);
1099
1175
  results.push({ label: 'Cursor version', ...cursor });
1100
1176
  }
1101
1177
 
@@ -1155,6 +1231,10 @@ export default async function status() {
1155
1231
  detail: `Global hooks active: ${hasGlobalHooks}\nProject hooks active: ${hasProjectHooks}${hasGlobalHooks && hasProjectHooks ? '\nWarning: both global and project hooks are active — events may be captured twice. Run init with --project-hooks to consolidate.' : ''}`,
1156
1232
  });
1157
1233
 
1234
+ // 6c. Install mode: detect how capture.js is referenced in hooks
1235
+ const installMode = detectInstallMode(toolsWithProject);
1236
+ printLine('Install mode', installMode);
1237
+
1158
1238
  // 7. Queue (before capture test so test event doesn't show as pending)
1159
1239
  const queueResult = checkQueue();
1160
1240
  printLine('Queue', queueResult);
@@ -1200,7 +1280,7 @@ export default async function status() {
1200
1280
  serverReachable: serverResult.ok === true,
1201
1281
  });
1202
1282
 
1203
- if (warnings.length > 0) {
1283
+ if (!report && warnings.length > 0) {
1204
1284
  blank();
1205
1285
  info('='.repeat(40));
1206
1286
  info(`${BOLD}Warnings${RESET}`);
@@ -1211,19 +1291,23 @@ export default async function status() {
1211
1291
  }
1212
1292
  }
1213
1293
 
1214
- // Write report file
1215
- const timestamp = new Date().toISOString();
1216
- // Merge global TOOL_CONFIGS (always listed, even if not installed) with project tools
1217
- const allToolsForReport = [...TOOL_CONFIGS, ...toolsWithProject.filter(t => !TOOL_CONFIGS.includes(t))];
1218
- const report = buildReport(results, timestamp, warnings, allToolsForReport);
1219
- try {
1220
- writeFileSync(REPORT_PATH, report);
1221
- blank();
1222
- info(`${DIM}Full report \u2192 ${REPORT_PATH}${RESET}`);
1223
- } catch (err) {
1294
+ if (report) {
1295
+ // --report mode: POST structured JSON to server, update marker file
1296
+ await sendStatusReport(results, warnings, version, commit, serverUrl, authToken);
1297
+ } else {
1298
+ // Normal mode: write text report file
1299
+ const timestamp = new Date().toISOString();
1300
+ // Merge global TOOL_CONFIGS (always listed, even if not installed) with project tools
1301
+ const allToolsForReport = [...TOOL_CONFIGS, ...toolsWithProject.filter(t => !TOOL_CONFIGS.includes(t))];
1302
+ const reportText = buildReport(results, timestamp, warnings, allToolsForReport);
1303
+ try {
1304
+ writeFileSync(REPORT_PATH, reportText);
1305
+ blank();
1306
+ info(`${DIM}Full report \u2192 ${REPORT_PATH}${RESET}`);
1307
+ } catch (err) {
1308
+ blank();
1309
+ error(`Could not write report: ${err.message}`);
1310
+ }
1224
1311
  blank();
1225
- error(`Could not write report: ${err.message}`);
1226
1312
  }
1227
-
1228
- blank();
1229
1313
  }
package/client/config.js CHANGED
@@ -22,6 +22,7 @@ export const GIT_REMOTES_DIR = join(DATA_DIR, 'git-remotes');
22
22
  export const LOG_PATH = join(DATA_DIR, 'sender.log');
23
23
  export const CAPTURE_LOG_PATH = join(DATA_DIR, 'capture.log');
24
24
  export const SENDER_BACKOFF_PATH = join(DATA_DIR, 'sender-backoff.json');
25
+ export const LAST_STATUS_REPORT_PATH = join(DATA_DIR, 'last-status-report');
25
26
  export const LOG_MAX_AGE_DAYS = 30;
26
27
  const GIT_ROOT_CACHE = new Map();
27
28
  // Pipe stderr (instead of inheriting it) so that "fatal: not a git repository"
package/client/sender.js CHANGED
@@ -12,16 +12,18 @@
12
12
 
13
13
  import {
14
14
  readFileSync, writeFileSync, writeSync, unlinkSync, rmSync, renameSync, realpathSync,
15
- readdirSync, mkdirSync, existsSync, openSync, closeSync,
15
+ readdirSync, mkdirSync, existsSync, openSync, closeSync, statSync,
16
16
  } from 'node:fs';
17
17
  import { join, dirname } from 'node:path';
18
18
  import { randomUUID } from 'node:crypto';
19
19
  import { fileURLToPath } from 'node:url';
20
+ import { spawn } from 'node:child_process';
20
21
  import {
21
22
  ensureDataDir,
22
23
  PENDING_DIR,
23
24
  SENDING_DIR,
24
25
  SENDER_BACKOFF_PATH,
26
+ LAST_STATUS_REPORT_PATH,
25
27
  STORAGE_VERSION_PATH,
26
28
  CURRENT_STORAGE_VERSION,
27
29
  QUEUE_PATH,
@@ -633,6 +635,49 @@ async function postEvents(serverUrl, events, identity, authToken) {
633
635
  throw new Error(`Server responded ${res.status}: ${data}`);
634
636
  }
635
637
 
638
+ // =============================================================================
639
+ // Status report (once per 24h)
640
+ // =============================================================================
641
+
642
+ const STATUS_REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000;
643
+
644
+ function resolveStatusBin() {
645
+ // 1. Relative to sender.js — works for --use-repo-path installs
646
+ const relBin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'ai-lens.js');
647
+ if (existsSync(relBin)) return relBin;
648
+ // 2. packageRoot from version.json — works for copy installs (npx, global)
649
+ try {
650
+ const { packageRoot } = getClientVersion();
651
+ if (packageRoot) {
652
+ const pkgBin = join(packageRoot, 'bin', 'ai-lens.js');
653
+ if (existsSync(pkgBin)) return pkgBin;
654
+ }
655
+ } catch {}
656
+ return null;
657
+ }
658
+
659
+ function trySpawnStatusReport() {
660
+ try {
661
+ if (existsSync(LAST_STATUS_REPORT_PATH)) {
662
+ const mtime = statSync(LAST_STATUS_REPORT_PATH).mtimeMs;
663
+ if (Date.now() - mtime < STATUS_REPORT_INTERVAL_MS) return;
664
+ }
665
+ const binPath = resolveStatusBin();
666
+ if (!binPath) return;
667
+ // Touch marker immediately to prevent multiple spawns
668
+ writeFileSync(LAST_STATUS_REPORT_PATH, new Date().toISOString());
669
+ const child = spawn(process.execPath, [binPath, 'status', '--report'], {
670
+ detached: true,
671
+ stdio: 'ignore',
672
+ windowsHide: true,
673
+ });
674
+ child.on('error', () => {});
675
+ child.unref();
676
+ } catch {
677
+ // Best-effort — don't break sender
678
+ }
679
+ }
680
+
636
681
  // =============================================================================
637
682
  // Main
638
683
  // =============================================================================
@@ -698,6 +743,7 @@ async function main() {
698
743
  }
699
744
  clearSenderBackoff();
700
745
  commitQueue(sendingDir, acquiredFiles);
746
+ trySpawnStatusReport();
701
747
  } catch (err) {
702
748
  const unsentIds = new Set(
703
749
  events.filter(e => !sentEventIds.has(e.event_id)).map(e => e.event_id).filter(Boolean)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.52",
3
+ "version": "0.8.53",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {