ai-lens 0.8.100 → 0.8.103

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
- 3ebf574
1
+ 0aee413
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.103 — 2026-06-18
6
+ - fix: `ai-lens status` no longer falsely reports the client as "outdated" on repo-path installs (where hooks run capture.js straight from a checkout that auto-updates on `git pull`). It now reads the client version from the repo the hooks actually run, instead of an unused leftover copy in `~/.ai-lens/client/` — which could be months stale and made the check show ✗ + "run init" even though the live client was current. The stale copy is now noted as ignored. Copy-mode installs are unchanged.
7
+
8
+ ## 0.8.102 — 2026-06-17
9
+ - fix: the per-machine `settings.local.json` overlay for committed Claude Code hooks now also writes the absolute launcher form on Windows (matching the main install path), so a repo whose tracked `settings.json` carries `$CLAUDE_PROJECT_DIR` hooks captures correctly on Windows without manual fix-up. `ai-lens status`/`init` now flag any leftover `$CLAUDE_PROJECT_DIR`/`%CLAUDE_PROJECT_DIR%` Claude hook on Windows as outdated so it migrates to the absolute form.
10
+ - fix: the Windows windowless hook launcher (`ai-lens-hook.ps1`) now sends the event payload to node as raw UTF-8 bytes (Cyrillic prompts and accented paths are no longer mangled by PowerShell's default codepage) and fails open — on any internal error it exits cleanly and writes nothing to stdout, so a launcher hiccup can never break a Cursor hook or disrupt the session.
11
+
12
+ ## 0.8.101 — 2026-06-17
13
+ - fix: Claude Code hooks on Windows now use an absolute path to capture.js instead of `$CLAUDE_PROJECT_DIR`, which Claude Code does not expand on Windows (the v0.8.100 form failed to find the launcher and, when Cursor also ran the hook, produced a visible error). Capture is restored. Existing installs migrate on the next `ai-lens init` / `/setup`. macOS/Linux keep `$CLAUDE_PROJECT_DIR` (expanded there). Note: a brief console window can still flash on Claude Code hooks on Windows — that's an upstream Claude Code behaviour (it launches the hook shell without hiding the window) and can't be fixed from the hook side
14
+
5
15
  ## 0.8.100 — 2026-06-17
6
16
  - fix: Claude Code hooks on Windows now actually capture. The previous windowless wrapper (`conhost.exe --headless`) hid the console window but silently swallowed the hook's stdin, so the event payload never reached AI Lens and nothing was recorded. Claude Code now uses the same windowless launcher as Cursor (`ai-lens-hook.ps1`), which both suppresses the window and delivers the payload. Existing installs migrate on the next `ai-lens init` / `/setup`. macOS/Linux unchanged. (The launcher was renamed from `cursor-ai-lens-hook.ps1` since it now serves both tools.)
7
17
 
package/cli/hooks.js CHANGED
@@ -316,16 +316,13 @@ export function captureCommand(opts = {}) {
316
316
  // binary or /home/<user> path. Windows Claude Code shells via cmd.exe (%VAR%);
317
317
  // POSIX shells use $VAR.
318
318
  if (projectDirRelPath != null) {
319
+ // POSIX/macOS only: bare node + $CLAUDE_PROJECT_DIR (the shell expands $VAR; the
320
+ // path stays machine-agnostic for committed hooks + cwd-robust). On WINDOWS this
321
+ // form must NOT be used — Claude Code's hook spawn (Git Bash / PowerShell) does NOT
322
+ // expand $CLAUDE_PROJECT_DIR nor %CLAUDE_PROJECT_DIR% (verified live: both reached
323
+ // the shell literally and the path failed). Callers therefore route Windows Claude
324
+ // hooks to the absolute getClaudeCodeHookDefsWithPath form instead of this one.
319
325
  const rel = projectDirRelPath.replace(/\\/g, '/').replace(/^\.?\/+/, '');
320
- if (winLauncher) {
321
- // Windowless form via the launcher. Uses $CLAUDE_PROJECT_DIR — Claude Code
322
- // substitutes that variable itself before exec on every OS. The launcher ships
323
- // next to capture.js, so it resolves under the same $CLAUDE_PROJECT_DIR path.
324
- // node stays bare (committed/machine-agnostic form) — the launcher's
325
- // ProcessStartInfo resolves it via PATH (Claude Code requires node on PATH).
326
- const launcherRel = rel.replace(/[^/]*$/, 'ai-lens-hook.ps1');
327
- return `powershell -NoProfile -ExecutionPolicy Bypass -File "$CLAUDE_PROJECT_DIR/${launcherRel}" node "$CLAUDE_PROJECT_DIR/${rel}"`;
328
- }
329
326
  const dir = isWin ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
330
327
  return `node "${dir}/${rel}"`;
331
328
  }
@@ -926,14 +923,17 @@ function isAcceptableHookCommand(cmd) {
926
923
  export function isWrongPlatformProjectDirCommand(cmd, platform = process.platform) {
927
924
  if (!isClaudeProjectDirCommand(cmd)) return false;
928
925
  const n = (cmd || '').replace(/\\/g, '/');
929
- // The Windows windowless launcher form (powershell -File "$CLAUDE_PROJECT_DIR/…
930
- // ai-lens-hook.ps1" …) and the older conhost.exe --headless form are unambiguously
931
- // Windows commands. Both deliberately use $CLAUDE_PROJECT_DIR Claude Code substitutes
932
- // that itself before exec, so it expands on Windows. They are correct on win32 and
933
- // wrong (won't run) anywhere else; the %VAR%/$VAR rule below doesn't apply to them.
934
- if (/ai-lens-hook\.ps1/i.test(n) || isConhostHeadlessCommand(n)) return platform !== 'win32';
935
- const correctVar = platform === 'win32' ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
936
- return !n.includes(correctVar);
926
+ // The legacy conhost.exe --headless form is unambiguously a Windows command and uses
927
+ // $CLAUDE_PROJECT_DIR: correct on win32, wrong elsewhere. (Migration off conhost is
928
+ // driven separately by the wrapper-kind check in isCurrentAiLensHook.)
929
+ if (isConhostHeadlessCommand(n)) return platform !== 'win32';
930
+ // On Windows, NEITHER $CLAUDE_PROJECT_DIR nor %CLAUDE_PROJECT_DIR% actually expands in
931
+ // Claude Code's hook spawn (verified live) so ANY variable-bearing form is wrong on
932
+ // win32 and must migrate to the absolute path form (incl. a stale $VAR-launcher or a
933
+ // %VAR% overlay). On POSIX the shell expands $CLAUDE_PROJECT_DIR, so $VAR is the only
934
+ // correct form there.
935
+ if (platform === 'win32') return true;
936
+ return !n.includes('$CLAUDE_PROJECT_DIR');
937
937
  }
938
938
 
939
939
  // Leading `conhost.exe --headless ` windowless wrapper, with the trailing space so a
@@ -1036,13 +1036,27 @@ export function analyzeClaudeLocalOverlay(tool, opts = {}) {
1036
1036
  if (!rel) return { applicable: false, reason: 'could not extract capture.js path from hook command' };
1037
1037
 
1038
1038
  const localPath = claudeLocalSettingsPath(tool);
1039
+ // Committed-file → per-machine overlay mirror. On Windows $CLAUDE_PROJECT_DIR/%VAR% does
1040
+ // NOT expand in Claude Code's hook spawn, so the overlay (like init's main path) writes
1041
+ // the absolute windowless-launcher form — rel resolved against the project root, node
1042
+ // resolved on this machine. POSIX keeps the $CLAUDE_PROJECT_DIR form (the shell expands it).
1043
+ const overlayCtx = { ...(opts.ctx ?? {}), platform };
1044
+ let overlayHookDefs;
1045
+ if (platform === 'win32') {
1046
+ const projectRoot = dirname(dirname(tool.configPath));
1047
+ const absCapture = join(projectRoot, rel).replace(/\\/g, '/');
1048
+ const nodeResolution = overlayCtx.nodeResolution ?? findStableNodePath({ platform });
1049
+ overlayHookDefs = getClaudeCodeHookDefsWithPath(absCapture, { ...overlayCtx, nodeResolution });
1050
+ } else {
1051
+ overlayHookDefs = getClaudeCodeHookDefsWithProjectDir(rel, overlayCtx);
1052
+ }
1039
1053
  const localTool = {
1040
1054
  ...tool,
1041
1055
  name: `${tool.name} (settings.local.json overlay)`,
1042
1056
  configPath: localPath,
1043
1057
  // Never backup/rename a user's settings.local.json wholesale on parse errors.
1044
1058
  sharedConfig: true,
1045
- hookDefs: getClaudeCodeHookDefsWithProjectDir(rel, { ...(opts.ctx ?? {}), platform }),
1059
+ hookDefs: overlayHookDefs,
1046
1060
  };
1047
1061
  const overlayAnalysis = analyzeToolHooks(localTool, { platform, allowPlatformRewrite: true });
1048
1062
  return {
package/cli/init.js CHANGED
@@ -182,9 +182,13 @@ function makeNestedClaudeTool(projectDir, capturePathInHooks, ctx = null) {
182
182
  // cwd. Absolute/tilde paths keep the legacy <node> <capture.js> form.
183
183
  const rel = capturePathInHooks.replace(/\\/g, '/');
184
184
  const isRelative = !rel.startsWith('/') && !rel.startsWith('~') && !/^[a-zA-Z]:\//.test(rel);
185
- tool.hookDefs = (isRelative && /(?:^|\/)ai-lens\//.test(rel))
185
+ // $CLAUDE_PROJECT_DIR is expanded by the shell on POSIX but NOT on Windows (Claude's
186
+ // hook spawn takes it literally) — so on Windows resolve rel to an absolute path
187
+ // against the nested project root instead of using the projectDir form.
188
+ const useProjectDir = isRelative && /(?:^|\/)ai-lens\//.test(rel) && ctx?.platform !== 'win32';
189
+ tool.hookDefs = useProjectDir
186
190
  ? getClaudeCodeHookDefsWithProjectDir(rel, ctx)
187
- : getClaudeCodeHookDefsWithPath(capturePathInHooks, ctx);
191
+ : getClaudeCodeHookDefsWithPath(isRelative ? join(projectDir, rel).replace(/\\/g, '/') : capturePathInHooks, ctx);
188
192
  }
189
193
  return tool;
190
194
  }
@@ -513,9 +517,13 @@ export default async function init() {
513
517
  : repoPathAbs.replace(/\\/g, '/');
514
518
  for (const tool of tools) {
515
519
  if (tool.name.startsWith('Claude Code')) {
516
- tool.hookDefs = claudeProjectDirRel
520
+ // On Windows, Claude Code's hook spawn does NOT expand $CLAUDE_PROJECT_DIR (Git
521
+ // Bash / PowerShell take it literally), so the projectDir form can't resolve —
522
+ // use the resolved absolute repo path instead (per-machine, so absolute is fine).
523
+ // POSIX/macOS keep $CLAUDE_PROJECT_DIR (cwd-robust + the shell expands it).
524
+ tool.hookDefs = (claudeProjectDirRel && ctx?.platform !== 'win32')
517
525
  ? getClaudeCodeHookDefsWithProjectDir(claudeProjectDirRel, ctx)
518
- : getClaudeCodeHookDefsWithPath(pathInHooks, ctx);
526
+ : getClaudeCodeHookDefsWithPath(claudeProjectDirRel ? repoPathAbs.replace(/\\/g, '/') : pathInHooks, ctx);
519
527
  }
520
528
  else if (tool.name.startsWith('Cursor')) tool.hookDefs = getCursorHookDefsWithPath(pathInHooks, ctx);
521
529
  else if (tool.name.startsWith('Codex')) tool.hookDefs = getCodexHookDefsWithPath(codexPathInHooks, ctx);
package/cli/status.js CHANGED
@@ -103,10 +103,12 @@ function expandTilde(pathStr) {
103
103
  }
104
104
 
105
105
  /**
106
- * Detect install mode from the capture.js path in hook commands.
107
- * Returns { ok, summary, detail } for the status output.
106
+ * Resolve the capture.js / launcher path(s) the hooks actually run, tagging each
107
+ * with its install mode. Shared by detectInstallMode and checkClientFiles so both
108
+ * reason about the SAME path the hooks execute (not an unrelated copy).
109
+ * Returns [{ tool, raw, resolved, mode: 'copy' | 'repo-path' }].
108
110
  */
109
- function detectInstallMode(tools) {
111
+ export function resolveHookClientPaths(tools) {
110
112
  // Normalise both sides to forward slashes before comparing — captureCommand
111
113
  // always emits forward-slash paths into the hook command, but join() on
112
114
  // Windows returns backslashes, so a raw startsWith() check would miss every
@@ -116,24 +118,40 @@ function detectInstallMode(tools) {
116
118
  for (const tool of tools) {
117
119
  const cmd = extractHookCommand(tool);
118
120
  if (!cmd) continue;
119
- // Match either a launcher (run.sh / run.cmd) or a legacy capture.js path. The launcher
120
- // always lives in the install dir, so it's by definition copy-mode.
121
- const m = cmd.match(/["']([^"']*(?:capture\.js|run\.(?:sh|cmd)))["']|(\S*(?:capture\.js|run\.(?:sh|cmd)))/);
122
- if (m) paths.push({ tool: tool.name, raw: m[1] || m[2], resolved: expandTilde(m[1] || m[2]).replace(/\\/g, '/') });
121
+ // Match either a launcher (run.sh / run.cmd / ai-lens-hook.ps1) or a capture.js path.
122
+ // A launcher always lives in the install dir, so it's by definition copy-mode.
123
+ const m = cmd.match(/["']([^"']*(?:capture\.js|run\.(?:sh|cmd)|ai-lens-hook\.ps1))["']|(\S*(?:capture\.js|run\.(?:sh|cmd)|ai-lens-hook\.ps1))/);
124
+ if (!m) continue;
125
+ const raw = m[1] || m[2];
126
+ const resolved = expandTilde(raw).replace(/\\/g, '/');
127
+ paths.push({ tool: tool.name, raw, resolved, mode: resolved.startsWith(copyDir) ? 'copy' : 'repo-path' });
128
+ }
129
+ return paths;
130
+ }
131
+
132
+ /** Read the `version` field from a package.json, or null if unreadable. */
133
+ function readPackageVersion(pkgJsonPath) {
134
+ try {
135
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
136
+ return pkg.version || null;
137
+ } catch {
138
+ return null;
123
139
  }
140
+ }
141
+
142
+ /**
143
+ * Detect install mode from the capture.js path in hook commands.
144
+ * Returns { ok, summary, detail } for the status output.
145
+ */
146
+ function detectInstallMode(tools) {
147
+ const paths = resolveHookClientPaths(tools);
124
148
  if (paths.length === 0) {
125
149
  return { ok: null, summary: 'unknown', detail: 'No hook commands found — cannot determine install mode' };
126
150
  }
127
- const modes = paths.map(p => {
128
- if (p.resolved.startsWith(copyDir)) return 'copy';
129
- return 'repo-path';
130
- });
151
+ const modes = paths.map(p => p.mode);
131
152
  const unique = [...new Set(modes)];
132
153
  const mode = unique.length === 1 ? unique[0] : 'mixed';
133
- const detail = paths.map(p => {
134
- const m = p.resolved.startsWith(copyDir) ? 'copy' : 'repo-path';
135
- return ` ${p.tool}: ${m} (${p.raw})`;
136
- }).join('\n');
154
+ const detail = paths.map(p => ` ${p.tool}: ${p.mode} (${p.raw})`).join('\n');
137
155
 
138
156
  if (mode === 'copy') {
139
157
  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}` };
@@ -492,7 +510,37 @@ function checkGitIdentity() {
492
510
  return { ok: false, summary: 'not configured', detail: 'Git identity not configured (git config user.email)' };
493
511
  }
494
512
 
495
- function checkClientFiles() {
513
+ export function checkClientFiles(tools = []) {
514
+ // In repo-path mode the hooks run capture.js straight from the repo, so the
515
+ // ~/.ai-lens/client/ copy (if any) is an UNUSED leftover from a prior copy-mode
516
+ // install. Reading that copy's version.json there would falsely flag the client
517
+ // "outdated" while the live client (the repo) is current — so check the version
518
+ // of the path the hooks actually execute.
519
+ const repoHook = resolveHookClientPaths(tools).find(p => p.mode === 'repo-path');
520
+ if (repoHook) {
521
+ // package.json sits one level up from the client/ dir the hook points into.
522
+ const pkgPath = repoHook.resolved.replace(/\/client\/[^/]+$/i, '/package.json');
523
+ const repoVersion = readPackageVersion(pkgPath);
524
+ const copyExists = existsSync(join(homedir(), '.ai-lens', 'client', 'capture.js'));
525
+ let detail = `Hooks run the client from the repo (repo-path mode) — auto-updates on git pull:\n ${repoHook.raw}`;
526
+ detail += repoVersion
527
+ ? `\n Repo client version: ${repoVersion}`
528
+ : `\n Repo client version: unknown (no package.json at ${pkgPath})`;
529
+ if (copyExists) {
530
+ 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
+ }
532
+ // Healthy as long as the hook-source version is readable. Don't compare against
533
+ // the CLI: `ai-lens status` may run from a global npx CLI while the hooks point
534
+ // at a checkout — that mismatch is expected, not an error. A stale repo checkout
535
+ // surfaces via git (and the version line above), not as a false ✗ here.
536
+ return {
537
+ ok: repoVersion != null,
538
+ summary: repoVersion ? `repo-path (v${repoVersion})` : 'repo-path (version unknown)',
539
+ detail,
540
+ };
541
+ }
542
+
543
+ // Copy-mode (or no hooks resolved): the ~/.ai-lens/client/ copy IS the live client.
496
544
  const dir = join(homedir(), '.ai-lens', 'client');
497
545
  const files = ['capture.js', 'sender.js', 'config.js'];
498
546
  const results = files.map(f => ({ name: f, exists: existsSync(join(dir, f)) }));
@@ -1427,16 +1475,19 @@ export default async function status({ report = false } = {}) {
1427
1475
  // 3. Git identity
1428
1476
  printLine('Git identity', checkGitIdentity());
1429
1477
 
1478
+ // Resolve the tools (incl. project hooks) up front — checkClientFiles needs them
1479
+ // to read the version of the path the hooks actually run (repo-path vs copy).
1480
+ const installedTools = detectInstalledTools();
1481
+ const toolsWithProject = getToolsForCaptureTest();
1482
+
1430
1483
  // 4. Client files
1431
- printLine('Client files', checkClientFiles());
1484
+ printLine('Client files', checkClientFiles(toolsWithProject));
1432
1485
 
1433
1486
  // 5. Config
1434
1487
  const configResult = checkConfig();
1435
1488
  printLine('Config', configResult);
1436
1489
 
1437
1490
  // 6. Hooks: global + project (Cursor then Claude Code; within each: global then project)
1438
- const installedTools = detectInstalledTools();
1439
- const toolsWithProject = getToolsForCaptureTest();
1440
1491
  const isGlobalTool = (tool) => TOOL_CONFIGS.includes(tool);
1441
1492
  const toolLabel = (tool) => (isGlobalTool(tool) ? `${tool.name} (global)` : tool.name);
1442
1493
  const hooksOrder = (a, b) => {
@@ -8,7 +8,8 @@
8
8
  # never reaches capture.js (it reads empty stdin and drops the event).
9
9
  # This launcher instead starts node via ProcessStartInfo with CreateNoWindow=$true (no
10
10
  # window) and RedirectStandardInput (real stdin forwarded) — so capture works AND there's
11
- # no flash, and nothing extra is written to stdout.
11
+ # no flash. It is fail-open: on ANY error it exits 0 and writes NOTHING to stdout (Cursor
12
+ # parses a hook's stdout as JSON — leaking an error there would break the hook).
12
13
  #
13
14
  # Payload source differs by caller, so read BOTH:
14
15
  # - Cursor pipes it through the PowerShell pipeline → $input.
@@ -18,22 +19,32 @@
18
19
  # Args: $args[0] = node path, $args[1] = capture.js path.
19
20
 
20
21
  $ErrorActionPreference = 'Stop'
21
- $node = $args[0]
22
- $capture = $args[1]
22
+ try {
23
+ $node = $args[0]
24
+ $capture = $args[1]
23
25
 
24
- $payload = @($input) -join "`n"
25
- if ([string]::IsNullOrEmpty($payload)) {
26
- $payload = [Console]::In.ReadToEnd()
27
- }
26
+ $payload = @($input) -join "`n"
27
+ if ([string]::IsNullOrEmpty($payload)) {
28
+ $payload = [Console]::In.ReadToEnd()
29
+ }
28
30
 
29
- $psi = New-Object System.Diagnostics.ProcessStartInfo
30
- $psi.FileName = $node
31
- $psi.Arguments = '"' + $capture + '"'
32
- $psi.UseShellExecute = $false
33
- $psi.CreateNoWindow = $true
34
- $psi.RedirectStandardInput = $true
31
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
32
+ $psi.FileName = $node
33
+ $psi.Arguments = '"' + $capture + '"'
34
+ $psi.UseShellExecute = $false
35
+ $psi.CreateNoWindow = $true
36
+ $psi.RedirectStandardInput = $true
35
37
 
36
- $proc = [System.Diagnostics.Process]::Start($psi)
37
- $proc.StandardInput.Write($payload)
38
- $proc.StandardInput.Close()
39
- $proc.WaitForExit()
38
+ $proc = [System.Diagnostics.Process]::Start($psi)
39
+ # Write the payload as raw UTF-8 bytes to node's stdin (capture.js reads UTF-8). Going
40
+ # through BaseStream avoids the StreamWriter's default codepage on Windows PowerShell
41
+ # 5.1 mangling non-ASCII content (e.g. Cyrillic prompts, accented paths).
42
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
43
+ $proc.StandardInput.BaseStream.Write($bytes, 0, $bytes.Length)
44
+ $proc.StandardInput.BaseStream.Flush()
45
+ $proc.StandardInput.Close()
46
+ $proc.WaitForExit()
47
+ } catch {
48
+ # Fail-open: never disrupt the session, never emit non-JSON to the hook's stdout.
49
+ exit 0
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.100",
3
+ "version": "0.8.103",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {