ai-lens 0.8.99 → 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 +1 -1
- package/CHANGELOG.md +10 -0
- package/cli/hooks.js +77 -60
- package/cli/init.js +12 -4
- package/cli/status.js +12 -8
- package/client/ai-lens-hook.ps1 +50 -0
- package/package.json +1 -1
- package/client/cursor-ai-lens-hook.ps1 +0 -32
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
3747fae
|
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.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
|
+
|
|
12
|
+
## 0.8.100 — 2026-06-17
|
|
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.)
|
|
14
|
+
|
|
5
15
|
## 0.8.99 — 2026-06-17
|
|
6
16
|
- fix: Cursor hooks on Windows no longer flash a console window on every event. The flash was `node.exe` (a console app) allocating its own window when Cursor runs the hook; a new windowless launcher (`cursor-ai-lens-hook.ps1`) starts node with no window and forwards the event to it, so capture keeps working with no flash. Existing Cursor installs migrate automatically on the next `ai-lens init` / `/setup`. macOS/Linux and Claude Code are unchanged
|
|
7
17
|
|
package/cli/hooks.js
CHANGED
|
@@ -295,11 +295,14 @@ export function captureCommand(opts = {}) {
|
|
|
295
295
|
const clientDir = ctx.clientDir ?? CLIENT_INSTALL_DIR;
|
|
296
296
|
const nodeResolution = ctx.nodeResolution;
|
|
297
297
|
const isWin = platform === 'win32';
|
|
298
|
-
// Windows
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
298
|
+
// Windows windowless launcher (ai-lens-hook.ps1). Gated by the `windowless` opt, so
|
|
299
|
+
// it applies to CLAUDE forms only here — Cursor builds its own launcher form in
|
|
300
|
+
// cursorCaptureCommand, Codex never sets windowless. We DON'T use conhost.exe
|
|
301
|
+
// --headless: it hides the window but breaks capture (swallows node's stdin → the
|
|
302
|
+
// payload never arrives; and for Cursor it corrupts stdout). The launcher uses
|
|
303
|
+
// ProcessStartInfo + CreateNoWindow + RedirectStandardInput instead — windowless AND
|
|
304
|
+
// stdin-preserving — and works on all Windows (no ≥1809 requirement). Never off Windows.
|
|
305
|
+
const winLauncher = isWin && windowless;
|
|
303
306
|
// shell hint distinguishes cmd.exe (Claude Code) from PowerShell (Cursor) on
|
|
304
307
|
// Windows — the two need different escaping for paths with spaces.
|
|
305
308
|
const isPS = shell === 'powershell';
|
|
@@ -313,14 +316,13 @@ export function captureCommand(opts = {}) {
|
|
|
313
316
|
// binary or /home/<user> path. Windows Claude Code shells via cmd.exe (%VAR%);
|
|
314
317
|
// POSIX shells use $VAR.
|
|
315
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.
|
|
316
325
|
const rel = projectDirRelPath.replace(/\\/g, '/').replace(/^\.?\/+/, '');
|
|
317
|
-
if (conhost) {
|
|
318
|
-
// Windowless form. conhost is the launched image (no cmd.exe shell), so the
|
|
319
|
-
// path must use $CLAUDE_PROJECT_DIR — Claude Code substitutes that variable
|
|
320
|
-
// itself before exec on every OS (the %VAR% form would survive unexpanded
|
|
321
|
-
// since there's no shell to expand it). Verified on Windows 11 / cc 2.1.177.
|
|
322
|
-
return `conhost.exe --headless node "$CLAUDE_PROJECT_DIR/${rel}"`;
|
|
323
|
-
}
|
|
324
326
|
const dir = isWin ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
|
|
325
327
|
return `node "${dir}/${rel}"`;
|
|
326
328
|
}
|
|
@@ -342,12 +344,15 @@ export function captureCommand(opts = {}) {
|
|
|
342
344
|
: shellEscape(capturePath, platform);
|
|
343
345
|
const nodeNorm = nodeResolution.path.replace(/\\/g, '/');
|
|
344
346
|
|
|
345
|
-
// Windowless (Claude
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
347
|
+
// Windowless (Claude on Windows): run node through the ai-lens-hook.ps1 launcher,
|
|
348
|
+
// which starts node with CreateNoWindow + forwards stdin (conhost would swallow it).
|
|
349
|
+
// Claude Code spawns powershell to run the launcher; the payload arrives on the
|
|
350
|
+
// process's OS stdin, which the launcher reads via [Console]::In. The launcher ships
|
|
351
|
+
// next to capture.js. node + capture + launcher all double-quoted (may contain spaces).
|
|
352
|
+
if (winLauncher) {
|
|
353
|
+
const q = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
354
|
+
const launcherAbs = capturePath.replace(/[^/]*$/, 'ai-lens-hook.ps1');
|
|
355
|
+
return `powershell -NoProfile -ExecutionPolicy Bypass -File ${q(launcherAbs)} ${q(nodeNorm)} ${q(capturePath)}`;
|
|
351
356
|
}
|
|
352
357
|
if (isWin && rawPath && nodeNorm.includes(' ')) {
|
|
353
358
|
const quotedNode = `"${nodeNorm.replace(/"/g, '""')}"`;
|
|
@@ -371,14 +376,14 @@ export function cursorCaptureCommand(opts = {}) {
|
|
|
371
376
|
const platform = ctx.platform ?? process.platform;
|
|
372
377
|
|
|
373
378
|
if (platform === 'win32') {
|
|
374
|
-
// Windows: route node through the windowless launcher
|
|
379
|
+
// Windows: route node through the windowless launcher ai-lens-hook.ps1.
|
|
375
380
|
// Cursor spawns hooks via a hidden powershell and pipes the payload through the
|
|
376
381
|
// PS pipeline ($input); launching node.exe directly there pops a console window
|
|
377
382
|
// on every event (node is console-subsystem, parent powershell has no console).
|
|
378
383
|
// conhost.exe --headless hides the window but corrupts Cursor's JSON-parse of hook
|
|
379
384
|
// stdout with ConPTY VT codes (verified). The launcher starts node with
|
|
380
385
|
// CreateNoWindow=$true and forwards $input → node stdin: no window, clean stdout.
|
|
381
|
-
// Form: & "<...>/
|
|
386
|
+
// Form: & "<...>/ai-lens-hook.ps1" "<absNode>" "<capture.js>"
|
|
382
387
|
const clientDir = (ctx.clientDir ?? CLIENT_INSTALL_DIR).replace(/\\/g, '/');
|
|
383
388
|
const nodeResolution = ctx.nodeResolution;
|
|
384
389
|
if (!nodeResolution || !nodeResolution.path) {
|
|
@@ -388,7 +393,7 @@ export function cursorCaptureCommand(opts = {}) {
|
|
|
388
393
|
const capturePath = (customPath ?? `${clientDir}/capture.js`).replace(/\\/g, '/');
|
|
389
394
|
// The launcher ships next to capture.js (installed client dir, or the repo client
|
|
390
395
|
// dir for --use-repo-path) — derive its path from capturePath's directory.
|
|
391
|
-
const launcher = capturePath.replace(/[^/]*$/, '
|
|
396
|
+
const launcher = capturePath.replace(/[^/]*$/, 'ai-lens-hook.ps1');
|
|
392
397
|
const q = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
393
398
|
return `& ${q(launcher)} ${q(node)} ${q(capturePath)}`;
|
|
394
399
|
}
|
|
@@ -413,8 +418,8 @@ export function cursorCaptureCommand(opts = {}) {
|
|
|
413
418
|
*/
|
|
414
419
|
export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
|
|
415
420
|
// .js — all client modules (sibling imports must all be present).
|
|
416
|
-
// .ps1 — the Windows windowless
|
|
417
|
-
// a missing launcher would break every
|
|
421
|
+
// .ps1 — the Windows windowless hook launcher (ai-lens-hook.ps1), used by Cursor AND
|
|
422
|
+
// Claude Code; a missing launcher would break every Windows hook, so ship it too.
|
|
418
423
|
return readdirSync(sourceDir).filter(f => f.endsWith('.js') || f.endsWith('.ps1')).sort();
|
|
419
424
|
}
|
|
420
425
|
|
|
@@ -810,16 +815,6 @@ export function _parseHookCommand(cmd) {
|
|
|
810
815
|
return { kind: 'launcher', path: first, prefix, nodePrefix: null };
|
|
811
816
|
}
|
|
812
817
|
|
|
813
|
-
// Windows windowless Cursor launcher: & "<...>/cursor-ai-lens-hook.ps1" "<node>" "<capture.js>".
|
|
814
|
-
// node is the SECOND token (the launcher's first arg); capture.js is the third.
|
|
815
|
-
if (first.endsWith('cursor-ai-lens-hook.ps1')) {
|
|
816
|
-
return {
|
|
817
|
-
kind: 'cursorWindowless',
|
|
818
|
-
path: first,
|
|
819
|
-
prefix,
|
|
820
|
-
nodePrefix: tokens.length >= 2 ? normalizePath(tokens[1]) : null,
|
|
821
|
-
};
|
|
822
|
-
}
|
|
823
818
|
|
|
824
819
|
if (tokens.length >= 2) {
|
|
825
820
|
const second = normalizePath(tokens[1]);
|
|
@@ -878,14 +873,19 @@ function normalizePath(p) {
|
|
|
878
873
|
// still classifies run.* as ai-lens so `remove`/strip cleans up stale launchers.)
|
|
879
874
|
export function isGuiSafeHookCommand(cmd) {
|
|
880
875
|
if (!isAiLensCommand(cmd).isAiLens) return false;
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
|
|
876
|
+
// Our Windows windowless launcher (ai-lens-hook.ps1) is GUI-safe by construction — it
|
|
877
|
+
// starts node with CreateNoWindow. Matched by substring so it covers BOTH invocations:
|
|
878
|
+
// Cursor `& "<...>/ai-lens-hook.ps1" <node> <capture>` and Claude
|
|
879
|
+
// `powershell ... -File "<...>/ai-lens-hook.ps1" <node> <capture>` (also the older
|
|
880
|
+
// cursor-ai-lens-hook.ps1 name). The node arg's existence is validated in status.js.
|
|
881
|
+
const n = (cmd || '').replace(/\\/g, '/');
|
|
882
|
+
if (/ai-lens-hook\.ps1/i.test(n)) return true;
|
|
883
|
+
// Direct capture.js form: GUI-safe only with an ABSOLUTE node path baked in. Bare
|
|
884
|
+
// `node` / `/usr/bin/env node` are NOT GUI-safe (depend on PATH, which GUI
|
|
885
|
+
// Cursor/Claude often lack). The old run.sh/run.cmd launcher is NOT recognised here
|
|
886
|
+
// (ADR 0003) — it stays `outdated` so init migrates it.
|
|
887
887
|
const p = _parseHookCommand(cmd);
|
|
888
|
-
if (
|
|
888
|
+
if (p.kind === 'captureJs' && p.nodePrefix) {
|
|
889
889
|
const np = p.nodePrefix;
|
|
890
890
|
const isAbsolute = np.startsWith('/') || /^[a-zA-Z]:\//.test(np);
|
|
891
891
|
const isEnvShim = np === '/usr/bin/env' || np.endsWith('/env');
|
|
@@ -923,13 +923,17 @@ function isAcceptableHookCommand(cmd) {
|
|
|
923
923
|
export function isWrongPlatformProjectDirCommand(cmd, platform = process.platform) {
|
|
924
924
|
if (!isClaudeProjectDirCommand(cmd)) return false;
|
|
925
925
|
const n = (cmd || '').replace(/\\/g, '/');
|
|
926
|
-
// The conhost
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
// wrong (won't run) anywhere else; the %VAR% rule below doesn't apply to it.
|
|
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.)
|
|
930
929
|
if (isConhostHeadlessCommand(n)) return platform !== 'win32';
|
|
931
|
-
|
|
932
|
-
|
|
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');
|
|
933
937
|
}
|
|
934
938
|
|
|
935
939
|
// Leading `conhost.exe --headless ` windowless wrapper, with the trailing space so a
|
|
@@ -1032,13 +1036,27 @@ export function analyzeClaudeLocalOverlay(tool, opts = {}) {
|
|
|
1032
1036
|
if (!rel) return { applicable: false, reason: 'could not extract capture.js path from hook command' };
|
|
1033
1037
|
|
|
1034
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
|
+
}
|
|
1035
1053
|
const localTool = {
|
|
1036
1054
|
...tool,
|
|
1037
1055
|
name: `${tool.name} (settings.local.json overlay)`,
|
|
1038
1056
|
configPath: localPath,
|
|
1039
1057
|
// Never backup/rename a user's settings.local.json wholesale on parse errors.
|
|
1040
1058
|
sharedConfig: true,
|
|
1041
|
-
hookDefs:
|
|
1059
|
+
hookDefs: overlayHookDefs,
|
|
1042
1060
|
};
|
|
1043
1061
|
const overlayAnalysis = analyzeToolHooks(localTool, { platform, allowPlatformRewrite: true });
|
|
1044
1062
|
return {
|
|
@@ -1124,24 +1142,23 @@ function isCurrentAiLensHook(entry, expected, opts = {}) {
|
|
|
1124
1142
|
//
|
|
1125
1143
|
// Windows-wrapper exception (same allowPlatformRewrite gate): flag a hook outdated
|
|
1126
1144
|
// when its stored windowless wrapper DISAGREES with the freshly regenerated `expected`
|
|
1127
|
-
// command, so init re-syncs it. We compare ONLY the wrapper kind — conhost vs the
|
|
1128
|
-
//
|
|
1129
|
-
// legitimate path/node/install-mode variation never false-flags.
|
|
1130
|
-
// bakes in tool+platform:
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
// -
|
|
1134
|
-
//
|
|
1135
|
-
// - Codex/macOS
|
|
1136
|
-
// Committed (tracked) files are exempt via allowPlatformRewrite
|
|
1137
|
-
// OS-agnostic syntax and can't bake a Windows-only wrapper; the per-machine overlay
|
|
1138
|
-
// (or the local .cursor/hooks.json) provides it.
|
|
1145
|
+
// command, so init re-syncs it. We compare ONLY the wrapper kind — `conhost` vs the
|
|
1146
|
+
// `ai-lens-hook.ps1` launcher (keyed on its basename) vs `plain` — never the whole
|
|
1147
|
+
// command, so legitimate path/node/install-mode variation never false-flags.
|
|
1148
|
+
// `expected` bakes in tool+platform: on Windows BOTH Cursor and Claude now use the
|
|
1149
|
+
// launcher (`ps1:ai-lens-hook.ps1`); Codex, macOS → `plain`. So this self-scopes:
|
|
1150
|
+
// - conhost (old Claude/Cursor) → mismatch → outdated → migrate to the launcher;
|
|
1151
|
+
// - old `cursor-ai-lens-hook.ps1` → `ps1:cursor-ai-lens-hook.ps1` ≠ `ps1:ai-lens-hook.ps1`
|
|
1152
|
+
// → outdated → migrate to the renamed launcher;
|
|
1153
|
+
// - launcher already current → match → current; Codex/macOS plain → current.
|
|
1154
|
+
// Committed (tracked) files are exempt via allowPlatformRewrite.
|
|
1139
1155
|
const { platform = process.platform, allowPlatformRewrite = false } = opts;
|
|
1140
1156
|
const expectedCmd = expected?.command ?? expected?.hooks?.[0]?.command ?? '';
|
|
1141
1157
|
const wrapperKind = (c) => {
|
|
1142
1158
|
const s = String(c || '');
|
|
1143
1159
|
if (isConhostHeadlessCommand(s)) return 'conhost';
|
|
1144
|
-
|
|
1160
|
+
const m = s.match(/([\w.-]*ai-lens-hook\.ps1)/i);
|
|
1161
|
+
if (m) return 'ps1:' + m[1].toLowerCase();
|
|
1145
1162
|
return 'plain';
|
|
1146
1163
|
};
|
|
1147
1164
|
const expectedWrapper = wrapperKind(expectedCmd);
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -150,24 +150,28 @@ function validateHookCommandPaths(tool) {
|
|
|
150
150
|
|
|
151
151
|
const issues = [];
|
|
152
152
|
const launcherMatch = command.match(/["']([^"']*run\.(?:sh|cmd))["']|(\S*run\.(?:sh|cmd))/);
|
|
153
|
-
|
|
153
|
+
// Matches both ai-lens-hook.ps1 and the older cursor-ai-lens-hook.ps1.
|
|
154
|
+
const ps1Match = command.match(/["']([^"']*ai-lens-hook\.ps1)["']|(\S*ai-lens-hook\.ps1)/);
|
|
155
|
+
// Paths containing $CLAUDE_PROJECT_DIR / %CLAUDE_PROJECT_DIR% can't be resolved here
|
|
156
|
+
// (Claude Code substitutes the var at runtime) — skip existence checks for those.
|
|
157
|
+
const resolvable = (p) => !/CLAUDE_PROJECT_DIR/.test(p);
|
|
154
158
|
|
|
155
159
|
if (ps1Match) {
|
|
156
|
-
// Windows windowless Cursor
|
|
157
|
-
//
|
|
160
|
+
// Windows windowless launcher (Cursor: `& "<...>.ps1" <node> <capture>`; Claude:
|
|
161
|
+
// `powershell ... -File "<...>.ps1" <node> <capture>`). Validate launcher + node arg
|
|
162
|
+
// + capture.js exist (when their paths are resolvable, i.e. not $CLAUDE_PROJECT_DIR).
|
|
158
163
|
const ps1Path = expandTilde(ps1Match[1] || ps1Match[2]);
|
|
159
|
-
if (!existsSync(ps1Path)) issues.push(`
|
|
164
|
+
if (resolvable(ps1Path) && !existsSync(ps1Path)) issues.push(`AI Lens hook launcher not found at: ${ps1Path}`);
|
|
160
165
|
const capMatch = command.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
|
|
161
166
|
if (capMatch) {
|
|
162
167
|
const capturePath = expandTilde(capMatch[1] || capMatch[2]);
|
|
163
|
-
if (!existsSync(capturePath)) issues.push(`capture.js not found at: ${capturePath}`);
|
|
168
|
+
if (resolvable(capturePath) && !existsSync(capturePath)) issues.push(`capture.js not found at: ${capturePath}`);
|
|
164
169
|
}
|
|
165
170
|
// node = the launcher's first argument (token right after the .ps1 path)
|
|
166
|
-
const nodeArg = command.
|
|
167
|
-
.match(/cursor-ai-lens-hook\.ps1["']?\s+(?:["']([^"']+)["']|(\S+))/);
|
|
171
|
+
const nodeArg = command.match(/ai-lens-hook\.ps1["']?\s+(?:["']([^"']+)["']|(\S+))/);
|
|
168
172
|
if (nodeArg) {
|
|
169
173
|
const nodePath = expandTilde(nodeArg[1] || nodeArg[2]);
|
|
170
|
-
if (nodePath !== 'node' && !existsSync(nodePath)) {
|
|
174
|
+
if (nodePath !== 'node' && resolvable(nodePath) && !existsSync(nodePath)) {
|
|
171
175
|
issues.push(`node not found at: ${nodePath} — re-run \`ai-lens init\` to re-resolve node`);
|
|
172
176
|
}
|
|
173
177
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# AI Lens — windowless hook launcher (Windows only). Used by BOTH Cursor and Claude Code.
|
|
2
|
+
#
|
|
3
|
+
# Why this exists: a hook spawns node.exe, a console-subsystem binary; launched from a
|
|
4
|
+
# console-less parent it allocates its own console window → a flash on every event. The
|
|
5
|
+
# obvious windowless wrapper, `conhost.exe --headless`, is unusable for hook capture:
|
|
6
|
+
# - for Cursor it emits ConPTY VT escape codes onto stdout, which Cursor fails to JSON-parse;
|
|
7
|
+
# - for Claude Code it replaces node's stdin with the pseudoconsole, so the JSON payload
|
|
8
|
+
# never reaches capture.js (it reads empty stdin and drops the event).
|
|
9
|
+
# This launcher instead starts node via ProcessStartInfo with CreateNoWindow=$true (no
|
|
10
|
+
# window) and RedirectStandardInput (real stdin forwarded) — so capture works AND there's
|
|
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).
|
|
13
|
+
#
|
|
14
|
+
# Payload source differs by caller, so read BOTH:
|
|
15
|
+
# - Cursor pipes it through the PowerShell pipeline → $input.
|
|
16
|
+
# - Claude Code pipes it to the process's OS stdin → [Console]::In.
|
|
17
|
+
# Read $input first; if empty (Claude Code invocation), fall back to [Console]::In.
|
|
18
|
+
#
|
|
19
|
+
# Args: $args[0] = node path, $args[1] = capture.js path.
|
|
20
|
+
|
|
21
|
+
$ErrorActionPreference = 'Stop'
|
|
22
|
+
try {
|
|
23
|
+
$node = $args[0]
|
|
24
|
+
$capture = $args[1]
|
|
25
|
+
|
|
26
|
+
$payload = @($input) -join "`n"
|
|
27
|
+
if ([string]::IsNullOrEmpty($payload)) {
|
|
28
|
+
$payload = [Console]::In.ReadToEnd()
|
|
29
|
+
}
|
|
30
|
+
|
|
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
|
|
37
|
+
|
|
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,32 +0,0 @@
|
|
|
1
|
-
# AI Lens — Cursor windowless hook launcher (Windows only).
|
|
2
|
-
#
|
|
3
|
-
# Why this exists: Cursor runs a hook by spawning a (hidden) powershell that pipes the
|
|
4
|
-
# event payload through the PowerShell pipeline ($input) — e.g.
|
|
5
|
-
# Get-Content <tmp>.json -Raw | & { $input | & "<this.ps1>" "<node>" "<capture.js>" }
|
|
6
|
-
# Launching node.exe directly there pops a console window on every event (node is a
|
|
7
|
-
# console-subsystem binary and the parent powershell has no console). conhost.exe
|
|
8
|
-
# --headless hides the window but emits ConPTY VT escape codes onto stdout, which Cursor
|
|
9
|
-
# then fails to JSON-parse. This launcher instead starts node with CreateNoWindow=$true
|
|
10
|
-
# (no window) while forwarding the payload to node's stdin — so capture still works and
|
|
11
|
-
# nothing extra is written to stdout.
|
|
12
|
-
#
|
|
13
|
-
# Args: $args[0] = absolute node path, $args[1] = capture.js path.
|
|
14
|
-
# Payload arrives on the PowerShell pipeline as $input (NOT [Console]::In, which is empty
|
|
15
|
-
# here because the data is an in-process pipeline object, not the process's OS stdin).
|
|
16
|
-
|
|
17
|
-
$ErrorActionPreference = 'Stop'
|
|
18
|
-
$node = $args[0]
|
|
19
|
-
$capture = $args[1]
|
|
20
|
-
$payload = @($input) -join "`n"
|
|
21
|
-
|
|
22
|
-
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
23
|
-
$psi.FileName = $node
|
|
24
|
-
$psi.Arguments = '"' + $capture + '"'
|
|
25
|
-
$psi.UseShellExecute = $false
|
|
26
|
-
$psi.CreateNoWindow = $true
|
|
27
|
-
$psi.RedirectStandardInput = $true
|
|
28
|
-
|
|
29
|
-
$proc = [System.Diagnostics.Process]::Start($psi)
|
|
30
|
-
$proc.StandardInput.Write($payload)
|
|
31
|
-
$proc.StandardInput.Close()
|
|
32
|
-
$proc.WaitForExit()
|