Cross-platform Claude Code hooks: skip .cmd, .sh, and .ps1 wrappers and invoke node directly so one .mjs file runs on macOS, Linux, and Windows across the team.
Problem: A hook you shipped from Windows via cmd /c or PowerShell lights up red the moment a Linux teammate opens the repo. The workaround most people land on is ugly. Three shim scripts per hook: a for Windows, a for Linux, a for PowerShell. All three do exactly the same thing, which is call the file that actually matters.
.cmd
.sh
.ps1
.mjs
Quick Win: Throw the wrappers away. Point the hook config at Node.js directly:
If nobody else touches your setup, none of this bites. Trouble begins the moment .claude/settings.json gets shared, the repo goes public, or you start alternating between a Windows tower and a macOS laptop. The second bash or powershell appears inside a command, half the team cannot run it.
Each of those wrappers is two lines of boilerplate around a node call. Three files, three platforms, same job on all of them. If the only layer that knows about the OS is the shim, you can delete the shim.
No cmd /c. No bash. No powershell. Just node. Every hook type takes the same shape, so PostToolUse, SessionStart/SessionEnd, Stop, and all 12 lifecycle events wire up identically.
Whichever command is not installed on the current machine stays silent. Listing both has no cost, and your hook picks whichever one actually exists without tripping a permission dialog. For deeper automation on this side, see the Permission Hook guide.
Got a hook that runs on one machine and fails on the next? Walk this list top to bottom:
Hardcoded path separators. Search every .mjs file for / or \\ appearing inside a path. Hand those cases to path.join().
Environment variable references. Look for process.env.HOME, process.env.USERPROFILE, or process.env.TEMP and swap each one out for os.homedir() or os.tmpdir().
Shell-specific commands in settings.json. Any command entry that mentions bash, cmd, powershell, or sh breaks everywhere except its home OS. Point it at node.
Fire the hook manually so the failure cannot hide:
echo '{"tool_name":"Write","tool_input":{"file_path":"test.js"}}' | node .claude/hooks/your-hook.mjsecho $? # Should output 0
A 0 on one OS and something else on the next points you at the .mjs file's path handling, never the hook entry in settings.
Yes. Call them through node rather than a shell that only lives on one OS, and they run the same on Windows, Linux, and macOS. Node.js is a hard requirement of Claude Code on every platform, so node is always on the $PATH. Put node .claude/hooks/your-hook.mjs in settings.json and you get identical behavior on all three.
Python works too, provided every teammate already has an interpreter on their machine. In the command field, reach for python3 rather than python, because certain Linux distros never ship a plain python. Node.js stays the safer pick: Claude Code promises it on every platform, Python it does not.
You mostly do not. readFileSync and writeFileSync already normalize the endings. Every hook reads JSON off stdin, and the JSON parser does not care whether the newlines are CRLF or LF. The exception is any hook that emits its own shell script. For that path, write \n and leave the rest to Git's autocrlf setting.