Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:26:37 +08:00
commit 5fdc9f2c12
67 changed files with 22481 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
# Hook Development Utility Scripts
These scripts help validate, test, and lint hook implementations before deployment.
## validate-hook-schema.sh
Validates `hooks.json` configuration files for correct structure and common issues.
**Usage:**
```bash
./validate-hook-schema.sh path/to/hooks.json
```
**Checks:**
- Valid JSON syntax
- Required fields present
- Valid hook event names
- Proper hook types (command/prompt)
- Timeout values in valid ranges
- Hardcoded path detection
- Prompt hook event compatibility
**Example:**
```bash
cd my-plugin
./validate-hook-schema.sh hooks/hooks.json
```
## test-hook.sh
Tests individual hook scripts with sample input before deploying to Claude Code.
**Usage:**
```bash
./test-hook.sh [options] <hook-script> <test-input.json>
```
**Options:**
- `-v, --verbose` - Show detailed execution information
- `-t, --timeout N` - Set timeout in seconds (default: 60)
- `--create-sample <event-type>` - Generate sample test input
**Example:**
```bash
# Create sample test input
./test-hook.sh --create-sample PreToolUse > test-input.json
# Test a hook script
./test-hook.sh my-hook.sh test-input.json
# Test with verbose output and custom timeout
./test-hook.sh -v -t 30 my-hook.sh test-input.json
```
**Features:**
- Sets up proper environment variables (CLAUDE_PROJECT_DIR, CLAUDE_PLUGIN_ROOT)
- Measures execution time
- Validates output JSON
- Shows exit codes and their meanings
- Captures environment file output
## hook-linter.sh
Checks hook scripts for common issues and best practices violations.
**Usage:**
```bash
./hook-linter.sh <hook-script.sh> [hook-script2.sh ...]
```
**Checks:**
- Shebang presence
- `set -euo pipefail` usage
- Stdin input reading
- Proper error handling
- Variable quoting (injection prevention)
- Exit code usage
- Hardcoded paths
- Long-running code detection
- Error output to stderr
- Input validation
**Example:**
```bash
# Lint single script
./hook-linter.sh ../examples/validate-write.sh
# Lint multiple scripts
./hook-linter.sh ../examples/*.sh
```
## Typical Workflow
1. **Write your hook script**
```bash
vim my-plugin/scripts/my-hook.sh
```
2. **Lint the script**
```bash
./hook-linter.sh my-plugin/scripts/my-hook.sh
```
3. **Create test input**
```bash
./test-hook.sh --create-sample PreToolUse > test-input.json
# Edit test-input.json as needed
```
4. **Test the hook**
```bash
./test-hook.sh -v my-plugin/scripts/my-hook.sh test-input.json
```
5. **Add to hooks.json**
```bash
# Edit my-plugin/hooks/hooks.json
```
6. **Validate configuration**
```bash
./validate-hook-schema.sh my-plugin/hooks/hooks.json
```
7. **Test in Claude Code**
```bash
claude --debug
```
## Tips
- Always test hooks before deploying to avoid breaking user workflows
- Use verbose mode (`-v`) to debug hook behavior
- Check the linter output for security and best practice issues
- Validate hooks.json after any changes
- Create different test inputs for various scenarios (safe operations, dangerous operations, edge cases)
## Common Issues
### Hook doesn't execute
Check:
- Script has shebang (`#!/bin/bash`)
- Script is executable (`chmod +x`)
- Path in hooks.json is correct (use `${CLAUDE_PLUGIN_ROOT}`)
### Hook times out
- Reduce timeout in hooks.json
- Optimize hook script performance
- Remove long-running operations
### Hook fails silently
- Check exit codes (should be 0 or 2)
- Ensure errors go to stderr (`>&2`)
- Validate JSON output structure
### Injection vulnerabilities
- Always quote variables: `"$variable"`
- Use `set -euo pipefail`
- Validate all input fields
- Run the linter to catch issues

View File

@@ -0,0 +1,153 @@
#!/bin/bash
# Hook Linter
# Checks hook scripts for common issues and best practices
set -euo pipefail
# Usage
if [ $# -eq 0 ]; then
echo "Usage: $0 <hook-script.sh> [hook-script2.sh ...]"
echo ""
echo "Checks hook scripts for:"
echo " - Shebang presence"
echo " - set -euo pipefail usage"
echo " - Input reading from stdin"
echo " - Proper error handling"
echo " - Variable quoting"
echo " - Exit code usage"
echo " - Hardcoded paths"
echo " - Timeout considerations"
exit 1
fi
check_script() {
local script="$1"
local warnings=0
local errors=0
echo "🔍 Linting: $script"
echo ""
if [ ! -f "$script" ]; then
echo "❌ Error: File not found"
return 1
fi
# Check 1: Executable
if [ ! -x "$script" ]; then
echo "⚠️ Not executable (chmod +x $script)"
((warnings++))
fi
# Check 2: Shebang
first_line=$(head -1 "$script")
if [[ ! "$first_line" =~ ^#!/ ]]; then
echo "❌ Missing shebang (#!/bin/bash)"
((errors++))
fi
# Check 3: set -euo pipefail
if ! grep -q "set -euo pipefail" "$script"; then
echo "⚠️ Missing 'set -euo pipefail' (recommended for safety)"
((warnings++))
fi
# Check 4: Reads from stdin
if ! grep -q "cat\|read" "$script"; then
echo "⚠️ Doesn't appear to read input from stdin"
((warnings++))
fi
# Check 5: Uses jq for JSON parsing
if grep -q "tool_input\|tool_name" "$script" && ! grep -q "jq" "$script"; then
echo "⚠️ Parses hook input but doesn't use jq"
((warnings++))
fi
# Check 6: Unquoted variables
if grep -E '\$[A-Za-z_][A-Za-z0-9_]*[^"]' "$script" | grep -v '#' | grep -q .; then
echo "⚠️ Potentially unquoted variables detected (injection risk)"
echo " Always use double quotes: \"\$variable\" not \$variable"
((warnings++))
fi
# Check 7: Hardcoded paths
if grep -E '^[^#]*/home/|^[^#]*/usr/|^[^#]*/opt/' "$script" | grep -q .; then
echo "⚠️ Hardcoded absolute paths detected"
echo " Use \$CLAUDE_PROJECT_DIR or \$CLAUDE_PLUGIN_ROOT"
((warnings++))
fi
# Check 8: Uses CLAUDE_PLUGIN_ROOT
if ! grep -q "CLAUDE_PLUGIN_ROOT\|CLAUDE_PROJECT_DIR" "$script"; then
echo "💡 Tip: Use \$CLAUDE_PLUGIN_ROOT for plugin-relative paths"
fi
# Check 9: Exit codes
if ! grep -q "exit 0\|exit 2" "$script"; then
echo "⚠️ No explicit exit codes (should exit 0 or 2)"
((warnings++))
fi
# Check 10: JSON output for decision hooks
if grep -q "PreToolUse\|Stop" "$script"; then
if ! grep -q "permissionDecision\|decision" "$script"; then
echo "💡 Tip: PreToolUse/Stop hooks should output decision JSON"
fi
fi
# Check 11: Long-running commands
if grep -E 'sleep [0-9]{3,}|while true' "$script" | grep -v '#' | grep -q .; then
echo "⚠️ Potentially long-running code detected"
echo " Hooks should complete quickly (< 60s)"
((warnings++))
fi
# Check 12: Error messages to stderr
if grep -q 'echo.*".*error\|Error\|denied\|Denied' "$script"; then
if ! grep -q '>&2' "$script"; then
echo "⚠️ Error messages should be written to stderr (>&2)"
((warnings++))
fi
fi
# Check 13: Input validation
if ! grep -q "if.*empty\|if.*null\|if.*-z" "$script"; then
echo "💡 Tip: Consider validating input fields aren't empty"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ $errors -eq 0 ] && [ $warnings -eq 0 ]; then
echo "✅ No issues found"
return 0
elif [ $errors -eq 0 ]; then
echo "⚠️ Found $warnings warning(s)"
return 0
else
echo "❌ Found $errors error(s) and $warnings warning(s)"
return 1
fi
}
echo "🔎 Hook Script Linter"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
total_errors=0
for script in "$@"; do
if ! check_script "$script"; then
((total_errors++))
fi
echo ""
done
if [ $total_errors -eq 0 ]; then
echo "✅ All scripts passed linting"
exit 0
else
echo "$total_errors script(s) had errors"
exit 1
fi

View File

@@ -0,0 +1,252 @@
#!/bin/bash
# Hook Testing Helper
# Tests a hook with sample input and shows output
set -euo pipefail
# Usage
show_usage() {
echo "Usage: $0 [options] <hook-script> <test-input.json>"
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " -v, --verbose Show detailed execution information"
echo " -t, --timeout N Set timeout in seconds (default: 60)"
echo ""
echo "Examples:"
echo " $0 validate-bash.sh test-input.json"
echo " $0 -v -t 30 validate-write.sh write-input.json"
echo ""
echo "Creates sample test input with:"
echo " $0 --create-sample <event-type>"
exit 0
}
# Create sample input
create_sample() {
event_type="$1"
case "$event_type" in
PreToolUse)
cat <<'EOF'
{
"session_id": "test-session",
"transcript_path": "/tmp/transcript.txt",
"cwd": "/tmp/test-project",
"permission_mode": "ask",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/tmp/test.txt",
"content": "Test content"
}
}
EOF
;;
PostToolUse)
cat <<'EOF'
{
"session_id": "test-session",
"transcript_path": "/tmp/transcript.txt",
"cwd": "/tmp/test-project",
"permission_mode": "ask",
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"tool_result": "Command executed successfully"
}
EOF
;;
Stop|SubagentStop)
cat <<'EOF'
{
"session_id": "test-session",
"transcript_path": "/tmp/transcript.txt",
"cwd": "/tmp/test-project",
"permission_mode": "ask",
"hook_event_name": "Stop",
"reason": "Task appears complete"
}
EOF
;;
UserPromptSubmit)
cat <<'EOF'
{
"session_id": "test-session",
"transcript_path": "/tmp/transcript.txt",
"cwd": "/tmp/test-project",
"permission_mode": "ask",
"hook_event_name": "UserPromptSubmit",
"user_prompt": "Test user prompt"
}
EOF
;;
SessionStart|SessionEnd)
cat <<'EOF'
{
"session_id": "test-session",
"transcript_path": "/tmp/transcript.txt",
"cwd": "/tmp/test-project",
"permission_mode": "ask",
"hook_event_name": "SessionStart"
}
EOF
;;
*)
echo "Unknown event type: $event_type"
echo "Valid types: PreToolUse, PostToolUse, Stop, SubagentStop, UserPromptSubmit, SessionStart, SessionEnd"
exit 1
;;
esac
}
# Parse arguments
VERBOSE=false
TIMEOUT=60
while [ $# -gt 0 ]; do
case "$1" in
-h|--help)
show_usage
;;
-v|--verbose)
VERBOSE=true
shift
;;
-t|--timeout)
TIMEOUT="$2"
shift 2
;;
--create-sample)
create_sample "$2"
exit 0
;;
*)
break
;;
esac
done
if [ $# -ne 2 ]; then
echo "Error: Missing required arguments"
echo ""
show_usage
fi
HOOK_SCRIPT="$1"
TEST_INPUT="$2"
# Validate inputs
if [ ! -f "$HOOK_SCRIPT" ]; then
echo "❌ Error: Hook script not found: $HOOK_SCRIPT"
exit 1
fi
if [ ! -x "$HOOK_SCRIPT" ]; then
echo "⚠️ Warning: Hook script is not executable. Attempting to run with bash..."
HOOK_SCRIPT="bash $HOOK_SCRIPT"
fi
if [ ! -f "$TEST_INPUT" ]; then
echo "❌ Error: Test input not found: $TEST_INPUT"
exit 1
fi
# Validate test input JSON
if ! jq empty "$TEST_INPUT" 2>/dev/null; then
echo "❌ Error: Test input is not valid JSON"
exit 1
fi
echo "🧪 Testing hook: $HOOK_SCRIPT"
echo "📥 Input: $TEST_INPUT"
echo ""
if [ "$VERBOSE" = true ]; then
echo "Input JSON:"
jq . "$TEST_INPUT"
echo ""
fi
# Set up environment
export CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/tmp/test-project}"
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(pwd)}"
export CLAUDE_ENV_FILE="${CLAUDE_ENV_FILE:-/tmp/test-env-$$}"
if [ "$VERBOSE" = true ]; then
echo "Environment:"
echo " CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR"
echo " CLAUDE_PLUGIN_ROOT=$CLAUDE_PLUGIN_ROOT"
echo " CLAUDE_ENV_FILE=$CLAUDE_ENV_FILE"
echo ""
fi
# Run the hook
echo "▶️ Running hook (timeout: ${TIMEOUT}s)..."
echo ""
start_time=$(date +%s)
set +e
output=$(timeout "$TIMEOUT" bash -c "cat '$TEST_INPUT' | $HOOK_SCRIPT" 2>&1)
exit_code=$?
set -e
end_time=$(date +%s)
duration=$((end_time - start_time))
# Analyze results
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Results:"
echo ""
echo "Exit Code: $exit_code"
echo "Duration: ${duration}s"
echo ""
case $exit_code in
0)
echo "✅ Hook approved/succeeded"
;;
2)
echo "🚫 Hook blocked/denied"
;;
124)
echo "⏱️ Hook timed out after ${TIMEOUT}s"
;;
*)
echo "⚠️ Hook returned unexpected exit code: $exit_code"
;;
esac
echo ""
echo "Output:"
if [ -n "$output" ]; then
echo "$output"
echo ""
# Try to parse as JSON
if echo "$output" | jq empty 2>/dev/null; then
echo "Parsed JSON output:"
echo "$output" | jq .
fi
else
echo "(no output)"
fi
# Check for environment file
if [ -f "$CLAUDE_ENV_FILE" ]; then
echo ""
echo "Environment file created:"
cat "$CLAUDE_ENV_FILE"
rm -f "$CLAUDE_ENV_FILE"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ $exit_code -eq 0 ] || [ $exit_code -eq 2 ]; then
echo "✅ Test completed successfully"
exit 0
else
echo "❌ Test failed"
exit 1
fi

View File

@@ -0,0 +1,159 @@
#!/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")
for event in $(jq -r '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 'keys[]' "$HOOKS_FILE"); do
hook_count=$(jq -r ".\"$event\" | length" "$HOOKS_FILE")
for ((i=0; i<hook_count; i++)); do
# Check matcher exists
matcher=$(jq -r ".\"$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 ".\"$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 ".\"$event\"[$i].hooks | length" "$HOOKS_FILE")
for ((j=0; j<hook_array_count; j++)); do
hook_type=$(jq -r ".\"$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 ".\"$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 ".\"$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 ".\"$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