ai-lens 0.8.81 → 0.8.84
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 +10 -0
- package/cli/hooks.js +51 -5
- package/cli/import/claude-code.js +20 -3
- package/cli/init.js +69 -8
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
8a405c3
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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.84 — 2026-06-08
|
|
6
|
+
- fix: `ai-lens init` no longer wipes your saved auth token when a re-authentication is started but not finished — a closed browser, a timeout, or a transient server hiccup that briefly mis-flags a valid token. Previously this left `authToken: null` in your config and silently stopped all event capture until someone noticed; now the existing token is kept until a new one is actually obtained. If a server that requires auth does end up without a token, init now prints a loud "capture is OFF" error instead of a quiet note.
|
|
7
|
+
|
|
8
|
+
## 0.8.83 — 2026-06-05
|
|
9
|
+
- fix: on Windows, a Claude Code project hook written with the macOS/Linux `$CLAUDE_PROJECT_DIR` syntax silently captures nothing (Windows needs `%CLAUDE_PROJECT_DIR%`). `ai-lens init` now detects this for per-machine (gitignored) hook files and rewrites it to the running OS's form, and `ai-lens status` flags it instead of showing it as fine. Committed shared hook files are left untouched.
|
|
10
|
+
|
|
11
|
+
## 0.8.82 — 2026-06-05
|
|
12
|
+
- feat: `ai-lens init` now offers to import your local Claude Code history right away, so a fresh dashboard isn't empty — `npx -y ai-lens init --server <url>` sets up and (on confirm) imports in one step. Use `--no-import` to skip or `--import` to force.
|
|
13
|
+
- fix: `ai-lens import claude-code` works on a self-host instance without login (personal mode) — it ships via your git identity, the same way live capture does, instead of requiring a token.
|
|
14
|
+
|
|
5
15
|
## 0.8.81 — 2026-06-05
|
|
6
16
|
- improve: `ai-lens status` now tells a stale error from an active one — a past send failure no longer shows red once sending has recovered, and the time and code of the last error are shown (e.g. "recovered — last error 1d ago (ECONNRESET)").
|
|
7
17
|
- improve: `ai-lens status` retries the server health check a few times before reporting "unreachable", so a one-off network blip no longer flags the server as down.
|
package/cli/hooks.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renam
|
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
5
6
|
|
|
6
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
8
|
const PKG_ROOT = join(__dirname, '..');
|
|
@@ -934,20 +935,59 @@ function isAcceptableHookCommand(cmd) {
|
|
|
934
935
|
return isGuiSafeHookCommand(cmd) || isClaudeProjectDirCommand(cmd);
|
|
935
936
|
}
|
|
936
937
|
|
|
937
|
-
|
|
938
|
+
// True when an ai-lens $CLAUDE_PROJECT_DIR / %CLAUDE_PROJECT_DIR% hook is written
|
|
939
|
+
// with the OTHER platform's variable syntax — `$VAR` on Windows (cmd.exe wants
|
|
940
|
+
// `%VAR%`) or `%VAR%` on POSIX (sh wants `$VAR`). Claude Code hands the command to
|
|
941
|
+
// the native OS shell, so a wrong-syntax var never expands and the hook silently
|
|
942
|
+
// fails (Anthropic claude-code#24710). Used to flag such a PER-MACHINE hook as
|
|
943
|
+
// outdated so init rewrites it to the running OS's form. Committed hooks are
|
|
944
|
+
// protected separately — see analyzeToolHooks / allowPlatformRewrite.
|
|
945
|
+
export function isWrongPlatformProjectDirCommand(cmd, platform = process.platform) {
|
|
946
|
+
if (!isClaudeProjectDirCommand(cmd)) return false;
|
|
947
|
+
const n = (cmd || '').replace(/\\/g, '/');
|
|
948
|
+
const correctVar = platform === 'win32' ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
|
|
949
|
+
return !n.includes(correctVar);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Whether a hook-config file is committed (tracked) in git. The anti-churn rule
|
|
953
|
+
// (treat both $ and % CLAUDE_PROJECT_DIR forms as current) exists ONLY to keep a
|
|
954
|
+
// COMMITTED cross-platform hook file from being flipped to one OS's syntax and
|
|
955
|
+
// re-committed, breaking teammates on the other OS. For per-machine files
|
|
956
|
+
// (untracked / gitignored — the supported model), there's nothing to protect, so
|
|
957
|
+
// init may rewrite a wrong-OS form to the running platform. Returns false on any
|
|
958
|
+
// error (git missing, not a repo, untracked) → treat as per-machine, free to rewrite.
|
|
959
|
+
function isGitTracked(filePath) {
|
|
960
|
+
try {
|
|
961
|
+
execFileSync('git', ['-C', dirname(filePath), 'ls-files', '--error-unmatch', '--', filePath], {
|
|
962
|
+
stdio: 'ignore',
|
|
963
|
+
});
|
|
964
|
+
return true;
|
|
965
|
+
} catch {
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function isCurrentAiLensHook(entry, expected, opts = {}) {
|
|
938
971
|
// "Current" = a GUI-safe install (launcher OR absolute-node capture.js) OR a
|
|
939
972
|
// committed Claude Code $CLAUDE_PROJECT_DIR project hook. We do NOT require an exact
|
|
940
973
|
// match against the expected command form — every valid install method captures
|
|
941
974
|
// reliably, so none should be reported outdated. Only PATH-dependent forms
|
|
942
975
|
// (bare `node`, `/usr/bin/env node`) WITHOUT $CLAUDE_PROJECT_DIR are outdated.
|
|
976
|
+
//
|
|
977
|
+
// Exception (allowPlatformRewrite, set for untracked/per-machine files): a
|
|
978
|
+
// $CLAUDE_PROJECT_DIR/%CLAUDE_PROJECT_DIR% hook written for the OTHER OS won't
|
|
979
|
+
// expand on this platform, so flag it outdated to let init rewrite it.
|
|
980
|
+
const { platform = process.platform, allowPlatformRewrite = false } = opts;
|
|
981
|
+
const ok = (cmd) => isAcceptableHookCommand(cmd)
|
|
982
|
+
&& !(allowPlatformRewrite && isWrongPlatformProjectDirCommand(cmd, platform));
|
|
943
983
|
// Flat format (Cursor): single command per entry.
|
|
944
984
|
if (entry?.command != null) {
|
|
945
|
-
return
|
|
985
|
+
return ok(entry.command);
|
|
946
986
|
}
|
|
947
987
|
// Nested format (Claude Code / Codex): { matcher, hooks: [{ command }] }
|
|
948
988
|
if (Array.isArray(entry?.hooks)) {
|
|
949
989
|
if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
|
|
950
|
-
return entry.hooks.some(h =>
|
|
990
|
+
return entry.hooks.some(h => ok(h?.command || ''));
|
|
951
991
|
}
|
|
952
992
|
return false;
|
|
953
993
|
}
|
|
@@ -970,7 +1010,7 @@ export function detectInstalledTools(ctx = null) {
|
|
|
970
1010
|
* Returns { status, config?, error? }
|
|
971
1011
|
* status: 'fresh' | 'current' | 'outdated' | 'absent' | 'malformed'
|
|
972
1012
|
*/
|
|
973
|
-
export function analyzeToolHooks(tool) {
|
|
1013
|
+
export function analyzeToolHooks(tool, opts = {}) {
|
|
974
1014
|
if (!existsSync(tool.configPath)) {
|
|
975
1015
|
return { status: 'fresh', disableAllHooks: false };
|
|
976
1016
|
}
|
|
@@ -1002,6 +1042,12 @@ export function analyzeToolHooks(tool) {
|
|
|
1002
1042
|
return { status: 'absent', config, disableAllHooks };
|
|
1003
1043
|
}
|
|
1004
1044
|
|
|
1045
|
+
// Per-machine (untracked/gitignored) hook files may be rewritten to the running
|
|
1046
|
+
// OS's CLAUDE_PROJECT_DIR syntax; committed (tracked) files are protected from
|
|
1047
|
+
// churn. Caller may override both (tests, or callers with known context).
|
|
1048
|
+
const platform = opts.platform ?? process.platform;
|
|
1049
|
+
const allowPlatformRewrite = opts.allowPlatformRewrite ?? !isGitTracked(tool.configPath);
|
|
1050
|
+
|
|
1005
1051
|
// Check if any AI Lens hooks exist
|
|
1006
1052
|
let hasAiLens = false;
|
|
1007
1053
|
let allCurrent = true;
|
|
@@ -1013,7 +1059,7 @@ export function analyzeToolHooks(tool) {
|
|
|
1013
1059
|
for (const entry of entries) {
|
|
1014
1060
|
if (isAiLensHook(entry)) {
|
|
1015
1061
|
hasAiLens = true;
|
|
1016
|
-
if (!isCurrentAiLensHook(entry, expected)) {
|
|
1062
|
+
if (!isCurrentAiLensHook(entry, expected, { platform, allowPlatformRewrite })) {
|
|
1017
1063
|
allCurrent = false;
|
|
1018
1064
|
}
|
|
1019
1065
|
}
|
|
@@ -94,13 +94,19 @@ export async function fetchCoverage(sessionIds, { fetchImpl = globalThis.fetch }
|
|
|
94
94
|
if (!sessionIds.length || !fetchImpl) return out;
|
|
95
95
|
const base = getServerUrl();
|
|
96
96
|
const token = getAuthToken();
|
|
97
|
+
// Auth: token preferred, else git-identity headers so coverage also works in
|
|
98
|
+
// self-host personal mode (no token) — same fallback the ingest path uses.
|
|
99
|
+
const ident = token ? null : getGitIdentity(process.cwd());
|
|
100
|
+
const authHeaders = token
|
|
101
|
+
? { 'X-Auth-Token': token }
|
|
102
|
+
: (ident?.email ? { 'X-Developer-Git-Email': ident.email, ...(ident.name ? { 'X-Developer-Name': ident.name } : {}) } : {});
|
|
97
103
|
const CHUNK = 400;
|
|
98
104
|
for (let i = 0; i < sessionIds.length; i += CHUNK) {
|
|
99
105
|
const batch = sessionIds.slice(i, i + CHUNK);
|
|
100
106
|
try {
|
|
101
107
|
const res = await fetchImpl(new URL('/api/events/coverage', base), {
|
|
102
108
|
method: 'POST',
|
|
103
|
-
headers: { 'Content-Type': 'application/json', ...
|
|
109
|
+
headers: { 'Content-Type': 'application/json', ...authHeaders },
|
|
104
110
|
body: JSON.stringify({ session_ids: batch }),
|
|
105
111
|
});
|
|
106
112
|
if (res.ok) Object.assign(out, await res.json());
|
|
@@ -109,6 +115,15 @@ export async function fetchCoverage(sessionIds, { fetchImpl = globalThis.fetch }
|
|
|
109
115
|
return out;
|
|
110
116
|
}
|
|
111
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Can the importer actually deliver events? A personal auth token OR a resolvable
|
|
120
|
+
* git email (the ingest path accepts either). Pure-ish; takes the two resolvers so
|
|
121
|
+
* it's testable without touching real git/env.
|
|
122
|
+
*/
|
|
123
|
+
export function hasDeliverableIdentity(token, gitEmail) {
|
|
124
|
+
return !!token || !!gitEmail;
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
/**
|
|
113
128
|
* Drop events at/after the live-coverage boundary for their session, so import
|
|
114
129
|
* only adds the pre-live backlog. No boundary ⇒ keep all. (Pure, for tests.)
|
|
@@ -209,8 +224,10 @@ export default async function importClaudeCode(flags) {
|
|
|
209
224
|
warn(`No Claude Code history found at ${PROJECTS_DIR}`);
|
|
210
225
|
return;
|
|
211
226
|
}
|
|
212
|
-
|
|
213
|
-
|
|
227
|
+
// Need a way to attribute events: a token, OR a git email (self-host personal
|
|
228
|
+
// mode ships via the git-identity fallback, just like live capture).
|
|
229
|
+
if (!dryRun && !hasDeliverableIdentity(getAuthToken(), getGitIdentity(process.cwd())?.email)) {
|
|
230
|
+
error('No identity to attribute events. Run `npx ai-lens init` first, or set a git user.email (or AI_LENS_AUTH_TOKEN).');
|
|
214
231
|
return;
|
|
215
232
|
}
|
|
216
233
|
if (noRedact) warn('⚠ --no-redact: secrets in raw transcripts are NOT redacted client-side and persist locally in the spool if a send fails. Use only for debugging.');
|
package/cli/init.js
CHANGED
|
@@ -361,6 +361,12 @@ function getInitArgs() {
|
|
|
361
361
|
case '--install-launcher':
|
|
362
362
|
flags.installLauncher = true;
|
|
363
363
|
break;
|
|
364
|
+
case '--import':
|
|
365
|
+
flags.importHistory = true;
|
|
366
|
+
break;
|
|
367
|
+
case '--no-import':
|
|
368
|
+
flags.noImport = true;
|
|
369
|
+
break;
|
|
364
370
|
case '--mcp-scope':
|
|
365
371
|
if (i + 1 < args.length) flags.mcpScope = args[++i];
|
|
366
372
|
else process.stderr.write('Warning: --mcp-scope requires a value\n');
|
|
@@ -628,29 +634,35 @@ export default async function init() {
|
|
|
628
634
|
|
|
629
635
|
// Authentication
|
|
630
636
|
heading('Authentication');
|
|
637
|
+
// Never null an existing token up front. An abandoned device-code login — or a
|
|
638
|
+
// transient server hiccup that mis-flags a good token (e.g. during a deploy) —
|
|
639
|
+
// must not leave the user with authToken:null, which silently breaks capture
|
|
640
|
+
// until someone notices. newConfig already inherits the prior token via the
|
|
641
|
+
// spread above; we only OVERWRITE it once we hold a confirmed new token.
|
|
642
|
+
let needsAuth = !currentConfig.authToken;
|
|
643
|
+
let authConfigured = true; // flipped false only if the server reports no Auth0
|
|
631
644
|
if (currentConfig.authToken) {
|
|
632
645
|
const tokenStatus = await validateExistingToken(serverUrl, currentConfig.authToken);
|
|
633
646
|
if (tokenStatus === 'valid') {
|
|
634
647
|
success(' Already authenticated (token verified)');
|
|
635
648
|
} else if (tokenStatus === 'unknown') {
|
|
636
649
|
warn(' Token format not recognized — re-authenticating...');
|
|
637
|
-
|
|
638
|
-
newConfig.authToken = null;
|
|
650
|
+
needsAuth = true;
|
|
639
651
|
} else if (tokenStatus === 'invalid') {
|
|
640
652
|
warn(' Existing token is invalid or revoked — re-authenticating...');
|
|
641
|
-
|
|
642
|
-
newConfig.authToken = null;
|
|
653
|
+
needsAuth = true;
|
|
643
654
|
} else {
|
|
644
655
|
warn(' Could not reach server to verify token — keeping existing token');
|
|
645
656
|
}
|
|
646
657
|
}
|
|
647
|
-
if (
|
|
658
|
+
if (needsAuth) {
|
|
648
659
|
let authResult = null;
|
|
649
660
|
try {
|
|
650
661
|
authResult = await deviceCodeAuth(serverUrl);
|
|
651
662
|
} catch (err) {
|
|
652
663
|
const msg = (err && err.message) ? err.message : String(err);
|
|
653
664
|
if (msg.includes('not configured')) {
|
|
665
|
+
authConfigured = false;
|
|
654
666
|
warn(` Auth not configured on server — personal mode (events sent via git identity)`);
|
|
655
667
|
} else {
|
|
656
668
|
warn(` Authentication failed: ${msg}`);
|
|
@@ -663,17 +675,29 @@ export default async function init() {
|
|
|
663
675
|
saveLensConfig(newConfig);
|
|
664
676
|
success(` Authenticated as ${authResult.name} (${authResult.email})`);
|
|
665
677
|
} catch (err) {
|
|
678
|
+
// Keep whatever token newConfig already holds — do NOT null it.
|
|
666
679
|
warn(` Authenticated but failed to save token: ${err.message}`);
|
|
667
680
|
warn(` Run "npx -y ai-lens init" again later to persist authentication`);
|
|
668
|
-
newConfig.authToken = null;
|
|
669
681
|
}
|
|
682
|
+
} else if (currentConfig.authToken) {
|
|
683
|
+
// Re-auth didn't complete. Preserve the prior token rather than stranding
|
|
684
|
+
// the user tokenless: a transient false-negative keeps capture working, and
|
|
685
|
+
// a genuinely revoked token simply re-prompts on the next init.
|
|
686
|
+
warn(' Re-authentication not completed — keeping the existing token for now.');
|
|
670
687
|
}
|
|
671
688
|
}
|
|
672
689
|
|
|
673
|
-
//
|
|
690
|
+
// Loud failure when capture is actually broken: a server with Auth0 drops every
|
|
691
|
+
// event without a token (the git-email header path is personal-mode only), so a
|
|
692
|
+
// missing token here is not a soft warning — it's a silent data outage.
|
|
674
693
|
if (!newConfig.authToken) {
|
|
675
694
|
const { email } = getGitIdentity();
|
|
676
|
-
if (
|
|
695
|
+
if (authConfigured) {
|
|
696
|
+
blank();
|
|
697
|
+
error(' No auth token — this server requires one, so EVERY event will be dropped.');
|
|
698
|
+
error(' Capture is OFF until you authenticate.');
|
|
699
|
+
info(' Fix: run "npx -y ai-lens init" and finish the browser login.');
|
|
700
|
+
} else if (!email) {
|
|
677
701
|
blank();
|
|
678
702
|
error(' No auth token and no git email configured.');
|
|
679
703
|
error(' Events will be silently dropped until one is available.');
|
|
@@ -1194,5 +1218,42 @@ export default async function init() {
|
|
|
1194
1218
|
}
|
|
1195
1219
|
blank();
|
|
1196
1220
|
|
|
1221
|
+
// Primary warm-start trigger: offer to import local Claude Code history right now,
|
|
1222
|
+
// so the dashboard isn't empty on first open.
|
|
1223
|
+
await maybeOfferImportHistory(flags);
|
|
1224
|
+
|
|
1197
1225
|
detail(`Log: ${getLogPath()}`);
|
|
1198
1226
|
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* After a successful init, offer to import the developer's local Claude Code
|
|
1230
|
+
* history. `--no-import` skips; `--import` or `--yes` runs it without prompting;
|
|
1231
|
+
* otherwise asks interactively. No-op if there's no `~/.claude/projects`.
|
|
1232
|
+
*/
|
|
1233
|
+
async function maybeOfferImportHistory(flags) {
|
|
1234
|
+
if (flags.noImport) return;
|
|
1235
|
+
if (!existsSync(join(homedir(), '.claude', 'projects'))) return;
|
|
1236
|
+
|
|
1237
|
+
let run = flags.importHistory || flags.yes;
|
|
1238
|
+
if (!run) {
|
|
1239
|
+
try {
|
|
1240
|
+
const answer = (await ask('Import your local Claude Code history now? (Y/n) ')).toLowerCase();
|
|
1241
|
+
run = answer === '' || answer === 'y' || answer === 'yes';
|
|
1242
|
+
} catch { run = false; }
|
|
1243
|
+
}
|
|
1244
|
+
if (!run) {
|
|
1245
|
+
blank();
|
|
1246
|
+
info(' Skipped import. Run `npx -y ai-lens import claude-code` anytime to bring it in.');
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
blank();
|
|
1251
|
+
heading('Importing local history');
|
|
1252
|
+
try {
|
|
1253
|
+
const { default: importClaudeCode } = await import('./import/claude-code.js');
|
|
1254
|
+
await importClaudeCode({ days: 30 }); // server URL + git identity were just configured
|
|
1255
|
+
} catch (err) {
|
|
1256
|
+
error(` Import failed: ${err.message}`);
|
|
1257
|
+
info(' You can retry later: `npx -y ai-lens import claude-code`');
|
|
1258
|
+
}
|
|
1259
|
+
}
|