ai-lens 0.8.81 → 0.8.85

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
- 0533332
1
+ 5905663
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
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.85 — 2026-06-09
6
+ - feat: manage your own data from the CLI. `ai-lens list-sessions` lists your sessions, `ai-lens find-session <query>` finds one by id / project / source, and `ai-lens delete-sessions` removes your own sessions — by id (the short id shown by `list-sessions` works) or by `--from`/`--to` date range. Delete is a dry-run by default and only acts with `--yes`; it can only ever touch your own data, and a mistyped or ambiguous id is reported rather than silently ignored.
7
+ - feat: `ai-lens import claude-code --from <date> --to <date>` imports a precise date window (events outside it are left out). Re-running an import over a window you've already imported is safe — it adds zero duplicates (every event has a stable id the server de-duplicates on), so you can re-seed a lost day without fear of doubling your history.
8
+
9
+ ## 0.8.84 — 2026-06-08
10
+ - 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.
11
+
12
+ ## 0.8.83 — 2026-06-05
13
+ - 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.
14
+
15
+ ## 0.8.82 — 2026-06-05
16
+ - 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.
17
+ - 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.
18
+
5
19
  ## 0.8.81 — 2026-06-05
6
20
  - 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
21
  - 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/bin/ai-lens.js CHANGED
@@ -23,6 +23,21 @@ switch (command) {
23
23
  await importCmd();
24
24
  break;
25
25
  }
26
+ case 'list-sessions': {
27
+ const { default: listSessions } = await import('../cli/list-sessions.js');
28
+ await listSessions();
29
+ break;
30
+ }
31
+ case 'find-session': {
32
+ const { default: findSession } = await import('../cli/find-session.js');
33
+ await findSession();
34
+ break;
35
+ }
36
+ case 'delete-sessions': {
37
+ const { default: deleteSessions } = await import('../cli/delete-sessions.js');
38
+ await deleteSessions();
39
+ break;
40
+ }
26
41
  case 'version':
27
42
  case '--version':
28
43
  case '-v': {
@@ -54,8 +69,15 @@ switch (command) {
54
69
  console.log(' import <source> Import local history (source: claude-code)');
55
70
  console.log(' --days N Window in days (default 30; 0 = all history)');
56
71
  console.log(' --since DATE Import from YYYY-MM-DD instead of --days');
72
+ console.log(' --from D --to D Import a bounded YYYY-MM-DD..YYYY-MM-DD window (idempotent re-import)');
57
73
  console.log(' --dry-run Scan + count, write nothing');
58
74
  console.log(' --projects LIST Only these project paths (comma-separated)');
75
+ console.log(' list-sessions List your own sessions');
76
+ console.log(' --days N --source S --limit N --json');
77
+ console.log(' find-session <q> Find your sessions by id / project / source substring');
78
+ console.log(' --days N --source S --limit N --json');
79
+ console.log(' delete-sessions Delete your own sessions (dry-run unless --yes)');
80
+ console.log(' <id…> | --from D --to D [--source S] [--days N] [--yes] [--json]');
59
81
  console.log(' version Show package version and commit hash');
60
82
  process.exit(command ? 1 : 0);
61
83
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared HTTP helpers for the self-service data commands (list-sessions,
3
+ * find-session, delete-sessions). Thin wrappers over the SAME endpoints the
4
+ * dashboard uses — `GET /api/sessions` and `DELETE /api/dashboard/chains/:id` —
5
+ * so there is no bespoke server surface to keep in sync.
6
+ *
7
+ * Auth: these endpoints sit behind `requireAuth`, which accepts an
8
+ * `ailens_dev_` personal token (or an Auth0 JWT). The git-email header fallback
9
+ * used by the ingest path does NOT authenticate here, so a token is required in
10
+ * any Auth0-configured deployment; a token-less self-host (no AUTH0_DOMAIN) is
11
+ * resolved to its singleton developer server-side. A 401 ⇒ tell the user to init.
12
+ */
13
+ import { getServerUrl, getAuthToken } from '../client/config.js';
14
+
15
+ const TIMEOUT_MS = 15_000;
16
+
17
+ export class ApiError extends Error {
18
+ constructor(message, status) {
19
+ super(message);
20
+ this.name = 'ApiError';
21
+ this.status = status;
22
+ }
23
+ }
24
+
25
+ /** Auth headers for a data request — token when we have one, else nothing. */
26
+ export function authHeaders() {
27
+ const token = getAuthToken();
28
+ return token ? { 'X-Auth-Token': token } : {};
29
+ }
30
+
31
+ async function apiFetch(path, opts = {}) {
32
+ const url = new URL(path, getServerUrl());
33
+ let res;
34
+ try {
35
+ res = await fetch(url, {
36
+ ...opts,
37
+ headers: { ...authHeaders(), ...(opts.headers || {}) },
38
+ signal: AbortSignal.timeout(TIMEOUT_MS),
39
+ });
40
+ } catch (err) {
41
+ throw new ApiError(`Cannot reach server at ${getServerUrl()} (${err.code || err.message}).`, 0);
42
+ }
43
+ if (res.status === 401) {
44
+ throw new ApiError('Not authenticated. Run `npx ai-lens init` to sign in (or set AI_LENS_AUTH_TOKEN).', 401);
45
+ }
46
+ return res;
47
+ }
48
+
49
+ /**
50
+ * List the caller's own sessions. Without the `team_members` scope the server
51
+ * forces `developer_id = self`, so this is always self-scoped for a normal token.
52
+ * Returns the raw session objects (session_id, source, project_path, started_at,
53
+ * last_event_at, duration_minutes, event_count, used_plan_mode, …).
54
+ */
55
+ export async function fetchSessions({ days, source, limit } = {}) {
56
+ const params = new URLSearchParams();
57
+ if (Number.isFinite(days) && days > 0) params.set('days', String(days));
58
+ if (source) params.set('source', source);
59
+ if (Number.isFinite(limit) && limit > 0) params.set('limit', String(limit));
60
+ const qs = params.toString();
61
+ const res = await apiFetch(`/api/sessions${qs ? `?${qs}` : ''}`);
62
+ if (!res.ok) throw new ApiError(`Server returned HTTP ${res.status} listing sessions.`, res.status);
63
+ const data = await res.json();
64
+ return Array.isArray(data) ? data : (data.sessions || []);
65
+ }
66
+
67
+ /**
68
+ * Delete one session by id. The server resolves the id to the activity CHAIN it
69
+ * belongs to and deletes the whole chain (events + analyses + chunks + costs),
70
+ * owner-scoped — only the caller's own data. 403/404 are returned, not thrown,
71
+ * so a batch can report-and-continue.
72
+ */
73
+ export async function deleteSessionById(id) {
74
+ const res = await apiFetch(`/api/dashboard/chains/${encodeURIComponent(id)}`, { method: 'DELETE' });
75
+ if (res.status === 403) return { ok: false, status: 403, reason: 'not yours' };
76
+ if (res.status === 404) return { ok: false, status: 404, reason: 'not found / already deleted' };
77
+ if (!res.ok) return { ok: false, status: res.status, reason: `HTTP ${res.status}` };
78
+ const body = await res.json().catch(() => ({}));
79
+ return { ok: true, status: res.status, body };
80
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Shared flag parsing + session formatting for the self-service data commands.
3
+ */
4
+ import { basename } from 'node:path';
5
+
6
+ /** Shared ISO-date validator for `--from`/`--to` flags (lenient Date.parse). */
7
+ export const isValidDate = (s) => typeof s === 'string' && !Number.isNaN(Date.parse(s));
8
+
9
+ /**
10
+ * Generic argv parser shared by list/find/delete. Mirrors cli/import.js: a value
11
+ * flag whose next token is missing or is itself another flag does NOT consume it
12
+ * (records a missing-value error instead) — so `--source --yes` can't silently
13
+ * swallow `--yes`. An int flag whose value isn't a number is an error too, never
14
+ * a silently-dropped NaN. Non-flag tokens collect into `positional`.
15
+ */
16
+ export function parseArgs(argv, { bools = {}, values = {}, ints = new Set() } = {}) {
17
+ const flags = {};
18
+ const positional = [];
19
+ const errors = [];
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const arg = argv[i];
22
+ if (bools[arg]) { flags[bools[arg]] = true; continue; }
23
+ if (values[arg]) {
24
+ const next = argv[i + 1];
25
+ if (next == null || next.startsWith('--')) { errors.push(`Missing value for ${arg}.`); continue; }
26
+ i++;
27
+ const key = values[arg];
28
+ if (ints.has(key)) {
29
+ const n = parseInt(next, 10);
30
+ if (Number.isNaN(n)) { errors.push(`Invalid number for ${arg}: "${next}".`); continue; }
31
+ flags[key] = n;
32
+ } else {
33
+ flags[key] = next;
34
+ }
35
+ continue;
36
+ }
37
+ if (arg.startsWith('--')) { errors.push(`Unknown flag "${arg}".`); continue; }
38
+ positional.push(arg);
39
+ }
40
+ return { flags, positional, errors };
41
+ }
42
+
43
+ export function shortId(id) {
44
+ return String(id || '').slice(0, 8);
45
+ }
46
+
47
+ /** ISO/Date/number → `YYYY-MM-DD HH:MM`. Tolerates the JSON-serialized shapes. */
48
+ export function fmtDate(value) {
49
+ if (!value) return '—';
50
+ const s = typeof value === 'string' ? value : new Date(value).toISOString();
51
+ return Number.isNaN(Date.parse(s)) ? '—' : s.slice(0, 16).replace('T', ' ');
52
+ }
53
+
54
+ function fmtProject(p) {
55
+ if (!p) return '—';
56
+ return basename(p) || p;
57
+ }
58
+
59
+ const pad = (s, n) => String(s).padEnd(n);
60
+ const padL = (s, n) => String(s).padStart(n);
61
+
62
+ // Single source of truth for column widths so the header and the rows can't drift.
63
+ const COL = { id: 8, source: 11, project: 24, started: 16, dur: 6, events: 6 };
64
+
65
+ export const SESSION_HEADER =
66
+ `${pad('id', COL.id)} ${pad('source', COL.source)} ${pad('project', COL.project)} ${pad('started', COL.started)} ${padL('dur', COL.dur)} ${padL('events', COL.events)}`;
67
+
68
+ /** One aligned line per session for the list/find tables. */
69
+ export function formatSessionLine(s) {
70
+ const id = pad(shortId(s.session_id), COL.id);
71
+ const src = pad(String(s.source || '?').slice(0, COL.source), COL.source);
72
+ const proj = pad(fmtProject(s.project_path).slice(0, COL.project), COL.project);
73
+ const started = pad(fmtDate(s.started_at), COL.started);
74
+ const dur = padL(`${Math.round(Number(s.duration_minutes) || 0)}m`, COL.dur);
75
+ const ev = padL(`${Number(s.event_count) || 0}`, COL.events);
76
+ const plan = s.used_plan_mode ? ' plan' : '';
77
+ return `${id} ${src} ${proj} ${started} ${dur} ${ev}${plan}`;
78
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `ai-lens delete-sessions` — delete your OWN sessions, by id or by date range.
3
+ *
4
+ * ai-lens delete-sessions <id> <id> … # explicit ids (full or short)
5
+ * ai-lens delete-sessions --from D --to D [--source S] # a window
6
+ *
7
+ * DRY-RUN BY DEFAULT: prints what would be deleted and stops. Pass --yes to
8
+ * actually delete. Both modes resolve the matching sessions client-side (same
9
+ * `GET /api/sessions` the dashboard uses), then delete EACH via the existing
10
+ * owner-scoped `DELETE /api/dashboard/chains/:id` — no new server endpoint.
11
+ *
12
+ * Ids are resolved against your session list, so a SHORT id (as printed by
13
+ * `ai-lens list-sessions`) works — exact match or unique prefix. A prefix that
14
+ * matches several sessions is rejected; one that matches none is reported, not
15
+ * silently treated as "already deleted".
16
+ *
17
+ * Blast radius: the server resolves each id to the activity CHAIN it belongs to
18
+ * and deletes the whole chain (events + analyses + chunks + costs). Two ids in
19
+ * the same chain ⇒ the second delete is a harmless "already deleted".
20
+ */
21
+ import { initLogger, heading, info, success, warn, error, detail, blank } from './logger.js';
22
+ import { getVersionInfo } from './hooks.js';
23
+ import { fetchSessions, deleteSessionById, ApiError } from './data-api.js';
24
+ import { parseArgs, shortId, formatSessionLine, SESSION_HEADER, isValidDate } from './data-format.js';
25
+
26
+ const DAY_MS = 86_400_000;
27
+ // Wide window for resolving ids against the session list — covers practically all
28
+ // history so a short id from `list-sessions` resolves regardless of its age.
29
+ const RESOLVE_WINDOW_DAYS = 3650;
30
+ // Matches the server's MAX_LIST_LIMIT; hitting it means older sessions were dropped.
31
+ const RESOLVE_LIMIT = 5000;
32
+ // A non-matching token this long is taken as a full id outside the list window
33
+ // (still attempted); a shorter non-match is treated as a typo and reported.
34
+ const LITERAL_ID_MIN_LEN = 12;
35
+
36
+ export function parseDeleteArgs(argv) {
37
+ return parseArgs(argv, {
38
+ bools: { '--yes': 'yes', '--json': 'json' },
39
+ values: { '--from': 'from', '--to': 'to', '--source': 'source', '--days': 'days' },
40
+ ints: new Set(['days']),
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Keep sessions whose START falls in the window. Lower bound is `--from` (00:00)
46
+ * or, when only `--days N` is given, a rolling `now - N days`. Upper bound is
47
+ * `--to` (inclusive of that whole day). Any bound may be absent.
48
+ */
49
+ export function inRange(session, { from, to, days }, now = Date.now()) {
50
+ const t = Date.parse(session.started_at);
51
+ if (Number.isNaN(t)) return false;
52
+ const lowerMs = from
53
+ ? Date.parse(from)
54
+ : (Number.isFinite(days) && days > 0 ? now - days * DAY_MS : null);
55
+ if (lowerMs != null && t < lowerMs) return false;
56
+ if (to && t >= Date.parse(to) + DAY_MS) return false;
57
+ return true;
58
+ }
59
+
60
+ /**
61
+ * Resolve an id token against the visible sessions: exact match or unique prefix.
62
+ * Returns { kind: 'resolved', session } | { kind: 'ambiguous', candidates }
63
+ * | { kind: 'literal' } (long, no match — try as-is) | { kind: 'none' }.
64
+ */
65
+ export function resolveId(token, sessions) {
66
+ const exact = sessions.find((s) => s.session_id === token);
67
+ if (exact) return { kind: 'resolved', session: exact };
68
+ const pre = sessions.filter((s) => String(s.session_id).startsWith(token));
69
+ if (pre.length === 1) return { kind: 'resolved', session: pre[0] };
70
+ if (pre.length > 1) return { kind: 'ambiguous', candidates: pre };
71
+ if (token.length >= LITERAL_ID_MIN_LEN) return { kind: 'literal' };
72
+ return { kind: 'none' };
73
+ }
74
+
75
+ export default async function deleteSessions() {
76
+ const { flags, positional, errors } = parseDeleteArgs(process.argv.slice(3));
77
+ const { version, commit } = getVersionInfo();
78
+ initLogger(`v${version} (${commit})`);
79
+
80
+ const usage = 'Usage: ai-lens delete-sessions <id…> | --from YYYY-MM-DD --to YYYY-MM-DD [--source S] [--days N] (add --yes to confirm)';
81
+ const rangeMode = flags.from != null || flags.to != null || flags.days != null;
82
+
83
+ if (errors.length) {
84
+ errors.forEach((e) => error(e));
85
+ info(usage);
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+ if (flags.from && !isValidDate(flags.from)) { error(`Invalid --from "${flags.from}". Use YYYY-MM-DD.`); process.exitCode = 1; return; }
90
+ if (flags.to && !isValidDate(flags.to)) { error(`Invalid --to "${flags.to}". Use YYYY-MM-DD.`); process.exitCode = 1; return; }
91
+ if (flags.from && flags.to && Date.parse(flags.from) > Date.parse(flags.to)) {
92
+ error(`--from "${flags.from}" is after --to "${flags.to}".`); process.exitCode = 1; return;
93
+ }
94
+ if (!positional.length && !rangeMode) {
95
+ error('Nothing to delete: pass session id(s) or a --from/--to range.');
96
+ info(usage);
97
+ process.exitCode = 1;
98
+ return;
99
+ }
100
+
101
+ // One session fetch serves both id-resolution and range selection.
102
+ let sessions;
103
+ try {
104
+ sessions = await fetchSessions({ days: RESOLVE_WINDOW_DAYS, source: flags.source, limit: RESOLVE_LIMIT });
105
+ } catch (err) {
106
+ error(err instanceof ApiError ? err.message : `Failed to load sessions: ${err.message}`);
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ // The list endpoint caps at RESOLVE_LIMIT rows (newest first). If we hit the cap,
111
+ // older sessions are invisible here — warn so a range that "matches nothing" or a
112
+ // short id that "doesn't resolve" isn't mistaken for "no such data".
113
+ if (sessions.length >= RESOLVE_LIMIT) {
114
+ warn(`Only the ${RESOLVE_LIMIT} most-recent sessions were loaded — older ones may not resolve. Use the full id, or a tighter --source.`);
115
+ }
116
+
117
+ const targets = [];
118
+ const seen = new Set();
119
+ const addTarget = (id, session) => {
120
+ if (!id || seen.has(id)) return;
121
+ seen.add(id);
122
+ targets.push({ id, session });
123
+ };
124
+
125
+ // Resolve explicit id tokens. Ambiguous prefixes abort the whole command
126
+ // (deleting the wrong chain is unrecoverable); unknown short tokens are skipped.
127
+ const ambiguous = [];
128
+ const notFound = [];
129
+ for (const token of positional) {
130
+ const r = resolveId(token, sessions);
131
+ if (r.kind === 'resolved') addTarget(r.session.session_id, r.session);
132
+ else if (r.kind === 'literal') addTarget(token, null);
133
+ else if (r.kind === 'ambiguous') ambiguous.push({ token, n: r.candidates.length });
134
+ else notFound.push(token);
135
+ }
136
+ if (ambiguous.length) {
137
+ for (const a of ambiguous) error(`"${a.token}" matches ${a.n} sessions — use a longer prefix or the full id.`);
138
+ info('Nothing deleted.');
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+ for (const t of notFound) warn(`No session matches "${t}" — skipping.`);
143
+
144
+ if (rangeMode) {
145
+ for (const s of sessions.filter((s) => inRange(s, flags))) addTarget(s.session_id, s);
146
+ }
147
+
148
+ if (targets.length === 0) {
149
+ info('No sessions matched — nothing to delete.');
150
+ return;
151
+ }
152
+
153
+ // Preview.
154
+ heading(`${flags.yes ? 'Deleting' : 'Would delete'} ${targets.length} session(s)`);
155
+ warn('Each id removes the WHOLE activity chain it belongs to (all grouped sessions + their analyses).');
156
+ if (rangeMode) {
157
+ const lo = flags.from || (flags.days ? `last ${flags.days}d` : '…');
158
+ detail(`Range selects sessions that STARTED in ${lo}…${flags.to || 'now'} (a session that began earlier and ran into the window is not matched).`);
159
+ }
160
+ const withMeta = targets.filter((t) => t.session);
161
+ if (withMeta.length) {
162
+ info(SESSION_HEADER);
163
+ for (const t of withMeta) info(formatSessionLine(t.session));
164
+ }
165
+ const idOnly = targets.filter((t) => !t.session).map((t) => t.id);
166
+ if (idOnly.length) info(`Plus ${idOnly.length} full id(s) given directly: ${idOnly.map(shortId).join(', ')}`);
167
+ blank();
168
+
169
+ if (!flags.yes) {
170
+ info('Dry run — nothing deleted. Re-run with --yes to confirm.');
171
+ return;
172
+ }
173
+
174
+ // Execute.
175
+ let deletedChains = 0, deletedEvents = 0, skipped = 0, failed = 0;
176
+ const results = [];
177
+ for (const { id } of targets) {
178
+ let r;
179
+ try {
180
+ r = await deleteSessionById(id);
181
+ } catch (err) {
182
+ // 401 (auth) or network — fatal for the whole batch.
183
+ error(err instanceof ApiError ? err.message : `Delete failed: ${err.message}`);
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+ if (r.ok) {
188
+ deletedChains++;
189
+ deletedEvents += Number(r.body?.deleted_events) || 0;
190
+ } else if (r.status === 404) {
191
+ skipped++;
192
+ } else {
193
+ failed++;
194
+ warn(` ${shortId(id)}: ${r.reason}`);
195
+ }
196
+ results.push({ id, ...r });
197
+ }
198
+
199
+ if (flags.json) {
200
+ console.log(JSON.stringify({ deletedChains, deletedEvents, skipped, failed, results }, null, 2));
201
+ return;
202
+ }
203
+ blank();
204
+ success(`Deleted ${deletedChains} chain(s), ${deletedEvents} event(s).`);
205
+ if (skipped) detail(`${skipped} already gone (same-chain or previously deleted).`);
206
+ if (failed) warn(`${failed} could not be deleted (not yours or server error).`);
207
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `ai-lens find-session <query>` — find your own sessions by a substring of the
3
+ * session id, project path, or source. Reuses the existing `GET /api/sessions`
4
+ * API (same data the dashboard lists), then filters client-side.
5
+ *
6
+ * The matcher (`matchSession`) is deliberately isolated so it can later be
7
+ * repointed at a richer/changed endpoint (e.g. content search) without touching
8
+ * the command shell.
9
+ */
10
+ import { initLogger, heading, info, error, detail, blank } from './logger.js';
11
+ import { getVersionInfo } from './hooks.js';
12
+ import { fetchSessions, ApiError } from './data-api.js';
13
+ import { parseArgs, SESSION_HEADER, formatSessionLine } from './data-format.js';
14
+
15
+ /** True when `query` (case-insensitive) appears in the session's id/project/source. */
16
+ export function matchSession(session, query) {
17
+ if (!query) return true;
18
+ const q = query.toLowerCase();
19
+ return [session.session_id, session.project_path, session.source]
20
+ .some((field) => String(field || '').toLowerCase().includes(q));
21
+ }
22
+
23
+ export function parseFindArgs(argv) {
24
+ return parseArgs(argv, {
25
+ bools: { '--json': 'json' },
26
+ values: { '--days': 'days', '--source': 'source', '--limit': 'limit' },
27
+ ints: new Set(['days', 'limit']),
28
+ });
29
+ }
30
+
31
+ export default async function findSession() {
32
+ const { flags, positional, errors } = parseFindArgs(process.argv.slice(3));
33
+ const { version, commit } = getVersionInfo();
34
+ initLogger(`v${version} (${commit})`);
35
+
36
+ const query = positional.join(' ').trim();
37
+ if (errors.length || !query) {
38
+ errors.forEach((e) => error(e));
39
+ if (!query) error('Missing search query.');
40
+ info('Usage: ai-lens find-session <query> [--days N] [--source S] [--limit N] [--json]');
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+
45
+ let sessions;
46
+ try {
47
+ sessions = await fetchSessions({ days: flags.days, source: flags.source, limit: flags.limit });
48
+ } catch (err) {
49
+ error(err instanceof ApiError ? err.message : `Failed to search sessions: ${err.message}`);
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+
54
+ const matches = sessions.filter((s) => matchSession(s, query));
55
+
56
+ if (flags.json) {
57
+ console.log(JSON.stringify(matches, null, 2));
58
+ return;
59
+ }
60
+
61
+ heading(`Sessions matching "${query}"`);
62
+ if (matches.length === 0) {
63
+ info(`No match in ${sessions.length} session(s). Widen with --days / --source.`);
64
+ return;
65
+ }
66
+ info(SESSION_HEADER);
67
+ for (const s of matches) info(formatSessionLine(s));
68
+ blank();
69
+ detail(`${matches.length} of ${sessions.length} session(s) matched. Full id with --json.`);
70
+ }
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
- function isCurrentAiLensHook(entry, expected) {
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 isAcceptableHookCommand(entry.command);
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 => isAcceptableHookCommand(h?.command || ''));
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
  }
@@ -5,7 +5,19 @@
5
5
  * (cli/import/transcript-map.js), enriches with developer + git identity,
6
6
  * redacts + spools via the live client pipeline (writeToSpool), and lets the
7
7
  * existing sender ship them to POST /api/events. Historical timestamps are kept
8
- * verbatim; re-import is idempotent via the server content-hash dedup.
8
+ * verbatim.
9
+ *
10
+ * Re-import is idempotent: every event carries a DETERMINISTIC event_id
11
+ * (computeEventId below) and the server inserts with ON CONFLICT DO NOTHING
12
+ * against the `events.event_id` UNIQUE constraint, so re-running over an
13
+ * already-ingested window inserts zero duplicate rows. This is the robust
14
+ * guarantee — independent of timestamp proximity and of payload size. (The
15
+ * server's 5-second content-hash window is a separate, secondary layer; because
16
+ * import keeps timestamps verbatim it happens to catch re-imports too, but it
17
+ * does nothing when `raw` is tiny — only the event_id UNIQUE constraint does.) A
18
+ * per-file fingerprint ledger additionally short-circuits unchanged transcripts
19
+ * on the fast path; a bounded `--from/--to` re-import bypasses the ledger and
20
+ * leans entirely on the event_id dedup.
9
21
  */
10
22
  import { createReadStream, existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, renameSync, realpathSync } from 'node:fs';
11
23
  import { join, basename } from 'node:path';
@@ -46,26 +58,63 @@ const DRAIN_BATCH = 4000;
46
58
  // terminal Stop/SubagentStop marker is safe to emit (matches the 5h chain-gap).
47
59
  const TERMINAL_DORMANT_MS = 5 * 60 * 60 * 1000;
48
60
 
61
+ const isValidDate = (s) => typeof s === 'string' && !Number.isNaN(Date.parse(s));
62
+
49
63
  /** Validate flags. Returns an error string, or null when ok. */
50
- export function validateFlags({ days, since }) {
51
- if (since != null) {
52
- if (typeof since !== 'string' || Number.isNaN(Date.parse(since))) {
53
- return `Invalid --since "${since}". Use YYYY-MM-DD.`;
64
+ export function validateFlags({ days, since, from, to }) {
65
+ // --from/--to define an explicit [from, to] window (lower = from, upper = to).
66
+ if (from != null || to != null) {
67
+ if (since != null) return 'Use either --since or --from/--to, not both.';
68
+ if (from != null && !isValidDate(from)) return `Invalid --from "${from}". Use YYYY-MM-DD.`;
69
+ if (to != null && !isValidDate(to)) return `Invalid --to "${to}". Use YYYY-MM-DD.`;
70
+ if (from != null && to != null && Date.parse(from) > Date.parse(to)) {
71
+ return `--from "${from}" is after --to "${to}".`;
54
72
  }
73
+ return null;
74
+ }
75
+ if (since != null) {
76
+ if (!isValidDate(since)) return `Invalid --since "${since}". Use YYYY-MM-DD.`;
55
77
  } else if (!Number.isInteger(days) || days < 0) {
56
78
  return `Invalid --days "${days}". Use a non-negative integer (0 = all history).`;
57
79
  }
58
80
  return null;
59
81
  }
60
82
 
61
- /** Resolve the cutoff ISO string from flags. `--days 0` / no window ⇒ epoch (all). */
62
- export function resolveCutoff({ days, since }, now = new Date()) {
63
- if (since) return new Date(Date.parse(since)).toISOString();
83
+ /**
84
+ * Resolve the lower-bound cutoff ISO string from flags. `--from` and `--since`
85
+ * are equivalent lower bounds; `--days 0` / no window ⇒ epoch (all history).
86
+ */
87
+ export function resolveCutoff({ days, since, from }, now = new Date()) {
88
+ const lower = from || since;
89
+ if (lower) return new Date(Date.parse(lower)).toISOString();
64
90
  if (days === 0) return new Date(0).toISOString();
65
91
  const d = Number.isInteger(days) && days >= 0 ? days : 30;
66
92
  return new Date(now.getTime() - d * DAY_MS).toISOString();
67
93
  }
68
94
 
95
+ /**
96
+ * Resolve the INCLUSIVE lower bound from `--from` (or null when open-ended).
97
+ * Unlike the file-level `cutoff` (which only decides whether to read a file),
98
+ * this clips PER EVENT so an explicit `--from/--to` window means exactly the
99
+ * events in [from 00:00, to+1day) — a file touched inside the window no longer
100
+ * drags in its older events. Only `--from`/`--to` engage this precise clipping;
101
+ * `--days`/`--since` keep their looser file-level semantics.
102
+ */
103
+ export function resolveLowerBound({ from }) {
104
+ if (!from || !isValidDate(from)) return null;
105
+ return new Date(Date.parse(from)).toISOString();
106
+ }
107
+
108
+ /**
109
+ * Resolve the EXCLUSIVE upper bound from `--to` (or null when open-ended).
110
+ * `--to 2026-06-07` includes the whole of June 7 ⇒ exclusive bound is the next
111
+ * midnight (2026-06-08T00:00Z).
112
+ */
113
+ export function resolveUpperBound({ to }) {
114
+ if (!to || !isValidDate(to)) return null;
115
+ return new Date(Date.parse(to) + DAY_MS).toISOString();
116
+ }
117
+
69
118
  /** Expand a leading ~ and resolve to a canonical project (git) root. */
70
119
  function normalizeProjectArg(p) {
71
120
  let s = (p || '').trim();
@@ -94,13 +143,19 @@ export async function fetchCoverage(sessionIds, { fetchImpl = globalThis.fetch }
94
143
  if (!sessionIds.length || !fetchImpl) return out;
95
144
  const base = getServerUrl();
96
145
  const token = getAuthToken();
146
+ // Auth: token preferred, else git-identity headers so coverage also works in
147
+ // self-host personal mode (no token) — same fallback the ingest path uses.
148
+ const ident = token ? null : getGitIdentity(process.cwd());
149
+ const authHeaders = token
150
+ ? { 'X-Auth-Token': token }
151
+ : (ident?.email ? { 'X-Developer-Git-Email': ident.email, ...(ident.name ? { 'X-Developer-Name': ident.name } : {}) } : {});
97
152
  const CHUNK = 400;
98
153
  for (let i = 0; i < sessionIds.length; i += CHUNK) {
99
154
  const batch = sessionIds.slice(i, i + CHUNK);
100
155
  try {
101
156
  const res = await fetchImpl(new URL('/api/events/coverage', base), {
102
157
  method: 'POST',
103
- headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Auth-Token': token } : {}) },
158
+ headers: { 'Content-Type': 'application/json', ...authHeaders },
104
159
  body: JSON.stringify({ session_ids: batch }),
105
160
  });
106
161
  if (res.ok) Object.assign(out, await res.json());
@@ -109,6 +164,15 @@ export async function fetchCoverage(sessionIds, { fetchImpl = globalThis.fetch }
109
164
  return out;
110
165
  }
111
166
 
167
+ /**
168
+ * Can the importer actually deliver events? A personal auth token OR a resolvable
169
+ * git email (the ingest path accepts either). Pure-ish; takes the two resolvers so
170
+ * it's testable without touching real git/env.
171
+ */
172
+ export function hasDeliverableIdentity(token, gitEmail) {
173
+ return !!token || !!gitEmail;
174
+ }
175
+
112
176
  /**
113
177
  * Drop events at/after the live-coverage boundary for their session, so import
114
178
  * only adds the pre-live backlog. No boundary ⇒ keep all. (Pure, for tests.)
@@ -199,7 +263,7 @@ async function drainSpool({ timeoutMs = 120_000 } = {}) {
199
263
 
200
264
  export default async function importClaudeCode(flags) {
201
265
  const {
202
- days = 30, since = null, dryRun = false, projects = null,
266
+ days = 30, since = null, from = null, to = null, dryRun = false, projects = null,
203
267
  noRedact = false, analysisMaxAgeDays: amAgeFlag = 30,
204
268
  } = flags;
205
269
  const analysisMaxAgeDays = Number.isInteger(amAgeFlag) && amAgeFlag >= 0 ? amAgeFlag : 30;
@@ -209,24 +273,44 @@ export default async function importClaudeCode(flags) {
209
273
  warn(`No Claude Code history found at ${PROJECTS_DIR}`);
210
274
  return;
211
275
  }
212
- if (!dryRun && !getAuthToken()) {
213
- error('No auth token. Run `npx ai-lens init` first (or set AI_LENS_AUTH_TOKEN).');
276
+ // Need a way to attribute events: a token, OR a git email (self-host personal
277
+ // mode ships via the git-identity fallback, just like live capture).
278
+ if (!dryRun && !hasDeliverableIdentity(getAuthToken(), getGitIdentity(process.cwd())?.email)) {
279
+ error('No identity to attribute events. Run `npx ai-lens init` first, or set a git user.email (or AI_LENS_AUTH_TOKEN).');
214
280
  return;
215
281
  }
216
282
  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.');
217
283
 
218
- const flagErr = validateFlags({ days, since });
284
+ const flagErr = validateFlags({ days, since, from, to });
219
285
  if (flagErr) { error(flagErr); process.exitCode = 1; return; }
220
286
 
221
- const cutoff = resolveCutoff({ days, since });
287
+ const lower = resolveLowerBound({ from }); // inclusive per-event lower, or null
288
+ const upper = resolveUpperBound({ to }); // exclusive per-event upper, or null
289
+ const lowerMs = lower != null ? Date.parse(lower) : null; // precomputed for the per-event filter
290
+ const upperMs = upper != null ? Date.parse(upper) : null;
291
+ // `--from`/`--to` is an explicit, precisely-clipped window. It re-reads files
292
+ // (the ledger marks a file "covered up to now", which a bounded window must not
293
+ // trust) and records NO coverage — re-import idempotency comes from the
294
+ // event_id UNIQUE dedup instead. `--days`/`--since` keep the fast ledger path.
295
+ const rangeMode = lower != null || upper != null;
296
+ // File-level cutoff (which transcripts to read). In range mode WITHOUT a lower
297
+ // bound (`--to` only) read everything and let the per-event upper clip decide —
298
+ // otherwise the default 30d cutoff would skip older files and import nothing.
299
+ const cutoff = (rangeMode && lower == null)
300
+ ? new Date(0).toISOString()
301
+ : resolveCutoff({ days, since, from });
222
302
  const projectFilter = projects
223
303
  ? projects.split(',').map(normalizeProjectArg).filter(Boolean)
224
304
  : null;
225
305
  ensureDataDir();
226
306
  const ledger = loadLedger();
307
+ // Human-readable window label for the progress lines.
308
+ const windowLabel = (from || to)
309
+ ? `${from || 'start'}…${to || 'now'}`
310
+ : (since || (days === 0 ? 'all' : days + 'd'));
227
311
  if (!dryRun) info(`Server: ${getServerUrl()}`);
228
- info(dryRun ? `Scanning (dry-run, window: ${since || (days === 0 ? 'all' : days + 'd')})…`
229
- : `Importing window: ${since || (days === 0 ? 'all' : days + 'd')} (cutoff ${cutoff.slice(0, 10)})`);
312
+ info(dryRun ? `Scanning (dry-run, window: ${windowLabel})…`
313
+ : `Importing window: ${windowLabel} (cutoff ${cutoff.slice(0, 10)}${upper ? `, until ${to}` : ''})`);
230
314
 
231
315
  const allFiles = [...new Set(walkJsonl(PROJECTS_DIR))];
232
316
  let filesIncluded = 0, filesSkipped = 0, eventCount = 0, sessionCount = 0, liveSkipped = 0;
@@ -259,7 +343,7 @@ export default async function importClaudeCode(flags) {
259
343
  try { st = statSync(filePath); } catch { continue; }
260
344
  const fp = { mtimeMs: Math.trunc(st.mtimeMs), size: st.size };
261
345
  if (st.mtime.toISOString() < cutoff) { filesSkipped++; continue; } // last write before window
262
- if (ledgerCovers(ledger[filePath], cutoff, fp)) { filesSkipped++; continue; }
346
+ if (!rangeMode && ledgerCovers(ledger[filePath], cutoff, fp)) { filesSkipped++; continue; }
263
347
  candidates.push({ filePath, fp });
264
348
  }
265
349
 
@@ -291,11 +375,22 @@ export default async function importClaudeCode(flags) {
291
375
  const emitTerminal = (nowMs - fp.mtimeMs) >= TERMINAL_DORMANT_MS;
292
376
  const mapped = mapTranscript(lines, { sessionId, projectPath, fileId, isSubagentFile, agentId, agentSlug: null, emitTerminal });
293
377
  if (mapped.length === 0) { filesSkipped++; continue; }
294
- const events = sliceEvents(mapped, coverage); // import only pre-live events
378
+ // De-overlap with live coverage, then clip to the explicit [from, to] window.
379
+ const events = sliceEvents(mapped, coverage).filter((ev) => {
380
+ if (lowerMs == null && upperMs == null) return true;
381
+ const t = Date.parse(ev.timestamp);
382
+ if (lowerMs != null && t < lowerMs) return false;
383
+ if (upperMs != null && t >= upperMs) return false;
384
+ return true;
385
+ });
295
386
  if (events.length === 0) {
296
- // Whole session is at/after the live boundary — live already owns it.
297
- liveSkipped++;
298
- if (!dryRun) pendingCommit.push({ filePath, entry: { covered_cutoff: cutoff, complete_all: days === 0, fingerprint: fp } });
387
+ // Nothing left for this file: either live already owns the session, or every
388
+ // event fell outside the [from, to] window. Only the former is "live skipped";
389
+ // only a full (unbounded) import may mark the file covered in the ledger.
390
+ if (!rangeMode) {
391
+ liveSkipped++;
392
+ if (!dryRun) pendingCommit.push({ filePath, entry: { covered_cutoff: cutoff, complete_all: days === 0, fingerprint: fp } });
393
+ }
299
394
  continue;
300
395
  }
301
396
 
@@ -334,7 +429,8 @@ export default async function importClaudeCode(flags) {
334
429
  if (!isSubagentFile) (lastTs >= new Date(nowMs - analysisMaxAgeDays * 86400_000).toISOString() ? withinWindow++ : older++);
335
430
  if (!lastDate || lastTs > lastDate) lastDate = lastTs;
336
431
  // Defer the ledger commit until this file's events have actually shipped (flush()).
337
- if (!dryRun) pendingCommit.push({ filePath, entry: { covered_cutoff: cutoff, complete_all: days === 0, fingerprint: fp } });
432
+ // A bounded `--from/--to` re-import never records coverage (it didn't read to "now").
433
+ if (!dryRun && !rangeMode) pendingCommit.push({ filePath, entry: { covered_cutoff: cutoff, complete_all: days === 0, fingerprint: fp } });
338
434
  if (filesIncluded % 25 === 0) info(` …${filesIncluded} files, ${eventCount} events — last ${(lastDate || '').slice(0, 10)}`);
339
435
  }
340
436
 
package/cli/import.js CHANGED
@@ -6,7 +6,7 @@ import { initLogger, error, info } from './logger.js';
6
6
  import { getVersionInfo } from './hooks.js';
7
7
 
8
8
  const BOOL_FLAGS = { '--dry-run': 'dryRun', '--no-redact': 'noRedact' };
9
- const VALUE_FLAGS = { '--days': 'days', '--since': 'since', '--projects': 'projects', '--analysis-max-age-days': 'analysisMaxAgeDays' };
9
+ const VALUE_FLAGS = { '--days': 'days', '--since': 'since', '--from': 'from', '--to': 'to', '--projects': 'projects', '--analysis-max-age-days': 'analysisMaxAgeDays' };
10
10
  const INT_KEYS = new Set(['days', 'analysisMaxAgeDays']);
11
11
 
12
12
  /**
@@ -17,7 +17,7 @@ const INT_KEYS = new Set(['days', 'analysisMaxAgeDays']);
17
17
  * REAL import instead of a preview.)
18
18
  */
19
19
  export function parseFlags(argv) {
20
- const flags = { days: 30, since: null, dryRun: false, projects: null, noRedact: false, analysisMaxAgeDays: 30 };
20
+ const flags = { days: 30, since: null, from: null, to: null, dryRun: false, projects: null, noRedact: false, analysisMaxAgeDays: 30 };
21
21
  const errors = [];
22
22
  for (let i = 0; i < argv.length; i++) {
23
23
  const arg = argv[i];
@@ -44,7 +44,7 @@ export default async function importCmd() {
44
44
 
45
45
  if (errors.length) {
46
46
  errors.forEach((e) => error(e));
47
- info('Usage: ai-lens import claude-code [--days N | --since YYYY-MM-DD] [--projects A,B] [--dry-run] [--no-redact]');
47
+ info('Usage: ai-lens import claude-code [--days N | --since YYYY-MM-DD | --from YYYY-MM-DD --to YYYY-MM-DD] [--projects A,B] [--dry-run] [--no-redact]');
48
48
  process.exitCode = 1;
49
49
  return;
50
50
  }
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
- currentConfig.authToken = null;
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
- currentConfig.authToken = null;
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 (!currentConfig.authToken) {
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
- // Validate identity: no token + no git email = events will be dropped
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 (!email) {
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
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `ai-lens list-sessions` — list your own sessions (thin client over
3
+ * `GET /api/sessions`). Filters: --days, --source, --limit. --json for raw output.
4
+ */
5
+ import { initLogger, heading, info, error, detail, blank } from './logger.js';
6
+ import { getVersionInfo } from './hooks.js';
7
+ import { fetchSessions, ApiError } from './data-api.js';
8
+ import { parseArgs, SESSION_HEADER, formatSessionLine } from './data-format.js';
9
+
10
+ export function parseListFlags(argv) {
11
+ return parseArgs(argv, {
12
+ bools: { '--json': 'json' },
13
+ values: { '--days': 'days', '--source': 'source', '--limit': 'limit' },
14
+ ints: new Set(['days', 'limit']),
15
+ });
16
+ }
17
+
18
+ export default async function listSessions() {
19
+ const { flags, errors } = parseListFlags(process.argv.slice(3));
20
+ const { version, commit } = getVersionInfo();
21
+ initLogger(`v${version} (${commit})`);
22
+
23
+ if (errors.length) {
24
+ errors.forEach((e) => error(e));
25
+ info('Usage: ai-lens list-sessions [--days N] [--source claude_code|cursor|codex] [--limit N] [--json]');
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+
30
+ let sessions;
31
+ try {
32
+ sessions = await fetchSessions({ days: flags.days, source: flags.source, limit: flags.limit });
33
+ } catch (err) {
34
+ error(err instanceof ApiError ? err.message : `Failed to list sessions: ${err.message}`);
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+
39
+ if (flags.json) {
40
+ console.log(JSON.stringify(sessions, null, 2));
41
+ return;
42
+ }
43
+
44
+ heading(`Your sessions${flags.source ? ` (${flags.source})` : ''}${flags.days ? `, last ${flags.days}d` : ''}`);
45
+ if (sessions.length === 0) {
46
+ info('No sessions found for this window.');
47
+ return;
48
+ }
49
+ info(SESSION_HEADER);
50
+ for (const s of sessions) info(formatSessionLine(s));
51
+ blank();
52
+ detail(`${sessions.length} session(s). Full id with --json. Remove with: ai-lens delete-sessions <id>`);
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.81",
3
+ "version": "0.8.85",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {