Skip to content
Development
Hook

Hooks

What project-starter runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
project-starter
1.4k14 skills7 agents26 commands6 hooks
Install
> /plugin marketplace add CloudAI-X/claude-workflow-v2
> /plugin install project-starter@claude-workflow

Ships with project-starter. Installing the plugin gets these hooks.

What fires, and when

PreToolUse

  • MatchesEdit|Writepython3 "${CLAUDE_PLUGIN_ROOT}/hooks/protect-files.py"python3 "${CLAUDE_PLUGIN_ROOT}/hooks/security-check.py"python3 "${CLAUDE_PLUGIN_ROOT}/hooks/pre-commit-check.py"
  • MatchesBashbash "${CLAUDE_PLUGIN_ROOT}/hooks/log-commands.sh"bash "${CLAUDE_PLUGIN_ROOT}/hooks/branch-protection.sh"

PostToolUse

  • MatchesEdit|Writepython3 "${CLAUDE_PLUGIN_ROOT}/hooks/format-on-edit.py"python3 "${CLAUDE_PLUGIN_ROOT}/hooks/typescript-check.py"

Notification

  • Matches.*bash "${CLAUDE_PLUGIN_ROOT}/hooks/notify-input.sh"

Stop

  • Matches.*python3 "${CLAUDE_PLUGIN_ROOT}/hooks/verify-on-complete.py"python3 "${CLAUDE_PLUGIN_ROOT}/hooks/suggest-doc-updates.py"bash "${CLAUDE_PLUGIN_ROOT}/hooks/notify-complete.sh"python3 "${CLAUDE_PLUGIN_ROOT}/hooks/track-metrics.py"

SessionStart

Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.

  • Matches.*python3 "${CLAUDE_PLUGIN_ROOT}/hooks/validate-environment.py"

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • Matches.*python3 "${CLAUDE_PLUGIN_ROOT}/hooks/validate-prompt.py"
Read hooks/hooks.json

Where it lives

  • hooks/branch-protection.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Warn when git operations target protected branches.
    # Informational only - never blocks operations.
    
    INPUT=$(cat)
    
    if command -v jq &> /dev/null; then
        COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
    else
        COMMAND=$(echo "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
    fi
    
    if [[ -z "$COMMAND" ]]; then
        exit 0
    fi
    
    if ! echo "$COMMAND" | grep -qE 'git (commit|push)'; then
        exit 0
    fi
    
    CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
    
    if [[ -z "$CURRENT_BRANCH" ]]; then
        exit 0
    fi
    
    PROTECTED_BRANCHES=("main" "master" "production")
    
    for BRANCH in "${PROTECTED_BRANCHES[@]}"; do
        if [[ "$CURRENT_BRANCH" == "$BRANCH" ]]; then
            # Plain stdout on exit 0 only reaches the debug log, so pass the
            # warning to Claude as additionalContext. $BRANCH is one of the
            # literals above, so it is safe to embed in JSON.
            printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"%s"}}\n' \
                "WARNING: You are on protected branch '$BRANCH'. Create a feature branch (git checkout -b feature/your-change) or use /project-starter:sync-branch before committing or pushing."
            exit 0
        fi
    done
    
    exit 0
    
  • hooks/format-on-edit.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """
    Auto-format files after Claude edits them.
    Detects file type and runs appropriate formatter.
    """
    import json
    import shutil
    import subprocess
    import sys
    import os
    
    def find_tool(name, start_dir):
        """Find a tool in the nearest node_modules/.bin, falling back to PATH.
    
        Deliberately not `npx <name>`: outside a TTY npx silently downloads and
        runs whatever registry package has that name (`npx tsc` fetches an
        unrelated, deprecated "tsc" package, not TypeScript).
        """
        directory = os.path.abspath(start_dir)
        while True:
            candidate = os.path.join(directory, 'node_modules', '.bin', name)
            if os.path.exists(candidate):
                return candidate
            parent = os.path.dirname(directory)
            if parent == directory:
                return shutil.which(name)
            directory = parent
    
    
    def get_formatter_command(file_path):
        """Return the formatter command for a given file type."""
        ext = os.path.splitext(file_path)[1].lower()
        
        formatters = {
            # JavaScript/TypeScript
            '.js': ['prettier', '--write'],
            '.jsx': ['prettier', '--write'],
            '.ts': ['prettier', '--write'],
            '.tsx': ['prettier', '--write'],
            '.json': ['prettier', '--write'],
            '.css': ['prettier', '--write'],
            '.scss': ['prettier', '--write'],
            '.md': ['prettier', '--write'],
            '.yaml': ['prettier', '--write'],
            '.yml': ['prettier', '--write'],
            
            # Python
            '.py': ['black', '--quiet'],
            
            # Go
            '.go': ['gofmt', '-w'],
            
            # Rust
            '.rs': ['rustfmt'],
        }
        
        return formatters.get(ext)
    
    def main():
        try:
            input_data = json.load(sys.stdin)
            file_path = input_data.get('tool_input', {}).get('file_path', '')
            
            if not file_path or not os.path.exists(file_path):
                sys.exit(0)
            
            formatter = get_formatter_command(file_path)
            if formatter:
                tool = find_tool(formatter[0], os.path.dirname(file_path))
                if not tool:
                    # Formatter not installed - skip silently
                    sys.exit(0)
                cmd = [tool] + formatter[1:] + [file_path]
                try:
                    subprocess.run(cmd, capture_output=True, timeout=10)
                except (subprocess.TimeoutExpired, FileNotFoundError):
                    # Formatter not installed or timed out - skip silently
                    pass
        except Exception:
            # Don't block on formatter errors
            pass
    
    if __name__ == '__main__':
        main()
    
  • hooks/log-commands.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Log all bash commands executed by Claude for auditing
    LOG_FILE="${CLAUDE_PROJECT_DIR:-$(pwd)}/.claude/command-history.log"
    mkdir -p "$(dirname "$LOG_FILE")"
    
    # Read JSON from stdin
    INPUT=$(cat)
    
    # Extract command using jq if available, otherwise use grep
    if command -v jq &> /dev/null; then
        COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
        DESCRIPTION=$(echo "$INPUT" | jq -r '.tool_input.description // "No description"' 2>/dev/null)
    else
        COMMAND=$(echo "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
        DESCRIPTION="(jq not installed)"
    fi
    
    if [ -n "$COMMAND" ]; then
        TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
        echo "[$TIMESTAMP] $COMMAND" >> "$LOG_FILE"
    fi
    
  • hooks/notify-complete.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Stop hook - Alerts when Claude completes a task
    # Triggered by: Stop event
    # Supports: macOS, Linux, Windows (WSL/Git Bash)
    
    # macOS notification
    if command -v osascript &> /dev/null; then
        osascript -e 'display notification "Task completed" with title "Claude Code" sound name "Ping"'
    # Linux notification (requires notify-send)
    elif command -v notify-send &> /dev/null; then
        notify-send "Claude Code" "Task completed" --urgency=low
    # Windows notification via PowerShell (WSL or Git Bash)
    elif command -v powershell.exe &> /dev/null; then
        powershell.exe -Command "
            [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
            [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
            \$template = '<toast><visual><binding template=\"ToastText02\"><text id=\"1\">Claude Code</text><text id=\"2\">Task completed</text></binding></visual></toast>'
            \$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
            \$xml.LoadXml(\$template)
            \$toast = [Windows.UI.Notifications.ToastNotification]::new(\$xml)
            [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Claude Code').Show(\$toast)
        " 2>/dev/null || true
    fi
    
    # Always exit 0 - notifications should never block
    exit 0
    
  • hooks/notify-input.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Notification hook - Alerts when Claude needs user input
    # Triggered by: Notification event
    # Supports: macOS, Linux, Windows (WSL/Git Bash)
    
    # macOS notification
    if command -v osascript &> /dev/null; then
        osascript -e 'display notification "Claude needs your input" with title "Claude Code" sound name "Glass"'
    # Linux notification (requires notify-send)
    elif command -v notify-send &> /dev/null; then
        notify-send "Claude Code" "Claude needs your input" --urgency=normal
    # Windows notification via PowerShell (WSL or Git Bash)
    elif command -v powershell.exe &> /dev/null; then
        powershell.exe -Command "
            [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
            [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
            \$template = '<toast><visual><binding template=\"ToastText02\"><text id=\"1\">Claude Code</text><text id=\"2\">Claude needs your input</text></binding></visual></toast>'
            \$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
            \$xml.LoadXml(\$template)
            \$toast = [Windows.UI.Notifications.ToastNotification]::new(\$xml)
            [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Claude Code').Show(\$toast)
        " 2>/dev/null || true
    fi
    
    # Always exit 0 - notifications should never block
    exit 0
    
  • hooks/pre-commit-check.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """
    Pre-commit quality check hook.
    Detects debug statements, temporary markers, and large file content.
    Runs on PreToolUse for Edit|Write operations.
    """
    import json
    import re
    import sys
    import os
    
    
    DEBUG_PATTERNS = [
        (r'\bconsole\.log\s*\(', "console.log", "Remove before commit, or use a proper logger (e.g., winston, pino)"),
        (r'\bconsole\.debug\s*\(', "console.debug", "Remove before commit, or use a proper logger (e.g., winston, pino)"),
        (r'\bdebugger\b', "debugger statement", "Remove before commit — debugger statements pause execution in production"),
        (r'\bbreakpoint\s*\(', "breakpoint()", "Remove before commit — use a conditional breakpoint or logging instead"),
        (r'\bpdb\.set_trace\s*\(', "pdb.set_trace()", "Remove before commit, or use a proper logger (e.g., logging module)"),
    ]
    
    TEMP_MARKERS = [
        (r'\bFIXME\b', "FIXME", "Address the issue or convert to a tracked GitHub issue"),
        (r'\bHACK\b', "HACK", "Refactor the workaround or document why it is necessary in a comment"),
        (r'\bXXX\b', "XXX", "Resolve the concern or convert to a tracked GitHub issue"),
    ]
    
    MAX_CONTENT_SIZE = 500 * 1024
    SKIP_EXTENSIONS = {'.md', '.markdown', '.txt', '.rst', '.json', '.yml', '.yaml'}
    
    
    def should_skip(file_path):
        basename = os.path.basename(file_path).lower()
        ext = os.path.splitext(file_path)[1].lower()
    
        if ext in SKIP_EXTENSIONS:
            return True
    
        if 'test' in basename or 'spec' in basename:
            return True
    
        path_lower = file_path.lower()
        if '/tests/' in path_lower or '/test/' in path_lower or '/__tests__/' in path_lower:
            return True
        if 'example' in path_lower or '/examples/' in path_lower:
            return True
    
        return False
    
    
    def check_content(content):
        issues = []
        for pattern, name, suggestion in DEBUG_PATTERNS:
            if re.search(pattern, content):
                issues.append(f"Debug statement found: {name} → {suggestion}")
    
        for pattern, name, suggestion in TEMP_MARKERS:
            if re.search(pattern, content):
                issues.append(f"Temporary marker found: {name} → {suggestion}")
    
        content_size = len(content.encode('utf-8', errors='replace'))
        if content_size > MAX_CONTENT_SIZE:
            size_kb = content_size // 1024
            issues.append(f"Large file content: {size_kb}KB (limit: {MAX_CONTENT_SIZE // 1024}KB) → Consider splitting into smaller modules or extracting data")
    
        return issues
    
    
    def main():
        try:
            input_data = json.load(sys.stdin)
            tool_input = input_data.get('tool_input', {})
    
            file_path = tool_input.get('file_path', '')
            content = tool_input.get('content', '') or tool_input.get('new_string', '')
    
            if not content:
                sys.exit(0)
    
            if file_path and should_skip(file_path):
                sys.exit(0)
    
            issues = check_content(content)
    
            if issues:
                lines = ["Quality check - issues detected:"]
                lines += [f"  - {issue}" for issue in issues]
                lines.append("Consider resolving before committing.")
                # Exit 0 - warn but don't block edits. Plain stdout on exit 0 only
                # reaches the debug log, so pass the warning as additionalContext.
                print(json.dumps({
                    "hookSpecificOutput": {
                        "hookEventName": "PreToolUse",
                        "additionalContext": "\n".join(lines),
                    }
                }))
                sys.exit(0)
    
        except Exception:
            sys.exit(0)
    
    
    if __name__ == '__main__':
        main()
    
  • hooks/protect-files.pyRunsGitHub
  • hooks/security-check.pyRunsGitHub
  • hooks/suggest-doc-updates.pyRunsGitHub
  • hooks/track-metrics.pyRunsGitHub
  • hooks/typescript-check.pyRunsGitHub
  • hooks/validate-environment.pyRunsGitHub
  • hooks/validate-prompt.pyRunsGitHub
  • hooks/verify-on-complete.pyRunsGitHub

All 14 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withproject-starter

A universal Claude Code workflow plugin with specialized agents, skills, hooks, and mode commands for any software project. Compatible with skills.sh — works with Claude Code, Cursor, Codex, and 35+ AI agents.

Get the whole plugin