project-hooks

SKILL.md

Project Hooks

Create project-specific hooks that only run in a specific workspace.

Quick Setup

# 1. Create hooks directory
mkdir -p .claude/hooks

# 2. Create settings.json with hook config (see templates below)

Hook Locations

Location Scope
~/.claude/hooks/ Global (all projects)
<project>/.claude/hooks/ Project-specific

settings.json Structure

Create .claude/settings.json in your project root:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Edit|Write",
        "command": ".claude/hooks/pre-tool.sh"
      }
    ],
    "PostToolUse": [
      {
        "matcher": "*",
        "command": ".claude/hooks/post-tool.sh"
      }
    ],
    "UserPromptSubmit": [
      {
        "command": ".claude/hooks/on-prompt.sh"
      }
    ]
  }
}

Hook Events

Event When Use For
PreToolUse Before tool runs Validation, blocking
PostToolUse After tool runs Logging, notifications
UserPromptSubmit When user sends message Context injection
Notification On notifications Alerts

Matcher Patterns

  • "*" - Match all tools
  • "Bash" - Match specific tool
  • "Bash|Edit|Write" - Match multiple tools (OR)
  • "mcp__playwright__*" - Wildcard patterns

Hook Input/Output

Input (stdin)

{
  "event": "PreToolUse",
  "tool": "Bash",
  "input": {"command": "ls -la"},
  "session_id": "abc123"
}

Output (stdout)

{
  "decision": "allow",
  "message": "Optional message to show"
}

Decisions

  • "allow" - Proceed normally
  • "block" - Stop the action
  • "modify" - Change the input (with modifiedInput)

Templates

Template 1: Lint on Save (TypeScript)

.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "command": ".claude/hooks/lint-on-save.sh"
      }
    ]
  }
}

.claude/hooks/lint-on-save.sh:

#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')

if [[ "$FILE" == *.ts || "$FILE" == *.tsx ]]; then
  npx eslint --fix "$FILE" 2>/dev/null
fi

echo '{"continue": true}'

Template 2: Block Dangerous Commands

.claude/hooks/block-dangerous.sh:

#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.input.command // empty')

# Block rm -rf /
if echo "$CMD" | grep -qE 'rm\s+-rf\s+/[^/]'; then
  echo '{"decision": "block", "message": "Blocked dangerous rm command"}'
  exit 0
fi

echo '{"decision": "allow"}'

Template 3: Auto-Format Python

.claude/hooks/format-python.sh:

#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')

if [[ "$FILE" == *.py ]]; then
  black "$FILE" 2>/dev/null
  ruff check --fix "$FILE" 2>/dev/null
fi

echo '{"continue": true}'

Template 4: Project Context Injection

.claude/hooks/inject-context.sh:

#!/bin/bash
# Inject project-specific context into every prompt
echo '{"message": "Project: MyApp | Stack: React + Supabase"}'

Template 5: Run Tests After Changes

.claude/hooks/run-tests.sh:

#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')

# Run tests for changed file
if [[ "$FILE" == *.ts && "$FILE" != *.test.ts ]]; then
  TEST_FILE="${FILE%.ts}.test.ts"
  if [[ -f "$TEST_FILE" ]]; then
    npm test -- "$TEST_FILE" 2>/dev/null &
  fi
fi

echo '{"continue": true}'

Scaffold Command

Run this to scaffold hooks for any project:

mkdir -p .claude/hooks && echo '{"hooks":{}}' > .claude/settings.json

Debugging Hooks

Test hook manually

echo '{"event":"PreToolUse","tool":"Bash","input":{"command":"ls"}}' | .claude/hooks/my-hook.sh

Add logging

echo "[$(date)] $INPUT" >> /tmp/hook-debug.log

Best Practices

  1. Keep hooks fast - Slow hooks delay every action
  2. Always output valid JSON - Invalid JSON = hook failure
  3. Use #!/bin/bash - Explicit shebang
  4. Make executable - chmod +x .claude/hooks/*.sh
  5. Handle missing tools - Check if eslint/black exist before running
Weekly Installs
2
First Seen
Jan 26, 2026
Installed on
mcpjam1
claude-code1
windsurf1
zencoder1
crush1
cline1