mirror of
https://github.com/alirezarezvani/ClaudeForge.git
synced 2026-07-03 02:13:15 -04:00
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:
Executable
+87
@@ -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())
|
||||
Executable
+169
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+123
@@ -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())
|
||||
Reference in New Issue
Block a user