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]>
This commit is contained in:
1 parent
7ec467451b
commit
d45d4bec97
10 files changed
+552
-14
No files matched your search
@@ -0,0 +1,214 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user