Files
ClaudeForge/hooks/claude-to-agents.py

170 lines
5.8 KiB
Python
Executable File

#!/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())