#!/usr/bin/env python3 """Install the lean Claude Code / Codex setup. Dry-run unless --apply is given.""" import argparse import os import shutil import sys import tempfile from datetime import datetime from pathlib import Path ROOT = Path(__file__).resolve().parent.parent LEAN = ROOT / "lean" BEGIN = "" END = "" SKILL = "lean-review" class InstallError(Exception): pass def block_text() -> str: body = (LEAN / "global" / "instructions.md").read_text(encoding="utf-8").strip() return f"{BEGIN}\n{body}\n{END}\n" def split_block(text: str) -> tuple[str, str, str] | None: starts, ends = text.count(BEGIN), text.count(END) if starts == 0 and ends == 0: return None if starts != 1 or ends != 1 or text.index(BEGIN) > text.index(END): raise InstallError("malformed lean-scaffold markers; fix or remove them manually") head, rest = text.split(BEGIN, 1) _, tail = rest.split(END, 1) return head, BEGIN, tail.lstrip("\n") def merged(existing: str | None) -> str: block = block_text() if existing is None or not existing.strip(): return block parts = split_block(existing) if parts is None: return existing.rstrip("\n") + "\n\n" + block head, _, tail = parts return head + block + (("\n" + tail) if tail else "") def without_block(existing: str) -> str: parts = split_block(existing) if parts is None: return existing head, _, tail = parts return (head.rstrip("\n") + ("\n\n" if head.strip() and tail else "") + tail).lstrip("\n") def check_writable(path: Path) -> None: if path.is_symlink(): raise InstallError(f"refusing symlink: {path}") if path.exists() and not path.is_file(): raise InstallError(f"not a regular file: {path}") parent = path.parent while parent != parent.parent: if parent.is_symlink() and parent.name in {"skills", SKILL}: raise InstallError(f"refusing symlinked directory: {parent}") parent = parent.parent def read_text(path: Path) -> str | None: check_writable(path) if not path.exists(): return None try: return path.read_text(encoding="utf-8") except UnicodeDecodeError as err: raise InstallError(f"not UTF-8 text: {path}") from err def backup_path(path: Path, now: datetime) -> Path: stamp = now.strftime("%Y%m%dT%H%M%S") candidate = path.with_name(f"{path.name}.bak-{stamp}") counter = 1 while candidate.exists() or candidate.is_symlink(): candidate = path.with_name(f"{path.name}.bak-{stamp}-{counter}") counter += 1 return candidate def write_file(path: Path, content: str, log: list[str], apply: bool, mode: int = 0o600) -> None: existing = read_text(path) if existing == content: log.append(f"unchanged {path}") return if existing is None: log.append(f"create {path}") else: target = backup_path(path, datetime.now()) log.append(f"backup {path} -> {target.name}") log.append(f"update {path}") if not apply: return path.parent.mkdir(parents=True, exist_ok=True) if existing is not None: shutil.copy2(path, target, follow_symlinks=False) mode = path.stat().st_mode & 0o777 fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.") try: with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(content) os.chmod(tmp, mode) os.replace(tmp, path) except BaseException: Path(tmp).unlink(missing_ok=True) raise def require_dir(path: Path, label: str) -> Path: if not path.is_dir(): raise InstallError(f"{label} is not an existing directory: {path}") return path.resolve() def claude_home(arg: str | None) -> Path: return Path(arg or os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude") def codex_home(arg: str | None) -> Path: return Path(arg or os.environ.get("CODEX_HOME") or Path.home() / ".codex") def install_global(args: argparse.Namespace, log: list[str]) -> None: plans = [] if not args.skip_claude: home = require_dir(claude_home(args.claude_home), "Claude home") plans.append((home / "CLAUDE.md", "block")) if not args.skip_skill: plans.append((home / "skills" / SKILL / "SKILL.md", "skill")) if not args.skip_codex: home = require_dir(codex_home(args.codex_home), "Codex home") plans.append((home / "AGENTS.md", "block")) for path, kind in plans: if kind == "block": write_file(path, merged(read_text(path)), log, args.apply) continue content = (LEAN / "skills" / SKILL / "SKILL.md").read_text(encoding="utf-8") existing = read_text(path) if existing not in (None, content) and not args.force: raise InstallError(f"{path} exists and differs; rerun with --force to back up and replace") write_file(path, content, log, args.apply) def uninstall_global(args: argparse.Namespace, log: list[str]) -> None: homes = [] if not args.skip_claude: homes.append(require_dir(claude_home(args.claude_home), "Claude home") / "CLAUDE.md") if not args.skip_codex: homes.append(require_dir(codex_home(args.codex_home), "Codex home") / "AGENTS.md") for path in homes: existing = read_text(path) if existing is None or split_block(existing) is None: log.append(f"absent {path}") continue write_file(path, without_block(existing), log, args.apply) def install_project(args: argparse.Namespace, log: list[str]) -> None: target = require_dir(Path(args.target), "project target") for name in ("AGENTS.md", "CLAUDE.md"): path = target / name content = (LEAN / "project" / name).read_text(encoding="utf-8") existing = read_text(path) if existing not in (None, content) and not args.force: raise InstallError(f"{path} exists and differs; rerun with --force to back up and replace") write_file(path, content, log, args.apply, mode=0o644) def parser() -> argparse.ArgumentParser: top = argparse.ArgumentParser(description=__doc__) sub = top.add_subparsers(dest="command", required=True) for name in ("global", "uninstall", "project"): cmd = sub.add_parser(name) cmd.add_argument("--apply", action="store_true", help="write changes (default: dry-run)") cmd.add_argument("--force", action="store_true", help="replace differing files after backup") if name == "project": cmd.add_argument("target", help="existing project directory") continue cmd.add_argument("--claude-home", help="default: $CLAUDE_CONFIG_DIR or ~/.claude") cmd.add_argument("--codex-home", help="default: $CODEX_HOME or ~/.codex") cmd.add_argument("--skip-claude", action="store_true") cmd.add_argument("--skip-codex", action="store_true") cmd.add_argument("--skip-skill", action="store_true", help="do not install the lean-review skill") return top def main(argv: list[str] | None = None) -> int: args = parser().parse_args(argv) log: list[str] = [] handler = {"global": install_global, "uninstall": uninstall_global, "project": install_project}[args.command] try: if args.apply: handler(argparse.Namespace(**{**vars(args), "apply": False}), []) handler(args, log) except InstallError as err: print(f"error: {err}", file=sys.stderr) return 2 print("\n".join(log)) if not args.apply: print("dry-run: no files written; add --apply to write") return 0 if __name__ == "__main__": sys.exit(main())