5.8 KiB
Cross-Platform Polyglot Hooks for Claude Code
Claude Code plugins need hooks that work on Windows, macOS, and Linux. This document describes the single generic dispatcher pattern used in hooks/run-hook.cmd.
Authoritative source:
hooks/run-hook.cmdis the canonical implementation. When this document and the code diverge, trust the code.
The Problem
Claude Code runs hook commands through the system's default shell:
- Windows: CMD.exe
- macOS/Linux: bash or sh
This creates several challenges:
- Script execution: Windows CMD can't execute
.shfiles directly - Path format: Windows uses backslashes (
C:\path), Unix uses forward slashes (/path) - Environment variables:
$VARsyntax doesn't work in CMD .shauto-prepend: Claude Code on Windows automatically prependsbashto any command that contains.shin its path — this interferes with the dispatcher if scripts have extensions
The Solution: Extensionless Scripts + Single Generic Dispatcher
The repo uses one generic run-hook.cmd dispatcher for all hooks. Hook scripts are extensionless (session-start, not session-start.sh). This is deliberate: it prevents Claude Code's Windows auto-detection from prepending bash to the dispatcher command and breaking it.
File Structure
hooks/
├── hooks.json # Points to run-hook.cmd with extensionless script name
├── run-hook.cmd # Cross-platform dispatcher (the polyglot wrapper)
└── session-start # Actual hook logic — extensionless bash script
hooks.json
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|clear|compact",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
"async": false
}
]
}
]
}
}
The path is quoted because ${CLAUDE_PLUGIN_ROOT} may contain spaces.
How run-hook.cmd Works at a High Level
run-hook.cmd is a polyglot script: Windows treats the first block as batch
commands, while Unix shells treat that block as a no-op heredoc and continue
after it.
Do not copy an implementation from this document. Read hooks/run-hook.cmd
directly when changing the dispatcher, and run tests/hooks/test-session-start.sh
afterward.
How it works on Windows (CMD.exe)
- The batch section validates the script name and resolves the hook directory from the dispatcher's own location.
- It tries bash in three places:
C:\Program Files\Git\bin\bash.exeC:\Program Files (x86)\Git\bin\bash.exebashonPATH(MSYS2, Cygwin, or a non-default Git install)
- If bash is found, it runs the named extensionless hook script from the hooks directory.
- If no bash is found, the dispatcher exits
0silently — the plugin continues working, it just skips the hook. exit /bstops CMD before it reaches the Unix section.
How it works on Unix (bash/sh)
: << 'CMDBLOCK'opens a heredoc on a no-op command.- The entire CMD batch block is consumed by the heredoc and ignored.
- After
CMDBLOCK, bash resolves the script directory andexecs the named extensionless script directly.
Key design decisions
| Decision | Why |
|---|---|
| Extensionless scripts | Prevents Claude Code's Windows .sh-auto-prepend from interfering with the dispatcher command |
No -l (login shell) |
Not needed; hook scripts should be self-contained and not depend on login-shell PATH setup |
No cygpath |
Bash receives the Windows path directly and handles it correctly; cygpath was needed by the old -c "..." invocation pattern, not by direct exec |
| Silent exit on no-bash | Avoids breaking the plugin for users who don't have Git for Windows; hook context injection is skipped gracefully |
Writing Cross-Platform Hook Scripts
Your hook logic goes in the extensionless script file. A few portable patterns:
Do
- Use pure bash builtins when possible
- Use
$(command)instead of backticks - Quote all variable expansions:
"$VAR"
Avoid
- Relying on PATH-dependent tools without fallbacks (the hook runs without
-l, so login-shell PATH is not set) - Giving scripts a
.shextension — this triggers Claude Code's Windows auto-prepend
Example: JSON escaping without external tools
escape_for_json() {
local input="$1"
local output=""
local i char
for (( i=0; i<${#input}; i++ )); do
char="${input:$i:1}"
case "$char" in
$'\\') output+='\\' ;;
'"') output+='\"' ;;
$'\n') output+='\n' ;;
$'\r') output+='\r' ;;
$'\t') output+='\t' ;;
*) output+="$char" ;;
esac
done
printf '%s' "$output"
}
Troubleshooting
"bash is not recognized"
CMD couldn't find bash in any of the three locations the dispatcher tries. The dispatcher exits silently (0) rather than erroring, so the hook is skipped. Install Git for Windows at the standard path or ensure bash is on PATH.
Hook runs on Unix but does nothing on Windows
Check that the script filename is extensionless in hooks.json. A command like run-hook.cmd session-start.sh can trigger Claude Code's .sh auto-detection and bypass the intended CMD dispatcher path, or just try to run a non-existent session-start.sh script.
Hook doesn't fire at all
Verify the matcher in hooks.json matches the event type your harness emits. Claude Code uses startup|clear|compact; Codex uses startup|resume|clear. Check hooks-codex.json for the Codex variant.
Related Issues
- anthropics/claude-code#9758 —
.shscripts open in editor on Windows - anthropics/claude-code#3417 — Hooks don't work on Windows