364 lines
9.4 KiB
Bash
Executable File
364 lines
9.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# validate-hook.sh - Validate Claude Code hook configuration and scripts
|
||
set -euo pipefail
|
||
|
||
# Colors for output
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
NC='\033[0m' # No Color
|
||
|
||
# Counters
|
||
ERRORS=0
|
||
WARNINGS=0
|
||
|
||
# Help text
|
||
show_help() {
|
||
cat << EOF
|
||
Usage: $(basename "$0") <config-file> [options]
|
||
|
||
Validate Claude Code hook configuration and referenced scripts.
|
||
|
||
Arguments:
|
||
config-file Path to settings.json file with hooks configuration
|
||
|
||
Options:
|
||
-s, --strict Strict mode (warnings become errors)
|
||
-q, --quiet Only show errors and warnings
|
||
--check-scripts Verify all referenced scripts exist and are executable
|
||
-h, --help Show this help
|
||
|
||
Examples:
|
||
# Validate project hooks
|
||
$(basename "$0") .claude/settings.json
|
||
|
||
# Validate personal hooks
|
||
$(basename "$0") ~/.claude/settings.json
|
||
|
||
# Strict validation with script checking
|
||
$(basename "$0") .claude/settings.json --strict --check-scripts
|
||
|
||
Exit Codes:
|
||
0 - Validation passed
|
||
1 - Validation failed with errors
|
||
EOF
|
||
}
|
||
|
||
# Error reporting
|
||
error() {
|
||
((ERRORS++))
|
||
echo -e "${RED}✗ Error:${NC} $1"
|
||
}
|
||
|
||
warning() {
|
||
((WARNINGS++))
|
||
echo -e "${YELLOW}⚠ Warning:${NC} $1"
|
||
}
|
||
|
||
info() {
|
||
if [[ "$QUIET" == "false" ]]; then
|
||
echo -e "${BLUE}ℹ Info:${NC} $1"
|
||
fi
|
||
}
|
||
|
||
success() {
|
||
if [[ "$QUIET" == "false" ]]; then
|
||
echo -e "${GREEN}✓ $1${NC}"
|
||
fi
|
||
}
|
||
|
||
# Parse arguments
|
||
CONFIG_FILE=""
|
||
STRICT=false
|
||
QUIET=false
|
||
CHECK_SCRIPTS=false
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
-h|--help)
|
||
show_help
|
||
exit 0
|
||
;;
|
||
-s|--strict)
|
||
STRICT=true
|
||
shift
|
||
;;
|
||
-q|--quiet)
|
||
QUIET=true
|
||
shift
|
||
;;
|
||
--check-scripts)
|
||
CHECK_SCRIPTS=true
|
||
shift
|
||
;;
|
||
-*)
|
||
echo -e "${RED}Error: Unknown option $1${NC}"
|
||
show_help
|
||
exit 1
|
||
;;
|
||
*)
|
||
CONFIG_FILE="$1"
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Validate file argument
|
||
if [[ -z "$CONFIG_FILE" ]]; then
|
||
echo -e "${RED}Error: Config file required${NC}"
|
||
show_help
|
||
exit 1
|
||
fi
|
||
|
||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||
error "File not found: $CONFIG_FILE"
|
||
exit 1
|
||
fi
|
||
|
||
# Check if jq is installed
|
||
if ! command -v jq &>/dev/null; then
|
||
error "jq is not installed (required for JSON validation)"
|
||
exit 1
|
||
fi
|
||
|
||
# Start validation
|
||
if [[ "$QUIET" == "false" ]]; then
|
||
echo -e "${BLUE}Validating: $CONFIG_FILE${NC}"
|
||
echo
|
||
fi
|
||
|
||
# 1. Validate JSON syntax
|
||
if ! jq empty "$CONFIG_FILE" 2>/dev/null; then
|
||
error "Invalid JSON syntax"
|
||
exit 1
|
||
fi
|
||
|
||
success "JSON syntax valid"
|
||
|
||
# 2. Check if hooks exist in config
|
||
if ! jq -e '.hooks' "$CONFIG_FILE" >/dev/null 2>&1; then
|
||
info "No hooks configuration found (file is valid but has no hooks)"
|
||
exit 0
|
||
fi
|
||
|
||
success "Hooks configuration found"
|
||
|
||
# 3. Validate hook structure
|
||
VALID_EVENTS=(PreToolUse PostToolUse UserPromptSubmit Notification Stop SubagentStop PreCompact SessionStart SessionEnd)
|
||
|
||
# Get all event names
|
||
EVENTS=$(jq -r '.hooks | keys[]' "$CONFIG_FILE" 2>/dev/null || echo "")
|
||
|
||
if [[ -z "$EVENTS" ]]; then
|
||
warning "No events defined in hooks configuration"
|
||
fi
|
||
|
||
TOTAL_HOOKS=0
|
||
|
||
while IFS= read -r event; do
|
||
[[ -z "$event" ]] && continue
|
||
|
||
# Check if valid event
|
||
if [[ ! " ${VALID_EVENTS[*]} " =~ " ${event} " ]]; then
|
||
error "Invalid event type: $event (valid: ${VALID_EVENTS[*]})"
|
||
continue
|
||
fi
|
||
|
||
success "Event: $event"
|
||
|
||
# Validate event structure
|
||
EVENT_ARRAY=$(jq -c ".hooks.\"$event\"" "$CONFIG_FILE")
|
||
|
||
# Check if array
|
||
if ! echo "$EVENT_ARRAY" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||
error "Event '$event' must be an array"
|
||
continue
|
||
fi
|
||
|
||
# Get array length
|
||
ARRAY_LENGTH=$(echo "$EVENT_ARRAY" | jq 'length')
|
||
info " Found $ARRAY_LENGTH matcher(s) for $event"
|
||
|
||
# Validate each matcher entry
|
||
for ((i=0; i<ARRAY_LENGTH; i++)); do
|
||
MATCHER_ENTRY=$(echo "$EVENT_ARRAY" | jq -c ".[$i]")
|
||
|
||
# Check for matcher field
|
||
if ! echo "$MATCHER_ENTRY" | jq -e '.matcher' >/dev/null 2>&1; then
|
||
error " Entry $i in $event: missing 'matcher' field"
|
||
continue
|
||
fi
|
||
|
||
MATCHER=$(echo "$MATCHER_ENTRY" | jq -r '.matcher')
|
||
info " Matcher: $MATCHER"
|
||
|
||
# Validate matcher pattern
|
||
if [[ -z "$MATCHER" ]]; then
|
||
warning " Empty matcher pattern"
|
||
fi
|
||
|
||
# Check for hooks array
|
||
if ! echo "$MATCHER_ENTRY" | jq -e '.hooks' >/dev/null 2>&1; then
|
||
error " Entry $i in $event: missing 'hooks' array"
|
||
continue
|
||
fi
|
||
|
||
if ! echo "$MATCHER_ENTRY" | jq -e '.hooks | type == "array"' >/dev/null 2>&1; then
|
||
error " Entry $i in $event: 'hooks' must be an array"
|
||
continue
|
||
fi
|
||
|
||
# Validate each hook in the array
|
||
HOOKS_LENGTH=$(echo "$MATCHER_ENTRY" | jq '.hooks | length')
|
||
((TOTAL_HOOKS += HOOKS_LENGTH))
|
||
|
||
for ((j=0; j<HOOKS_LENGTH; j++)); do
|
||
HOOK=$(echo "$MATCHER_ENTRY" | jq -c ".hooks[$j]")
|
||
|
||
# Check type field
|
||
if ! echo "$HOOK" | jq -e '.type' >/dev/null 2>&1; then
|
||
error " Hook $j: missing 'type' field"
|
||
continue
|
||
fi
|
||
|
||
HOOK_TYPE=$(echo "$HOOK" | jq -r '.type')
|
||
if [[ "$HOOK_TYPE" != "command" ]]; then
|
||
error " Hook $j: invalid type '$HOOK_TYPE' (must be 'command')"
|
||
fi
|
||
|
||
# Check command field
|
||
if ! echo "$HOOK" | jq -e '.command' >/dev/null 2>&1; then
|
||
error " Hook $j: missing 'command' field"
|
||
continue
|
||
fi
|
||
|
||
COMMAND=$(echo "$HOOK" | jq -r '.command')
|
||
info " Command: $COMMAND"
|
||
|
||
# Check if command is empty
|
||
if [[ -z "$COMMAND" ]]; then
|
||
error " Hook $j: empty command"
|
||
continue
|
||
fi
|
||
|
||
# Check timeout
|
||
if echo "$HOOK" | jq -e '.timeout' >/dev/null 2>&1; then
|
||
TIMEOUT=$(echo "$HOOK" | jq -r '.timeout')
|
||
if ! [[ "$TIMEOUT" =~ ^[0-9]+$ ]]; then
|
||
error " Hook $j: timeout must be a number"
|
||
elif [[ "$TIMEOUT" -lt 1 ]]; then
|
||
error " Hook $j: timeout must be at least 1 second"
|
||
elif [[ "$TIMEOUT" -gt 300 ]]; then
|
||
warning " Hook $j: timeout is very long ($TIMEOUT seconds)"
|
||
fi
|
||
else
|
||
info " Using default timeout (30 seconds)"
|
||
fi
|
||
|
||
# Check script if enabled
|
||
if [[ "$CHECK_SCRIPTS" == "true" ]]; then
|
||
# Extract script path (handle variables)
|
||
SCRIPT_PATH="$COMMAND"
|
||
|
||
# Replace common variables
|
||
SCRIPT_PATH="${SCRIPT_PATH//\$CLAUDE_PROJECT_DIR/$(dirname "$CONFIG_FILE")}"
|
||
SCRIPT_PATH="${SCRIPT_PATH//\$\{CLAUDE_PROJECT_DIR\}/$(dirname "$CONFIG_FILE")}"
|
||
SCRIPT_PATH="${SCRIPT_PATH//\~/~}"
|
||
|
||
# Extract first argument (script path)
|
||
SCRIPT_PATH=$(echo "$SCRIPT_PATH" | awk '{print $1}')
|
||
|
||
# Remove quotes
|
||
SCRIPT_PATH="${SCRIPT_PATH//\"/}"
|
||
SCRIPT_PATH="${SCRIPT_PATH//\'/}"
|
||
|
||
# Check if script exists
|
||
if [[ -f "$SCRIPT_PATH" ]]; then
|
||
success " Script exists: $SCRIPT_PATH"
|
||
|
||
# Check if executable
|
||
if [[ ! -x "$SCRIPT_PATH" ]]; then
|
||
warning " Script not executable: $SCRIPT_PATH (run: chmod +x $SCRIPT_PATH)"
|
||
else
|
||
success " Script is executable"
|
||
fi
|
||
|
||
# Check shebang
|
||
FIRST_LINE=$(head -n 1 "$SCRIPT_PATH")
|
||
if [[ ! "$FIRST_LINE" =~ ^#! ]]; then
|
||
warning " Script missing shebang line"
|
||
fi
|
||
elif [[ "$SCRIPT_PATH" =~ ^\$ ]] || [[ "$SCRIPT_PATH" =~ ^(echo|printf|cat|true|false|test|:)$ ]]; then
|
||
info " Inline/built-in command, skipping script check"
|
||
else
|
||
warning " Script not found: $SCRIPT_PATH"
|
||
fi
|
||
fi
|
||
done
|
||
done
|
||
|
||
echo
|
||
done <<< "$EVENTS"
|
||
|
||
# Summary
|
||
info "Total hooks validated: $TOTAL_HOOKS"
|
||
echo
|
||
|
||
# 4. Check for common issues
|
||
|
||
# Duplicate matchers
|
||
DUPLICATE_MATCHERS=$(jq -r '.hooks | to_entries[] | .key as $event | .value[] | .matcher | "\($event):\(.)"' "$CONFIG_FILE" | sort | uniq -c | awk '$1 > 1 {print $0}')
|
||
if [[ -n "$DUPLICATE_MATCHERS" ]]; then
|
||
warning "Duplicate matchers found (may cause hooks to run multiple times):"
|
||
echo "$DUPLICATE_MATCHERS" | while read -r line; do
|
||
warning " $line"
|
||
done
|
||
fi
|
||
|
||
# Very long commands
|
||
LONG_COMMANDS=$(jq -r '.hooks | to_entries[] | .value[] | .hooks[] | .command | select(length > 200)' "$CONFIG_FILE" 2>/dev/null || echo "")
|
||
if [[ -n "$LONG_COMMANDS" ]]; then
|
||
warning "Very long commands found (consider using script files):"
|
||
echo "$LONG_COMMANDS" | while IFS= read -r cmd; do
|
||
warning " ${cmd:0:100}..."
|
||
done
|
||
fi
|
||
|
||
# Hooks without timeout
|
||
HOOKS_WITHOUT_TIMEOUT=$(jq '[.hooks | to_entries[] | .value[] | .hooks[] | select(.timeout == null)] | length' "$CONFIG_FILE")
|
||
if [[ "$HOOKS_WITHOUT_TIMEOUT" -gt 0 ]]; then
|
||
info "$HOOKS_WITHOUT_TIMEOUT hook(s) using default timeout"
|
||
fi
|
||
|
||
# PreToolUse hooks with long timeouts
|
||
SLOW_PRE_HOOKS=$(jq -r '.hooks.PreToolUse[]? | .hooks[] | select(.timeout > 10) | .command' "$CONFIG_FILE" 2>/dev/null || echo "")
|
||
if [[ -n "$SLOW_PRE_HOOKS" ]]; then
|
||
warning "PreToolUse hooks with timeout >10s (may slow down operations):"
|
||
echo "$SLOW_PRE_HOOKS" | while IFS= read -r cmd; do
|
||
warning " $cmd"
|
||
done
|
||
fi
|
||
|
||
# Convert warnings to errors in strict mode
|
||
if [[ "$STRICT" == "true" && $WARNINGS -gt 0 ]]; then
|
||
ERRORS=$((ERRORS + WARNINGS))
|
||
WARNINGS=0
|
||
fi
|
||
|
||
# Final summary
|
||
echo
|
||
if [[ $ERRORS -eq 0 && $WARNINGS -eq 0 ]]; then
|
||
echo -e "${GREEN}✓ Validation passed!${NC}"
|
||
exit 0
|
||
elif [[ $ERRORS -eq 0 ]]; then
|
||
echo -e "${YELLOW}⚠ Validation passed with $WARNINGS warning(s)${NC}"
|
||
exit 0
|
||
else
|
||
echo -e "${RED}✗ Validation failed with $ERRORS error(s)${NC}"
|
||
if [[ $WARNINGS -gt 0 ]]; then
|
||
echo -e "${YELLOW} and $WARNINGS warning(s)${NC}"
|
||
fi
|
||
exit 1
|
||
fi
|