Files
ai-development-scaffold/scripts/lean-install.py
T
J-BlandandClaude Sonnet 5.5 d45d4bec97 feat: lean default install for Claude Code and Codex with safe installer
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]>
2026-09-30 13:43:26 -04:00

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())