From cff83e47382c426ab2213687a539310759caec48 Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Fri, 20 Mar 2026 04:38:18 +0200 Subject: [PATCH] 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. --- .claude/hooks/async-bats.sh | 13 +++++++ .claude/hooks/log-failures.sh | 14 +++++++ .claude/hooks/notify-idle.sh | 13 +++++++ .claude/hooks/post-edit-format.sh | 30 ++++++++++++++ .claude/hooks/pre-edit-block.sh | 23 +++++++++++ .claude/hooks/session-start-context.sh | 15 +++++++ .claude/hooks/stop-lint-gate.sh | 16 ++++++++ .claude/settings.json | 54 +++++++++++++++++++++++--- 8 files changed, 173 insertions(+), 5 deletions(-) create mode 100755 .claude/hooks/async-bats.sh create mode 100755 .claude/hooks/log-failures.sh create mode 100755 .claude/hooks/notify-idle.sh create mode 100755 .claude/hooks/post-edit-format.sh create mode 100755 .claude/hooks/pre-edit-block.sh create mode 100755 .claude/hooks/session-start-context.sh create mode 100755 .claude/hooks/stop-lint-gate.sh diff --git a/.claude/hooks/async-bats.sh b/.claude/hooks/async-bats.sh new file mode 100755 index 0000000..070f142 --- /dev/null +++ b/.claude/hooks/async-bats.sh @@ -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" diff --git a/.claude/hooks/log-failures.sh b/.claude/hooks/log-failures.sh new file mode 100755 index 0000000..ad57cb4 --- /dev/null +++ b/.claude/hooks/log-failures.sh @@ -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 diff --git a/.claude/hooks/notify-idle.sh b/.claude/hooks/notify-idle.sh new file mode 100755 index 0000000..4d4459b --- /dev/null +++ b/.claude/hooks/notify-idle.sh @@ -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 diff --git a/.claude/hooks/post-edit-format.sh b/.claude/hooks/post-edit-format.sh new file mode 100755 index 0000000..e8fa8e3 --- /dev/null +++ b/.claude/hooks/post-edit-format.sh @@ -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 diff --git a/.claude/hooks/pre-edit-block.sh b/.claude/hooks/pre-edit-block.sh new file mode 100755 index 0000000..6ae9299 --- /dev/null +++ b/.claude/hooks/pre-edit-block.sh @@ -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 diff --git a/.claude/hooks/session-start-context.sh b/.claude/hooks/session-start-context.sh new file mode 100755 index 0000000..d72bc38 --- /dev/null +++ b/.claude/hooks/session-start-context.sh @@ -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 diff --git a/.claude/hooks/stop-lint-gate.sh b/.claude/hooks/stop-lint-gate.sh new file mode 100755 index 0000000..43399c9 --- /dev/null +++ b/.claude/hooks/stop-lint-gate.sh @@ -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 diff --git a/.claude/settings.json b/.claude/settings.json index 018a721..d96c5c8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,12 +1,23 @@ { "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start-context.sh", + "statusMessage": "Loading project context..." + } + ] + } + ], "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "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": [ { "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", - "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", - "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..." } ] }