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.
> /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
- Matches
Edit|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" - Matches
Bashbash "${CLAUDE_PLUGIN_ROOT}/hooks/log-commands.sh"bash "${CLAUDE_PLUGIN_ROOT}/hooks/branch-protection.sh"
PostToolUse
- Matches
Edit|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"
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.
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.
Repo: CloudAI-X/claude-workflow-v2

