feat: ClaudeForge 2.1.0 — installable plugin with 150-line cap, forked audit skills, /sync --weekly, and AGENTS.md export (#26)

This commit is contained in:
Alireza Rezvani
2026-05-19 04:07:05 +02:00
committed by GitHub
parent ffff0fc4c3
commit 032c5e5a0f
35 changed files with 1740 additions and 629 deletions
+87
View File
@@ -0,0 +1,87 @@
#!/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())
+169
View File
@@ -0,0 +1,169 @@
#!/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())
+12
View File
@@ -0,0 +1,12 @@
{
"$comment": "ClaudeForge shared hook config. Commit this file. Per-developer overrides go in hooks/hooks-config.local.json (gitignored).",
"validateClaudeMd": {
"enabled": true,
"maxLines": 150,
"exemptFilenameSuffix": ".local.md",
"exitCodeOnViolation": 2
},
"stopAuditLine": {
"enabled": true
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://json.schemastore.org/claude-code-hooks.json",
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/validate-claude-md.py",
"timeout": 10
}
]
}
],
"InstructionsLoaded": [
{
"matcher": "session_start|nested_traversal|path_glob_match|include|compact",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/validate-claude-md.py",
"timeout": 10
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/audit-claude-md.py",
"timeout": 10
}
]
}
]
}
}
+123
View File
@@ -0,0 +1,123 @@
#!/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())