Skip to content
Documentation
Hook

Hooks

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

From plugin
claudeforge
4344 commands3 hooks

What fires, and when

PostToolUse

  • MatchesWrite|Editpython3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/validate-claude-md.py

InstructionsLoaded

  • Matchessession_start|nested_traversal|path_glob_match|include|compactpython3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/validate-claude-md.py

Stop

  • python3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/audit-claude-md.py
Read hooks/hooks.json

Where it lives

  • hooks/audit-claude-md.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """ClaudeForge Stop hook: print a 1-line CLAUDE.md health summary.
    
    Walks the project tree from ``CLAUDE_PROJECT_DIR`` (or the cwd) and prints a
    single line to stderr summarising how many CLAUDE.md files exist, how close
    they are to the 150-line cap, and whether any are over. Designed to be the
    last signal before a session's context is lost — drift visible to the user
    without forcing them to run ``/sync-claude-md`` blindly.
    
    Honours ``hooks/hooks-config.json`` and ``hooks/hooks-config.local.json``:
    when ``stopAuditLine.enabled`` is ``false``, this script exits silently.
    """
    from __future__ import annotations
    
    import json
    import os
    import sys
    
    HERE = os.path.dirname(os.path.abspath(__file__))
    DEFAULT_MAX_LINES = 150
    
    
    def _load_config() -> dict:
        cfg: dict = {}
        for name in ("hooks-config.json", "hooks-config.local.json"):
            path = os.path.join(HERE, name)
            if not os.path.exists(path):
                continue
            try:
                with open(path, encoding="utf-8") as fh:
                    data = json.load(fh)
            except (OSError, json.JSONDecodeError):
                continue
            cfg.update(data.get("stopAuditLine") or {})
            cfg.setdefault("maxLines", (data.get("validateClaudeMd") or {}).get("maxLines"))
        return cfg
    
    
    def _project_root() -> str:
        return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
    
    
    def _iter_claude_md(root: str):
        skip_dirs = {".git", "node_modules", ".venv", "venv", "dist", "build", "vendor"}
        for dirpath, dirnames, filenames in os.walk(root):
            dirnames[:] = [d for d in dirnames if d not in skip_dirs]
            for name in filenames:
                if name == "CLAUDE.md":
                    yield os.path.join(dirpath, name)
    
    
    def main() -> int:
        cfg = _load_config()
        if cfg.get("enabled") is False:
            return 0
        cap = int(cfg.get("maxLines") or DEFAULT_MAX_LINES)
        warn_at = max(1, int(cap * 0.8))
    
        total = 0
        over = 0
        near = 0
        for path in _iter_claude_md(_project_root()):
            try:
                with open(path, encoding="utf-8") as fh:
                    lines = sum(1 for _ in fh)
            except OSError:
                continue
            total += 1
            if lines > cap:
                over += 1
            elif lines >= warn_at:
                near += 1
    
        if total == 0:
            return 0
    
        suffix = ""
        if over:
            suffix = f" — {over} OVER {cap}-line cap; run /sync-claude-md"
        elif near:
            suffix = f" — {near} near cap ({warn_at}+)"
        print(f"ClaudeForge: {total} CLAUDE.md tracked{suffix}", file=sys.stderr)
        return 0
    
    
    if __name__ == "__main__":
        sys.exit(main())
    
  • hooks/claude-to-agents.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """ClaudeForge: convert a CLAUDE.md tree into an AGENTS.md for Codex / Gemini users.
    
    Three modes:
    
    * ``--symlink`` (default on macOS/Linux): create ``AGENTS.md`` as a symlink to
      ``CLAUDE.md``. Single source of truth forever. Windows falls back to
      ``--copy`` with a warning printed to stderr.
    * ``--copy``: byte-copy ``CLAUDE.md`` → ``AGENTS.md``. Snapshot, not live-linked.
    * ``--inline-chain``: walk every ``@path/.../CLAUDE.md`` import recursively and
      produce a single flat ``AGENTS.md`` with every chained file inlined under a
      header that names its relative path. The right choice for Codex / Gemini
      because those tools don't auto-resolve Claude's ``@`` imports.
    
    An existing ``AGENTS.md`` is backed up to ``AGENTS.md.backup.<UTC-timestamp>``
    before overwrite, unless ``--force`` is passed.
    
    Exit codes: ``0`` success, ``1`` user error (missing source, unsafe overwrite,
    unknown mode), ``2`` filesystem error (permission denied, symlink unsupported).
    """
    from __future__ import annotations
    
    import argparse
    import datetime as _dt
    import os
    import re
    import shutil
    import sys
    from pathlib import Path
    
    CHAIN_IMPORT = re.compile(r"^\s*@([^\s]+)\s*$")
    BACKLINK_LINE = re.compile(
        r"^\s*>\s*(Parent context:|Chained import:).*$", re.IGNORECASE
    )
    
    
    def _ts() -> str:
        # Microsecond precision so two backups in the same second don't collide
        # (the smoke test for the inline-chain workflow re-backs-up within ms).
        return _dt.datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f")
    
    
    def _backup(dest: Path) -> Path | None:
        if not dest.exists() and not dest.is_symlink():
            return None
        backup = dest.with_name(f"{dest.name}.backup.{_ts()}")
        # ``shutil.move`` preserves symlinks-as-symlinks across same-filesystem.
        shutil.move(str(dest), str(backup))
        return backup
    
    
    def _resolve_imports(root: Path, seen: set[Path]) -> list[tuple[Path, str]]:
        """Depth-first walk of ``@path`` chain imports rooted at ``root``.
    
        Returns a list of ``(relative_path, content)`` tuples in encounter order.
        Cycles are short-circuited (a file is read at most once).
        """
        root = root.resolve()
        if root in seen:
            return []
        if not root.exists():
            return []
        seen.add(root)
    
        lines = root.read_text(encoding="utf-8").splitlines()
        cleaned: list[str] = []
        children: list[tuple[Path, str]] = []
        parent_dir = root.parent
    
        for line in lines:
            m = CHAIN_IMPORT.match(line)
            if m:
                target = (parent_dir / m.group(1)).resolve()
                if target.suffix == "" and target.is_dir():
                    target = target / "CLAUDE.md"
                children.extend(_resolve_imports(target, seen))
                # Drop the @-import line from the inlined parent body.
                continue
            if BACKLINK_LINE.match(line):
                # Backlinks like "> Parent context: see the root [CLAUDE.md]..."
                # are Claude-specific scaffolding. Drop in inline-chain output.
                continue
            cleaned.append(line)
    
        rel = str(root.relative_to(Path.cwd().resolve())) if root.is_relative_to(
            Path.cwd().resolve()
        ) else root.name
        return [(rel, "\n".join(cleaned).strip() + "\n")] + children
    
    
    def _do_symlink(src: Path, dest: Path) -> int:
        if os.name == "nt":
            print(
                "claude-to-agents: symlinks on Windows require admin / dev mode; "
                "falling back to --copy",
                file=sys.stderr,
            )
            return _do_copy(src, dest)
        try:
            dest.symlink_to(src.name)
        except OSError as exc:
            print(f"claude-to-agents: symlink failed ({exc}); use --copy or --inline-chain", file=sys.stderr)
            return 2
        return 0
    
    
    def _do_copy(src: Path, dest: Path) -> int:
        shutil.copyfile(src, dest)
        return 0
    
    
    def _do_inline_chain(src: Path, dest: Path) -> int:
        chain = _resolve_imports(src, set())
        if not chain:
            print(f"claude-to-agents: source {src} not found", file=sys.stderr)
            return 1
        out: list[str] = [
            "<!-- Generated by ClaudeForge /claude-to-agents --inline-chain -->",
            "<!-- Source: CLAUDE.md (this file flattens the @path chain for tools that don't auto-import). -->",
            "",
        ]
        for rel, body in chain:
            out.append(f"<!-- inlined from {rel} -->")
            out.append("")
            out.append(body.strip())
            out.append("")
        dest.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8")
        return 0
    
    
    def main(argv: list[str] | None = None) -> int:
        parser = argparse.ArgumentParser(prog="claude-to-agents", description=__doc__.split("\n\n")[0])
        parser.add_argument("--source", default="CLAUDE.md")
        parser.add_argument("--output", default="AGENTS.md")
        parser.add_argument(
            "--mode",
            choices=("symlink", "copy", "inline-chain"),
            default="symlink",
        )
        parser.add_argument("--force", action="store_true", help="overwrite without backup")
        args = parser.parse_args(argv)
    
        src = Path(args.source)
        dest = Path(args.output)
        if not src.exists():
            print(f"claude-to-agents: source {src} not found", file=sys.stderr)
            return 1
    
        if (dest.exists() or dest.is_symlink()) and not args.force:
            backup = _backup(dest)
            if backup is not None:
                print(f"claude-to-agents: backed up existing {dest} -> {backup}", file=sys.stderr)
        elif args.force and (dest.exists() or dest.is_symlink()):
            dest.unlink()
    
        handlers = {
            "symlink": _do_symlink,
            "copy": _do_copy,
            "inline-chain": _do_inline_chain,
        }
        rc = handlers[args.mode](src, dest)
        if rc == 0:
            kind = "symlink" if args.mode == "symlink" and dest.is_symlink() else "file"
            print(f"claude-to-agents: wrote {dest} ({args.mode}, {kind})", file=sys.stderr)
        return rc
    
    
    if __name__ == "__main__":
        sys.exit(main())
    
  • hooks/pre-commit.shGitHub
    Read the script
    #!/bin/bash
    # ClaudeForge Quality Hook - Pre-Commit Validation
    # Validates CLAUDE.md file quality before allowing commits
    
    set -e
    
    # Colors for output
    RED='\033[0;31m'
    GREEN='\033[0;32m'
    YELLOW='\033[1;33m'
    NC='\033[0m' # No Color
    
    echo "🔍 ClaudeForge Quality Hook: Validating CLAUDE.md..."
    echo ""
    
    # Check if CLAUDE.md exists
    if [ ! -f "CLAUDE.md" ]; then
        echo -e "${YELLOW}⚠  Warning: CLAUDE.md not found${NC}"
        echo "   Skipping validation (no CLAUDE.md to validate)"
        exit 0
    fi
    
    # Check if skill modules are available (for advanced validation)
    SKILL_AVAILABLE=false
    if [ -f "skill/validator.py" ] || [ -f "$HOME/.claude/skills/claudeforge-skill/validator.py" ]; then
        SKILL_AVAILABLE=true
    fi
    
    # Basic Validation (always run)
    echo "Running basic validation checks..."
    
    # 1. Check file length
    echo -n "  ✓ Checking file length... "
    LINES=$(wc -l < CLAUDE.md)
    if [ $LINES -lt 20 ]; then
        echo -e "${RED}FAILED${NC}"
        echo -e "    ${RED}Error: CLAUDE.md too short ($LINES lines)${NC}"
        echo "    Minimum: 20 lines recommended"
        exit 1
    elif [ $LINES -gt 400 ]; then
        echo -e "${YELLOW}WARNING${NC}"
        echo -e "    ${YELLOW}Warning: CLAUDE.md very long ($LINES lines)${NC}"
        echo "    Recommended: <300 lines (or use modular architecture)"
        echo "    Continuing anyway..."
    else
        echo -e "${GREEN}OK${NC} ($LINES lines)"
    fi
    
    # 2. Check required sections
    echo -n "  ✓ Checking required sections... "
    MISSING_SECTIONS=()
    
    for section in "Core Principles" "Tech Stack" "Workflow"; do
        if ! grep -qi "$section" CLAUDE.md; then
            MISSING_SECTIONS+=("$section")
        fi
    done
    
    if [ ${#MISSING_SECTIONS[@]} -gt 0 ]; then
        echo -e "${RED}FAILED${NC}"
        echo -e "    ${RED}Missing required sections:${NC}"
        for section in "${MISSING_SECTIONS[@]}"; do
            echo "      - $section"
        done
        echo ""
        echo "  Run: /enhance-claude-md to add missing sections"
        exit 1
    else
        echo -e "${GREEN}OK${NC}"
    fi
    
    # 3. Check for code blocks
    echo -n "  ✓ Checking for code examples... "
    CODE_BLOCKS=$(grep -c '```' CLAUDE.md || echo "0")
    if [ $CODE_BLOCKS -lt 2 ]; then
        echo -e "${YELLOW}WARNING${NC}"
        echo -e "    ${YELLOW}Warning: Few code examples ($CODE_BLOCKS blocks)${NC}"
        echo "    Recommended: Include code examples in your CLAUDE.md"
    else
        echo -e "${GREEN}OK${NC} ($CODE_BLOCKS blocks)"
    fi
    
    # 4. Check for TODO/FIXME placeholders
    echo -n "  ✓ Checking for placeholders... "
    if grep -qi "TODO\|FIXME\|XXX\|\[TBD\]" CLAUDE.md; then
        echo -e "${YELLOW}WARNING${NC}"
        echo -e "    ${YELLOW}Warning: Found TODO/FIXME placeholders${NC}"
        grep -n "TODO\|FIXME\|XXX\|\[TBD\]" CLAUDE.md | head -3
        echo "    Consider completing these before committing"
    else
        echo -e "${GREEN}OK${NC}"
    fi
    
    # 5. Check for potential secrets
    echo -n "  ✓ Checking for hardcoded secrets... "
    if grep -Ei "API_KEY|password|token|secret" CLAUDE.md | grep -v "example\|sample\|placeholder"; then
        echo -e "${RED}FAILED${NC}"
        echo -e "    ${RED}Error: Potential hardcoded secrets found${NC}"
        grep -n -Ei "API_KEY|password|token|secret" CLAUDE.md
        exit 1
    else
        echo -e "${GREEN}OK${NC}"
    fi
    
    # Advanced Validation (if skill modules available)
    if [ "$SKILL_AVAILABLE" = true ]; then
        echo ""
        echo "Running advanced validation (using ClaudeForge skill)..."
    
        # Determine skill path
        if [ -f "skill/validator.py" ]; then
            SKILL_PATH="skill"
        else
            SKILL_PATH="$HOME/.claude/skills/claudeforge-skill"
        fi
    
        # Run Python validation
        python3 << EOF
    import sys
    sys.path.insert(0, '$SKILL_PATH')
    
    try:
        from validator import BestPracticesValidator
    
        with open('CLAUDE.md', 'r') as f:
            content = f.read()
    
        validator = BestPracticesValidator(content)
        results = validator.validate_all()
    
        passed = sum(1 for r in results if r['passed'])
        total = len(results)
    
        print(f"  ✓ Advanced validation: {passed}/{total} checks passed")
    
        if passed < 4:
            print(f"\n  \033[0;31mError: Only {passed}/{total} validation checks passed\033[0m")
            print("  Run: /enhance-claude-md to improve quality")
            sys.exit(1)
    
    except Exception as e:
        print(f"  ⚠  Advanced validation skipped (error: {e})")
        pass
    EOF
    
        if [ $? -ne 0 ]; then
            exit 1
        fi
    fi
    
    # All checks passed
    echo ""
    echo -e "${GREEN}✅ CLAUDE.md validation passed!${NC}"
    echo ""
    echo "Proceeding with commit..."
    exit 0
    
  • hooks/validate-claude-md.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """ClaudeForge hook: validate every touched CLAUDE.md against the 150-line cap.
    
    Wired into the plugin's ``hooks/hooks.json`` for both ``PostToolUse`` (after
    ``Edit`` or ``Write``) and ``InstructionsLoaded`` (after any of the five
    ``load_reason`` values fire). Reads the hook payload from stdin, extracts any
    referenced file path, and exits non-zero with stderr feedback when the file
    exists and exceeds the cap.
    
    Exit codes follow the Claude Code hook contract:
    - ``0``  pass
    - ``2``  surface stderr to Claude as actionable feedback (do not block)
    """
    from __future__ import annotations
    
    import json
    import os
    import sys
    
    DEFAULT_MAX_LINES = 150
    DEFAULT_EXEMPT_SUFFIX = ".local.md"
    DEFAULT_VIOLATION_RC = 2
    
    
    def _load_config() -> dict:
        """Merge ``hooks-config.json`` and optional ``hooks-config.local.json``.
    
        Local file overrides the shared one key-by-key inside ``validateClaudeMd``.
        Missing files are silently ignored — the script falls back to defaults.
        """
        here = os.path.dirname(os.path.abspath(__file__))
        shared = os.path.join(here, "hooks-config.json")
        local = os.path.join(here, "hooks-config.local.json")
        cfg: dict = {}
        for path in (shared, local):
            if not os.path.exists(path):
                continue
            try:
                with open(path, encoding="utf-8") as fh:
                    data = json.load(fh)
            except (OSError, json.JSONDecodeError):
                continue
            validate_block = data.get("validateClaudeMd") or {}
            cfg.update(validate_block)
        return cfg
    
    
    def _candidate_paths(payload: dict) -> list[str]:
        """Extract every file path the hook payload might be referring to.
    
        We accept several payload shapes so the same script works for ``PostToolUse``
        (tool_input.file_path) and ``InstructionsLoaded`` (path / file).
        """
        paths: list[str] = []
    
        tool_input = payload.get("tool_input") or {}
        for key in ("file_path", "path", "target_file"):
            value = tool_input.get(key)
            if isinstance(value, str):
                paths.append(value)
    
        for key in ("path", "file", "file_path"):
            value = payload.get(key)
            if isinstance(value, str):
                paths.append(value)
    
        return paths
    
    
    def _is_claude_md(path: str, exempt_suffix: str) -> bool:
        base = os.path.basename(path)
        # Personal-tier overrides (CLAUDE.local.md and any matching suffix) are
        # exempt from the cap — they live outside the chained team-shared tree.
        if base.endswith(exempt_suffix):
            return False
        return base == "CLAUDE.md" or "/.claude/rules/" in path
    
    
    def main() -> int:
        if sys.stdin.isatty():
            return 0
    
        raw = sys.stdin.read().strip()
        if not raw:
            return 0
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError:
            return 0
    
        cfg = _load_config()
        if cfg.get("enabled") is False:
            return 0
        max_lines = int(cfg.get("maxLines", DEFAULT_MAX_LINES))
        exempt_suffix = str(cfg.get("exemptFilenameSuffix", DEFAULT_EXEMPT_SUFFIX))
        violation_rc = int(cfg.get("exitCodeOnViolation", DEFAULT_VIOLATION_RC))
    
        violations: list[tuple[str, int]] = []
        for path in _candidate_paths(payload):
            if not _is_claude_md(path, exempt_suffix) or not os.path.exists(path):
                continue
            try:
                with open(path, encoding="utf-8") as fh:
                    line_count = sum(1 for _ in fh)
            except OSError:
                continue
            if line_count > max_lines:
                violations.append((path, line_count))
    
        if not violations:
            return 0
    
        for path, line_count in violations:
            print(
                f"ClaudeForge: {path} is {line_count} lines (cap is {max_lines}). "
                "Run /sync-claude-md to split into chained sub-files.",
                file=sys.stderr,
            )
        return violation_rc
    
    
    if __name__ == "__main__":
        sys.exit(main())
    

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.

@alirezarezvani
Ships withclaudeforge

Automated CLAUDE.md creation, enhancement, and maintenance for Claude Code projects ClaudeForge is a comprehensive toolkit that eliminates the tedious process of manually creating and maintaining CLAUDE.md files.

Stars on GitHub

Momentum

stars in total
434
Star history of alirezarezvani/ClaudeForge on GitHub, a chart by star-history.com

More plugins that ship hooks.

claude-code-mastery

claude-code-mastery

Gives you a guide to setting up Claude Code, covering global instructions, MCP servers, custom commands, hooks and skills, plus tools for generating commit messages and auditing code for security issues. This version is marked obsolete, with a newer starter kit recommended instead.

2 skills+2560
@thedecipherist@thedecipherist
Install Plugin
okf

okf

Gives your coding agent skills to author, maintain, validate and visualize Open Knowledge Format bundles, portable markdown knowledge that both your team and your agents can read. Includes a backfill skill for building a bundle from an existing repository's git history and session transcripts.

4 skills+3452
@scaccogatto@scaccogatto
Install Plugin
reviewable-html-workbench

reviewable-html-workbe…

Turns design docs and plan drafts into HTML pages you can comment on inline. Fires when you ask to review a design, preview a plan, or render visual output.

3 skills1 hook303
@u-ichi@u-ichi
Install Plugin
filetree

filetree

A Claude Code plugin that maintains FILETREE.md, a one-line description per file with content hashes for staleness detection. Lets the LLM grasp repo layout in a few hundred tokens before touching code.

1 skill+2164
@nekocode@nekocode
Install Plugin
fluencyloop

fluencyloop

Keeps you fluent in code that AI writes: each feature is designed, built with the reasoning behind each decision explained, and recorded as docs, decisions and review summaries, so the understanding stays with the code.

6 skills1 hook47
@baokhang83@baokhang83
Install Plugin
d2

d2

A Claude Code plugin that reads your codebase and draws its infrastructure and architecture with D2, then keeps those diagrams in step with the code as it changes. Command: /d2:diagram.

1 skill+218
@heathdutton@heathdutton
Install Plugin
trigger-tree

trigger-tree

Shows which docs your AI agent actually discovers, using local telemetry with no token cost. Includes a CI gate that fails a pull request with the affected file and a suggested fix.

1 skill4 hooks16
@hedde@hedde
Install Plugin

Questions about the hooks in claudeforge.

Which coding agents can use the hooks in claudeforge?
The hooks in claudeforge install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.