mirror of
https://github.com/alirezarezvani/ClaudeForge.git
synced 2026-07-03 02:13:15 -04:00
170 lines
5.8 KiB
Python
Executable File
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())
|