feat: add 4 ClaudeKit-inspired hooks (checkpoint, validation, file-guard)

- Add auto-checkpoint.sh (Stop event, git stash automation)
- Add typecheck-on-save.sh (PostToolUse, TypeScript validation)
- Add test-on-change.sh (PostToolUse, smart test detection)
- Add file-guard.sh (PreToolUse, unified file protection)
- Add ClaudeKit evaluation (3/5, patterns extracted)
- Version bump 3.21.0 → 3.21.1 (sync across all docs)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Florian BRUNIAUX 2026-02-02 21:50:48 +01:00
parent 6910c06981
commit 975b8019ac
8 changed files with 948 additions and 9 deletions

View file

@ -0,0 +1,38 @@
#!/bin/bash
# .claude/hooks/auto-checkpoint.sh
# Event: Stop
# Auto-creates git stash checkpoint when Claude Code session ends
# Inspired by checkpoint pattern for safe experimentation
set -euo pipefail
INPUT=$(cat)
# Extract session metadata
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
# Check if there are uncommitted changes
if ! git diff-index --quiet HEAD -- 2>/dev/null; then
# Create descriptive stash name
STASH_NAME="claude-checkpoint-${BRANCH}-${TIMESTAMP}"
# Stash with descriptive message
git stash push -u -m "$STASH_NAME" >/dev/null 2>&1
# Log checkpoint creation
LOG_DIR="$HOME/.claude/logs"
mkdir -p "$LOG_DIR"
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Created checkpoint: $STASH_NAME (session: $SESSION_ID)" \
>> "$LOG_DIR/checkpoints.log"
# Notify user
cat << EOF
{
"systemMessage": "✓ Checkpoint created: $STASH_NAME\n\nRestore with:\n git stash list # find the checkpoint\n git stash apply stash@{N} # restore it"
}
EOF
fi
exit 0

113
examples/hooks/bash/file-guard.sh Executable file
View file

@ -0,0 +1,113 @@
#!/bin/bash
# .claude/hooks/file-guard.sh
# Event: PreToolUse
# Unified file protection with pattern matching and bash bypass detection
# Prevents Claude from reading/writing protected files
set -euo pipefail
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Only check file operations
if [[ "$TOOL_NAME" != "Read" && "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" ]]; then
exit 0
fi
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
# Load protection patterns from .agentignore or .aiignore
IGNORE_FILE=""
if [[ -f ".agentignore" ]]; then
IGNORE_FILE=".agentignore"
elif [[ -f ".aiignore" ]]; then
IGNORE_FILE=".aiignore"
fi
# Default critical patterns (if no ignore file)
CRITICAL_PATTERNS=(
".env"
".env.local"
".env.production"
"*.key"
"*.pem"
"*.p12"
"credentials.json"
"secrets.yaml"
"config/secrets/*"
".aws/credentials"
".ssh/id_*"
)
# Check against patterns
is_protected() {
local file="$1"
# Check ignore file patterns
if [[ -n "$IGNORE_FILE" ]]; then
while IFS= read -r pattern; do
# Skip comments and empty lines
[[ "$pattern" =~ ^#.*$ || -z "$pattern" ]] && continue
# Convert gitignore pattern to bash glob
if [[ "$file" == $pattern || "$file" =~ $pattern ]]; then
return 0
fi
done < "$IGNORE_FILE"
fi
# Check critical patterns
for pattern in "${CRITICAL_PATTERNS[@]}"; do
if [[ "$file" == $pattern || "$file" =~ $pattern ]]; then
return 0
fi
done
return 1
}
# Detect bash variable expansion bypass attempts
detect_bypass() {
local file="$1"
# Check for variable expansion patterns
if [[ "$file" =~ \$\{?[A-Za-z_][A-Za-z0-9_]*\}? ]]; then
return 0
fi
# Check for command substitution
if [[ "$file" =~ \$\( || "$file" =~ \` ]]; then
return 0
fi
return 1
}
# Validate file path
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
# Check for bypass attempts
if detect_bypass "$FILE_PATH"; then
cat << EOF
{
"block": true,
"systemMessage": "⛔ File access blocked: Variable expansion detected in path\n\nPath: $FILE_PATH\n\nThis looks like a bypass attempt. Use literal paths only."
}
EOF
exit 1
fi
# Check protection patterns
if is_protected "$FILE_PATH"; then
cat << EOF
{
"block": true,
"systemMessage": "⛔ File access blocked: Protected file\n\nPath: $FILE_PATH\n\nThis file is protected by .agentignore or security policy.\nTo access it, remove from ignore file and confirm manually."
}
EOF
exit 1
fi
exit 0

View file

@ -0,0 +1,73 @@
#!/bin/bash
# .claude/hooks/test-on-change.sh
# Event: PostToolUse
# Detects and runs associated tests after code changes
# Part of validation pipeline pattern
set -euo pipefail
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Only run after Edit/Write operations
if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then
exit 0
fi
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
# Skip if not a code file
if [[ ! "$FILE_PATH" =~ \.(ts|tsx|js|jsx|py|go|rs)$ ]]; then
exit 0
fi
# Find associated test file
TEST_FILE=""
BASENAME=$(basename "$FILE_PATH" | sed 's/\.[^.]*$//')
DIRNAME=$(dirname "$FILE_PATH")
# Common test patterns
for pattern in "${BASENAME}.test.ts" "${BASENAME}.test.js" "${BASENAME}_test.py" "${BASENAME}_test.go"; do
if [[ -f "$DIRNAME/$pattern" ]]; then
TEST_FILE="$DIRNAME/$pattern"
break
fi
done
# Try adjacent __tests__ directory
if [[ -z "$TEST_FILE" ]]; then
for pattern in "__tests__/${BASENAME}.test.ts" "__tests__/${BASENAME}.test.js"; do
if [[ -f "$DIRNAME/$pattern" ]]; then
TEST_FILE="$DIRNAME/$pattern"
break
fi
done
fi
# If test file found, run it
if [[ -n "$TEST_FILE" ]]; then
# Determine test runner
if [[ -f "package.json" ]]; then
TEST_CMD="npm test -- $TEST_FILE"
elif [[ -f "pytest.ini" || -f "pyproject.toml" ]]; then
TEST_CMD="pytest $TEST_FILE"
elif [[ -f "go.mod" ]]; then
TEST_CMD="go test $(dirname $TEST_FILE)"
else
exit 0
fi
# Run tests
TEST_OUTPUT=$($TEST_CMD 2>&1 || true)
# Check for failures
if echo "$TEST_OUTPUT" | grep -qE "(FAIL|failed|error|Error)"; then
cat << EOF
{
"systemMessage": "⚠ Tests failed in $TEST_FILE:\n\n$TEST_OUTPUT\n\nFix implementation or update tests."
}
EOF
fi
fi
exit 0

View file

@ -0,0 +1,41 @@
#!/bin/bash
# .claude/hooks/typecheck-on-save.sh
# Event: PostToolUse
# Runs TypeScript compiler on changed files after edits
# Part of validation pipeline pattern
set -euo pipefail
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Only run after Edit/Write operations
if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then
exit 0
fi
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
# Only check TypeScript files
if [[ ! "$FILE_PATH" =~ \.(ts|tsx)$ ]]; then
exit 0
fi
# Check if tsconfig exists
if [[ ! -f "tsconfig.json" ]]; then
exit 0
fi
# Run tsc on the specific file (noEmit mode)
TSC_OUTPUT=$(npx tsc --noEmit "$FILE_PATH" 2>&1 || true)
# Check if there are errors (not just warnings)
if echo "$TSC_OUTPUT" | grep -q "error TS"; then
cat << EOF
{
"systemMessage": "⚠ TypeScript errors in $FILE_PATH:\n\n$TSC_OUTPUT\n\nFix these before proceeding."
}
EOF
fi
exit 0