refactor(claude): migrate hooks to external scripts and add new hooks

Replace inline command strings in settings.json with external scripts
in .claude/hooks/ for readability and maintainability. Consolidate
three PostToolUse formatters into one script and add markdown/yaml
formatting. Add new hooks: SessionStart context banner, Stop lint
gate, async Bats test runner, idle desktop notification, and
PostToolUseFailure logger.
This commit is contained in:
2026-03-20 04:38:18 +02:00
parent 2b867c3348
commit cff83e4738
8 changed files with 173 additions and 5 deletions

13
.claude/hooks/async-bats.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Async Bats runner: run matching test file when a script is edited.
# Runs in background (async: true) — output appears on next turn.
fp=$(jq -r '.tool_input.file_path // empty')
[ -z "$fp" ] && exit 0
name=$(basename "$fp")
test_file="$CLAUDE_PROJECT_DIR/tests/${name}.bats"
[ ! -f "$test_file" ] && exit 0
echo "Running $test_file ..."
"$CLAUDE_PROJECT_DIR/node_modules/.bin/bats" "$test_file"

14
.claude/hooks/log-failures.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# PostToolUseFailure logger: append tool failures to a local log file.
log_file="$CLAUDE_PROJECT_DIR/.claude/hook-failures.log"
entry=$(jq -c '{
time: (now | strftime("%Y-%m-%dT%H:%M:%SZ")),
tool: .tool_name,
error: .error
}')
echo "$entry" >> "$log_file"
exit 0

13
.claude/hooks/notify-idle.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Notification hook: alert when Claude goes idle.
# Uses pushover if available, falls back to macOS native notification.
msg=$(jq -r '.message // "Claude is waiting for input"')
if command -v pushover > /dev/null; then
pushover "Claude Code" "$msg"
elif command -v osascript > /dev/null; then
osascript -e "display notification \"$msg\" with title \"Claude Code\""
fi
exit 0

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Post-edit formatter: auto-format file based on extension.
# Receives tool output JSON on stdin.
fp=$(jq -r '.tool_input.file_path // empty')
[ -z "$fp" ] || [ ! -f "$fp" ] && exit 0
case "$fp" in
*.sh | */bin/*)
head -1 "$fp" | grep -qE '^#!.*(ba)?sh' \
&& command -v shfmt > /dev/null \
&& shfmt -i 2 -bn -ci -sr -fn -w "$fp"
;;
*.fish)
command -v fish_indent > /dev/null && fish_indent -w "$fp"
;;
*.lua)
command -v stylua > /dev/null && stylua "$fp"
;;
*.md)
command -v biome > /dev/null && biome format --write "$fp" 2> /dev/null
command -v markdown-table-formatter > /dev/null \
&& markdown-table-formatter "$fp" 2> /dev/null
;;
*.yml | *.yaml)
command -v prettier > /dev/null && prettier --write "$fp" 2> /dev/null
;;
esac
exit 0

23
.claude/hooks/pre-edit-block.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Pre-edit guard: block vendor/lock files and secrets.d real fish files.
# Receives tool input JSON on stdin.
fp=$(jq -r '.tool_input.file_path // empty')
[ -z "$fp" ] && exit 0
case "$fp" in
*/fzf-tmux | */yarn.lock | */.yarn/*)
echo "BLOCKED: $fp is a vendor/lock file — do not edit directly" >&2
exit 2
;;
*/secrets.d/*.fish)
case "$(basename "$fp")" in
*.example.fish | *.fish.example) exit 0 ;;
esac
echo "BLOCKED: do not edit $fp directly — it is gitignored." >&2
echo "Copy the matching .fish.example file and edit that locally." >&2
exit 2
;;
esac
exit 0

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# SessionStart context: print branch, dirty file count, and last commit.
cd "$CLAUDE_PROJECT_DIR" || exit 0
branch=$(git branch --show-current 2> /dev/null)
dirty=$(git status --short 2> /dev/null | wc -l | tr -d ' ')
last=$(git log -1 --oneline 2> /dev/null)
echo "=== Dotfiles session context ==="
echo "Branch : ${branch:-unknown}"
echo "Dirty : ${dirty} file(s)"
echo "Last : ${last}"
exit 0

16
.claude/hooks/stop-lint-gate.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Stop gate: run yarn lint before Claude finishes.
# Exit 2 sends feedback back and keeps Claude working.
cd "$CLAUDE_PROJECT_DIR" || exit 0
output=$(yarn lint 2>&1)
status=$?
if [ $status -ne 0 ]; then
echo "Lint failed — fix before finishing:" >&2
echo "$output" >&2
exit 2
fi
exit 0

View File

@@ -1,12 +1,23 @@
{ {
"hooks": { "hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start-context.sh",
"statusMessage": "Loading project context..."
}
]
}
],
"PreToolUse": [ "PreToolUse": [
{ {
"matcher": "Edit|Write", "matcher": "Edit|Write",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "fp=$(cat | jq -r '.tool_input.file_path // empty') && [ -n \"$fp\" ] && case \"$fp\" in */fzf-tmux|*/yarn.lock|*/.yarn/*) echo \"BLOCKED: $fp is a vendor/lock file — do not edit directly\" >&2; exit 2;; esac; exit 0" "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-edit-block.sh"
} }
] ]
} }
@@ -17,15 +28,48 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "fp=$(cat | jq -r '.tool_input.file_path // empty') && [ -n \"$fp\" ] && [ -f \"$fp\" ] && case \"$fp\" in *.sh|*/bin/*) head -1 \"$fp\" | grep -qE '^#!.*(ba)?sh' && command -v shfmt > /dev/null && shfmt -i 2 -bn -ci -sr -fn -w \"$fp\";; esac; exit 0" "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-format.sh",
"statusMessage": "Formatting..."
}, },
{ {
"type": "command", "type": "command",
"command": "fp=$(cat | jq -r '.tool_input.file_path // empty') && [ -n \"$fp\" ] && [ -f \"$fp\" ] && case \"$fp\" in *.fish) command -v fish_indent > /dev/null && fish_indent -w \"$fp\";; esac; exit 0" "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/async-bats.sh",
}, "async": true,
"statusMessage": "Running tests..."
}
]
}
],
"PostToolUseFailure": [
{
"hooks": [
{ {
"type": "command", "type": "command",
"command": "fp=$(cat | jq -r '.tool_input.file_path // empty') && [ -n \"$fp\" ] && [ -f \"$fp\" ] && case \"$fp\" in *.lua) command -v stylua > /dev/null && stylua \"$fp\";; esac; exit 0" "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/log-failures.sh",
"async": true
}
]
}
],
"Notification": [
{
"matcher": "idle_prompt",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/notify-idle.sh",
"async": true
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-lint-gate.sh",
"statusMessage": "Running lint gate..."
} }
] ]
} }