Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions plugins/explanatory-output-style/hooks-handlers/run-hook.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
:; # Cross-platform hook wrapper (polyglot: cmd.exe + bash)
:; # On Windows: invokes Git Bash explicitly to avoid WSL bash.exe
:; # On Unix/macOS: passes through to bash natively
:;
:; # --- Unix/macOS execution path ---
:; SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
:; exec bash "$SCRIPT_DIR/$1" "${@:2}"
:; exit $?

@echo off
REM --- Windows execution path ---
set "SCRIPT_DIR=%~dp0"
set "HOOK_SCRIPT=%SCRIPT_DIR%%~1"

if exist "C:\Program Files\Git\bin\bash.exe" (
"C:\Program Files\Git\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)
if exist "C:\Program Files (x86)\Git\bin\bash.exe" (
"C:\Program Files (x86)\Git\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)

for /f "tokens=*" %%i in ('where git 2^>nul') do (
for %%j in ("%%~dpi..") do (
if exist "%%~fj\bin\bash.exe" (
"%%~fj\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)
)
)

echo ERROR: Git Bash not found. Install Git for Windows. >&2
exit /b 1
2 changes: 1 addition & 1 deletion plugins/explanatory-output-style/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh"
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/run-hook.cmd session-start.sh"
}
]
}
Expand Down
34 changes: 34 additions & 0 deletions plugins/learning-output-style/hooks-handlers/run-hook.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
:; # Cross-platform hook wrapper (polyglot: cmd.exe + bash)
:; # On Windows: invokes Git Bash explicitly to avoid WSL bash.exe
:; # On Unix/macOS: passes through to bash natively
:;
:; # --- Unix/macOS execution path ---
:; SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
:; exec bash "$SCRIPT_DIR/$1" "${@:2}"
:; exit $?

@echo off
REM --- Windows execution path ---
set "SCRIPT_DIR=%~dp0"
set "HOOK_SCRIPT=%SCRIPT_DIR%%~1"

if exist "C:\Program Files\Git\bin\bash.exe" (
"C:\Program Files\Git\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)
if exist "C:\Program Files (x86)\Git\bin\bash.exe" (
"C:\Program Files (x86)\Git\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)

for /f "tokens=*" %%i in ('where git 2^>nul') do (
for %%j in ("%%~dpi..") do (
if exist "%%~fj\bin\bash.exe" (
"%%~fj\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)
)
)

echo ERROR: Git Bash not found. Install Git for Windows. >&2
exit /b 1
2 changes: 1 addition & 1 deletion plugins/learning-output-style/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh"
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/run-hook.cmd session-start.sh"
}
]
}
Expand Down
64 changes: 64 additions & 0 deletions plugins/plugin-dev/skills/hook-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,69 @@ echo "$output" | jq .
- ❌ Modify global state unpredictably
- ❌ Log sensitive information

## Cross-Platform Compatibility (Windows)

### The Problem

On Windows, when WSL (Windows Subsystem for Linux) is installed — which is extremely common due to Docker Desktop — the `bash` command resolves to WSL's `C:\Windows\System32\bash.exe` instead of Git Bash. Claude Code detects `.sh` extensions in hook commands and auto-prepends `bash`, which causes hooks to fail with:

```
WSL ERROR: CreateProcessCommon: execvpe(/bin/bash) failed: No such file or directory
```

This affects **all** command hooks that reference `.sh` scripts directly.

### The Solution: Polyglot `.cmd` Wrappers

Use a `.cmd` wrapper that works on both Windows (cmd.exe) and Unix/macOS (bash). The wrapper:
- On **Windows**: explicitly invokes Git Bash at its standard installation path
- On **Unix/macOS**: passes through to bash natively via the polyglot pattern

### How to Use

1. Create a `run-hook.cmd` file next to your `.sh` scripts (see `examples/cross-platform-hook.cmd`)

2. Update your `hooks.json` to call the `.cmd` wrapper instead of the `.sh` script directly:

**Before (breaks on Windows with WSL):**
```json
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/my-hook.sh"
}
```

**After (works everywhere):**
```json
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd my-hook.sh"
}
```

3. The wrapper passes the script name as an argument and resolves the path relative to itself.

### How the Polyglot Works

Lines starting with `:;` are valid labels in cmd.exe (ignored) and valid no-ops in bash (label syntax). This allows a single file to contain both bash and cmd.exe code paths:

```
:; # This line is ignored by cmd.exe, executed by bash
:; exec bash "$SCRIPT_DIR/$1" # bash exec's the target script
:; exit $? # bash never reaches here

@echo off
REM cmd.exe starts executing here (skips :; lines)
"C:\Program Files\Git\bin\bash.exe" "%HOOK_SCRIPT%"
```

### Best Practices

- **Always use `.cmd` wrappers** for command hooks in plugins intended for cross-platform use
- Keep the original `.sh` scripts unchanged — the wrapper delegates to them
- The wrapper tries standard Git Bash locations and falls back to finding bash via `where git`
- Test on both Windows (`cmd /c run-hook.cmd script.sh`) and Unix (`bash run-hook.cmd script.sh`)

## Additional Resources

### Reference Files
Expand All @@ -679,6 +742,7 @@ Working examples in `examples/`:
- **`validate-write.sh`** - File write validation example
- **`validate-bash.sh`** - Bash command validation example
- **`load-context.sh`** - SessionStart context loading example
- **`cross-platform-hook.cmd`** - Windows/Unix polyglot wrapper for cross-platform hooks

### Utility Scripts

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
:; # ============================================================================
:; # Cross-Platform Hook Wrapper (Polyglot: cmd.exe + bash)
:; # ============================================================================
:; #
:; # PURPOSE:
:; # Ensures hook .sh scripts work on Windows even when WSL is installed.
:; # On Windows, `bash` in PATH often resolves to WSL's bash.exe instead of
:; # Git Bash, causing hooks to fail. This wrapper explicitly invokes Git Bash.
:; #
:; # USAGE:
:; # In hooks.json, replace direct .sh references:
:; # BEFORE: "command": "${CLAUDE_PLUGIN_ROOT}/hooks/my-hook.sh"
:; # AFTER: "command": "${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd my-hook.sh"
:; #
:; # HOW IT WORKS:
:; # Lines starting with `:;` are valid labels in cmd.exe (ignored) and valid
:; # no-ops in bash. When bash runs this file, it executes the commands after
:; # `:;` and `exec`s the target script. When cmd.exe runs it, it skips the
:; # `:;` labels and executes the @echo off section below.
:; #
:; # COPY THIS FILE into your plugin's hooks directory and rename to run-hook.cmd
:; # ============================================================================
:;
:; # --- Unix/macOS execution path ---
:; SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
:; exec bash "$SCRIPT_DIR/$1" "${@:2}"
:; exit $?

@echo off
REM --- Windows execution path ---
REM Resolve the hook script path relative to this .cmd file
set "SCRIPT_DIR=%~dp0"
set "HOOK_SCRIPT=%SCRIPT_DIR%%~1"

REM Try standard Git for Windows installation paths
if exist "C:\Program Files\Git\bin\bash.exe" (
"C:\Program Files\Git\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)
if exist "C:\Program Files (x86)\Git\bin\bash.exe" (
"C:\Program Files (x86)\Git\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)

REM Fallback: locate bash via Git's installation directory
for /f "tokens=*" %%i in ('where git 2^>nul') do (
for %%j in ("%%~dpi..") do (
if exist "%%~fj\bin\bash.exe" (
"%%~fj\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)
)
)

echo ERROR: Git Bash not found. Install Git for Windows from https://git-scm.com >&2
exit /b 1
55 changes: 55 additions & 0 deletions plugins/plugin-dev/skills/hook-development/references/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,58 @@ fi
- Per-project settings
- Team-specific rules
- Dynamic validation criteria

## Pattern 11: Cross-Platform Hook Wrapper

Use a polyglot `.cmd` wrapper to ensure hooks work on Windows (where WSL's `bash.exe` can intercept `.sh` execution) and Unix/macOS:

**Wrapper file (`run-hook.cmd`):**
```cmd
:; # Cross-platform hook wrapper (polyglot: cmd.exe + bash)
:; # On Unix/macOS: passes through to bash natively
:; SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
:; exec bash "$SCRIPT_DIR/$1" "${@:2}"
:; exit $?

@echo off
set "SCRIPT_DIR=%~dp0"
set "HOOK_SCRIPT=%SCRIPT_DIR%%~1"

if exist "C:\Program Files\Git\bin\bash.exe" (
"C:\Program Files\Git\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)
if exist "C:\Program Files (x86)\Git\bin\bash.exe" (
"C:\Program Files (x86)\Git\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)

echo ERROR: Git Bash not found. Install Git for Windows. >&2
exit /b 1
```

**hooks.json configuration:**
```json
{
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd my-hook.sh"
}
]
}
]
}
```

**How it works:**
- Lines starting with `:;` are labels in cmd.exe (ignored) and no-ops in bash (valid syntax)
- When bash runs the file, it executes the `:;` lines, which `exec` the target `.sh` script
- When cmd.exe runs the file, it skips `:;` labels and runs the `@echo off` section, which explicitly invokes Git Bash

**Use for:**
- Any plugin that needs to work on Windows, macOS, and Linux
- Avoiding WSL bash interception on Windows systems with Docker Desktop
- Plugins distributed via the marketplace (must work for all users)
2 changes: 1 addition & 1 deletion plugins/ralph-wiggum/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd stop-hook.sh"
}
]
}
Expand Down
34 changes: 34 additions & 0 deletions plugins/ralph-wiggum/hooks/run-hook.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
:; # Cross-platform hook wrapper (polyglot: cmd.exe + bash)
:; # On Windows: invokes Git Bash explicitly to avoid WSL bash.exe
:; # On Unix/macOS: passes through to bash natively
:;
:; # --- Unix/macOS execution path ---
:; SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
:; exec bash "$SCRIPT_DIR/$1" "${@:2}"
:; exit $?

@echo off
REM --- Windows execution path ---
set "SCRIPT_DIR=%~dp0"
set "HOOK_SCRIPT=%SCRIPT_DIR%%~1"

if exist "C:\Program Files\Git\bin\bash.exe" (
"C:\Program Files\Git\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)
if exist "C:\Program Files (x86)\Git\bin\bash.exe" (
"C:\Program Files (x86)\Git\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)

for /f "tokens=*" %%i in ('where git 2^>nul') do (
for %%j in ("%%~dpi..") do (
if exist "%%~fj\bin\bash.exe" (
"%%~fj\bin\bash.exe" "%HOOK_SCRIPT%" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %errorlevel%
)
)
)

echo ERROR: Git Bash not found. Install Git for Windows. >&2
exit /b 1