-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Add Windows support for plugin hooks #134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cff914b
f493df0
0721316
9e2da49
03d2d05
f0e3cbe
e3598c1
ca77a6f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| # Cross-Platform Polyglot Hooks for Claude Code | ||
|
|
||
| Claude Code plugins need hooks that work on Windows, macOS, and Linux. This document explains the polyglot wrapper technique that makes this possible. | ||
|
|
||
| ## 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: | ||
|
|
||
| 1. **Script execution**: Windows CMD can't execute `.sh` files directly - it tries to open them in a text editor | ||
| 2. **Path format**: Windows uses backslashes (`C:\path`), Unix uses forward slashes (`/path`) | ||
| 3. **Environment variables**: `$VAR` syntax doesn't work in CMD | ||
| 4. **No `bash` in PATH**: Even with Git Bash installed, `bash` isn't in the PATH when CMD runs | ||
|
|
||
| ## The Solution: Polyglot `.cmd` Wrapper | ||
|
|
||
| A polyglot script is valid syntax in multiple languages simultaneously. Our wrapper is valid in both CMD and bash: | ||
|
|
||
| ```cmd | ||
| : << '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" | ||
| ``` | ||
|
|
||
| ### How It Works | ||
|
|
||
| #### On Windows (CMD.exe) | ||
|
|
||
| 1. `: << 'CMDBLOCK'` - CMD sees `:` as a label (like `:label`) and ignores `<< 'CMDBLOCK'` | ||
| 2. `@echo off` - Suppresses command echoing | ||
| 3. The bash.exe command runs with: | ||
| - `-l` (login shell) to get proper PATH with Unix utilities | ||
| - `cygpath -u` converts Windows path to Unix format (`C:\foo` → `/c/foo`) | ||
| 4. `exit /b` - Exits the batch script, stopping CMD here | ||
| 5. Everything after `CMDBLOCK` is never reached by CMD | ||
|
|
||
| #### On Unix (bash/sh) | ||
|
|
||
| 1. `: << 'CMDBLOCK'` - `:` is a no-op, `<< 'CMDBLOCK'` starts a heredoc | ||
| 2. Everything until `CMDBLOCK` is consumed by the heredoc (ignored) | ||
| 3. `# Unix shell runs from here` - Comment | ||
| 4. The script runs directly with the Unix path | ||
|
|
||
| ## File Structure | ||
|
|
||
| ``` | ||
| hooks/ | ||
| ├── 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.json | ||
|
|
||
| ```json | ||
| { | ||
| "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\...`). | ||
|
|
||
| ## Requirements | ||
|
|
||
| ### Windows | ||
| - **Git for Windows** must be installed (provides `bash.exe` and `cygpath`) | ||
| - Default installation path: `C:\Program Files\Git\bin\bash.exe` | ||
| - If Git is installed elsewhere, the wrapper needs modification | ||
|
|
||
| ### Unix (macOS/Linux) | ||
| - Standard bash or sh shell | ||
| - The `.cmd` file must have execute permission (`chmod +x`) | ||
|
|
||
| ## Writing Cross-Platform Hook Scripts | ||
|
|
||
| Your actual hook logic goes in the `.sh` file. To ensure it works on Windows (via Git Bash): | ||
|
|
||
| ### Do: | ||
| - Use pure bash builtins when possible | ||
| - Use `$(command)` instead of backticks | ||
| - Quote all variable expansions: `"$VAR"` | ||
| - Use `printf` or here-docs for output | ||
|
|
||
| ### Avoid: | ||
| - External commands that may not be in PATH (sed, awk, grep) | ||
| - If you must use them, they're available in Git Bash but ensure PATH is set up (use `bash -l`) | ||
|
|
||
| ### Example: JSON Escaping Without sed/awk | ||
|
|
||
| Instead of: | ||
| ```bash | ||
| escaped=$(echo "$content" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | awk '{printf "%s\\n", $0}') | ||
| ``` | ||
|
|
||
| Use pure bash: | ||
| ```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" | ||
| } | ||
| ``` | ||
|
|
||
| ## Reusable Wrapper Pattern | ||
|
|
||
| For plugins with multiple hooks, you can create a generic wrapper that takes the script name as an argument: | ||
|
|
||
| ### run-hook.cmd | ||
| ```cmd | ||
| : << '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 "${BASH_SOURCE[0]:-$0}")" && pwd)" | ||
| SCRIPT_NAME="$1" | ||
| shift | ||
| "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@" | ||
| ``` | ||
|
|
||
| ### hooks.json using the reusable wrapper | ||
| ```json | ||
| { | ||
| "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" | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Troubleshooting | ||
|
|
||
| ### "bash is not recognized" | ||
| 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. | ||
|
|
||
| ### "cygpath: command not found" or "dirname: command not found" | ||
| Bash isn't running as a login shell. Ensure `-l` flag is used. | ||
|
|
||
| ### Path has weird `\/` in it | ||
| `${CLAUDE_PLUGIN_ROOT}` expanded to a Windows path ending with backslash, then `/hooks/...` was appended. Use `cygpath` to convert the entire path. | ||
|
|
||
| ### Script opens in text editor instead of running | ||
| The hooks.json is pointing directly to the `.sh` file. Point to the `.cmd` wrapper instead. | ||
|
|
||
| ### Works in terminal but not as hook | ||
| Claude Code may run hooks differently. Test by simulating the hook environment: | ||
| ```powershell | ||
| $env:CLAUDE_PLUGIN_ROOT = "C:\path\to\plugin" | ||
| cmd /c "C:\path\to\plugin\hooks\session-start.cmd" | ||
| ``` | ||
|
|
||
| ## Related Issues | ||
|
|
||
| - [anthropics/claude-code#9758](https://github.com/anthropics/claude-code/issues/9758) - .sh scripts open in editor on Windows | ||
| - [anthropics/claude-code#3417](https://github.com/anthropics/claude-code/issues/3417) - Hooks don't work on Windows | ||
| - [anthropics/claude-code#6023](https://github.com/anthropics/claude-code/issues/6023) - CLAUDE_PROJECT_DIR not found | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,15 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : << 'CMDBLOCK' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @echo off | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| REM Polyglot wrapper: runs .sh scripts cross-platform | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| REM Usage: run-hook.cmd <script-name> [args...] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| REM The script should be in the same directory as this wrapper | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "C:\Program Files\Git\bin\bash.exe" -l "%~dp0%~1" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| exit /b | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| CMDBLOCK | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Unix shell runs from here | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SCRIPT_NAME="$1" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| shift | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "run-hook.cmd" -o -name "run-hook.sh" | head -20Repository: obra/superpowers Length of output: 80 🏁 Script executed: cat -n ./hooks/run-hook.cmdRepository: obra/superpowers Length of output: 574 🏁 Script executed: rg "run-hook" --type sh --type cmd --type json --type yaml -A 2 -B 2Repository: obra/superpowers Length of output: 87 🏁 Script executed: rg "run-hook" -A 2 -B 2Repository: obra/superpowers Length of output: 1500 Windows side drops additional args; align with documented The Unix section forwards extra args ( To keep behavior symmetric and future‑proof, update the CMD block to forward all arguments: : << 'CMDBLOCK'
@echo off
REM Polyglot wrapper: runs .sh scripts cross-platform
REM Usage: run-hook.cmd <script-name> [args...]
REM The script should be in the same directory as this wrapper
-
-"C:\Program Files\Git\bin\bash.exe" -l "%~dp0%~1"
-exit /b
+if "%~1"=="" (
+ echo run-hook.cmd: missing script name 1>&2
+ exit /b 1
+)
+set "SCRIPT_DIR=%~dp0"
+set "SCRIPT_NAME=%~1"
+shift
+"C:\Program Files\Git\bin\bash.exe" -l "%SCRIPT_DIR%%SCRIPT_NAME%" %*
+exit /b
CMDBLOCKThis preserves the polyglot structure, provides clearer error messaging when the script name is missing, and forwards all additional args on Windows to match the Unix behavior. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a language to the file-structure code fence to satisfy markdownlint
The file-structure block is the only fenced block without a language, which triggers MD040. You can fix this by tagging it, e.g.:
In docs/windows/polyglot-hooks.md around lines 54 to 59, the fenced
file-structure block lacks a language tag which triggers markdownlint rule
MD040; update the opening fence to include a language (for example change ``` to