611 lines
11 KiB
Markdown
611 lines
11 KiB
Markdown
# Hook Patterns Library
|
|
|
|
Reusable patterns for common hook use cases.
|
|
|
|
## Pattern: File Path Validation
|
|
|
|
Safely validate and sanitize file paths in hooks.
|
|
|
|
```bash
|
|
validate_file_path() {
|
|
local path="$1"
|
|
|
|
# Remove null/empty
|
|
if [ -z "$path" ] || [ "$path" == "null" ]; then
|
|
return 1
|
|
fi
|
|
|
|
# Must be absolute path
|
|
if [[ ! "$path" =~ ^/ ]]; then
|
|
return 1
|
|
fi
|
|
|
|
# Must exist
|
|
if [ ! -f "$path" ]; then
|
|
return 1
|
|
fi
|
|
|
|
# Check file extension whitelist
|
|
if [[ ! "$path" =~ \.(ts|tsx|js|jsx|py|rs|go|java)$ ]]; then
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Usage
|
|
if validate_file_path "$file_path"; then
|
|
# Safe to operate on file
|
|
process_file "$file_path"
|
|
fi
|
|
```
|
|
|
|
## Pattern: Finding Project Root
|
|
|
|
Locate the project root directory from any file path.
|
|
|
|
```bash
|
|
find_project_root() {
|
|
local dir="$1"
|
|
|
|
# Start from file's directory
|
|
if [ -f "$dir" ]; then
|
|
dir=$(dirname "$dir")
|
|
fi
|
|
|
|
# Walk up until finding markers
|
|
while [ "$dir" != "/" ]; do
|
|
# Check for project markers
|
|
if [ -f "$dir/package.json" ] || \
|
|
[ -f "$dir/Cargo.toml" ] || \
|
|
[ -f "$dir/go.mod" ] || \
|
|
[ -d "$dir/.git" ]; then
|
|
echo "$dir"
|
|
return 0
|
|
fi
|
|
dir=$(dirname "$dir")
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
# Usage
|
|
project_root=$(find_project_root "$file_path")
|
|
if [ -n "$project_root" ]; then
|
|
cd "$project_root"
|
|
npm run build
|
|
fi
|
|
```
|
|
|
|
## Pattern: Conditional Hook Execution
|
|
|
|
Run hook only when certain conditions are met.
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
|
|
# Configuration
|
|
MIN_CHANGES=3
|
|
TARGET_REPO="backend"
|
|
|
|
# Check if should run
|
|
should_run() {
|
|
# Count recent edits
|
|
local edit_count=$(tail -20 ~/.claude/edit-log.txt | wc -l)
|
|
|
|
if [ "$edit_count" -lt "$MIN_CHANGES" ]; then
|
|
return 1
|
|
fi
|
|
|
|
# Check if target repo was modified
|
|
if ! tail -20 ~/.claude/edit-log.txt | grep -q "$TARGET_REPO"; then
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Main execution
|
|
if ! should_run; then
|
|
echo '{}'
|
|
exit 0
|
|
fi
|
|
|
|
# Run actual hook logic
|
|
perform_build_check
|
|
```
|
|
|
|
## Pattern: Rate Limiting
|
|
|
|
Prevent hooks from running too frequently.
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
|
|
RATE_LIMIT_FILE="/tmp/hook-last-run"
|
|
MIN_INTERVAL=30 # seconds
|
|
|
|
# Check if enough time has passed
|
|
should_run() {
|
|
if [ ! -f "$RATE_LIMIT_FILE" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local last_run=$(cat "$RATE_LIMIT_FILE")
|
|
local now=$(date +%s)
|
|
local elapsed=$((now - last_run))
|
|
|
|
if [ "$elapsed" -lt "$MIN_INTERVAL" ]; then
|
|
echo "Skipping (ran ${elapsed}s ago, min interval ${MIN_INTERVAL}s)"
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Update last run time
|
|
mark_run() {
|
|
date +%s > "$RATE_LIMIT_FILE"
|
|
}
|
|
|
|
# Usage
|
|
if should_run; then
|
|
perform_expensive_operation
|
|
mark_run
|
|
fi
|
|
|
|
echo '{}'
|
|
```
|
|
|
|
## Pattern: Multi-Project Detection
|
|
|
|
Detect which project/repo a file belongs to.
|
|
|
|
```bash
|
|
detect_project() {
|
|
local file="$1"
|
|
local project_root="/Users/myuser/projects"
|
|
|
|
# Extract project name from path
|
|
if [[ "$file" =~ $project_root/([^/]+) ]]; then
|
|
echo "${BASH_REMATCH[1]}"
|
|
return 0
|
|
fi
|
|
|
|
echo "unknown"
|
|
return 1
|
|
}
|
|
|
|
# Usage
|
|
project=$(detect_project "$file_path")
|
|
|
|
case "$project" in
|
|
"frontend")
|
|
npm --prefix ~/projects/frontend run build
|
|
;;
|
|
"backend")
|
|
cargo build --manifest-path ~/projects/backend/Cargo.toml
|
|
;;
|
|
*)
|
|
echo "Unknown project: $project"
|
|
;;
|
|
esac
|
|
```
|
|
|
|
## Pattern: Graceful Degradation
|
|
|
|
Handle failures gracefully without blocking workflow.
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
|
|
# Try operation with fallback
|
|
try_with_fallback() {
|
|
local primary_cmd="$1"
|
|
local fallback_cmd="$2"
|
|
local description="$3"
|
|
|
|
echo "Attempting: $description"
|
|
|
|
# Try primary command
|
|
if eval "$primary_cmd" 2>/dev/null; then
|
|
echo "✅ Success"
|
|
return 0
|
|
fi
|
|
|
|
echo "⚠️ Primary failed, trying fallback..."
|
|
|
|
# Try fallback
|
|
if eval "$fallback_cmd" 2>/dev/null; then
|
|
echo "✅ Fallback succeeded"
|
|
return 0
|
|
fi
|
|
|
|
echo "❌ Both failed, continuing anyway"
|
|
return 1
|
|
}
|
|
|
|
# Usage
|
|
try_with_fallback \
|
|
"npm run build" \
|
|
"npm run build:dev" \
|
|
"Building project"
|
|
|
|
# Always return empty response (non-blocking)
|
|
echo '{}'
|
|
```
|
|
|
|
## Pattern: Parallel Execution
|
|
|
|
Run multiple checks in parallel for speed.
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
|
|
# Run checks in parallel
|
|
run_parallel_checks() {
|
|
local pids=()
|
|
|
|
# Start each check in background
|
|
check_typescript &
|
|
pids+=($!)
|
|
|
|
check_eslint &
|
|
pids+=($!)
|
|
|
|
check_tests &
|
|
pids+=($!)
|
|
|
|
# Wait for all to complete
|
|
local exit_code=0
|
|
for pid in "${pids[@]}"; do
|
|
wait "$pid" || exit_code=1
|
|
done
|
|
|
|
return $exit_code
|
|
}
|
|
|
|
check_typescript() {
|
|
npx tsc --noEmit > /tmp/tsc-output.txt 2>&1
|
|
if [ $? -ne 0 ]; then
|
|
echo "TypeScript errors found"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
check_eslint() {
|
|
npx eslint . > /tmp/eslint-output.txt 2>&1
|
|
}
|
|
|
|
check_tests() {
|
|
npm test > /tmp/test-output.txt 2>&1
|
|
}
|
|
|
|
# Usage
|
|
if run_parallel_checks; then
|
|
echo "✅ All checks passed"
|
|
else
|
|
echo "⚠️ Some checks failed"
|
|
cat /tmp/tsc-output.txt
|
|
cat /tmp/eslint-output.txt
|
|
fi
|
|
|
|
echo '{}'
|
|
```
|
|
|
|
## Pattern: Smart Caching
|
|
|
|
Cache results to avoid redundant work.
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
|
|
CACHE_DIR="$HOME/.claude/hook-cache"
|
|
mkdir -p "$CACHE_DIR"
|
|
|
|
# Generate cache key
|
|
cache_key() {
|
|
local file="$1"
|
|
echo -n "$file:$(stat -f %m "$file" 2>/dev/null || stat -c %Y "$file")" | md5sum | cut -d' ' -f1
|
|
}
|
|
|
|
# Check cache
|
|
check_cache() {
|
|
local file="$1"
|
|
local key=$(cache_key "$file")
|
|
local cache_file="$CACHE_DIR/$key"
|
|
|
|
if [ -f "$cache_file" ]; then
|
|
# Cache hit
|
|
cat "$cache_file"
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# Update cache
|
|
update_cache() {
|
|
local file="$1"
|
|
local result="$2"
|
|
local key=$(cache_key "$file")
|
|
local cache_file="$CACHE_DIR/$key"
|
|
|
|
echo "$result" > "$cache_file"
|
|
|
|
# Clean old cache entries (older than 1 day)
|
|
find "$CACHE_DIR" -type f -mtime +1 -delete 2>/dev/null
|
|
}
|
|
|
|
# Usage
|
|
if cached=$(check_cache "$file_path"); then
|
|
echo "Cache hit: $cached"
|
|
else
|
|
result=$(expensive_operation "$file_path")
|
|
update_cache "$file_path" "$result"
|
|
echo "Computed: $result"
|
|
fi
|
|
```
|
|
|
|
## Pattern: Progressive Output
|
|
|
|
Show progress for long-running hooks.
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
|
|
# Progress indicator
|
|
show_progress() {
|
|
local message="$1"
|
|
echo -n "$message..."
|
|
}
|
|
|
|
complete_progress() {
|
|
local status="$1"
|
|
if [ "$status" == "success" ]; then
|
|
echo " ✅"
|
|
else
|
|
echo " ❌"
|
|
fi
|
|
}
|
|
|
|
# Usage
|
|
show_progress "Running TypeScript compiler"
|
|
if npx tsc --noEmit 2>/dev/null; then
|
|
complete_progress "success"
|
|
else
|
|
complete_progress "failure"
|
|
fi
|
|
|
|
show_progress "Running linter"
|
|
if npx eslint . 2>/dev/null; then
|
|
complete_progress "success"
|
|
else
|
|
complete_progress "failure"
|
|
fi
|
|
|
|
echo '{}'
|
|
```
|
|
|
|
## Pattern: Context Injection
|
|
|
|
Inject helpful context into Claude's prompt.
|
|
|
|
```javascript
|
|
// UserPromptSubmit hook
|
|
function injectContext(prompt) {
|
|
const context = [];
|
|
|
|
// Add relevant documentation
|
|
if (prompt.includes('API')) {
|
|
context.push('📖 API Documentation: https://docs.example.com/api');
|
|
}
|
|
|
|
// Add recent changes
|
|
const recentFiles = getRecentlyEditedFiles();
|
|
if (recentFiles.length > 0) {
|
|
context.push(`📝 Recently edited: ${recentFiles.join(', ')}`);
|
|
}
|
|
|
|
// Add project status
|
|
const buildStatus = getLastBuildStatus();
|
|
if (!buildStatus.passed) {
|
|
context.push(`⚠️ Current build has ${buildStatus.errorCount} errors`);
|
|
}
|
|
|
|
if (context.length === 0) {
|
|
return { decision: 'approve' };
|
|
}
|
|
|
|
return {
|
|
decision: 'approve',
|
|
additionalContext: `\n\n---\n${context.join('\n')}\n---\n`
|
|
};
|
|
}
|
|
```
|
|
|
|
## Pattern: Error Accumulation
|
|
|
|
Collect multiple errors before reporting.
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
|
|
ERRORS=()
|
|
|
|
# Add error to collection
|
|
add_error() {
|
|
ERRORS+=("$1")
|
|
}
|
|
|
|
# Report all errors
|
|
report_errors() {
|
|
if [ ${#ERRORS[@]} -eq 0 ]; then
|
|
echo "✅ No errors found"
|
|
return 0
|
|
fi
|
|
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo "⚠️ Found ${#ERRORS[@]} issue(s):"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
local i=1
|
|
for error in "${ERRORS[@]}"; do
|
|
echo "$i. $error"
|
|
((i++))
|
|
done
|
|
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
return 1
|
|
}
|
|
|
|
# Usage
|
|
if ! run_typescript_check; then
|
|
add_error "TypeScript compilation failed"
|
|
fi
|
|
|
|
if ! run_lint_check; then
|
|
add_error "Linting issues found"
|
|
fi
|
|
|
|
if ! run_test_check; then
|
|
add_error "Tests failing"
|
|
fi
|
|
|
|
report_errors
|
|
|
|
echo '{}'
|
|
```
|
|
|
|
## Pattern: Conditional Blocking
|
|
|
|
Block only on critical errors, warn on others.
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
|
|
ERROR_LEVEL="none" # none, warning, critical
|
|
|
|
# Check for issues
|
|
check_critical_issues() {
|
|
if grep -q "FIXME\|XXX\|TODO: CRITICAL" "$file_path"; then
|
|
ERROR_LEVEL="critical"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
check_warnings() {
|
|
if grep -q "console.log\|debugger" "$file_path"; then
|
|
ERROR_LEVEL="warning"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Run checks
|
|
check_critical_issues
|
|
check_warnings
|
|
|
|
# Return appropriate decision
|
|
case "$ERROR_LEVEL" in
|
|
"critical")
|
|
echo '{
|
|
"decision": "block",
|
|
"reason": "🚫 CRITICAL: Found critical TODOs or FIXMEs that must be addressed"
|
|
}' | jq -c '.'
|
|
;;
|
|
"warning")
|
|
echo "⚠️ Warning: Found debug statements (console.log, debugger)"
|
|
echo '{}'
|
|
;;
|
|
*)
|
|
echo '{}'
|
|
;;
|
|
esac
|
|
```
|
|
|
|
## Pattern: Hook Coordination
|
|
|
|
Coordinate between multiple hooks using shared state.
|
|
|
|
```bash
|
|
# Hook 1: Track state
|
|
#!/bin/bash
|
|
STATE_FILE="/tmp/hook-state.json"
|
|
|
|
# Update state
|
|
jq -n \
|
|
--arg timestamp "$(date +%s)" \
|
|
--arg files "$files_edited" \
|
|
'{lastRun: $timestamp, filesEdited: ($files | split(","))}' \
|
|
> "$STATE_FILE"
|
|
|
|
echo '{}'
|
|
```
|
|
|
|
```bash
|
|
# Hook 2: Read state
|
|
#!/bin/bash
|
|
STATE_FILE="/tmp/hook-state.json"
|
|
|
|
if [ -f "$STATE_FILE" ]; then
|
|
last_run=$(jq -r '.lastRun' "$STATE_FILE")
|
|
files=$(jq -r '.filesEdited[]' "$STATE_FILE")
|
|
|
|
# Use state from previous hook
|
|
for file in $files; do
|
|
process_file "$file"
|
|
done
|
|
fi
|
|
|
|
echo '{}'
|
|
```
|
|
|
|
## Pattern: User Notification
|
|
|
|
Notify user of important events without blocking.
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
|
|
# Send desktop notification (macOS)
|
|
notify_macos() {
|
|
osascript -e "display notification \"$1\" with title \"Claude Code Hook\""
|
|
}
|
|
|
|
# Send desktop notification (Linux)
|
|
notify_linux() {
|
|
notify-send "Claude Code Hook" "$1"
|
|
}
|
|
|
|
# Notify based on OS
|
|
notify() {
|
|
local message="$1"
|
|
|
|
case "$OSTYPE" in
|
|
darwin*)
|
|
notify_macos "$message"
|
|
;;
|
|
linux*)
|
|
notify_linux "$message"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Usage
|
|
if [ "$error_count" -gt 10 ]; then
|
|
notify "⚠️ Build has $error_count errors"
|
|
fi
|
|
|
|
echo '{}'
|
|
```
|
|
|
|
## Remember
|
|
|
|
- **Keep it simple** - Start with basic patterns, add complexity only when needed
|
|
- **Test thoroughly** - Test each pattern in isolation before combining
|
|
- **Fail gracefully** - Non-blocking hooks should never crash workflow
|
|
- **Log everything** - You'll need it for debugging
|
|
- **Document patterns** - Future you will thank present you
|