
Meta Hook Creator
- 62 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
meta-hook-creator is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- meta-hook-creator
- AI & Agent Building
- AI-coding skill
Meta Hook Creator by the numbers
- 62 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #6,310 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill meta-hook-creatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 62 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Hook Creator
Overview
Claude Code hooks are user-defined shell commands, LLM prompts, or agent evaluations that execute automatically at specific lifecycle points. Hooks receive JSON context via stdin, take action, and communicate results through exit codes, stdout, and stderr.
When to use: Blocking dangerous commands, auto-formatting after writes, protecting sensitive files, custom notifications, environment setup, enforcing project conventions, auto-approving safe tools, running tests after changes.
When NOT to use: Static context injection (use CLAUDE.md), simple permission rules (use allowlist settings), one-time setup (use shell scripts directly).
Quick Reference
| Pattern | Event | Matcher | Key Points |
|---|---|---|---|
| Block tool call | PreToolUse | Tool name | Exit 2 or JSON permissionDecision: "deny" |
| Auto-approve tool | PreToolUse | Tool name | JSON permissionDecision: "allow" |
| Modify tool input | PreToolUse | Tool name | JSON updatedInput with modified parameters |
| Format after write | PostToolUse | `Write\ | Edit` |
| Log tool failures | PostToolUseFailure | Tool name | Fires when tool throws error or returns failure |
| Handle permission | PermissionRequest | Tool name | JSON decision.behavior: "allow" or "deny" |
| Validate user prompt | UserPromptSubmit | No matcher | Exit 2 blocks prompt, stdout adds context |
| Desktop notification | Notification | Notification type | permission_prompt, idle_prompt, etc. |
| Force continue | Stop | No matcher | JSON decision: "block" with reason |
| Subagent lifecycle | SubagentStop | Agent type | Same decision control as Stop |
| Environment setup | SessionStart | Source type | Write to CLAUDE_ENV_FILE to persist env vars |
| Session cleanup | SessionEnd | Exit reason | Cannot block termination |
| Pre-compact context | PreCompact | `manual\ | auto` |
| Background tasks | Any post-event | Any | Set async: true on command hooks |
| LLM evaluation | Supported events | Any | Use type: "prompt" for single-turn LLM check |
| Multi-turn verification | Supported events | Any | Use type: "agent" for subagent with tool access |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Exit 1 expecting to block a tool call | Use exit 2 to block in PreToolUse and PermissionRequest |
| Printing JSON on exit 2 | JSON output is only processed on exit 0; stderr is used on exit 2 |
| Complex inline bash in settings.json | Extract to a script file, reference with $CLAUDE_PROJECT_DIR |
| Missing timeout on slow hooks | Set timeout field; defaults are 600s command, 30s prompt, 60s agent |
Not quoting $CLAUDE_PROJECT_DIR | Always quote: "$CLAUDE_PROJECT_DIR"/.claude/hooks/script.sh |
Expecting CLAUDE_ENV_FILE in all hooks | Only available in SessionStart hooks |
| Adding matcher to Stop or UserPromptSubmit | These events ignore matchers; they always fire |
Using decision/reason at top level for PreToolUse | Use hookSpecificOutput.permissionDecision and permissionDecisionReason |
Not checking stop_hook_active in Stop hooks | Check this field to prevent infinite continuation loops |
| Mixing exit codes and JSON decisions | Choose one approach per hook: exit codes alone or exit 0 with JSON |
Delegation
- Hook pattern discovery: Use
Exploreagent to find existing hooks in the project - Hook testing and verification: Use
Taskagent to validate hook behavior - Code review: Delegate to
code-revieweragent for hook script review
References
- Event types, matchers, and input schemas
- Hook configuration, exit codes, JSON output, and environment variables
- Hook templates for common automation patterns
Event Types
Lifecycle Overview
Events fire in this order during a session:
1. SessionStart -- session begins or resumes 2. UserPromptSubmit -- user sends a prompt 3. PreToolUse -- before each tool call (can block) 4. PermissionRequest -- when permission dialog would appear (can auto-approve/deny) 5. PostToolUse -- after tool succeeds 6. PostToolUseFailure -- after tool fails 7. SubagentStart -- when a subagent spawns 8. SubagentStop -- when a subagent finishes (can force continue) 9. Notification -- when Claude Code sends a notification 10. Stop -- when Claude finishes responding (can force continue) 11. PreCompact -- before context compaction 12. SessionEnd -- session terminates
SessionStart
Fires when a session begins or resumes. Use for environment setup and loading context.
Matcher values: startup, resume, clear, compact
Input fields (in addition to common fields):
| Field | Description |
|---|---|
source | How session started: startup, resume, clear, compact |
model | Model identifier |
agent_type | Present when started with claude --agent <name> |
Decision control: stdout text or additionalContext is added to Claude's context.
Special: CLAUDE_ENV_FILE env var is available only in this event. Write export statements to persist environment variables for the session.
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
echo 'export PATH="$PATH:./node_modules/.bin"' >> "$CLAUDE_ENV_FILE"
fi
exit 0UserPromptSubmit
Fires when the user submits a prompt, before Claude processes it. Use for validation or adding context.
Matcher: Not supported. Always fires on every prompt.
Input fields:
| Field | Description |
|---|---|
prompt | The text the user submitted |
Decision control:
| Field | Description |
|---|---|
decision | "block" prevents processing and erases the prompt |
reason | Shown to user when blocked |
additionalContext | String added to Claude's context |
Plain text stdout on exit 0 is also added as context.
PreToolUse
Fires before a tool call executes. Use for validation, blocking, modifying inputs, or auto-approving.
Matcher: Tool name. Values include Bash, Edit, Write, Read, Glob, Grep, Task, WebFetch, WebSearch, and MCP tools (mcp__<server>__<tool>).
Input fields:
| Field | Description |
|---|---|
tool_name | Name of the tool |
tool_input | Tool parameters (varies by tool) |
tool_use_id | Unique identifier for this tool invocation |
Tool input schemas by tool:
// Bash
{ "command": "npm test", "description": "Run tests", "timeout": 120000 }
// Write
{ "file_path": "/path/to/file.txt", "content": "file content" }
// Edit
{ "file_path": "/path/to/file.txt", "old_string": "original", "new_string": "replacement" }
// Read
{ "file_path": "/path/to/file.txt", "offset": 10, "limit": 50 }
// Glob
{ "pattern": "**/*.ts", "path": "/path/to/dir" }
// Grep
{ "pattern": "TODO.*fix", "path": "/path/to/dir", "glob": "*.ts" }Decision control (via hookSpecificOutput):
| Field | Description |
|---|---|
permissionDecision | "allow", "deny", or "ask" |
permissionDecisionReason | Shown to user (allow/ask) or Claude (deny) |
updatedInput | Modified tool parameters, combine with "allow" or "ask" |
additionalContext | String added to Claude's context before tool executes |
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database writes are not allowed"
}
}PermissionRequest
Fires when a permission dialog is about to appear. Use for auto-approving or denying.
Matcher: Tool name, same values as PreToolUse.
Input fields: Same as PreToolUse but without tool_use_id. Includes permission_suggestions array with the "always allow" options.
Decision control (via hookSpecificOutput.decision):
| Field | Description |
|---|---|
behavior | "allow" grants permission, "deny" denies it |
updatedInput | For "allow": modifies tool input before execution |
updatedPermissions | For "allow": applies permission rule updates |
message | For "deny": tells Claude why permission was denied |
interrupt | For "deny": if true, stops Claude entirely |
PostToolUse
Fires after a tool completes successfully. Use for formatting, logging, or feedback.
Matcher: Tool name, same values as PreToolUse.
Input fields:
| Field | Description |
|---|---|
tool_name | Name of the tool |
tool_input | Arguments sent to the tool |
tool_response | Result returned by the tool |
tool_use_id | Unique identifier for the tool call |
Decision control:
| Field | Description |
|---|---|
decision | "block" prompts Claude with the reason |
reason | Explanation shown to Claude when blocked |
additionalContext | Additional context for Claude |
updatedMCPToolOutput | For MCP tools: replaces the tool's output |
PostToolUseFailure
Fires when a tool execution fails. Use for logging, alerts, or corrective feedback.
Matcher: Tool name, same values as PreToolUse.
Input fields: Same as PostToolUse but with error info instead of tool_response:
| Field | Description |
|---|---|
error | String describing what went wrong |
is_interrupt | Whether failure was caused by user interruption |
Decision control: additionalContext only.
Notification
Fires when Claude Code sends a notification.
Matcher values: permission_prompt, idle_prompt, auth_success, elicitation_dialog
Input fields:
| Field | Description |
|---|---|
message | Notification text |
title | Optional title |
notification_type | Which notification fired |
Decision control: Cannot block notifications. additionalContext adds context to the conversation.
SubagentStart
Fires when a subagent spawns via the Task tool.
Matcher values: Agent type -- Bash, Explore, Plan, or custom agent names.
Input fields:
| Field | Description |
|---|---|
agent_id | Unique identifier for subagent |
agent_type | Agent name (used for matching) |
Decision control: Cannot block creation. additionalContext is injected into the subagent's context.
SubagentStop
Fires when a subagent finishes. Uses same decision control as Stop.
Matcher values: Same as SubagentStart.
Input fields:
| Field | Description |
|---|---|
stop_hook_active | true if already continuing from a stop hook |
agent_id | Subagent identifier |
agent_type | Agent name (used for matching) |
agent_transcript_path | Path to subagent's transcript |
Stop
Fires when the main agent finishes responding. Does not fire on user interrupt.
Matcher: Not supported. Always fires.
Input fields:
| Field | Description |
|---|---|
stop_hook_active | true if already continuing from a previous stop hook |
Decision control:
| Field | Description |
|---|---|
decision | "block" prevents Claude from stopping |
reason | Required when blocking; tells Claude why it should continue |
Always check stop_hook_active to prevent infinite loops.
PreCompact
Fires before context compaction.
Matcher values: manual, auto
Input fields:
| Field | Description |
|---|---|
trigger | "manual" or "auto" |
custom_instructions | User input from /compact (empty for auto) |
SessionEnd
Fires when a session terminates. Use for cleanup and logging.
Matcher values: clear, logout, prompt_input_exit, bypass_permissions_disabled, other
Input fields:
| Field | Description |
|---|---|
reason | Why the session ended |
Cannot block session termination.
Matcher Patterns
| Pattern | Matches |
|---|---|
"Bash" | Specific tool |
| `"Edit\ | Write"` |
"Bash.*" | Regex pattern |
"mcp__memory__.*" | All tools from an MCP server |
"mcp__.*__write.*" | Write tools from any server |
"*" or "" | All occurrences |
| Omit matcher | All occurrences |
Common Input Fields
All events receive these fields via stdin JSON:
| Field | Description |
|---|---|
session_id | Current session identifier |
transcript_path | Path to conversation JSON |
cwd | Current working directory |
permission_mode | Current permission mode |
hook_event_name | Name of the event that fired |
Hook Configuration
Choosing an Approach
| Approach | Complexity | Use Case | When |
|---|---|---|---|
| Hookify | Low | Pattern-based warn/block | Regex patterns, no logic |
| Python hooks | Medium | Complex logic, external tools | File checks, API calls, parsing |
| Inline bash | Low | Simple one-liners | Quick commands, jq filters |
Hookify Example
Pattern-based rules without code. Personal rules use .local.md suffix and aren't committed:
# .claude/hookify.warn-console-log.local.md
---
name: warn-console-log
enabled: true
event: file
pattern: console\.log\(
action: warn
---
Remove console.log before committing.See the hookify skill for full hookify documentation.
Settings Locations
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json | All your projects | No, local to your machine |
.claude/settings.json | Single project | Yes, commit to repo |
.claude/settings.local.json | Single project (personal) | No, gitignored |
| Managed policy settings | Organization-wide | Yes, admin-controlled |
Plugin hooks/hooks.json | When plugin is enabled | Yes, bundled with plugin |
| Skill/agent frontmatter | While component is active | Yes, defined in component |
Hooks are snapshotted at startup. Mid-session changes require review in the /hooks menu before taking effect.
Configuration Structure
Three levels of nesting: event, matcher group, hook handlers.
{
"hooks": {
"<EventType>": [
{
"matcher": "<regex-pattern>",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/script.sh",
"timeout": 30
}
]
}
]
}
}Multiple matcher groups per event and multiple hooks per matcher are supported. All matching hooks run in parallel. Identical handlers are deduplicated automatically.
Hook Types
Command Hooks
Execute a shell command. Receives JSON input on stdin, communicates via exit codes and stdout.
| Field | Required | Description |
|---|---|---|
type | Yes | "command" |
command | Yes | Shell command to execute |
timeout | No | Seconds before canceling (default: 600) |
async | No | If true, runs in background without blocking |
statusMessage | No | Custom spinner message while hook runs |
once | No | If true, runs only once per session (skills only) |
Prompt Hooks
Send a prompt to an LLM for single-turn evaluation. Returns a yes/no JSON decision.
| Field | Required | Description |
|---|---|---|
type | Yes | "prompt" |
prompt | Yes | Prompt text; use $ARGUMENTS for hook input JSON |
model | No | Model to use (defaults to a fast model) |
timeout | No | Seconds before canceling (default: 30) |
statusMessage | No | Custom spinner message |
Supported events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, UserPromptSubmit, Stop, SubagentStop.
Response schema:
{
"ok": true,
"reason": "Explanation (required when ok is false)"
}Agent Hooks
Spawn a subagent with tool access (Read, Grep, Glob) for multi-turn verification.
| Field | Required | Description |
|---|---|---|
type | Yes | "agent" |
prompt | Yes | Prompt describing what to verify; use $ARGUMENTS |
model | No | Model to use (defaults to a fast model) |
timeout | No | Seconds before canceling (default: 60) |
statusMessage | No | Custom spinner message |
Same supported events and response schema as prompt hooks. Up to 50 turns.
Exit Codes
| Code | Meaning | Effect |
|---|---|---|
0 | Success | Continue normally; stdout parsed for JSON output |
2 | Blocking error | Block the action; stderr fed back to Claude as error |
| Other | Non-blocking | stderr shown in verbose mode; execution continues |
Exit 2 behavior per event:
| Event | Can Block? | Effect on Exit 2 |
|---|---|---|
PreToolUse | Yes | Blocks the tool call |
PermissionRequest | Yes | Denies the permission |
UserPromptSubmit | Yes | Blocks prompt processing, erases prompt |
Stop | Yes | Prevents stopping, continues conversation |
SubagentStop | Yes | Prevents subagent from stopping |
PostToolUse | No | Shows stderr to Claude (tool already ran) |
PostToolUseFailure | No | Shows stderr to Claude |
Notification | No | Shows stderr to user only |
SubagentStart | No | Shows stderr to user only |
SessionStart | No | Shows stderr to user only |
SessionEnd | No | Shows stderr to user only |
PreCompact | No | Shows stderr to user only |
JSON Output
Print JSON to stdout on exit 0 for structured control. JSON is ignored on non-zero exit.
Top-level fields (all events):
| Field | Default | Description |
|---|---|---|
continue | true | false stops Claude entirely; overrides other decisions |
stopReason | none | Message shown to user when continue is false |
suppressOutput | false | true hides stdout from verbose mode |
systemMessage | none | Warning message shown to the user |
Event-specific fields go in hookSpecificOutput with a hookEventName field:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Not allowed in production"
}
}Choose one approach per hook: exit codes alone, or exit 0 with JSON. Do not mix.
Environment Variables
| Variable | Available In | Description |
|---|---|---|
CLAUDE_PROJECT_DIR | All hooks | Project root path |
CLAUDE_ENV_FILE | SessionStart only | File path to persist env vars |
CLAUDE_PLUGIN_ROOT | Plugin hooks | Plugin directory |
CLAUDE_CODE_REMOTE | All hooks | "true" if remote web session |
Standard shell environment variables are also available.
Async Hooks
Set "async": true on command hooks to run in the background. Claude continues immediately.
- Only
type: "command"supports async - Cannot return decisions (action already proceeded)
- Output delivered on next conversation turn via
systemMessageoradditionalContext - Each firing creates a separate background process (no deduplication)
- Default timeout same as sync hooks (600s)
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh",
"async": true,
"timeout": 120
}
]
}
]
}
}Hooks in Skills and Agents
Skills and agents can define hooks in YAML frontmatter. These hooks are scoped to the component's lifecycle and cleaned up when it finishes.
---
name: secure-operations
hooks:
PreToolUse:
- matcher: 'Bash'
hooks:
- type: command
command: './scripts/security-check.sh'
once: true
---All hook events are supported. For subagents, Stop hooks are automatically converted to SubagentStop. The once: true option runs the hook only once per session (skills only).
The /hooks Menu
Type /hooks in Claude Code to view, add, and delete hooks interactively. Hook source labels:
| Label | Source |
|---|---|
[User] | ~/.claude/settings.json |
[Project] | .claude/settings.json |
[Local] | .claude/settings.local.json |
[Plugin] | Plugin's hooks/hooks.json |
Disabling Hooks
- Remove the hook entry from settings JSON, or delete via
/hooksmenu - Set
"disableAllHooks": truein settings to temporarily disable all hooks - No way to disable individual hooks while keeping them in config
Hook Execution Details
| Property | Value |
|---|---|
| Default timeout | 60 seconds (command), 30 seconds (prompt/agent) |
| Execution | All matching hooks run in parallel |
| Deduplication | Identical commands deduplicated automatically |
| Environment | Current directory with Claude's environment |
| Snapshot | Hooks snapshotted at startup |
| Reload | Mid-session changes require /hooks review |
Plugin Hooks
Plugins define hooks in hooks/hooks.json within the plugin directory. Use $CLAUDE_PLUGIN_ROOT for paths relative to the plugin.
{
"description": "Automatic code formatting",
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/format.sh",
"timeout": 30
}
]
}
]
}
}Plugin hooks run alongside user/project hooks in parallel. They use the [Plugin] label in the /hooks menu.
Security Considerations
1. Validate inputs — never trust stdin data blindly; use try/except or jq -r '... // empty' 2. Quote shell variables — use "$VAR" not $VAR to prevent word splitting 3. Block path traversal — check for .. in file paths before processing 4. Use absolute paths — reference scripts with "$CLAUDE_PROJECT_DIR"/.claude/hooks/script.sh 5. Skip sensitive files — avoid processing .env, .git/, credential files 6. Hooks are snapshotted — external modifications require /hooks review before taking effect 7. Changes don't affect current session — a hook change mid-session only loads after explicit reload
Debugging
Enable Debug Mode
claude --debugDebug output shows hook matching and execution:
[DEBUG] Executing hooks for PostToolUse:Write
[DEBUG] Found 1 hook matchers in settings
[DEBUG] Matched 1 hooks for query "Write"
[DEBUG] Hook command completed with status 0Toggle verbose mode during a session with Ctrl+O.
Test Hook Manually
Pipe sample JSON to the hook script to verify behavior outside Claude:
echo '{"tool_input":{"file_path":"test.ts"}}' | .claude/hooks/my-hook.sh
echo $?Log Hook Execution
Wrap a hook command to capture input for debugging:
{
"type": "command",
"command": "bash -c 'input=$(cat); echo \"$input\" >> ~/.claude/hook-debug.log; echo \"$input\" | .claude/hooks/actual-hook.sh'"
}jq Quick Reference
Common patterns for extracting fields from hook stdin JSON:
# Extract file path
jq -r '.tool_input.file_path // empty'
# Extract command
jq -r '.tool_input.command // empty'
# Check pattern match (exit 0 if match, 1 if not)
jq -e '.tool_input.command | test("pattern")'
# Safe extraction with fallback
jq -r '.field // "default"' 2>/dev/nullHook Templates
Block Dangerous Commands
Block shell commands matching a pattern. Uses exit 2 to prevent the tool call.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'COMMAND=$(jq -r \".tool_input.command\"); if echo \"$COMMAND\" | grep -q \"rm -rf\"; then echo \"Blocked: destructive command\" >&2; exit 2; fi'"
}
]
}
]
}
}Script-based version with JSON decision output:
#!/bin/bash
# .claude/hooks/block-dangerous.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
echo '{"decision":"block","reason":"Destructive command blocked by hook"}'
else
exit 0
fiProtect Files from Modification
Block writes to sensitive files like .env, lock files, or config.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'PATH_VAL=$(jq -r \".tool_input.file_path // empty\"); if [[ \"$PATH_VAL\" == *.env* ]] || [[ \"$PATH_VAL\" == *lock* ]]; then echo \"Blocked: protected file\" >&2; exit 2; fi'"
}
]
}
]
}
}Auto-Format After Write
Run a formatter after Claude writes or edits TypeScript files.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'FILE=$(jq -r \".tool_input.file_path // empty\"); if [[ \"$FILE\" == *.ts ]] || [[ \"$FILE\" == *.tsx ]]; then npx prettier --write \"$FILE\" 2>/dev/null; fi'",
"timeout": 10
}
]
}
]
}
}Desktop Notification on Permission Request
Send a macOS notification when Claude needs permission approval.
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude needs permission\" with title \"Claude Code\"'"
}
]
}
]
}
}For Linux, use notify-send:
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Claude needs permission'"
}
]
}
]
}
}Auto-Approve Safe Commands
Automatically approve specific safe commands without prompting.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'CMD=$(jq -r \".tool_input.command\"); if [[ \"$CMD\" == npm\\ test* ]] || [[ \"$CMD\" == npx\\ prettier* ]]; then echo \"{\\\"hookSpecificOutput\\\":{\\\"hookEventName\\\":\\\"PreToolUse\\\",\\\"permissionDecision\\\":\\\"allow\\\",\\\"permissionDecisionReason\\\":\\\"Safe command auto-approved\\\"}}\"; fi'"
}
]
}
]
}
}Environment Setup on Session Start
Set environment variables that persist for all Bash commands in the session.
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "bash -c 'if [ -n \"$CLAUDE_ENV_FILE\" ]; then echo \"export NODE_ENV=development\" >> \"$CLAUDE_ENV_FILE\"; echo \"export PATH=\\\"\\$PATH:./node_modules/.bin\\\"\" >> \"$CLAUDE_ENV_FILE\"; fi'"
}
]
}
]
}
}Script-based version that captures environment from a setup command:
#!/bin/bash
# .claude/hooks/setup-env.sh
ENV_BEFORE=$(export -p | sort)
source ~/.nvm/nvm.sh
nvm use 20
if [ -n "$CLAUDE_ENV_FILE" ]; then
ENV_AFTER=$(export -p | sort)
comm -13 <(echo "$ENV_BEFORE") <(echo "$ENV_AFTER") >> "$CLAUDE_ENV_FILE"
fi
exit 0Force Continue Until Tests Pass (Prompt Hook)
Use an LLM-based hook to evaluate whether Claude should stop.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate if Claude should stop: $ARGUMENTS. Check if all tasks are complete and no errors remain. Respond with {\"ok\": true} to allow stopping or {\"ok\": false, \"reason\": \"explanation\"} to continue.",
"timeout": 30
}
]
}
]
}
}Run Tests After File Changes (Async)
Run tests in the background without blocking Claude. Results are delivered on the next turn.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests-async.sh",
"async": true,
"timeout": 300
}
]
}
]
}
}#!/bin/bash
# .claude/hooks/run-tests-async.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.js ]]; then
exit 0
fi
RESULT=$(npm test 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo "{\"systemMessage\": \"Tests passed after editing $FILE_PATH\"}"
else
echo "{\"systemMessage\": \"Tests failed after editing $FILE_PATH: $RESULT\"}"
fiPython Hooks
For complex logic, use Python with uv for dependency management. Python hooks are more readable and maintainable than inline bash for multi-step logic.
Python Hook Template
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# ///
"""Hook description."""
import json
import sys
def main() -> int:
input_data = json.loads(sys.stdin.read())
# Extract relevant fields
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
content = tool_input.get("content", "")
# Your logic here
# Exit codes: 0=success, 1=error, 2=block (PreToolUse only)
return 0
if __name__ == "__main__":
sys.exit(main())PreToolUse Validator
Block dangerous commands before execution using regex patterns:
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# ///
import json
import re
import sys
BLOCKED_PATTERNS = [
(r"\brm\s+-rf\b", "Blocked: rm -rf is dangerous"),
(r"--force\b", "Blocked: --force operations disabled"),
]
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(1)
command = data.get("tool_input", {}).get("command", "")
for pattern, message in BLOCKED_PATTERNS:
if re.search(pattern, command, re.I):
print(message, file=sys.stderr)
sys.exit(2)
sys.exit(0)UserPromptSubmit with Context
Add context to every user prompt:
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# ///
import json
import sys
import datetime
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(1)
# Add context to conversation
context = f"Current time: {datetime.datetime.now()}"
print(context)
sys.exit(0)Auto-Approve Documentation Files
Automatically approve read access to documentation without prompting:
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# ///
import json
import sys
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(1)
tool_name = data.get("tool_name", "")
file_path = data.get("tool_input", {}).get("file_path", "")
if tool_name == "Read" and file_path.endswith((".md", ".txt", ".json")):
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Documentation auto-approved"
},
"suppressOutput": True
}
print(json.dumps(output))
sys.exit(0)Schema Change Notification
Notify when database schema files are modified:
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# ///
import json
import sys
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(1)
file_path = data.get("tool_input", {}).get("file_path", "")
if "schema" in file_path and file_path.endswith(".ts"):
print("Schema modified. Remember to run: pnpm db:generate")
sys.exit(0)Protected File Blocker
Block modifications to sensitive files:
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# ///
import json
import sys
PROTECTED_PATTERNS = [
".env",
".env.local",
"credentials",
"secrets",
".git/",
]
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(1)
file_path = data.get("tool_input", {}).get("file_path", "")
for pattern in PROTECTED_PATTERNS:
if pattern in file_path:
print(f"Blocked: Cannot modify protected file ({pattern})", file=sys.stderr)
sys.exit(2)
sys.exit(0)Post-Write Formatter
Format files automatically after writing based on file type:
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# ///
import json
import subprocess
import sys
from pathlib import Path
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(1)
file_path = data.get("tool_input", {}).get("file_path", "")
if not file_path or not Path(file_path).exists():
sys.exit(0)
if file_path.endswith((".ts", ".tsx", ".js", ".jsx")):
subprocess.run(["npx", "prettier", "--write", file_path], capture_output=True)
elif file_path.endswith(".py"):
subprocess.run(["ruff", "format", file_path], capture_output=True)
elif file_path.endswith(".md"):
subprocess.run(["npx", "markdownlint", "--fix", file_path], capture_output=True)
sys.exit(0)#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# ///
"""
Validate Claude Code hooks in settings.json files.
Usage:
uv run scripts/validate-hook.py <path> # settings.json file
uv run scripts/validate-hook.py .claude/ # auto-finds settings.json
Examples:
uv run scripts/validate-hook.py .claude/settings.json
uv run scripts/validate-hook.py .claude/settings.local.json
uv run scripts/validate-hook.py .claude/
"""
import glob
import json
import sys
from pathlib import Path
VALID_EVENTS = {
"PreToolUse",
"PostToolUse",
"PermissionRequest",
"UserPromptSubmit",
"Notification",
"Stop",
"SubagentStop",
"SessionStart",
"SessionEnd",
"PreCompact",
}
EVENTS_WITH_MATCHER = {"PreToolUse", "PostToolUse", "PermissionRequest", "Notification", "PreCompact", "SessionStart"}
VALID_HOOK_TYPES = {"command", "prompt"}
def resolve_settings_paths(path_arg: str) -> tuple[list[Path], str | None]:
"""
Resolve a path argument to a list of settings.json files.
Handles:
- Direct file path: .claude/settings.json
- Directory path: .claude/ (finds settings*.json files)
- Glob pattern: .claude/settings*.json
Returns:
Tuple of (list of Path objects, error message if any)
"""
path = Path(path_arg)
# Case 1: Direct file path
if path.is_file():
if not path.suffix == ".json":
return [], f"Expected .json file, got: {path.name}"
return [path], None
# Case 2: Directory - find settings*.json files
if path.is_dir():
settings_files = list(path.glob("settings*.json"))
if settings_files:
return sorted(settings_files), None
return [], f"No settings*.json files found in: {path}"
# Case 3: Glob pattern
if "*" in path_arg or "?" in path_arg:
matches = glob.glob(path_arg, recursive=True)
settings_files = [Path(m) for m in matches if m.endswith(".json")]
if settings_files:
return sorted(settings_files), None
return [], f"No settings files match pattern: {path_arg}"
# Path doesn't exist
return [], f"Path not found: {path_arg}"
def validate_hooks(path: Path) -> tuple[list[str], list[str]]:
"""Validate hooks in a settings.json file."""
errors: list[str] = []
warnings: list[str] = []
try:
content = path.read_text()
except PermissionError:
return [f"Permission denied: {path}"], []
except OSError as e:
return [f"Cannot read file: {e}"], []
try:
settings = json.loads(content)
except json.JSONDecodeError as e:
return [f"Invalid JSON: {e}"], []
if "hooks" not in settings:
return ["No 'hooks' key found in settings"], []
hooks = settings["hooks"]
if not isinstance(hooks, dict):
return ["'hooks' must be an object"], []
for event, matchers in hooks.items():
# Validate event type
if event not in VALID_EVENTS:
errors.append(f"Invalid event type: '{event}'. Valid: {sorted(VALID_EVENTS)}")
continue
if not isinstance(matchers, list):
errors.append(f"Event '{event}' must have an array of matchers")
continue
for i, matcher_config in enumerate(matchers):
if not isinstance(matcher_config, dict):
errors.append(f"Event '{event}' matcher {i}: must be an object")
continue
# Check matcher field
has_matcher = "matcher" in matcher_config
if event in EVENTS_WITH_MATCHER and not has_matcher:
warnings.append(f"Event '{event}' matcher {i}: consider adding 'matcher' field")
# Check hooks array
if "hooks" not in matcher_config:
errors.append(f"Event '{event}' matcher {i}: missing 'hooks' array")
continue
hook_list = matcher_config["hooks"]
if not isinstance(hook_list, list):
errors.append(f"Event '{event}' matcher {i}: 'hooks' must be an array")
continue
for j, hook in enumerate(hook_list):
if not isinstance(hook, dict):
errors.append(f"Event '{event}' matcher {i} hook {j}: must be an object")
continue
# Validate hook type
hook_type = hook.get("type")
if not hook_type:
errors.append(f"Event '{event}' matcher {i} hook {j}: missing 'type'")
elif hook_type not in VALID_HOOK_TYPES:
errors.append(f"Event '{event}' matcher {i} hook {j}: invalid type '{hook_type}'. Valid: {VALID_HOOK_TYPES}")
# Validate command or prompt
if hook_type == "command":
if "command" not in hook:
errors.append(f"Event '{event}' matcher {i} hook {j}: missing 'command'")
else:
cmd = hook["command"]
# Check for common issues
if "$CLAUDE_PROJECT_DIR" in cmd and '"$CLAUDE_PROJECT_DIR"' not in cmd:
warnings.append(f"Event '{event}' matcher {i} hook {j}: $CLAUDE_PROJECT_DIR should be quoted")
elif hook_type == "prompt":
if "prompt" not in hook:
errors.append(f"Event '{event}' matcher {i} hook {j}: missing 'prompt'")
# Prompt hooks only work well with certain events
if event not in {"Stop", "SubagentStop", "UserPromptSubmit", "PreToolUse", "PermissionRequest"}:
warnings.append(f"Event '{event}' matcher {i} hook {j}: prompt hooks work best with Stop/SubagentStop")
# Check timeout
if "timeout" in hook:
timeout = hook["timeout"]
if not isinstance(timeout, (int, float)):
errors.append(f"Event '{event}' matcher {i} hook {j}: timeout must be a number")
elif timeout <= 0:
errors.append(f"Event '{event}' matcher {i} hook {j}: timeout must be positive")
elif timeout > 300:
warnings.append(f"Event '{event}' matcher {i} hook {j}: timeout {timeout}s is very long")
return errors, warnings
def print_result(path: Path, errors: list[str], warnings: list[str], verbose: bool = True) -> None:
"""Print validation results for a single settings file."""
file_name = path.name
if errors:
print(f"❌ {file_name}: FAILED")
if verbose:
for error in errors:
print(f" ✗ {error}")
elif warnings:
print(f"✓ {file_name}: valid (with {len(warnings)} warning(s))")
if verbose:
for warning in warnings:
print(f" ⚠ {warning}")
else:
print(f"✓ {file_name}: passed")
def main() -> int:
"""Main entry point."""
if len(sys.argv) < 2:
print("Usage: uv run scripts/validate-hook.py <path>")
print()
print("Accepts:")
print(" - File path: .claude/settings.json")
print(" - Directory: .claude/ (finds settings*.json)")
print(" - Glob pattern: '.claude/settings*.json'")
print()
print("Examples:")
print(" uv run scripts/validate-hook.py .claude/settings.json")
print(" uv run scripts/validate-hook.py .claude/")
return 1
path_arg = sys.argv[1]
settings_paths, error = resolve_settings_paths(path_arg)
if error:
print(f"❌ Error: {error}")
return 1
if not settings_paths:
print("❌ No settings files found to validate")
return 1
# Validate all files
total_errors = 0
total_warnings = 0
failed_files = []
# Single file - verbose output
if len(settings_paths) == 1:
path = settings_paths[0]
errors, warnings = validate_hooks(path)
if errors:
print("❌ Hook validation FAILED\n")
print("Errors:")
for error in errors:
print(f" ✗ {error}")
print()
if warnings:
print("Warnings:")
for warning in warnings:
print(f" ⚠ {warning}")
print()
if not errors and not warnings:
print("✓ Hook validation passed")
elif not errors:
print("✓ Hooks valid (with warnings)")
return 1 if errors else 0
# Multiple files - summary output
print(f"Validating {len(settings_paths)} settings file(s)...\n")
for path in settings_paths:
errors, warnings = validate_hooks(path)
total_errors += len(errors)
total_warnings += len(warnings)
if errors:
failed_files.append(path.name)
print_result(path, errors, warnings, verbose=bool(errors))
# Summary
print()
if failed_files:
print(f"❌ {len(failed_files)} file(s) failed: {', '.join(failed_files)}")
else:
print(f"✓ All {len(settings_paths)} file(s) passed")
if total_warnings:
print(f" {total_warnings} total warning(s)")
return 1 if failed_files else 0
if __name__ == "__main__":
sys.exit(main())