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.
Claude Code runs hook commands through a shell:
Neither Windows fallback shell can parse our command string: PowerShell treats
a leading quoted path as a string expression and errors on the next bareword,
and CMD.exe's /c quoting rules strip the outer quotes when the path contains
a metacharacter such as (. Our hooks therefore declare "shell": "bash"
(supported since Claude Code 2.1.81; older versions ignore the key), which
forces the Git Bash route and, when Git Bash is absent, produces an actionable
"install Git for Windows" error instead of a shell parser failure.
This creates several challenges:
.sh files directlyC:\path), Unix uses forward slashes (/path)$VAR syntax doesn't work in CMD.sh auto-prepend: Claude Code on Windows automatically prepends bash to any command that contains .sh in its path — this interferes with the dispatcher if scripts have extensionsThe 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.
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": {
"SessionStart": [
{
"matcher": "startup|clear|compact",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
"shell": "bash",
"async": false
}
]
}
]
}
}
The path is quoted because ${CLAUDE_PLUGIN_ROOT} may contain spaces.
run-hook.cmd Works at a High Levelrun-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.
C:\Program Files\Git\bin\bash.exeC:\Program Files (x86)\Git\bin\bash.exebash on PATH (MSYS2, Cygwin, or a non-default Git install)0 silently — the plugin
continues working, it just skips the hook.exit /b stops CMD before it reaches the Unix section.: << 'CMDBLOCK' opens a heredoc on a no-op command.CMDBLOCK, bash resolves the script directory and execs the named
extensionless script directly.| 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 |
Your hook logic goes in the extensionless script file. A few portable patterns:
$(command) instead of backticks"$VAR"-l, so login-shell PATH is not set).sh extension — this triggers Claude Code's Windows auto-prependescape_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"
}
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.
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.
Verify the matcher in hooks.json matches the event type your harness emits. Claude Code uses startup|clear|compact; Cursor uses sessionStart. Check hooks-cursor.json for the Cursor variant.
.sh scripts open in editor on Windows