ai-lens 0.8.98 → 0.8.99
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 +3 -0
- package/cli/hooks.js +73 -40
- package/cli/status.js +22 -2
- package/client/cursor-ai-lens-hook.ps1 +32 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
b23bf01
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
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.99 — 2026-06-17
|
|
6
|
+
- 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
|
+
|
|
5
8
|
## 0.8.98 — 2026-06-17
|
|
6
9
|
- fix: Cursor hooks on Windows no longer use the `conhost.exe --headless` wrapper — it made Cursor report a "JSON Parse Error" on every hook (conhost emits terminal escape codes that Cursor tries to parse as the hook's output) and broke capture. Cursor returns to the plain working form; machines that briefly got the conhost'd Cursor hook are auto-downgraded on the next `ai-lens init`/`/setup`. Claude Code keeps the windowless wrapper (it tolerates the output). The minor Cursor console flash on Windows is upstream Cursor behaviour
|
|
7
10
|
|
package/cli/hooks.js
CHANGED
|
@@ -369,24 +369,35 @@ export function cursorCaptureCommand(opts = {}) {
|
|
|
369
369
|
const { useTilde = false, customPath = null } = opts;
|
|
370
370
|
const ctx = opts.ctx ?? resolveDefaultCtx();
|
|
371
371
|
const platform = ctx.platform ?? process.platform;
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
372
|
+
|
|
373
|
+
if (platform === 'win32') {
|
|
374
|
+
// Windows: route node through the windowless launcher cursor-ai-lens-hook.ps1.
|
|
375
|
+
// Cursor spawns hooks via a hidden powershell and pipes the payload through the
|
|
376
|
+
// PS pipeline ($input); launching node.exe directly there pops a console window
|
|
377
|
+
// on every event (node is console-subsystem, parent powershell has no console).
|
|
378
|
+
// conhost.exe --headless hides the window but corrupts Cursor's JSON-parse of hook
|
|
379
|
+
// stdout with ConPTY VT codes (verified). The launcher starts node with
|
|
380
|
+
// CreateNoWindow=$true and forwards $input → node stdin: no window, clean stdout.
|
|
381
|
+
// Form: & "<...>/cursor-ai-lens-hook.ps1" "<absNode>" "<capture.js>"
|
|
382
|
+
const clientDir = (ctx.clientDir ?? CLIENT_INSTALL_DIR).replace(/\\/g, '/');
|
|
383
|
+
const nodeResolution = ctx.nodeResolution;
|
|
384
|
+
if (!nodeResolution || !nodeResolution.path) {
|
|
385
|
+
throw new Error('cursorCaptureCommand: nodeResolution is required');
|
|
386
|
+
}
|
|
387
|
+
const node = nodeResolution.path.replace(/\\/g, '/');
|
|
388
|
+
const capturePath = (customPath ?? `${clientDir}/capture.js`).replace(/\\/g, '/');
|
|
389
|
+
// The launcher ships next to capture.js (installed client dir, or the repo client
|
|
390
|
+
// dir for --use-repo-path) — derive its path from capturePath's directory.
|
|
391
|
+
const launcher = capturePath.replace(/[^/]*$/, 'cursor-ai-lens-hook.ps1');
|
|
392
|
+
const q = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
393
|
+
return `& ${q(launcher)} ${q(node)} ${q(capturePath)}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// macOS / Linux: direct node, unchanged. (No console-window concept; conhost/launcher
|
|
397
|
+
// are Windows-only.)
|
|
398
|
+
return customPath != null
|
|
399
|
+
? captureCommand({ rawPath: true, customPath, ctx })
|
|
400
|
+
: captureCommand({ useTilde, ctx });
|
|
390
401
|
}
|
|
391
402
|
|
|
392
403
|
// ---------------------------------------------------------------------------
|
|
@@ -401,7 +412,10 @@ export function cursorCaptureCommand(opts = {}) {
|
|
|
401
412
|
* every hook with ERR_MODULE_NOT_FOUND on a missing sibling import.
|
|
402
413
|
*/
|
|
403
414
|
export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
|
|
404
|
-
|
|
415
|
+
// .js — all client modules (sibling imports must all be present).
|
|
416
|
+
// .ps1 — the Windows windowless Cursor hook launcher (cursor-ai-lens-hook.ps1);
|
|
417
|
+
// a missing launcher would break every Cursor hook on Windows, so ship it too.
|
|
418
|
+
return readdirSync(sourceDir).filter(f => f.endsWith('.js') || f.endsWith('.ps1')).sort();
|
|
405
419
|
}
|
|
406
420
|
|
|
407
421
|
/**
|
|
@@ -796,6 +810,17 @@ export function _parseHookCommand(cmd) {
|
|
|
796
810
|
return { kind: 'launcher', path: first, prefix, nodePrefix: null };
|
|
797
811
|
}
|
|
798
812
|
|
|
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
|
+
|
|
799
824
|
if (tokens.length >= 2) {
|
|
800
825
|
const second = normalizePath(tokens[1]);
|
|
801
826
|
if (second.endsWith('capture.js')) {
|
|
@@ -853,10 +878,14 @@ function normalizePath(p) {
|
|
|
853
878
|
// still classifies run.* as ai-lens so `remove`/strip cleans up stale launchers.)
|
|
854
879
|
export function isGuiSafeHookCommand(cmd) {
|
|
855
880
|
if (!isAiLensCommand(cmd).isAiLens) return false;
|
|
856
|
-
//
|
|
857
|
-
// (
|
|
881
|
+
// GUI-safe when an ABSOLUTE node path is baked in — either the direct capture.js form
|
|
882
|
+
// (`<absNode> capture.js`) or the Windows windowless launcher form
|
|
883
|
+
// (`& "<...>/cursor-ai-lens-hook.ps1" "<absNode>" "<capture.js>"`), where node is the
|
|
884
|
+
// launcher's first arg. Bare `node` / `/usr/bin/env node` are NOT GUI-safe (depend on
|
|
885
|
+
// PATH, which GUI Cursor/Claude often lack). The old run.sh/run.cmd launcher is NOT
|
|
886
|
+
// recognised here (ADR 0003) — it stays `outdated` so init migrates it.
|
|
858
887
|
const p = _parseHookCommand(cmd);
|
|
859
|
-
if (p.kind === 'captureJs' && p.nodePrefix) {
|
|
888
|
+
if ((p.kind === 'captureJs' || p.kind === 'cursorWindowless') && p.nodePrefix) {
|
|
860
889
|
const np = p.nodePrefix;
|
|
861
890
|
const isAbsolute = np.startsWith('/') || /^[a-zA-Z]:\//.test(np);
|
|
862
891
|
const isEnvShim = np === '/usr/bin/env' || np.endsWith('/env');
|
|
@@ -1093,28 +1122,32 @@ function isCurrentAiLensHook(entry, expected, opts = {}) {
|
|
|
1093
1122
|
// $CLAUDE_PROJECT_DIR/%CLAUDE_PROJECT_DIR% hook written for the OTHER OS won't
|
|
1094
1123
|
// expand on this platform, so flag it outdated to let init rewrite it.
|
|
1095
1124
|
//
|
|
1096
|
-
//
|
|
1097
|
-
// its stored
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
// files are exempt via allowPlatformRewrite — they carry one OS-agnostic syntax
|
|
1111
|
-
// and can't bake a Windows-only wrapper; the per-machine overlay provides it.
|
|
1125
|
+
// Windows-wrapper exception (same allowPlatformRewrite gate): flag a hook outdated
|
|
1126
|
+
// 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
|
+
// Cursor `cursor-ai-lens-hook.ps1` launcher vs plain — never the whole command, so
|
|
1129
|
+
// legitimate path/node/install-mode variation never false-flags. `expected` already
|
|
1130
|
+
// bakes in tool+platform: Claude on conhost-capable Windows → `conhost`; Cursor on
|
|
1131
|
+
// Windows → `cursorPs1` (the windowless launcher; conhost corrupts Cursor's stdout
|
|
1132
|
+
// JSON-parse); Codex, macOS, and old Windows → `plain`. So this self-scopes per tool:
|
|
1133
|
+
// - Claude: stored plain → outdated (add conhost); stored conhost → current.
|
|
1134
|
+
// - Cursor: stored plain/conhost → outdated (migrate to the .ps1 launcher); ps1 → current.
|
|
1135
|
+
// - Codex/macOS: expected plain, stored plain → current (untouched).
|
|
1136
|
+
// Committed (tracked) files are exempt via allowPlatformRewrite — they carry one
|
|
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.
|
|
1112
1139
|
const { platform = process.platform, allowPlatformRewrite = false } = opts;
|
|
1113
1140
|
const expectedCmd = expected?.command ?? expected?.hooks?.[0]?.command ?? '';
|
|
1114
|
-
const
|
|
1141
|
+
const wrapperKind = (c) => {
|
|
1142
|
+
const s = String(c || '');
|
|
1143
|
+
if (isConhostHeadlessCommand(s)) return 'conhost';
|
|
1144
|
+
if (/cursor-ai-lens-hook\.ps1/i.test(s)) return 'cursorPs1';
|
|
1145
|
+
return 'plain';
|
|
1146
|
+
};
|
|
1147
|
+
const expectedWrapper = wrapperKind(expectedCmd);
|
|
1115
1148
|
const ok = (cmd) => isAcceptableHookCommand(cmd)
|
|
1116
1149
|
&& !(allowPlatformRewrite && isWrongPlatformProjectDirCommand(cmd, platform))
|
|
1117
|
-
&& !(allowPlatformRewrite &&
|
|
1150
|
+
&& !(allowPlatformRewrite && wrapperKind(cmd) !== expectedWrapper);
|
|
1118
1151
|
// Flat format (Cursor): single command per entry.
|
|
1119
1152
|
if (entry?.command != null) {
|
|
1120
1153
|
return ok(entry.command);
|
package/cli/status.js
CHANGED
|
@@ -150,8 +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
|
-
|
|
154
|
-
|
|
153
|
+
const ps1Match = command.match(/["']([^"']*cursor-ai-lens-hook\.ps1)["']|(\S*cursor-ai-lens-hook\.ps1)/);
|
|
154
|
+
|
|
155
|
+
if (ps1Match) {
|
|
156
|
+
// Windows windowless Cursor launcher: & "<...>/cursor-ai-lens-hook.ps1" "<node>" "<capture.js>".
|
|
157
|
+
// Validate the launcher, the node arg (2nd token), and capture.js (3rd token) all exist.
|
|
158
|
+
const ps1Path = expandTilde(ps1Match[1] || ps1Match[2]);
|
|
159
|
+
if (!existsSync(ps1Path)) issues.push(`Cursor hook launcher not found at: ${ps1Path}`);
|
|
160
|
+
const capMatch = command.match(/["']([^"']*capture\.js)["']|(\S*capture\.js)/);
|
|
161
|
+
if (capMatch) {
|
|
162
|
+
const capturePath = expandTilde(capMatch[1] || capMatch[2]);
|
|
163
|
+
if (!existsSync(capturePath)) issues.push(`capture.js not found at: ${capturePath}`);
|
|
164
|
+
}
|
|
165
|
+
// node = the launcher's first argument (token right after the .ps1 path)
|
|
166
|
+
const nodeArg = command.replace(/^& /, '')
|
|
167
|
+
.match(/cursor-ai-lens-hook\.ps1["']?\s+(?:["']([^"']+)["']|(\S+))/);
|
|
168
|
+
if (nodeArg) {
|
|
169
|
+
const nodePath = expandTilde(nodeArg[1] || nodeArg[2]);
|
|
170
|
+
if (nodePath !== 'node' && !existsSync(nodePath)) {
|
|
171
|
+
issues.push(`node not found at: ${nodePath} — re-run \`ai-lens init\` to re-resolve node`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} else if (launcherMatch) {
|
|
155
175
|
// Launcher form (0.8.68+): one path, no separate node validation — the node binary
|
|
156
176
|
// baked into the launcher is invoked from inside the script, not the hook command.
|
|
157
177
|
const launcherPath = expandTilde(launcherMatch[1] || launcherMatch[2]);
|
|
@@ -0,0 +1,32 @@
|
|
|
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()
|