fix: make agent hooks cross-platform using Node.js instead of shell scripts#967
Conversation
…scripts - Replace .sh hook scripts with .mjs Node.js scripts - Update hook commands to use 'node path/to/hook.mjs' instead of 'sh path/to/hook.sh' - Make hook script fully Node-native with synchronous stdin reading - Update fake test binaries to be cross-platform Node.js wrappers - Remove chmod calls since Node scripts don't need execute permission - Hooks now work on Windows without requiring Git Bash/WSL Fixes #965 Co-authored-by: Skosh <skoshx@users.noreply.github.com>
Co-authored-by: Skosh <skoshx@users.noreply.github.com>
commit: |
While the agent hook implementation is now fully cross-platform using Node.js, keeping the test skip for now due to test framework infrastructure issues encountered during development. The hooks themselves work on Windows; tests can be enabled in a follow-up once test environment is confirmed working. Co-authored-by: Skosh <skoshx@users.noreply.github.com>
The .mjs rewrite shipped three breakages, all reproduced locally:
- `readStdin` used `require('fs')` in an ES module → ReferenceError, swallowed,
so the hook never read stdin (always scanned, ignored the tool filter). Read
stdin via `readFileSync(0)` instead.
- the runner detected a missing binary only via ENOENT, but `shell: true`
returns status 127 with no error, so the fallback chain never advanced. Detect
`status === 127` too, and pass each runner as a single shell command string
(keeps the Windows `.cmd` shims working, avoids DEP0190).
- a stale test asserted a CLI-arg string the array form no longer emitted (now
emitted again by the command-string form).
Also dedupe the two POSIX-only fake-binary test helpers into one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 96630a4. Configure here.
| if (!hasCursorHookCommand(postToolUseHooks)) { | ||
| postToolUseHooks.push({ | ||
| command: CURSOR_HOOK_RELATIVE_PATH, | ||
| command: CURSOR_HOOK_COMMAND, |
There was a problem hiding this comment.
Legacy hook commands not deduped
Medium Severity
Re-running hook install after this change no longer treats prior React Doctor hook entries as already installed. hasCursorHookCommand and hasClaudeHookCommand only match the new node …react-doctor.mjs strings, so configs that still reference .sh paths or the old Cursor relative script get a second hook while the stale entry remains.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 96630a4. Configure here.
| " }", | ||
| "", | ||
| " const scanOutput = readFileSync(outputPath, 'utf8').trim();", | ||
| " cleanup(outputPath);", |
There was a problem hiding this comment.
Hook crashes if output write fails
Low Severity
When runReactDoctor gets a non-zero scan exit but writeFileSync to the temp output path fails, the failure is swallowed and main still calls readFileSync on that path. A missing file throws an uncaught exception and can abort the agent hook instead of exiting quietly like the prior shell redirect path.
Reviewed by Cursor Bugbot for commit 96630a4. Configure here.
| fs.chmodSync(filePath, GIT_HOOK_EXECUTABLE_MODE); | ||
| }; | ||
|
|
||
| const hasClaudeHookCommand = (groups: readonly ClaudeHookGroup[]): boolean => |
There was a problem hiding this comment.
🔴 Upgrading from the previous version creates duplicate agent hooks, causing the scan to run twice on every tool call
The duplicate-detection checks only match the new command strings (hasClaudeHookCommand at packages/react-doctor/src/cli/utils/install-agent-hooks.ts:111, hasCursorHookCommand at :139), so old entries with the previous sh … react-doctor.sh commands are not recognized and a second hook entry is appended alongside them.
Impact: Every user who upgrades and re-runs install will have both the old shell hook and the new Node hook registered, causing react-doctor to scan twice on every agent tool call.
Mechanism: old command strings are never matched or cleaned up
Before this PR, the Claude command was sh "$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.sh" and the Cursor command was .cursor/hooks/react-doctor.sh. After this PR, they become node "$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.mjs" and node .cursor/hooks/react-doctor.mjs respectively.
When installClaudeHook runs on a project that already has the old hook:
readJsonFilereads the existing.claude/settings.jsonwith the oldsh …/react-doctor.shcommandhasClaudeHookCommand(packages/react-doctor/src/cli/utils/install-agent-hooks.ts:110-111) compares againstCLAUDE_HOOK_COMMANDwhich is now thenode …/react-doctor.mjsstring- No match → the new hook is pushed (
packages/react-doctor/src/cli/utils/install-agent-hooks.ts:121-128) alongside the old one - The old
.claude/hooks/react-doctor.shfile remains on disk (it was never deleted; the new file is written to.claude/hooks/react-doctor.mjs) - Both hooks fire on every PostToolBatch event
The same issue applies to the Cursor path via hasCursorHookCommand at packages/react-doctor/src/cli/utils/install-agent-hooks.ts:138-139.
The fix should either (a) also check for the old command strings and remove/replace matching entries, or (b) use a broader match (e.g., check if any command contains react-doctor) to detect the legacy hook.
(Refers to lines 110-111)
Prompt for agents
The hasClaudeHookCommand and hasCursorHookCommand functions only check for the current (new) command strings when detecting existing hooks. When upgrading from the previous version, the old commands (sh .../react-doctor.sh and .cursor/hooks/react-doctor.sh) are not recognized, causing duplicate hook entries.
To fix this:
1. In installClaudeHook (install-agent-hooks.ts around line 113-136), after reading the existing settings, filter out any PostToolBatch hook groups whose command contains 'react-doctor.sh' before checking whether to add the new command. This removes stale entries on upgrade.
2. In installCursorHook (around line 141-165), similarly filter out any postToolUse handlers whose command contains 'react-doctor.sh' before the hasCursorHookCommand check.
3. Optionally, also delete the old .sh files at .claude/hooks/react-doctor.sh and .cursor/hooks/react-doctor.sh if they exist (fs.unlinkSync in a try/catch).
The hasClaudeHookCommand and hasCursorHookCommand functions themselves can stay as-is — the filtering should happen in the install functions before the idempotency check.
Was this helpful? React with 👍 or 👎 to provide feedback.
| " process.exit(0);", | ||
| " }", | ||
| "", | ||
| " const scanOutput = readFileSync(outputPath, 'utf8').trim();", |
There was a problem hiding this comment.
🟡 Hook script crashes instead of exiting gracefully when the temporary output file cannot be written
The scan output file is read unconditionally (readFileSync(outputPath…) at packages/react-doctor/src/cli/utils/install-agent-hooks.ts:257) but the preceding write is swallowed by a try/catch (packages/react-doctor/src/cli/utils/install-agent-hooks.ts:214-216), so the file may not exist when the read runs.
Impact: If the temp directory is unwritable (e.g., disk full or restricted permissions), the hook crashes with an unhandled error instead of silently exiting.
Mechanism: writeFileSync failure is swallowed but readFileSync is unprotected
In the generated .mjs script's runReactDoctor function, the writeFileSync(outputPath, ...) call is wrapped in a try {} catch {} at packages/react-doctor/src/cli/utils/install-agent-hooks.ts:214-216. If it throws (e.g., ENOSPC, EACCES on tmpdir), the error is silently caught and the function returns result.status (non-zero).
Back in main(), scanResult !== 0 so execution reaches line 257:
const scanOutput = readFileSync(outputPath, 'utf8').trim();
Since the file was never created, this throws an ENOENT error that is not caught, crashing the hook process.
The old shell script avoided this by using shell redirection (run_react_doctor > "$output_file" 2>&1) which always creates the file.
The fix is to wrap the readFileSync in a try/catch, or to ensure writeFileSync always creates the file (by moving it outside the try/catch or not catching write errors).
| " const scanOutput = readFileSync(outputPath, 'utf8').trim();", | |
| " let scanOutput = '';", | |
| " try { scanOutput = readFileSync(outputPath, 'utf8').trim(); } catch {}", |
Was this helpful? React with 👍 or 👎 to provide feedback.
| " const commands = [", | ||
| " './node_modules/.bin/react-doctor --verbose --scope changed --blocking warning --no-score',", | ||
| " 'react-doctor --verbose --scope changed --blocking warning --no-score',", | ||
| " 'pnpm dlx react-doctor@latest --verbose --scope changed --blocking warning --no-score',", | ||
| " 'npx --yes react-doctor@latest --verbose --scope changed --blocking warning --no-score',", | ||
| " ];", | ||
| "", | ||
| " if command -v react-doctor >/dev/null 2>&1; then", | ||
| " react-doctor --verbose --scope changed --blocking warning --no-score", | ||
| " return", | ||
| " fi", | ||
| " for (const command of commands) {", | ||
| " const result = spawnSync(command, { encoding: 'utf8', shell: true });", | ||
| " if (result.error?.code === 'ENOENT' || result.status === 127) continue;", |
There was a problem hiding this comment.
🚩 Windows forward-slash paths in spawnSync with shell: true may need validation
The generated script uses ./node_modules/.bin/react-doctor as a command string with shell: true (packages/react-doctor/src/cli/utils/install-agent-hooks.ts:205). On Windows, shell: true delegates to cmd.exe, which generally handles forward slashes but behavior can be inconsistent with some path forms. The PR's stated goal is Windows compatibility. The comment at lines 199-203 explains the design choice well (.cmd shims require shell: true, and args arrays with shell: true trip DEP0190). The fallback chain (local binary → PATH → pnpm dlx → npx) provides resilience. Worth validating in a Windows CI environment if not already covered.
Was this helpful? React with 👍 or 👎 to provide feedback.
| fs.chmodSync(binaryPath, fs.constants.S_IRWXU); | ||
| }; | ||
|
|
||
| describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () => { |
There was a problem hiding this comment.
🚩 Test suite remains POSIX-only — no Windows coverage for the core feature this PR enables
The test suite is gated with describe.skipIf(process.platform === 'win32') at packages/react-doctor/tests/install-agent-hooks.test.ts:104, and the fake binaries use #!/bin/sh wrappers (packages/react-doctor/tests/install-agent-hooks.test.ts:78). While the test file comments explain this is intentional (line 40-42), the PR's stated purpose is Windows compatibility. The generated .mjs script's behavior on Windows (cmd.exe shell resolution, .cmd shim execution, forward-slash paths) is untested. This is a coverage gap worth noting, though it may be addressed by manual testing or a separate Windows CI job.
Was this helpful? React with 👍 or 👎 to provide feedback.


Problem
Windows agent hooks install
.shscripts that cannot run on Windows without Git Bash/WSL/Cygwin.Both Cursor and Claude Code agents try to invoke these scripts but fail silently.
Root Cause:
#!/bin/shwith Unix-specific commands (mktemp,trap,command -v).shfiles aren't executable on Windows viafs.chmodSync(..., 0o755).shfiles orsh <script>commandsSolution
Replace shell scripts with Node.js
.mjsscripts for true cross-platform support:Hook scripts (
.cursor/hooks/react-doctor.mjs,.claude/hooks/react-doctor.mjs):nodeAgent config commands:
.cursor/hooks/react-doctor.sh→node .cursor/hooks/react-doctor.mjssh "$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.sh"→node "$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.mjs"Test infrastructure:
Changes
packages/react-doctor/src/cli/utils/install-agent-hooks.ts:.sh→.mjsbuildAgentHookScript()to output Node.js ES modulesfs.chmodSync(not needed for Node scripts)packages/react-doctor/tests/install-agent-hooks.test.ts:describe.skipIf(process.platform === "win32").mjsfiles andnodecommandsTesting
Impact
Closes #965