Claude Code plugins need hooks that work on Windows, macOS, and Linux. This document explains the polyglot wrapper technique that makes this possible.
Claude Code runs hook commands through the system's default shell:
This creates several challenges:
.sh files directly - it tries to open them in a text editorC:\path), Unix uses forward slashes (/path)$VAR syntax doesn't work in CMDbash in PATH: Even with Git Bash installed, bash isn't in the PATH when CMD runs.cmd WrapperA polyglot script is valid syntax in multiple languages simultaneously. Our wrapper is valid in both CMD and bash:
: << 'CMDBLOCK'
@echo off
"C:\Program Files\Git\bin\bash.exe" -l -c "\"$(cygpath -u \"$CLAUDE_PLUGIN_ROOT\")/hooks/session-start.sh\""
exit /b
CMDBLOCK
# Unix shell runs from here
"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"
: << 'CMDBLOCK' - CMD sees : as a label (like :label) and ignores << 'CMDBLOCK'@echo off - Suppresses command echoing-l (login shell) to get proper PATH with Unix utilitiescygpath -u converts Windows path to Unix format (C:\foo → /c/foo)exit /b - Exits the batch script, stopping CMD hereCMDBLOCK is never reached by CMD: << 'CMDBLOCK' - : is a no-op, << 'CMDBLOCK' starts a heredocCMDBLOCK is consumed by the heredoc (ignored)# Unix shell runs from here - Commenthooks/
├── hooks.json # Points to the .cmd wrapper
├── session-start.cmd # Polyglot wrapper (cross-platform entry point)
└── session-start.sh # Actual hook logic (bash script)
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.cmd\""
}
]
}
]
}
}
Note: The path must be quoted because ${CLAUDE_PLUGIN_ROOT} may contain spaces on Windows (e.g., C:\Program Files\...).
bash.exe and cygpath)C:\Program Files\Git\bin\bash.exe.cmd file must have execute permission (chmod +x)Your actual hook logic goes in the .sh file. To ensure it works on Windows (via Git Bash):
$(command) instead of backticks"$VAR"printf or here-docs for outputbash -l)Instead of:
escaped=$(echo "$content" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | awk '{printf "%s\\n", $0}')
Use pure bash:
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"
}
For plugins with multiple hooks, you can create a generic wrapper that takes the script name as an argument:
: << 'CMDBLOCK'
@echo off
set "SCRIPT_DIR=%~dp0"
set "SCRIPT_NAME=%~1"
"C:\Program Files\Git\bin\bash.exe" -l -c "cd \"$(cygpath -u \"%SCRIPT_DIR%\")\" && \"./%SCRIPT_NAME%\""
exit /b
CMDBLOCK
# Unix shell runs from here
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_NAME="$1"
shift
"${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" validate-bash.sh"
}
]
}
]
}
}
CMD can't find bash. The wrapper uses the full path C:\Program Files\Git\bin\bash.exe. If Git is installed elsewhere, update the path.
Bash isn't running as a login shell. Ensure -l flag is used.
\/ in it${CLAUDE_PLUGIN_ROOT} expanded to a Windows path ending with backslash, then /hooks/... was appended. Use cygpath to convert the entire path.
The hooks.json is pointing directly to the .sh file. Point to the .cmd wrapper instead.
Claude Code may run hooks differently. Test by simulating the hook environment:
$env:CLAUDE_PLUGIN_ROOT = "C:\path\to\plugin"
cmd /c "C:\path\to\plugin\hooks\session-start.cmd"