mirror of
https://github.com/alirezarezvani/ClaudeForge.git
synced 2026-07-04 10:53:16 -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
+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())
|
||||
Reference in New Issue
Block a user