Cross-Platform Hooks for Claude Code
Skip the .cmd, .sh, and .ps1 wrappers. Invoke node directly and the same hook file runs on every operating system your team uses.
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 .cmd for Windows, a .sh for Linux, a .ps1 for PowerShell. All three do exactly the same thing, which is call the .mjs file that actually matters.
Quick Win: Throw the wrappers away. Point the hook config at Node.js directly:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/formatter.mjs"
}
]
}
]
}
}Runs on Windows, Linux, and macOS without changes. node is always on the $PATH because Claude Code lists Node.js as a hard requirement.
Why Hooks Break When the OS Changes
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.
Most tutorials go platform-specific anyway:
// Windows-only
"command": "cmd /c \".claude\\hooks\\formatter.cmd\""
// Linux-only
"command": "bash .claude/hooks/formatter.sh"
// PowerShell-only
"command": "powershell -NoProfile -File .claude/hooks/statusline.ps1"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.
The Universal Pattern in settings.json
Hooks inside settings.json share one shape:
{
"statusLine": {
"type": "command",
"command": "node .claude/hooks/statusline-monitor.mjs"
},
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/skill-activation.mjs"
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/backup.mjs",
"async": true
}
]
}
]
}
}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.
Three Rules for Cross-Platform Hook Logic
Inside each .mjs file, three habits keep the logic portable no matter which OS ends up running it.
Use os.homedir() Instead of Platform Variables
import { homedir } from "os";
import { join } from "path";
// Cross-platform: resolves to C:\Users\you or /home/you
const settingsPath = join(homedir(), ".claude", "settings.json");Keep $HOME, $env:USERPROFILE, and %USERPROFILE% out of the source. Let the helper pick the right one.
Use os.tmpdir() for Temporary File Paths
import { tmpdir } from "os";
import { join } from "path";
// Cross-platform: resolves to C:\Users\you\AppData\Local\Temp or /tmp
const cacheFile = join(tmpdir(), "my-hook-cache.json");Do not reach for /tmp or $env:TEMP by hand.
Use path.join() for All File Path Construction
import { join } from "path";
// Cross-platform path construction
const logFile = join(".claude", "hooks", "logs", "hook.log");Stop gluing paths together with / or \\ by hand. Node.js already knows the right separator for whichever OS the hook lands on.
Permissions That Cover Both Platforms
The permissions block in settings.json should hold the Windows name and the Unix name side by side:
{
"permissions": {
"allow": [
"Bash(where:*)",
"Bash(which:*)",
"Bash(tasklist:*)",
"Bash(ps:*)",
"Bash(taskkill:*)",
"Bash(kill:*)",
"Bash(findstr:*)",
"Bash(node:*)"
]
}
}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.
Complete Example: Cross-Platform File Logger
One full hook, portable as-is. The job is a file logger. Any Write or Edit that Claude performs gets appended to a log:
#!/usr/bin/env node
import { readFileSync, appendFileSync, mkdirSync } from "fs";
import { join } from "path";
const logDir = join(".claude", "hooks", "logs");
mkdirSync(logDir, { recursive: true });
try {
const input = JSON.parse(readFileSync(0, "utf-8"));
const toolName = input.tool_name;
const filePath = input.tool_input?.file_path || "unknown";
const timestamp = new Date().toISOString();
appendFileSync(
join(logDir, "file-changes.log"),
`${timestamp} | ${toolName} | ${filePath}\n`,
);
} catch {
// Silent fail -- don't block Claude
}
process.exit(0);Register it in your settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/file-logger.mjs"
}
]
}
]
}
}Same behavior on Windows 11, Arch Linux, and macOS Sequoia. Zero wrappers.
Debugging When One OS Breaks
Got a hook that runs on one machine and fails on the next? Walk this list top to bottom:
- Hardcoded path separators. Search every
.mjsfile for/or\\appearing inside a path. Hand those cases topath.join(). - Environment variable references. Look for
process.env.HOME,process.env.USERPROFILE, orprocess.env.TEMPand swap each one out foros.homedir()oros.tmpdir(). - Shell-specific commands in settings.json. Any
commandentry that mentionsbash,cmd,powershell, orshbreaks everywhere except its home OS. Point it atnode.
Fire the hook manually so the failure cannot hide:
echo '{"tool_name":"Write","tool_input":{"file_path":"test.js"}}' | node .claude/hooks/your-hook.mjs
echo $? # Should output 0A 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.
Release Checklist
Run through this list before a team rollout or a public release:
- Every
commandinsettings.jsonpoints atnode, notcmd,powershell, orbash - Home directories come from
os.homedir(), never$HOMEor%USERPROFILE% - Temp paths come from
os.tmpdir(), never/tmpor$env:TEMP - Paths get built with
path.join(), not hand-typed separators - Permissions list both the Windows and the Unix equivalents
- The
statusLinecommand resolves tonode, notpowershell
One file. Three platforms. No upkeep.
Do Claude Code hooks work on Windows?
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.
Can I use Python instead of Node.js for hooks?
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.
How do I handle line endings across platforms?
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.
Stop configuring. Start building.