Files
claude-code/plugins/plugin-dev/skills/hook-development/scripts/validate-hook-schema.sh
Claude 411381bf3e fix: Add missing matcher fields to hooks.json files
Stop hooks (and other hook types) were not firing because they were
missing the required "matcher" field. According to the hook development
documentation, all hooks must have a matcher field - "*" for wildcard
matching.

Changes:
- Add matcher: "*" to all hooks in hookify, ralph-wiggum,
  explanatory-output-style, and learning-output-style plugins
- Update validate-hook-schema.sh to properly handle plugin format
  (with 'hooks' wrapper) vs settings format (events at root)
- Add validate-all-hooks.sh script to validate all hooks.json files

Fixes: https://anthropic.slack.com/archives/C08EHE6JF3L/p1765822035850959
2025-12-15 18:14:58 +00:00

180 lines
5.7 KiB
Bash
Executable File

#!/bin/bash
# Hook Schema Validator
# Validates hooks.json structure and checks for common issues
set -euo pipefail
# Usage
if [ $# -eq 0 ]; then
echo "Usage: $0 <path/to/hooks.json>"
echo ""
echo "Validates hook configuration file for:"
echo " - Valid JSON syntax"
echo " - Required fields"
echo " - Hook type validity"
echo " - Matcher patterns"
echo " - Timeout ranges"
exit 1
fi
HOOKS_FILE="$1"
if [ ! -f "$HOOKS_FILE" ]; then
echo "❌ Error: File not found: $HOOKS_FILE"
exit 1
fi
echo "🔍 Validating hooks configuration: $HOOKS_FILE"
echo ""
# Check 1: Valid JSON
echo "Checking JSON syntax..."
if ! jq empty "$HOOKS_FILE" 2>/dev/null; then
echo "❌ Invalid JSON syntax"
exit 1
fi
echo "✅ Valid JSON"
# Check 2: Root structure
echo ""
echo "Checking root structure..."
VALID_EVENTS=("PreToolUse" "PostToolUse" "UserPromptSubmit" "Stop" "SubagentStop" "SessionStart" "SessionEnd" "PreCompact" "Notification")
# Detect format: plugin format has { description?, hooks: {...} } wrapper
# Settings format has events directly at root level
is_plugin_format=false
if jq -e '.hooks' "$HOOKS_FILE" >/dev/null 2>&1; then
is_plugin_format=true
HOOKS_PATH=".hooks"
echo "Detected plugin format (with 'hooks' wrapper)"
# Validate allowed root keys for plugin format
for key in $(jq -r 'keys[]' "$HOOKS_FILE"); do
if [ "$key" != "hooks" ] && [ "$key" != "description" ]; then
echo "⚠️ Unknown root key in plugin format: $key (expected: 'hooks', 'description')"
fi
done
else
HOOKS_PATH="."
echo "Detected settings format (events at root)"
fi
# Validate event types
for event in $(jq -r "$HOOKS_PATH | keys[]" "$HOOKS_FILE"); do
found=false
for valid_event in "${VALID_EVENTS[@]}"; do
if [ "$event" = "$valid_event" ]; then
found=true
break
fi
done
if [ "$found" = false ]; then
echo "⚠️ Unknown event type: $event"
fi
done
echo "✅ Root structure valid"
# Check 3: Validate each hook
echo ""
echo "Validating individual hooks..."
error_count=0
warning_count=0
for event in $(jq -r "$HOOKS_PATH | keys[]" "$HOOKS_FILE"); do
hook_count=$(jq -r "$HOOKS_PATH.\"$event\" | length" "$HOOKS_FILE")
for ((i=0; i<hook_count; i++)); do
# Check matcher exists
matcher=$(jq -r "$HOOKS_PATH.\"$event\"[$i].matcher // empty" "$HOOKS_FILE")
if [ -z "$matcher" ]; then
echo "$event[$i]: Missing 'matcher' field"
((error_count++))
continue
fi
# Check hooks array exists
hooks=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks // empty" "$HOOKS_FILE")
if [ -z "$hooks" ] || [ "$hooks" = "null" ]; then
echo "$event[$i]: Missing 'hooks' array"
((error_count++))
continue
fi
# Validate each hook in the array
hook_array_count=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks | length" "$HOOKS_FILE")
for ((j=0; j<hook_array_count; j++)); do
hook_type=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks[$j].type // empty" "$HOOKS_FILE")
if [ -z "$hook_type" ]; then
echo "$event[$i].hooks[$j]: Missing 'type' field"
((error_count++))
continue
fi
if [ "$hook_type" != "command" ] && [ "$hook_type" != "prompt" ]; then
echo "$event[$i].hooks[$j]: Invalid type '$hook_type' (must be 'command' or 'prompt')"
((error_count++))
continue
fi
# Check type-specific fields
if [ "$hook_type" = "command" ]; then
command=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks[$j].command // empty" "$HOOKS_FILE")
if [ -z "$command" ]; then
echo "$event[$i].hooks[$j]: Command hooks must have 'command' field"
((error_count++))
else
# Check for hardcoded paths
if [[ "$command" == /* ]] && [[ "$command" != *'${CLAUDE_PLUGIN_ROOT}'* ]]; then
echo "⚠️ $event[$i].hooks[$j]: Hardcoded absolute path detected. Consider using \${CLAUDE_PLUGIN_ROOT}"
((warning_count++))
fi
fi
elif [ "$hook_type" = "prompt" ]; then
prompt=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks[$j].prompt // empty" "$HOOKS_FILE")
if [ -z "$prompt" ]; then
echo "$event[$i].hooks[$j]: Prompt hooks must have 'prompt' field"
((error_count++))
fi
# Check if prompt-based hooks are used on supported events
if [ "$event" != "Stop" ] && [ "$event" != "SubagentStop" ] && [ "$event" != "UserPromptSubmit" ] && [ "$event" != "PreToolUse" ]; then
echo "⚠️ $event[$i].hooks[$j]: Prompt hooks may not be fully supported on $event (best on Stop, SubagentStop, UserPromptSubmit, PreToolUse)"
((warning_count++))
fi
fi
# Check timeout
timeout=$(jq -r "$HOOKS_PATH.\"$event\"[$i].hooks[$j].timeout // empty" "$HOOKS_FILE")
if [ -n "$timeout" ] && [ "$timeout" != "null" ]; then
if ! [[ "$timeout" =~ ^[0-9]+$ ]]; then
echo "$event[$i].hooks[$j]: Timeout must be a number"
((error_count++))
elif [ "$timeout" -gt 600 ]; then
echo "⚠️ $event[$i].hooks[$j]: Timeout $timeout seconds is very high (max 600s)"
((warning_count++))
elif [ "$timeout" -lt 5 ]; then
echo "⚠️ $event[$i].hooks[$j]: Timeout $timeout seconds is very low"
((warning_count++))
fi
fi
done
done
done
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ $error_count -eq 0 ] && [ $warning_count -eq 0 ]; then
echo "✅ All checks passed!"
exit 0
elif [ $error_count -eq 0 ]; then
echo "⚠️ Validation passed with $warning_count warning(s)"
exit 0
else
echo "❌ Validation failed with $error_count error(s) and $warning_count warning(s)"
exit 1
fi