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.
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
Stats
110
Stars
17
Forks
Maintained
Maintenance
JavaScript
Language
MIT
License
1mo ago
Last commit
1y ago
Created
6h ago
Added

