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
4144 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.

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.

Get the whole plugin
Stats
414
Stars
52
Forks
Maintained
Maintenance
Python
Language
MIT
License
2mo ago
Last commit
9mo ago
Created

Repo: alirezarezvani/ClaudeForge