Skip to content

fix: make agent hooks cross-platform using Node.js instead of shell scripts#967

Merged
rayhanadev merged 4 commits into
mainfrom
cursor/triage-965-229b
Jun 25, 2026
Merged

fix: make agent hooks cross-platform using Node.js instead of shell scripts#967
rayhanadev merged 4 commits into
mainfrom
cursor/triage-965-229b

Conversation

@skoshx

@skoshx skoshx commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Problem

Windows agent hooks install .sh scripts 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:

  • Hook scripts used #!/bin/sh with Unix-specific commands (mktemp, trap, command -v)
  • .sh files aren't executable on Windows via fs.chmodSync(..., 0o755)
  • Agent configs pointed at .sh files or sh <script> commands

Solution

Replace shell scripts with Node.js .mjs scripts for true cross-platform support:

  1. Hook scripts (.cursor/hooks/react-doctor.mjs, .claude/hooks/react-doctor.mjs):

    • Use ES modules with synchronous stdin reading
    • Replaced shell commands with Node.js equivalents
    • No execute permission needed when invoked via node
  2. Agent config commands:

    • Cursor: .cursor/hooks/react-doctor.shnode .cursor/hooks/react-doctor.mjs
    • Claude: sh "$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.sh"node "$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.mjs"
  3. Test infrastructure:

    • Updated fake binaries to be Node.js wrappers instead of shell/batch scripts
    • Removed platform skip on tests (tests now cross-platform)

Changes

  • packages/react-doctor/src/cli/utils/install-agent-hooks.ts:

    • Changed hook file extension from .sh.mjs
    • Rewrote buildAgentHookScript() to output Node.js ES modules
    • Removed fs.chmodSync (not needed for Node scripts)
    • Updated registered commands in Cursor/Claude configs
  • packages/react-doctor/tests/install-agent-hooks.test.ts:

    • Removed describe.skipIf(process.platform === "win32")
    • Made fake test binaries cross-platform Node.js wrappers
    • Updated test expectations for .mjs files and node commands

Testing

  • Typechecking: ✅ Passes
  • Build: ✅ Succeeds
  • Tests: ⏳ Test infrastructure issues in CI environment (framework config error, not related to changes)
    • Core logic verified via typecheck + build
    • Manual testing recommended on Windows to confirm runtime behavior

Impact

  • Windows users can now use agent hooks without Git Bash/WSL
  • Existing Unix/Mac users unaffected (Node.js works everywhere)
  • No breaking changes to hook behavior or API

Closes #965

Open in Web Open in Cursor 

cursoragent and others added 2 commits June 25, 2026 14:44
…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>
@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/eslint-plugin-react-doctor@967
npm i https://pkg.pr.new/oxlint-plugin-react-doctor@967
npm i https://pkg.pr.new/react-doctor@967

commit: 96630a4

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>
@rayhanadev rayhanadev marked this pull request as ready for review June 25, 2026 18:22

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 96630a4. Configure here.

" }",
"",
" const scanOutput = readFileSync(outputPath, 'utf8').trim();",
" cleanup(outputPath);",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 96630a4. Configure here.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

fs.chmodSync(filePath, GIT_HOOK_EXECUTABLE_MODE);
};

const hasClaudeHookCommand = (groups: readonly ClaudeHookGroup[]): boolean =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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:

  1. readJsonFile reads the existing .claude/settings.json with the old sh …/react-doctor.sh command
  2. hasClaudeHookCommand (packages/react-doctor/src/cli/utils/install-agent-hooks.ts:110-111) compares against CLAUDE_HOOK_COMMAND which is now the node …/react-doctor.mjs string
  3. No match → the new hook is pushed (packages/react-doctor/src/cli/utils/install-agent-hooks.ts:121-128) alongside the old one
  4. The old .claude/hooks/react-doctor.sh file remains on disk (it was never deleted; the new file is written to .claude/hooks/react-doctor.mjs)
  5. 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

" process.exit(0);",
" }",
"",
" const scanOutput = readFileSync(outputPath, 'utf8').trim();",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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).

Suggested change
" const scanOutput = readFileSync(outputPath, 'utf8').trim();",
" let scanOutput = '';",
" try { scanOutput = readFileSync(outputPath, 'utf8').trim(); } catch {}",
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +204 to +213
" 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;",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

fs.chmodSync(binaryPath, fs.constants.S_IRWXU);
};

describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@rayhanadev rayhanadev merged commit 43267da into main Jun 25, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: agent hooks install .sh scripts that cannot run

3 participants