Skip to content
Development
Hook

Hooks

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

From plugin
claude-code
11045 commands29 hooks

Where it lives

  • hooks/check-commit-signing.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """PreToolUse hook: verify GPG/SSH commit signing before git commit.
    
    Reads a PreToolUse JSON event from stdin. If the Bash command is a
    git commit, verifies that commit signing is configured. Blocks with
    remediation instructions if not.
    """
    
    from __future__ import annotations
    
    import json
    import re
    import subprocess
    import sys
    
    _GIT_COMMIT_RE = re.compile(r"\bgit\s+commit\b")
    
    
    def _is_git_commit(command: str) -> bool:
        """Return True if command contains a git commit invocation."""
        return bool(_GIT_COMMIT_RE.search(command))
    
    
    def _git_config(key: str) -> str:
        """Read a git config value, returning empty string on failure."""
        try:
            result = subprocess.run(
                ["git", "config", "--get", key],
                capture_output=True, text=True, timeout=5,
            )
            return result.stdout.strip()
        except (subprocess.SubprocessError, OSError):
            return ""
    
    
    def _check_gpg_signing() -> str | None:
        """Verify GPG signing config. Return error message or None."""
        gpg_format = _git_config("gpg.format")
        if gpg_format == "ssh":
            return _check_ssh_signing()
        program = _git_config("gpg.program")
        if not program:
            return "gpg.program is not configured"
        return None
    
    
    def _check_ssh_signing() -> str | None:
        """Verify SSH signing config. Return error message or None."""
        allowed = _git_config("gpg.ssh.allowedSignersFile")
        if not allowed:
            return "gpg.ssh.allowedSignersFile is not configured"
        return None
    
    
    _REMEDIATION_TEMPLATE = """\
    Commit signing is not properly configured: {error}
    
    IMPORTANT: Ask the user which option they prefer before proceeding.
    
    **Option 1 -- GPG signing** (traditional, widely supported):
    ```
    git config --global commit.gpgsign true
    git config --global gpg.program gpg
    ```
    Requires: GPG installed, a GPG key generated (`gpg --gen-key`)
    
    **Option 2 -- SSH signing** (simpler, uses existing SSH keys):
    
    Step 1 -- Generate an SSH key (skip if ~/.ssh/id_ed25519.pub exists):
    ```
    ssh-keygen -t ed25519 -C "your_email@example.com"
    ```
    
    Step 2 -- Configure git to use SSH signing:
    ```
    git config --global commit.gpgsign true
    git config --global gpg.format ssh
    git config --global user.signingkey ~/.ssh/id_ed25519.pub
    ```
    
    Step 3 -- Create the allowed signers file:
    ```
    echo "$(git config --get user.email) namespaces=\\"git\\" $(cat ~/.ssh/id_ed25519.pub)" > ~/.ssh/allowed_signers
    git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
    ```
    
    Step 4 -- Add the key to GitHub as a **signing key**:
    ```
    gh ssh-key add ~/.ssh/id_ed25519.pub --type signing
    ```
    (Or manually: GitHub -> Settings -> SSH and GPG keys -> New SSH key -> \
    Key type: **Signing Key**)
    
    Ask the user: "Commit signing isn't configured. Would you like me to \
    set it up for you? I can configure GPG or SSH signing -- which do you \
    prefer?" Then run the commands for their chosen option.\
    """
    
    
    def _build_remediation(error: str) -> str:
        """Build a user-friendly remediation message."""
        return _REMEDIATION_TEMPLATE.format(error=error)
    
    
    def main() -> None:
        """Entry point: check commit signing config before git commit."""
        try:
            event = json.load(sys.stdin)
        except (json.JSONDecodeError, EOFError):
            sys.exit(0)
        command = event.get("tool_input", {}).get("command", "")
        if not _is_git_commit(command):
            sys.exit(0)
        gpgsign = _git_config("commit.gpgsign")
        if gpgsign != "true":
            reason = _build_remediation("commit.gpgsign is not enabled")
            print(json.dumps({"decision": "block", "reason": reason}))
            sys.exit(0)
        error = _check_gpg_signing()
        if error:
            reason = _build_remediation(error)
            print(json.dumps({"decision": "block", "reason": reason}))
        sys.exit(0)
    
    
    if __name__ == "__main__":
        main()
    
  • hooks/check-complexity.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """PostToolUse hook: detect common code smells in modified files.
    
    Delegates all analysis to smell_checks module. Reads the PostToolUse
    JSON event from stdin, checks the written file, and emits a blocking
    result when violations are found.
    """
    
    from __future__ import annotations
    
    import json
    import os
    import sys
    
    # Allow importing sibling module from the same directory.
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    
    from smell_checks import check_file, format_violations  # noqa: E402
    
    
    def main() -> None:
        """Entry point: read PostToolUse event, check file, emit result."""
        try:
            event = json.load(sys.stdin)
        except (json.JSONDecodeError, EOFError):
            sys.exit(0)
        file_path = event.get("tool_input", {}).get("file_path", "")
        if not file_path or not os.path.isfile(file_path):
            sys.exit(0)
        smells = check_file(file_path)
        if smells:
            reason = format_violations(file_path, smells)
            print(json.dumps({"decision": "block", "reason": reason}))
        sys.exit(0)
    
    
    if __name__ == "__main__":
        main()
    
  • hooks/check-security.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """PostToolUse hook: detect security violations in modified files.
    
    Delegates analysis to security_checks module. Reads the PostToolUse
    JSON event from stdin, checks the written file, and emits a blocking
    result when violations are found.
    """
    
    from __future__ import annotations
    
    import json
    import os
    import sys
    
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    
    from security_checks import check_security, format_security_violations  # noqa: E402
    from smell_types import blocks_commit, severity_for  # noqa: E402
    
    
    def _format_report(file_path: str, smells: list) -> str:
        """Build a non-blocking notice for sub-HIGH security findings."""
        head = f"SECURITY NOTICE (non-blocking) in {file_path}:"
        rows = [
            f"  [{severity_for(s.kind).upper()}] {s.name} line {s.line}: {s.detail}"
            for s in smells
        ]
        return "\n".join([head, *rows])
    
    
    def _emit(file_path: str, smells: list) -> None:
        """Block on secrets/HIGH+; report sub-HIGH findings without blocking."""
        blocking = [s for s in smells if blocks_commit(s)]
        if blocking:
            reason = format_security_violations(file_path, smells)
            print(json.dumps({"decision": "block", "reason": reason}))
        else:
            print(_format_report(file_path, smells), file=sys.stderr)
    
    
    def main() -> None:
        """Entry point: read PostToolUse event, check file, emit result."""
        try:
            event = json.load(sys.stdin)
        except (json.JSONDecodeError, EOFError):
            sys.exit(0)
        file_path = event.get("tool_input", {}).get("file_path", "")
        if not file_path or not os.path.isfile(file_path):
            sys.exit(0)
        smells = check_security(file_path)
        if smells:
            _emit(file_path, smells)
        sys.exit(0)
    
    
    if __name__ == "__main__":
        main()
    
  • hooks/check_ash_expirations.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """Fail the ASH gate when a suppression expiration has lapsed.
    
    ASH validates every ``expiration`` in ``.ash/ash.yaml`` at config load and
    rejects the ENTIRE file if any one of them is not in the future:
    
        YYYY-MM-DD: expiration date must be in the future
        WARNING  Using default configuration due to validation error
    
    It then scans with built-in defaults, which silently discards every
    suppression and every ignore_path -- not just the expired entry. The scan goes
    red, but with a wall of triaged false positives rather than the one-line cause.
    That happened on 2026-09-01: two lapsed dates turned a green gate into 149
    "actionable" findings with no code change behind them.
    
    This check runs BEFORE ASH and names the real cause. It also warns while an
    expiration is still valid but close, so the renew-or-harden decision happens on
    purpose instead of on the morning the gate breaks.
    
    Stdlib only and Python 3.9 compatible, matching the rest of hooks/: these run in
    pre-push and in CI, where PyYAML is not installed.
    
    Usage:
        python3 hooks/check_ash_expirations.py [--config PATH]
                                               [--warn-days N] [--fail-days N]
    
    Exit codes:
        0  no expiration lapsed (warnings may have been emitted)
        1  an expiration has lapsed, is malformed, or the config is missing
    """
    
    from __future__ import annotations
    
    import argparse
    import os
    import re
    import sys
    from datetime import date, timedelta
    
    DEFAULT_CONFIG = ".ash/ash.yaml"
    DEFAULT_WARN_DAYS = 14
    DEFAULT_FAIL_DAYS = 0
    
    # expiration: "2026-12-01" | expiration: 2026-12-01 | expiration: '2026-12-01'
    _EXPIRATION_RE = re.compile(
        r"""^(?P<indent>\s*)expiration:\s*(?P<quote>["']?)(?P<value>[^"'#\s]*)(?P=quote)\s*(?:\#.*)?$"""
    )
    # A key introducing a block scalar: `reason: >-`, `reason: |`, `note: >+`, ...
    _BLOCK_SCALAR_RE = re.compile(r"^(?P<indent>\s*)[\w.-]+:\s*[|>][-+]?\d*\s*(?:\#.*)?$")
    
    
    def _iter_expiration_lines(text: str):
        """Yield (line_number, raw_value) for each real ``expiration:`` key.
    
        Skips matches inside block scalars, so an ``expiration:`` written in prose
        under ``reason: >-`` is not mistaken for a key.
        """
        block_indent = None
        for lineno, line in enumerate(text.splitlines(), start=1):
            stripped = line.strip()
    
            if block_indent is not None:
                # Blank lines stay inside the block; the block ends at the first
                # non-blank line indented no deeper than the key that opened it.
                if not stripped:
                    continue
                if len(line) - len(line.lstrip()) > block_indent:
                    continue
                block_indent = None
    
            block = _BLOCK_SCALAR_RE.match(line)
            if block:
                block_indent = len(block.group("indent"))
                continue
    
            match = _EXPIRATION_RE.match(line)
            if match:
                yield lineno, match.group("value")
    
    
    def _parse_date(value: str):
        """Return the parsed date, or None when the value is not YYYY-MM-DD."""
        if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value or ""):
            return None
        try:
            year, month, day = (int(part) for part in value.split("-"))
            return date(year, month, day)
        except ValueError:
            return None
    
    
    def _annotate(level: str, config: str, lineno: int, message: str) -> None:
        """Print a GitHub Actions annotation in CI, plain text everywhere else."""
        if os.environ.get("GITHUB_ACTIONS") == "true":
            print("::{0} file={1},line={2}::{3}".format(level, config, lineno, message))
        else:
            print("{0}: {1}:{2}: {3}".format(level.upper(), config, lineno, message))
    
    
    def check(config: str, today: date, warn_days: int, fail_days: int) -> int:
        """Check every expiration in ``config``. Returns a process exit code."""
        try:
            with open(config, encoding="utf-8") as handle:
                text = handle.read()
        except OSError as exc:
            print("ERROR: cannot read ASH config {0}: {1}".format(config, exc))
            return 1
    
        fail_before = today + timedelta(days=fail_days)
        warn_before = today + timedelta(days=warn_days)
        failures = 0
        checked = 0
    
        for lineno, raw in _iter_expiration_lines(text):
            checked += 1
            expires = _parse_date(raw)
    
            if expires is None:
                _annotate(
                    "error", config, lineno,
                    "expiration {0!r} is not a YYYY-MM-DD date; ASH will reject the "
                    "whole config and scan with defaults".format(raw),
                )
                failures += 1
                continue
    
            # ASH requires the date to be in the future, so today itself is lapsed.
            if expires <= fail_before:
                remaining = (expires - today).days
                detail = (
                    "lapsed {0} day(s) ago".format(-remaining) if remaining < 0
                    else "expires today" if remaining == 0
                    else "expires in {0} day(s)".format(remaining)
                )
                _annotate(
                    "error", config, lineno,
                    "suppression expiration {0} {1}. ASH rejects the entire config "
                    "when any expiration is not in the future, dropping every "
                    "suppression and ignore_path. Renew the date or land the "
                    "hardening it is holding open.".format(expires.isoformat(), detail),
                )
                failures += 1
            elif expires <= warn_before:
                _annotate(
                    "warning", config, lineno,
                    "suppression expiration {0} is {1} day(s) away. When it lapses, "
                    "ASH discards every suppression and ignore_path in this file.".format(
                        expires.isoformat(), (expires - today).days
                    ),
                )
    
        if failures:
            print(
                "\n{0}: {1} of {2} expiration(s) lapsed or malformed. "
                "ASH would fall back to default configuration.".format(
                    config, failures, checked
                )
            )
            return 1
    
        print
  • hooks/claude-wrapper.shGitHub
    Read the script
    #!/bin/bash
    # claude-wrapper.sh - Shell wrapper for claude that manages iTerm2 tab colors
    #
    # Source this file in ~/.zshrc:
    #   source ~/Code/claude-code/hooks/claude-wrapper.sh
    #
    # Sets gray tab color on launch, red on non-zero exit, then resets.
    # Mid-session colors (blue=working, green=done) are handled by Claude Code hooks.
    # See ~/.claude/settings.json and tab-color.sh
    
    # Exported so child shells inherit it. Claude Code snapshots shell functions into
    # the shells it runs tools in, but does not carry unexported variables — so this
    # function ran there with an empty CCDK_HOOKS_DIR and printed
    # "no such file or directory: /tab-color.sh" on every single claude invocation.
    CCDK_HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
    export CCDK_HOOKS_DIR
    
    claude() {
      # Resolved inside the function, with a fallback, so a snapshot that restores
      # this definition without the variable still finds the script. Deliberately
      # not a helper function: the snapshot may not carry that either.
      local tab="${CCDK_HOOKS_DIR:-$HOME/Code/claude-code/hooks}/tab-color.sh"
    
      # Colouring the tab is decoration. If the script is missing — moved checkout,
      # lost variable — do nothing rather than fail loudly on a command run dozens
      # of times a day.
      [ -x "$tab" ] && "$tab" gray < /dev/null
    
      # Pass all args through to the real claude binary
      command claude "$@"
      local exit_code=$?
    
      if [ $exit_code -ne 0 ]; then
        [ -x "$tab" ] && "$tab" red < /dev/null
      fi
    
      [ -x "$tab" ] && "$tab" reset < /dev/null
    
      return $exit_code
    }
    
  • hooks/config.pyGitHub
    Read the script
    """Per-project configuration loader for code smell and security hooks.
    
    Searches for .smellrc.json walking up from the target file's directory.
    Falls back to defaults matching the hardcoded thresholds in smell_types.
    """
    
    from __future__ import annotations
    
    import fnmatch
    import json
    import os
    from dataclasses import dataclass, field
    from functools import lru_cache
    
    
    @dataclass(frozen=True)
    class Config:
        """Immutable configuration for all hooks."""
    
        max_complexity: int = 10
        max_function_lines: int = 20
        max_nesting_depth: int = 3
        max_parameters: int = 4
        max_file_lines: int = 300
        duplicate_min_lines: int = 4
        security_enabled: bool = True
        trojan_enabled: bool = True
        suppress_files: tuple[str, ...] = ()
    
    
    DEFAULT_CONFIG = Config()
    
    
    def _find_config_file(start_dir: str) -> str | None:
        """Walk up from start_dir looking for .smellrc.json."""
        current = os.path.abspath(start_dir)
        root = os.path.dirname(current)
        while current != root:
            candidate = os.path.join(current, ".smellrc.json")
            if os.path.isfile(candidate):
                return candidate
            root = current
            current = os.path.dirname(current)
        return None
    
    
    def _parse_config(path: str) -> Config:
        """Parse a .smellrc.json file into a Config object."""
        with open(path, "r", encoding="utf-8") as fh:
            raw = json.load(fh)
        thresholds = raw.get("thresholds", {})
        security = raw.get("security", {})
        suppress = raw.get("suppress_files", [])
        return Config(
            max_complexity=thresholds.get("max_complexity", DEFAULT_CONFIG.max_complexity),
            max_function_lines=thresholds.get("max_function_lines", DEFAULT_CONFIG.max_function_lines),
            max_nesting_depth=thresholds.get("max_nesting_depth", DEFAULT_CONFIG.max_nesting_depth),
            max_parameters=thresholds.get("max_parameters", DEFAULT_CONFIG.max_parameters),
            max_file_lines=thresholds.get("max_file_lines", DEFAULT_CONFIG.max_file_lines),
            duplicate_min_lines=thresholds.get("duplicate_min_lines", DEFAULT_CONFIG.duplicate_min_lines),
            security_enabled=security.get("enabled", DEFAULT_CONFIG.security_enabled),
            trojan_enabled=security.get("trojan_enabled", DEFAULT_CONFIG.trojan_enabled),
            suppress_files=tuple(suppress),
        )
    
    
    @lru_cache(maxsize=32)
    def _cached_load(config_path: str) -> Config:
        """Load and cache a config file by its resolved path."""
        return _parse_config(config_path)
    
    
    def load_config(file_path: str) -> Config:
        """Load config for a given source file.
    
        Args:
            file_path: Path to the source file being checked.
    
        Returns:
            Config from nearest .smellrc.json, or DEFAULT_CONFIG.
        """
        start = os.path.dirname(os.path.abspath(file_path))
        config_path = _find_config_file(start)
        if config_path is None:
            return DEFAULT_CONFIG
        try:
            return _cached_load(config_path)
        except (json.JSONDecodeError, OSError, KeyError, TypeError):
            return DEFAULT_CONFIG
    
    
    def is_file_suppressed(file_path: str, config: Config) -> bool:
        """Check if a file matches any suppress_files glob patterns.
    
        Args:
            file_path: Path to check against suppression globs.
            config: Config with suppress_files patterns.
    
        Returns:
            True if the file should be skipped.
        """
        if not config.suppress_files:
            return False
        basename = os.path.basename(file_path)
        for pattern in config.suppress_files:
            if fnmatch.fnmatch(basename, pattern):
                return True
            if fnmatch.fnmatch(file_path, pattern):
                return True
        return False
    
  • hooks/file-logger.shGitHub
  • hooks/on-error-debug.shGitHub
  • hooks/pre-commit-quality.shGitHub
  • hooks/pre-commit-test-runner.shGitHub
  • hooks/pre-write-security.shGitHub
  • hooks/precommit_checks.pyGitHub
  • hooks/prepush_checks.pyGitHub
  • hooks/prevent-credential-exposure.shGitHub
  • hooks/scan_router.pyGitHub
  • hooks/security_bandit.pyGitHub
  • hooks/security_checks.pyGitHub
  • hooks/security_secrets.pyGitHub
  • hooks/security_trojan.pyGitHub
  • hooks/smell_checks.pyGitHub
  • hooks/smell_javascript.pyGitHub
  • hooks/smell_python.pyGitHub
  • hooks/smell_ruff.pyGitHub
  • hooks/smell_types.pyGitHub
  • hooks/statusline.shGitHub
  • hooks/subagent-trigger.shGitHub
  • hooks/suppression.pyGitHub
  • hooks/tab-color.shGitHub
  • hooks/verify-before-edit.shGitHub

All 29 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 withclaude-code

Transform Claude Code into a complete development platform with 45 AI-powered commands that automate your entire software development workflow. Now with improved git identity management!

Get the whole plugin