#!/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.`` 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] = [ "", "", "", ] for rel, body in chain: out.append(f"") 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())