ai-lens 0.8.100 → 0.8.102

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
+ 3747fae
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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.102 — 2026-06-17
6
+ - 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.
7
+ - 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.
8
+
9
+ ## 0.8.101 — 2026-06-17
10
+ - 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
11
+
5
12
  ## 0.8.100 — 2026-06-17
6
13
  - 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
14
 
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);
@@ -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.102",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {