Compare commits

..

1 Commits

Author SHA1 Message Date
Drew Ritter
867238cfc1 Add shell lint script 2026-06-01 14:12:54 -07:00
5 changed files with 526 additions and 84 deletions

View File

@@ -755,9 +755,10 @@ Two rules this enforces, which you must respect:
- Don't write per-OS variants of the hook script. One extensionless bash script - Don't write per-OS variants of the hook script. One extensionless bash script
plus the polyglot wrapper covers all three platforms. plus the polyglot wrapper covers all three platforms.
`hooks/run-hook.cmd` itself is the authoritative implementation — read it. See `hooks/run-hook.cmd` itself is the authoritative implementation — read it.
`docs/windows/polyglot-hooks.md` for the background and rationale behind the (`docs/windows/polyglot-hooks.md` covers the background and rationale but
dispatcher pattern. describes an earlier per-script `.cmd`/`.sh` variant, so trust the code over that
doc where they differ.)
--- ---

View File

@@ -1,8 +1,6 @@
# Cross-Platform Polyglot Hooks for Claude Code # 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`. Claude Code plugins need hooks that work on Windows, macOS, and Linux. This document explains the polyglot wrapper technique that makes this possible.
> **Authoritative source:** `hooks/run-hook.cmd` is the canonical implementation. When this document and the code diverge, trust the code.
## The Problem ## The Problem
@@ -12,22 +10,52 @@ Claude Code runs hook commands through the system's default shell:
This creates several challenges: This creates several challenges:
1. **Script execution**: Windows CMD can't execute `.sh` files directly 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`) 2. **Path format**: Windows uses backslashes (`C:\path`), Unix uses forward slashes (`/path`)
3. **Environment variables**: `$VAR` syntax doesn't work in CMD 3. **Environment variables**: `$VAR` syntax doesn't work in CMD
4. **`.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 extensions 4. **No `bash` in PATH**: Even with Git Bash installed, `bash` isn't in the PATH when CMD runs
## The Solution: Extensionless Scripts + Single Generic Dispatcher ## The Solution: Polyglot `.cmd` Wrapper
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. A polyglot script is valid syntax in multiple languages simultaneously. Our wrapper is valid in both CMD and bash:
### File Structure ```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/
├── hooks.json # Points to run-hook.cmd with extensionless script name ├── hooks.json # Points to the .cmd wrapper
├── run-hook.cmd # Cross-platform dispatcher (the polyglot wrapper) ├── session-start.cmd # Polyglot wrapper (cross-platform entry point)
└── session-start # Actual hook logic — extensionless bash script └── session-start.sh # Actual hook logic (bash script)
``` ```
### hooks.json ### hooks.json
@@ -37,12 +65,11 @@ hooks/
"hooks": { "hooks": {
"SessionStart": [ "SessionStart": [
{ {
"matcher": "startup|clear|compact", "matcher": "startup|resume|clear|compact",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start", "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.cmd\""
"async": false
} }
] ]
} }
@@ -51,63 +78,41 @@ hooks/
} }
``` ```
The path is quoted because `${CLAUDE_PLUGIN_ROOT}` may contain spaces. Note: The path must be quoted because `${CLAUDE_PLUGIN_ROOT}` may contain spaces on Windows (e.g., `C:\Program Files\...`).
## How `run-hook.cmd` Works at a High Level ## Requirements
`run-hook.cmd` is a polyglot script: Windows treats the first block as batch ### Windows
commands, while Unix shells treat that block as a no-op heredoc and continue - **Git for Windows** must be installed (provides `bash.exe` and `cygpath`)
after it. - Default installation path: `C:\Program Files\Git\bin\bash.exe`
- If Git is installed elsewhere, the wrapper needs modification
Do not copy an implementation from this document. Read `hooks/run-hook.cmd` ### Unix (macOS/Linux)
directly when changing the dispatcher, and run `tests/hooks/test-session-start.sh` - Standard bash or sh shell
afterward. - The `.cmd` file must have execute permission (`chmod +x`)
### How it works on Windows (CMD.exe)
1. The batch section validates the script name and resolves the hook directory
from the dispatcher's own location.
2. It tries bash in three places:
- `C:\Program Files\Git\bin\bash.exe`
- `C:\Program Files (x86)\Git\bin\bash.exe`
- `bash` on `PATH` (MSYS2, Cygwin, or a non-default Git install)
3. If bash is found, it runs the named extensionless hook script from the hooks
directory.
4. If no bash is found, the dispatcher exits `0` silently — the plugin
continues working, it just skips the hook.
5. `exit /b` stops CMD before it reaches the Unix section.
### How it works on Unix (bash/sh)
1. `: << 'CMDBLOCK'` opens a heredoc on a no-op command.
2. The entire CMD batch block is consumed by the heredoc and ignored.
3. After `CMDBLOCK`, bash resolves the script directory and `exec`s 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 ## Writing Cross-Platform Hook Scripts
Your hook logic goes in the extensionless script file. A few portable patterns: Your actual hook logic goes in the `.sh` file. To ensure it works on Windows (via Git Bash):
### Do ### Do:
- Use pure bash builtins when possible - Use pure bash builtins when possible
- Use `$(command)` instead of backticks - Use `$(command)` instead of backticks
- Quote all variable expansions: `"$VAR"` - Quote all variable expansions: `"$VAR"`
- Use `printf` or here-docs for output
### Avoid ### Avoid:
- Relying on PATH-dependent tools without fallbacks (the hook runs without `-l`, so login-shell PATH is not set) - External commands that may not be in PATH (sed, awk, grep)
- Giving scripts a `.sh` extension — this triggers Claude Code's Windows auto-prepend - If you must use them, they're available in Git Bash but ensure PATH is set up (use `bash -l`)
### Example: JSON escaping without external tools ### 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 ```bash
escape_for_json() { escape_for_json() {
local input="$1" local input="$1"
@@ -128,21 +133,80 @@ escape_for_json() {
} }
``` ```
## 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 "$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 ## Troubleshooting
### "bash is not recognized" ### "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.
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`. ### "cygpath: command not found" or "dirname: command not found"
Bash isn't running as a login shell. Ensure `-l` flag is used.
### Hook runs on Unix but does nothing on Windows ### 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.
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. ### Script opens in text editor instead of running
The hooks.json is pointing directly to the `.sh` file. Point to the `.cmd` wrapper instead.
### Hook doesn't fire at all ### Works in terminal but not as hook
Claude Code may run hooks differently. Test by simulating the hook environment:
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. ```powershell
$env:CLAUDE_PLUGIN_ROOT = "C:\path\to\plugin"
cmd /c "C:\path\to\plugin\hooks\session-start.cmd"
```
## Related Issues ## Related Issues
- [anthropics/claude-code#9758](https://github.com/anthropics/claude-code/issues/9758) `.sh` scripts open in editor on Windows - [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#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

211
scripts/lint-shell.sh Executable file
View File

@@ -0,0 +1,211 @@
#!/usr/bin/env bash
#
# Lint shell scripts in this repository.
#
# Usage:
# scripts/lint-shell.sh [--all] [--format] [--strict] [file ...]
#
# By default, runs ShellCheck and shell syntax checks on changed shell scripts.
# Use --format to format with shfmt before linting. Use --all for the full tracked
# baseline, or pass files explicitly to lint a smaller set.
set -euo pipefail
usage() {
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
}
die() {
echo "error: $*" >&2
exit 1
}
require_tool() {
command -v "$1" >/dev/null 2>&1 || die "required tool '$1' is not on PATH"
}
is_shell_file() {
local path="$1"
local first_line=""
[[ -f "$path" ]] || return 1
case "$path" in
*.sh)
return 0
;;
esac
IFS= read -r first_line <"$path" || true
[[ "$first_line" =~ ^#!.*[/[:space:]](bash|dash|ksh|sh)([[:space:]]|$) ]]
}
ensure_git_work_tree() {
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| die "run this from inside a git work tree, or pass files explicitly"
}
add_shell_file() {
local path
local existing
path="$1"
if ! is_shell_file "$path"; then
return 0
fi
if [[ "${#files[@]}" -gt 0 ]]; then
for existing in "${files[@]}"; do
if [[ "$existing" == "$path" ]]; then
return 0
fi
done
fi
files+=("$path")
}
collect_all_shell_files() {
local path
ensure_git_work_tree
while IFS= read -r -d '' path; do
add_shell_file "$path"
done < <(git ls-files -z)
}
collect_changed_shell_files() {
local path
ensure_git_work_tree
if git rev-parse --verify HEAD >/dev/null 2>&1; then
while IFS= read -r -d '' path; do
add_shell_file "$path"
done < <(git diff --name-only -z --diff-filter=ACMR HEAD)
while IFS= read -r -d '' path; do
add_shell_file "$path"
done < <(git diff --cached --name-only -z --diff-filter=ACMR)
else
collect_all_shell_files
fi
while IFS= read -r -d '' path; do
add_shell_file "$path"
done < <(git ls-files --others --exclude-standard -z)
}
collect_requested_shell_files() {
local path
for path in "$@"; do
add_shell_file "$path"
done
}
syntax_shell_for() {
local path="$1"
local first_line=""
IFS= read -r first_line <"$path" || true
case "$first_line" in
*"/sh"* | *" env sh"* | *"/dash"* | *" env dash"*)
printf 'sh'
;;
*)
printf 'bash'
;;
esac
}
run_syntax_checks() {
local file
local shell_name
for file in "$@"; do
shell_name="$(syntax_shell_for "$file")"
case "$shell_name" in
sh)
sh -n "$file"
;;
bash)
bash -n "$file"
;;
*)
die "unsupported shell for syntax check: $shell_name"
;;
esac
done
}
format=false
strict=false
all=false
requested_files=()
while [[ $# -gt 0 ]]; do
case "$1" in
--all)
all=true
;;
--format)
format=true
;;
--strict)
strict=true
;;
-h | --help)
usage
exit 0
;;
--)
shift
requested_files+=("$@")
break
;;
-*)
die "unknown option: $1"
;;
*)
requested_files+=("$1")
;;
esac
shift
done
require_tool shellcheck
if [[ "$format" == true ]]; then
require_tool shfmt
fi
files=()
if [[ "${#requested_files[@]}" -gt 0 ]]; then
collect_requested_shell_files "${requested_files[@]}"
elif [[ "$all" == true ]]; then
collect_all_shell_files
else
collect_changed_shell_files
fi
if [[ "${#files[@]}" -eq 0 ]]; then
echo "No shell files found."
exit 0
fi
if [[ "$format" == true ]]; then
echo "Formatting ${#files[@]} shell files"
shfmt_args=(-i 2 -ci -bn)
shfmt "${shfmt_args[@]}" -w "${files[@]}"
fi
echo "Linting ${#files[@]} shell files"
shellcheck_args=(--severity=warning --external-sources --source-path=SCRIPTDIR)
if [[ "$strict" == true ]]; then
shellcheck_args+=("--enable=check-extra-masked-returns,check-set-e-suppressed,quote-safe-variables,deprecate-which,avoid-nullary-conditions")
fi
shellcheck "${shellcheck_args[@]}" "${files[@]}"
run_syntax_checks "${files[@]}"

View File

@@ -107,23 +107,10 @@ if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then
OWNER_PID="$PPID" OWNER_PID="$PPID"
fi fi
# Windows/MSYS2: Node.js cannot see POSIX PIDs from the MSYS2 namespace.
# Passing a PID node cannot verify causes server to log owner-pid-invalid
# and self-terminate at the 60-second lifecycle check. Clear it so the
# watchdog is disabled and the idle timeout becomes the only shutdown trigger.
case "${OSTYPE:-}" in
msys*|cygwin*|mingw*) OWNER_PID="" ;;
esac
if [[ -n "${MSYSTEM:-}" ]]; then
OWNER_PID=""
fi
# Foreground mode for environments that reap detached/background processes. # Foreground mode for environments that reap detached/background processes.
if [[ "$FOREGROUND" == "true" ]]; then if [[ "$FOREGROUND" == "true" ]]; then
env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs & echo "$$" > "$PID_FILE"
SERVER_PID=$! env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs
echo "$SERVER_PID" > "$PID_FILE"
wait "$SERVER_PID"
exit $? exit $?
fi fi

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SCRIPT_UNDER_TEST="$REPO_ROOT/scripts/lint-shell.sh"
FAILURES=0
TEST_ROOT="$(mktemp -d)"
cleanup() {
rm -rf "$TEST_ROOT"
}
trap cleanup EXIT
pass() {
echo " [PASS] $1"
}
fail() {
echo " [FAIL] $1"
FAILURES=$((FAILURES + 1))
}
assert_contains() {
local haystack="$1"
local needle="$2"
local description="$3"
if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
pass "$description"
else
fail "$description"
echo " expected to find: $needle"
echo " in:"
printf '%s\n' "$haystack" | sed 's/^/ /'
fi
}
assert_not_contains() {
local haystack="$1"
local needle="$2"
local description="$3"
if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
fail "$description"
echo " did not expect to find: $needle"
echo " in:"
printf '%s\n' "$haystack" | sed 's/^/ /'
else
pass "$description"
fi
}
configure_git_identity() {
local repo="$1"
git -C "$repo" config user.name "Test Bot"
git -C "$repo" config user.email "test@example.com"
}
write_stub_tool() {
local path="$1"
local name="$2"
cat >"$path" <<EOF
#!/usr/bin/env bash
{
printf '${name}:'
for arg in "\$@"; do
printf ' <%s>' "\$arg"
done
printf '\n'
} >> "\$SUPERPOWERS_SHELL_LINT_TEST_LOG"
exit 0
EOF
chmod +x "$path"
}
make_fixture_repo() {
local repo="$1"
git init -q -b main "$repo"
configure_git_identity "$repo"
mkdir -p "$repo/hooks"
cat >"$repo/tracked.sh" <<'EOF'
#!/usr/bin/env bash
echo "tracked"
EOF
cat >"$repo/hooks/session-start" <<'EOF'
#!/bin/sh
echo "extensionless"
EOF
cat >"$repo/README.md" <<'EOF'
# Fixture
```bash
echo "not a shell script"
```
EOF
cat >"$repo/untracked.sh" <<'EOF'
#!/usr/bin/env bash
echo "untracked"
EOF
git -C "$repo" add tracked.sh hooks/session-start README.md
git -C "$repo" commit -q -m "fixture"
printf '\necho "changed"\n' >>"$repo/tracked.sh"
printf '\necho "changed extensionless"\n' >>"$repo/hooks/session-start"
}
run_lint_shell() {
local repo="$1"
local fakebin="$2"
local log="$3"
shift 3
(
cd "$repo"
PATH="$fakebin:$PATH" \
SUPERPOWERS_SHELL_LINT_TEST_LOG="$log" \
bash "$SCRIPT_UNDER_TEST" "$@"
)
}
echo "Shell lint script tests"
fixture="$TEST_ROOT/repo"
fakebin="$TEST_ROOT/bin"
log="$TEST_ROOT/tool.log"
mkdir -p "$fixture" "$fakebin"
: >"$log"
write_stub_tool "$fakebin/shellcheck" "shellcheck"
write_stub_tool "$fakebin/shfmt" "shfmt"
make_fixture_repo "$fixture"
if output="$(run_lint_shell "$fixture" "$fakebin" "$log" 2>&1)"; then
pass "lint-shell check mode exits successfully with stub tools"
else
fail "lint-shell check mode exits successfully with stub tools"
printf '%s\n' "$output" | sed 's/^/ /'
fi
tool_log="$(cat "$log")"
assert_contains "$output" "Linting 3 shell files" "reports changed shell file count"
assert_not_contains "$tool_log" "shfmt:" "does not run shfmt in lint mode"
assert_contains "$tool_log" "shellcheck:" "runs ShellCheck"
assert_contains "$tool_log" "<--severity=warning>" "uses warning severity as the baseline"
assert_contains "$tool_log" "<--external-sources>" "allows ShellCheck to follow sourced files"
assert_contains "$tool_log" "<--source-path=SCRIPTDIR>" "resolves ShellCheck sources relative to each script"
assert_contains "$tool_log" "<hooks/session-start>" "includes changed extensionless shell shebang file"
assert_contains "$tool_log" "<tracked.sh>" "includes changed tracked .sh file"
assert_contains "$tool_log" "<untracked.sh>" "includes untracked shell files by default"
assert_not_contains "$tool_log" "README.md" "ignores Markdown with shell snippets"
: >"$log"
if output="$(run_lint_shell "$fixture" "$fakebin" "$log" --all --format 2>&1)"; then
pass "lint-shell --format exits successfully with stub tools"
else
fail "lint-shell --format exits successfully with stub tools"
printf '%s\n' "$output" | sed 's/^/ /'
fi
tool_log="$(cat "$log")"
assert_contains "$tool_log" "<-w>" "uses shfmt write mode with --format"
assert_contains "$tool_log" "shellcheck:" "runs ShellCheck after --format"
assert_contains "$tool_log" "<--severity=warning>" "keeps warning severity after --format"
assert_contains "$tool_log" "<hooks/session-start>" "--all includes tracked extensionless shell shebang file"
assert_contains "$tool_log" "<tracked.sh>" "--all includes tracked .sh file"
assert_not_contains "$tool_log" "untracked.sh" "--all ignores untracked shell files"
if [[ "$FAILURES" -eq 0 ]]; then
echo "All shell lint script tests passed"
else
echo "$FAILURES shell lint script test(s) failed"
exit 1
fi