Adds a marked global rules block, project AGENTS.md/CLAUDE.md template, an on-demand lean-review skill, a dry-run-first stdlib installer with backups, and tests using temp homes. README quick start no longer moves ~/.claude; legacy library stays opt-in. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
215 lines
7.6 KiB
Python
215 lines
7.6 KiB
Python
#!/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 = "<!-- lean-scaffold:begin -->"
|
|
END = "<!-- lean-scaffold: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())
|