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.
What fires, and when
PostToolUse
- Matches
Write|Editpython3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/validate-claude-md.py
InstructionsLoaded
- Matches
session_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
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

