📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-05-29 08:33:53 +00:00
parent fdb52f1e96
commit 06e0d13d57
1615 changed files with 232858 additions and 0 deletions
@@ -0,0 +1,550 @@
#!/usr/bin/env bash
# scaffold-hook.sh - Generate hook configuration and script from templates
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
# Help text
show_help() {
cat << EOF
Usage: $(basename "$0") <hook-name> [options]
Generate a new Claude Code hook with configuration and script.
Arguments:
hook-name Name of the hook (kebab-case)
Options:
-e, --event Event type (required): PreToolUse, PostToolUse,
UserPromptSubmit, Notification, Stop, SubagentStop,
PreCompact, SessionStart, SessionEnd
-m, --matcher Matcher pattern (default: *)
-t, --type Template type: validation, formatting, logging,
notification, context (default: validation)
-l, --language Script language: bash, typescript, python (default: bash)
-o, --output Output directory for script (default: .claude/hooks)
-c, --config Config file to update (default: .claude/settings.json)
-p, --personal Use personal config (~/.claude/settings.json)
--timeout Hook timeout in seconds (default: 30)
-i, --interactive Interactive mode (prompts for all values)
-h, --help Show this help
Examples:
# Simple validation hook
$(basename "$0") validate-bash -e PreToolUse -m Bash
# Python formatter with PostToolUse
$(basename "$0") format-python -e PostToolUse -m "Write(*.py)" -t formatting
# Interactive mode
$(basename "$0") my-hook -i
# Personal hook with custom output
$(basename "$0") log-ops -e PreToolUse -m "*" -p
Event Types:
PreToolUse - Before tool execution (can block)
PostToolUse - After tool completes successfully
UserPromptSubmit - When user submits prompt
Notification - When notification sent
Stop - When main agent finishes
SubagentStop - When subagent finishes
PreCompact - Before conversation compacts
SessionStart - When session starts/resumes
SessionEnd - When session ends
Matcher Examples:
"*" - Match all tools
"Write" - Match Write tool only
"Write|Edit" - Match Write or Edit
"Write(*.py)" - Match Python file writes
"mcp__memory__.*" - Match memory MCP tools
EOF
}
# Parse arguments
HOOK_NAME=""
EVENT_TYPE=""
MATCHER="*"
TEMPLATE_TYPE="validation"
LANGUAGE="bash"
OUTPUT_DIR=".claude/hooks"
CONFIG_FILE=".claude/settings.json"
TIMEOUT=30
INTERACTIVE=false
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
-e|--event)
EVENT_TYPE="$2"
shift 2
;;
-m|--matcher)
MATCHER="$2"
shift 2
;;
-t|--type)
TEMPLATE_TYPE="$2"
shift 2
;;
-l|--language)
LANGUAGE="$2"
shift 2
;;
-o|--output)
OUTPUT_DIR="$2"
shift 2
;;
-c|--config)
CONFIG_FILE="$2"
shift 2
;;
-p|--personal)
CONFIG_FILE="$HOME/.claude/settings.json"
OUTPUT_DIR="$HOME/.claude/hooks"
shift
;;
--timeout)
TIMEOUT="$2"
shift 2
;;
-i|--interactive)
INTERACTIVE=true
shift
;;
-*)
echo -e "${RED}Error: Unknown option $1${NC}"
show_help
exit 1
;;
*)
HOOK_NAME="$1"
shift
;;
esac
done
# Validate hook name
if [[ -z "$HOOK_NAME" ]]; then
echo -e "${RED}Error: Hook name required${NC}"
show_help
exit 1
fi
# Validate hook name format (kebab-case)
if [[ ! "$HOOK_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
echo -e "${RED}Error: Hook name must be kebab-case (e.g., my-hook)${NC}"
exit 1
fi
# Interactive mode
if [[ "$INTERACTIVE" == "true" ]]; then
echo -e "${BLUE}=== Interactive Hook Setup ===${NC}"
echo
# Event type
echo -e "${BLUE}Select event type:${NC}"
echo "1) PreToolUse - Before tool execution (can block)"
echo "2) PostToolUse - After tool completes"
echo "3) UserPromptSubmit - When user submits prompt"
echo "4) Notification - When notification sent"
echo "5) Stop - When agent finishes"
echo "6) SubagentStop - When subagent finishes"
echo "7) PreCompact - Before compact"
echo "8) SessionStart - Session starts"
echo "9) SessionEnd - Session ends"
read -r -p "Enter number (1-9): " EVENT_NUM
case $EVENT_NUM in
1) EVENT_TYPE="PreToolUse" ;;
2) EVENT_TYPE="PostToolUse" ;;
3) EVENT_TYPE="UserPromptSubmit" ;;
4) EVENT_TYPE="Notification" ;;
5) EVENT_TYPE="Stop" ;;
6) EVENT_TYPE="SubagentStop" ;;
7) EVENT_TYPE="PreCompact" ;;
8) EVENT_TYPE="SessionStart" ;;
9) EVENT_TYPE="SessionEnd" ;;
*) echo -e "${RED}Invalid selection${NC}"; exit 1 ;;
esac
# Matcher
echo
echo -e "${BLUE}Enter matcher pattern (e.g., 'Write', '*.py', '*'):${NC}"
read -r MATCHER
# Template type
echo
echo -e "${BLUE}Select template type:${NC}"
echo "1) validation - Validate input and block if needed"
echo "2) formatting - Format files after modification"
echo "3) logging - Log operations"
echo "4) notification - Send notifications"
echo "5) context - Add context to prompts"
read -r -p "Enter number (1-5): " TEMPLATE_NUM
case $TEMPLATE_NUM in
1) TEMPLATE_TYPE="validation" ;;
2) TEMPLATE_TYPE="formatting" ;;
3) TEMPLATE_TYPE="logging" ;;
4) TEMPLATE_TYPE="notification" ;;
5) TEMPLATE_TYPE="context" ;;
*) echo -e "${RED}Invalid selection${NC}"; exit 1 ;;
esac
# Language
echo
echo -e "${BLUE}Select script language:${NC}"
echo "1) bash"
echo "2) typescript"
echo "3) python"
read -r -p "Enter number (1-3): " LANG_NUM
case $LANG_NUM in
1) LANGUAGE="bash" ;;
2) LANGUAGE="typescript" ;;
3) LANGUAGE="python" ;;
*) echo -e "${RED}Invalid selection${NC}"; exit 1 ;;
esac
# Timeout
echo
read -r -p "Timeout in seconds (default: 30): " INPUT_TIMEOUT
if [[ -n "$INPUT_TIMEOUT" ]]; then
if [[ "$INPUT_TIMEOUT" =~ ^[0-9]+$ ]] && [[ "$INPUT_TIMEOUT" -gt 0 ]] && [[ "$INPUT_TIMEOUT" -le 300 ]]; then
TIMEOUT="$INPUT_TIMEOUT"
else
echo -e "${YELLOW}Warning: Invalid timeout value, using default (30s)${NC}"
fi
fi
fi
# Validate event type
VALID_EVENTS=(PreToolUse PostToolUse UserPromptSubmit Notification Stop SubagentStop PreCompact SessionStart SessionEnd)
if [[ -z "$EVENT_TYPE" ]]; then
echo -e "${RED}Error: Event type required (-e/--event)${NC}"
echo "Valid events: ${VALID_EVENTS[*]}"
exit 1
fi
valid_event=false
for evt in "${VALID_EVENTS[@]}"; do
if [[ "$evt" == "$EVENT_TYPE" ]]; then
valid_event=true
break
fi
done
if [[ "$valid_event" == "false" ]]; then
echo -e "${RED}Error: Invalid event type: $EVENT_TYPE${NC}"
echo "Valid events: ${VALID_EVENTS[*]}"
exit 1
fi
# Validate language
VALID_LANGUAGES=(bash typescript python)
valid_lang=false
for lang in "${VALID_LANGUAGES[@]}"; do
if [[ "$lang" == "$LANGUAGE" ]]; then
valid_lang=true
break
fi
done
if [[ "$valid_lang" == "false" ]]; then
echo -e "${RED}Error: Invalid language: $LANGUAGE${NC}"
echo "Valid languages: ${VALID_LANGUAGES[*]}"
exit 1
fi
# Determine script extension
case "$LANGUAGE" in
bash) SCRIPT_EXT="sh" ;;
typescript) SCRIPT_EXT="ts" ;;
python) SCRIPT_EXT="py" ;;
esac
# Create output directory
mkdir -p "$OUTPUT_DIR"
SCRIPT_PATH="$OUTPUT_DIR/$HOOK_NAME.$SCRIPT_EXT"
# Check if script already exists
if [[ -f "$SCRIPT_PATH" ]]; then
echo -e "${YELLOW}Warning: Script already exists: $SCRIPT_PATH${NC}"
read -r -p "Overwrite? (y/N): " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "Aborted"
exit 0
fi
fi
# Generate script based on template and language
case "$LANGUAGE" in
bash)
cat > "$SCRIPT_PATH" << 'BASH_EOF'
#!/usr/bin/env bash
# HOOK_NAME - DESCRIPTION
set -euo pipefail
# Read input from stdin
INPUT=$(cat)
# Parse JSON input
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# TEMPLATE_LOGIC
exit 0
BASH_EOF
;;
typescript)
cat > "$SCRIPT_PATH" << 'TS_EOF'
#!/usr/bin/env bun
// HOOK_NAME - DESCRIPTION
import { stdin } from "process";
interface HookInput {
session_id: string;
transcript_path: string;
cwd: string;
hook_event_name: string;
tool_name?: string;
tool_input?: Record<string, any>;
reason?: string;
}
// Read stdin
const chunks: Buffer[] = [];
for await (const chunk of stdin) {
chunks.push(chunk);
}
const input: HookInput = JSON.parse(Buffer.concat(chunks).toString());
// Parse input
const hookEvent = input.hook_event_name;
const toolName = input.tool_name || "";
const filePath = input.tool_input?.file_path || "";
// TEMPLATE_LOGIC
process.exit(0);
TS_EOF
;;
python)
cat > "$SCRIPT_PATH" << 'PY_EOF'
#!/usr/bin/env python3
"""HOOK_NAME - DESCRIPTION"""
import json
import sys
from typing import Any, Dict
def main() -> None:
"""Main hook logic"""
# Read input from stdin
try:
input_data: Dict[str, Any] = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error parsing JSON: {e}", file=sys.stderr)
sys.exit(1)
# Parse input
hook_event = input_data.get("hook_event_name", "")
tool_name = input_data.get("tool_name", "")
file_path = input_data.get("tool_input", {}).get("file_path", "")
# TEMPLATE_LOGIC
sys.exit(0)
if __name__ == "__main__":
main()
PY_EOF
;;
esac
# Replace placeholders with actual values
# Use perl instead of sed for better multiline support
perl -i -pe "s/HOOK_NAME/$HOOK_NAME/g" "$SCRIPT_PATH"
perl -i -pe "s/DESCRIPTION/Generated hook for $EVENT_TYPE event/g" "$SCRIPT_PATH"
# Insert template-specific logic by replacing the placeholder line
# Generate language-appropriate code based on the language choice
insert_template_logic() {
local template=$1
local lang=$2
local replacement=""
case "$lang" in
bash)
case "$template" in
validation)
replacement='# Validation logic\nif [[ -z "$TOOL_NAME" ]]; then\n exit 0\nfi\n\n# Add your validation rules here\n# Example: Block dangerous operations\necho "✓ Validation passed"'
;;
formatting)
replacement='# Formatting logic\nif [[ -z "$FILE_PATH" ]]; then\n exit 0\nfi\n\n# Add your formatting commands here\necho "✓ Formatting completed"'
;;
logging)
replacement='# Logging logic\nLOG_FILE="${CLAUDE_PROJECT_DIR:-.}/.claude/hook-logs.log"\nTIMESTAMP=$(date -Iseconds)\necho "[$TIMESTAMP] Event: $HOOK_EVENT, Tool: $TOOL_NAME" >> "$LOG_FILE"'
;;
notification)
replacement='# Notification logic\n# Add your notification code here\necho "✓ Notification sent"'
;;
context)
replacement='# Context injection logic\necho "## Additional Context"\necho "Hook Event: $HOOK_EVENT"\necho "Tool: $TOOL_NAME"\necho "Timestamp: $(date -Iseconds)"'
;;
esac
;;
typescript)
case "$template" in
validation)
replacement='// Validation logic\n\tif (!toolName) {\n\t\tprocess.exit(0);\n\t}\n\n\t// Add your validation rules here\n\t// Example: Block dangerous operations\n\tstdout.write("✓ Validation passed\\n");\n\tprocess.exit(0);'
;;
formatting)
replacement='// Formatting logic\n\tif (!filePath) {\n\t\tprocess.exit(0);\n\t}\n\n\t// Add your formatting commands here\n\tstdout.write("✓ Formatting completed\\n");\n\tprocess.exit(0);'
;;
logging)
replacement='// Logging logic\n\tconst logFile = `${process.env.CLAUDE_PROJECT_DIR || "."}/.claude/hook-logs.log`;\n\tconst timestamp = new Date().toISOString();\n\tconst logLine = `[${timestamp}] Event: ${hookEvent}, Tool: ${toolName}\\n`;\n\tfs.appendFileSync(logFile, logLine);\n\tprocess.exit(0);'
;;
notification)
replacement='// Notification logic\n\t// Add your notification code here\n\tstdout.write("✓ Notification sent\\n");\n\tprocess.exit(0);'
;;
context)
replacement='// Context injection logic\n\tstdout.write("## Additional Context\\n");\n\tstdout.write(`Hook Event: ${hookEvent}\\n`);\n\tstdout.write(`Tool: ${toolName}\\n`);\n\tstdout.write(`Timestamp: ${new Date().toISOString()}\\n`);\n\tprocess.exit(0);'
;;
esac
;;
python)
case "$template" in
validation)
replacement='# Validation logic\n if not tool_name:\n sys.exit(0)\n\n # Add your validation rules here\n # Example: Block dangerous operations\n print("✓ Validation passed")'
;;
formatting)
replacement='# Formatting logic\n if not file_path:\n sys.exit(0)\n\n # Add your formatting commands here\n print("✓ Formatting completed")'
;;
logging)
replacement='# Logging logic\n import os\n from datetime import datetime\n log_file = os.path.join(os.environ.get("CLAUDE_PROJECT_DIR", "."), ".claude", "hook-logs.log")\n timestamp = datetime.now().isoformat()\n with open(log_file, "a") as f:\n f.write(f"[{timestamp}] Event: {hook_event}, Tool: {tool_name}\\n")'
;;
notification)
replacement='# Notification logic\n # Add your notification code here\n print("✓ Notification sent")'
;;
context)
replacement='# Context injection logic\n from datetime import datetime\n print("## Additional Context")\n print(f"Hook Event: {hook_event}")\n print(f"Tool: {tool_name}")\n print(f"Timestamp: {datetime.now().isoformat()}")'
;;
esac
;;
esac
if [[ -n "$replacement" ]]; then
perl -i -pe "BEGIN{undef \$/;} s/# TEMPLATE_LOGIC/$replacement/smg" "$SCRIPT_PATH"
fi
}
insert_template_logic "$TEMPLATE_TYPE" "$LANGUAGE"
# Make script executable
chmod +x "$SCRIPT_PATH"
echo -e "${GREEN}✓ Created hook script: $SCRIPT_PATH${NC}"
# Generate hook configuration
echo
echo -e "${BLUE}Hook configuration to add to $CONFIG_FILE:${NC}"
echo
# Construct relative path if in project
if [[ "$OUTPUT_DIR" == ".claude/hooks" ]] || [[ "$OUTPUT_DIR" == "$PWD/.claude/hooks" ]]; then
COMMAND_PATH="\$CLAUDE_PROJECT_DIR/.claude/hooks/$HOOK_NAME.$SCRIPT_EXT"
elif [[ "$OUTPUT_DIR" == "$HOME/.claude/hooks" ]]; then
COMMAND_PATH="$HOME/.claude/hooks/$HOOK_NAME.$SCRIPT_EXT"
else
COMMAND_PATH="$SCRIPT_PATH"
fi
cat << EOF
{
"hooks": {
"$EVENT_TYPE": [
{
"matcher": "$MATCHER",
"hooks": [
{
"type": "command",
"command": "$COMMAND_PATH",
"timeout": $TIMEOUT
}
]
}
]
}
}
EOF
echo
echo -e "${BLUE}Next steps:${NC}"
echo " 1. Edit $SCRIPT_PATH to add your hook logic"
echo " 2. Test the hook: ./scripts/test-hook.ts $SCRIPT_PATH"
echo " 3. Add configuration to $CONFIG_FILE"
echo " 4. Validate config: ./scripts/validate-hook.sh $CONFIG_FILE"
echo
# Offer to add to config
if [[ -f "$CONFIG_FILE" ]]; then
read -r -p "Add this hook to $CONFIG_FILE now? (y/N): " ADD_CONFIG
if [[ "$ADD_CONFIG" =~ ^[Yy]$ ]]; then
# Check if hooks already exist in config
if jq -e '.hooks' "$CONFIG_FILE" >/dev/null 2>&1; then
# Hooks exist, merge
TEMP_FILE=$(mktemp)
jq --arg event "$EVENT_TYPE" \
--arg matcher "$MATCHER" \
--arg cmd "$COMMAND_PATH" \
--argjson timeout "$TIMEOUT" \
'.hooks[$event] += [{
"matcher": $matcher,
"hooks": [{
"type": "command",
"command": $cmd,
"timeout": $timeout
}]
}]' "$CONFIG_FILE" > "$TEMP_FILE"
mv "$TEMP_FILE" "$CONFIG_FILE"
else
# No hooks, create new structure
TEMP_FILE=$(mktemp)
jq --arg event "$EVENT_TYPE" \
--arg matcher "$MATCHER" \
--arg cmd "$COMMAND_PATH" \
--argjson timeout "$TIMEOUT" \
'. + {
"hooks": {
($event): [{
"matcher": $matcher,
"hooks": [{
"type": "command",
"command": $cmd,
"timeout": $timeout
}]
}]
}
}' "$CONFIG_FILE" > "$TEMP_FILE"
mv "$TEMP_FILE" "$CONFIG_FILE"
fi
echo -e "${GREEN}✓ Added hook to $CONFIG_FILE${NC}"
fi
else
echo -e "${YELLOW}Note: Config file $CONFIG_FILE doesn't exist yet${NC}"
echo "You'll need to create it and add the hook configuration manually"
fi
@@ -0,0 +1,482 @@
#!/usr/bin/env bun
/**
* test-hook.ts - Test Claude Code hook scripts with sample input
*
* Usage:
* ./test-hook.ts <hook-script> [options]
* ./test-hook.ts validate-bash.sh --event PreToolUse --tool Bash
*/
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { spawn } from "bun";
// ANSI colors
const colors = {
red: "\x1b[0;31m",
green: "\x1b[0;32m",
yellow: "\x1b[1;33m",
blue: "\x1b[0;34m",
reset: "\x1b[0m",
};
/**
* JSON input structure for Claude Code hooks.
*/
interface HookInput {
session_id: string;
transcript_path: string;
cwd: string;
hook_event_name: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
reason?: string;
}
/**
* Options for testing a hook script.
*/
interface TestOptions {
event: string;
tool?: string;
filePath?: string;
content?: string;
command?: string;
reason?: string;
customInput?: string;
verbose: boolean;
timeout: number;
}
// Show help
function showHelp() {
console.log(`Usage: test-hook.ts <hook-script> [options]
Test Claude Code hook scripts with sample input.
Arguments:
hook-script Path to hook script to test
Options:
-e, --event Event type (default: PreToolUse)
PreToolUse, PostToolUse, UserPromptSubmit, Notification,
Stop, SubagentStop, PreCompact, SessionStart, SessionEnd
-t, --tool Tool name (e.g., Write, Edit, Bash)
-f, --file File path for tool input
-c, --content File content for Write tool
--command Command for Bash tool
-r, --reason Reason for session events
--input Custom JSON input (overrides all other options)
--timeout Timeout in milliseconds (default: 5000)
-v, --verbose Verbose output
-h, --help Show this help
Examples:
# Test PreToolUse hook with Bash tool
./test-hook.ts validate-bash.sh -e PreToolUse -t Bash --command "rm -rf /"
# Test PostToolUse hook with Write tool
./test-hook.ts format-code.sh -e PostToolUse -t Write -f test.ts -c "console.log('test');"
# Test with custom JSON input
./test-hook.ts my-hook.sh --input '{"tool_name":"Write","tool_input":{"file_path":"test.txt"}}'
# Test SessionStart hook
./test-hook.ts welcome.sh -e SessionStart -r startup
Event Types:
PreToolUse - Before tool execution (can block)
PostToolUse - After tool completes successfully
UserPromptSubmit - When user submits prompt
Notification - When notification sent
Stop - When main agent finishes
SubagentStop - When subagent finishes
PreCompact - Before conversation compacts
SessionStart - When session starts/resumes
SessionEnd - When session ends
`);
}
// Parse arguments
function parseArgs(): { scriptPath: string; options: TestOptions } {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
showHelp();
process.exit(0);
}
const scriptPath = args[0];
const options: TestOptions = {
event: "PreToolUse",
verbose: false,
timeout: 5000,
};
for (let i = 1; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
switch (arg) {
case "-e":
case "--event":
options.event = next;
i++;
break;
case "-t":
case "--tool":
options.tool = next;
i++;
break;
case "-f":
case "--file":
options.filePath = next;
i++;
break;
case "-c":
case "--content":
options.content = next;
i++;
break;
case "--command":
options.command = next;
i++;
break;
case "-r":
case "--reason":
options.reason = next;
i++;
break;
case "--input":
options.customInput = next;
i++;
break;
case "--timeout": {
const parsed = parseInt(next, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
console.error(
`${colors.red}Error: --timeout must be a positive number${colors.reset}`,
);
process.exit(1);
}
options.timeout = parsed;
i++;
break;
}
case "-v":
case "--verbose":
options.verbose = true;
break;
default:
console.error(
`${colors.red}Error: Unknown option ${arg}${colors.reset}`,
);
process.exit(1);
}
}
return { scriptPath, options };
}
// Generate sample input based on event and options
function generateInput(options: TestOptions): HookInput {
const baseInput: HookInput = {
session_id: `test-session-${Date.now()}`,
transcript_path: "/tmp/transcript.jsonl",
cwd: process.cwd(),
hook_event_name: options.event,
};
// Add tool-specific fields
if (options.tool) {
baseInput.tool_name = options.tool;
// Generate appropriate tool_input
switch (options.tool) {
case "Write":
baseInput.tool_input = {
file_path: options.filePath || "/tmp/test-file.txt",
content: options.content || "Test content",
};
break;
case "Edit":
baseInput.tool_input = {
file_path: options.filePath || "/tmp/test-file.txt",
old_string: "old",
new_string: "new",
replace_all: false,
};
break;
case "Read":
baseInput.tool_input = {
file_path: options.filePath || "/tmp/test-file.txt",
offset: 0,
limit: 2000,
};
break;
case "Bash":
baseInput.tool_input = {
command: options.command || "echo 'test'",
description: "Test bash command",
};
break;
case "Grep":
baseInput.tool_input = {
pattern: "test",
path: options.filePath || ".",
};
break;
default:
baseInput.tool_input = {
file_path: options.filePath,
};
}
}
// Add reason for session events
if (["SessionStart", "SessionEnd", "PreCompact"].includes(options.event)) {
baseInput.reason = options.reason || "test";
}
return baseInput;
}
// Run hook script with input
async function runHook(
scriptPath: string,
input: HookInput,
timeout: number,
verbose: boolean,
): Promise<{
exitCode: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
}> {
const inputJson = JSON.stringify(input, null, 2);
if (verbose) {
console.log(`${colors.blue}Input JSON:${colors.reset}`);
console.log(inputJson);
console.log();
}
// Spawn process
const proc = spawn({
cmd: [scriptPath],
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
CLAUDE_PROJECT_DIR: process.cwd(),
},
});
// Write input to stdin
proc.stdin.write(inputJson);
proc.stdin.end();
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
proc.kill();
}, timeout);
try {
const result = await proc.exited;
clearTimeout(timeoutId);
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return {
exitCode: timedOut ? null : result,
stdout,
stderr,
timedOut,
};
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
// Main function
async function main() {
const { scriptPath, options } = parseArgs();
// Validate script path
const resolvedPath = resolve(scriptPath);
if (!existsSync(resolvedPath)) {
console.error(
`${colors.red}Error: Script not found: ${scriptPath}${colors.reset}`,
);
process.exit(1);
}
// Check if executable
const stats = statSync(resolvedPath);
if (!(stats.mode & 0o111)) {
console.error(
`${colors.yellow}Warning: Script is not executable${colors.reset}`,
);
console.error(`Run: chmod +x ${scriptPath}`);
console.log();
}
console.log(
`${colors.blue}Testing hook script: ${scriptPath}${colors.reset}`,
);
console.log();
// Generate or parse input
let input: HookInput;
if (options.customInput) {
try {
input = JSON.parse(options.customInput);
console.log(`${colors.blue}Using custom input${colors.reset}`);
} catch (_error) {
console.error(
`${colors.red}Error: Invalid JSON in --input${colors.reset}`,
);
process.exit(1);
}
} else {
input = generateInput(options);
console.log(
`${colors.blue}Generated input for ${options.event}${options.tool ? ` with ${options.tool}` : ""}${colors.reset}`,
);
}
if (options.verbose) {
console.log();
}
// Run hook
console.log(`${colors.blue}Running hook...${colors.reset}`);
console.log();
const startTime = Date.now();
let result: Awaited<ReturnType<typeof runHook>>;
try {
result = await runHook(
resolvedPath,
input,
options.timeout,
options.verbose,
);
} catch (error) {
console.error(`${colors.red}✗ Hook execution failed${colors.reset}`);
console.error(error);
process.exit(1);
}
const duration = Date.now() - startTime;
// Display results
console.log(`${colors.blue}=== Results ===${colors.reset}`);
console.log();
// Handle timeout
if (result.timedOut) {
console.log(
`${colors.red}✗ Hook timed out after ${options.timeout}ms${colors.reset}`,
);
console.log();
}
// Exit code
const exitCode = result.exitCode ?? -1;
let exitCodeColor = colors.green;
let exitCodeLabel = "Success";
if (result.timedOut) {
exitCodeColor = colors.red;
exitCodeLabel = "Timeout (killed)";
} else if (exitCode === 2) {
exitCodeColor = colors.red;
exitCodeLabel = "Blocked (exit 2)";
} else if (exitCode !== 0) {
exitCodeColor = colors.yellow;
exitCodeLabel = "Warning (non-zero)";
}
console.log(
`${exitCodeColor}Exit Code: ${exitCode} - ${exitCodeLabel}${colors.reset}`,
);
console.log(`Duration: ${duration}ms`);
console.log();
// Stdout
if (result.stdout) {
console.log(`${colors.green}Stdout:${colors.reset}`);
console.log(result.stdout);
console.log();
} else {
console.log(`${colors.blue}Stdout: (empty)${colors.reset}`);
console.log();
}
// Stderr
if (result.stderr) {
console.log(`${colors.yellow}Stderr:${colors.reset}`);
console.log(result.stderr);
console.log();
} else {
console.log(`${colors.blue}Stderr: (empty)${colors.reset}`);
console.log();
}
// Summary
console.log(`${colors.blue}=== Summary ===${colors.reset}`);
console.log();
if (exitCode === 0) {
console.log(`${colors.green}✓ Hook executed successfully${colors.reset}`);
if (result.stdout) {
console.log(" Stdout will be shown to user");
}
} else if (exitCode === 2) {
console.log(
`${colors.red}✗ Hook blocked operation (exit 2)${colors.reset}`,
);
if (result.stderr) {
console.log(" Stderr will be shown to Claude");
}
} else {
console.log(
`${colors.yellow}⚠ Hook returned warning (exit ${exitCode})${colors.reset}`,
);
if (result.stderr) {
console.log(" Stderr will be shown to user");
}
}
// Performance warning
if (duration > 1000) {
console.log(
`${colors.yellow}⚠ Hook took ${duration}ms (>1s)${colors.reset}`,
);
console.log(" Consider optimizing for faster execution");
}
console.log();
// Exit with same code as hook
process.exit(exitCode === 0 ? 0 : 1);
}
// Run main
main().catch((error) => {
console.error(`${colors.red}Fatal error:${colors.reset}`, error);
process.exit(1);
});
@@ -0,0 +1,363 @@
#!/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