Files
ai-development-scaffold/tests/test_lean_install.py
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

225 lines
9.7 KiB
Python

import contextlib
import hashlib
import importlib.util
import io
import os
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
spec = importlib.util.spec_from_file_location("lean_install", ROOT / "scripts" / "lean-install.py")
lean = importlib.util.module_from_spec(spec)
spec.loader.exec_module(lean)
SETTINGS = b'{"model": "opusplan", "unknownKey": {"keep": true}}\n'
CONFIG = b'model = "x"\n[unknown]\nkeep = 1\n'
AUTH = b'{"token": "not-a-real-secret"}\n'
def run(*argv):
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = lean.main(list(argv))
return code, out.getvalue(), err.getvalue()
def digest(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def make_homes(tmp):
claude, codex = Path(tmp) / "claude", Path(tmp) / "codex"
claude.mkdir()
codex.mkdir()
(claude / "settings.json").write_bytes(SETTINGS)
(codex / "config.toml").write_bytes(CONFIG)
(codex / "auth.json").write_bytes(AUTH)
return claude, codex
def global_args(claude, codex, *extra):
return ["global", "--claude-home", str(claude), "--codex-home", str(codex), *extra]
class GlobalInstallTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.claude, self.codex = make_homes(self._tmp.name)
def snapshot(self):
return sorted((str(p), p.read_bytes()) for p in Path(self._tmp.name).rglob("*") if p.is_file())
def test_dry_run_writes_nothing(self):
before = self.snapshot()
code, out, _ = run(*global_args(self.claude, self.codex))
self.assertEqual(code, 0)
self.assertIn("dry-run", out)
self.assertEqual(before, self.snapshot())
def test_apply_installs_and_preserves_config_and_auth(self):
code, _, _ = run(*global_args(self.claude, self.codex, "--apply"))
self.assertEqual(code, 0)
self.assertEqual((self.claude / "settings.json").read_bytes(), SETTINGS)
self.assertEqual((self.codex / "config.toml").read_bytes(), CONFIG)
self.assertEqual((self.codex / "auth.json").read_bytes(), AUTH)
for target in (self.claude / "CLAUDE.md", self.codex / "AGENTS.md"):
self.assertIn(lean.BEGIN, target.read_text())
self.assertTrue((self.claude / "skills" / "lean-review" / "SKILL.md").is_file())
self.assertFalse((self.codex / "skills").exists())
def test_repeat_apply_is_idempotent_and_makes_no_backups(self):
run(*global_args(self.claude, self.codex, "--apply"))
before = self.snapshot()
code, out, _ = run(*global_args(self.claude, self.codex, "--apply"))
self.assertEqual(code, 0)
self.assertNotIn("backup", out)
self.assertEqual(before, self.snapshot())
def test_existing_instructions_are_merged_with_backup(self):
original = "# My rules\n\nBe kind.\n"
(self.claude / "CLAUDE.md").write_text(original)
run(*global_args(self.claude, self.codex, "--apply"))
merged = (self.claude / "CLAUDE.md").read_text()
self.assertTrue(merged.startswith(original.rstrip("\n")))
self.assertIn(lean.BEGIN, merged)
backups = list(self.claude.glob("CLAUDE.md.bak-*"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_text(), original)
def test_stale_block_is_replaced_and_user_text_kept(self):
(self.codex / "AGENTS.md").write_text(f"before\n\n{lean.BEGIN}\nSTALE-MARK\n{lean.END}\n\nafter\n")
run(*global_args(self.claude, self.codex, "--apply"))
text = (self.codex / "AGENTS.md").read_text()
self.assertNotIn("STALE-MARK", text)
self.assertIn("before", text)
self.assertIn("after", text)
self.assertEqual(text.count(lean.BEGIN), 1)
def test_backup_name_collision_never_overwrites_existing_backup(self):
(self.claude / "CLAUDE.md").write_text("mine\n")
now = lean.datetime(2026, 1, 1, 0, 0, 0)
first = lean.backup_path(self.claude / "CLAUDE.md", now)
first.write_text("earlier backup")
second = lean.backup_path(self.claude / "CLAUDE.md", now)
self.assertNotEqual(first, second)
self.assertEqual(first.read_text(), "earlier backup")
def test_uninstall_restores_original_content(self):
original = "# Mine\n\ntext\n"
(self.codex / "AGENTS.md").write_text(original)
run(*global_args(self.claude, self.codex, "--apply"))
run("uninstall", "--claude-home", str(self.claude), "--codex-home", str(self.codex), "--apply")
self.assertEqual((self.codex / "AGENTS.md").read_text().strip(), original.strip())
self.assertNotIn(lean.BEGIN, (self.claude / "CLAUDE.md").read_text())
def test_symlinked_target_is_refused(self):
real = Path(self._tmp.name) / "real.md"
real.write_text("x")
(self.claude / "CLAUDE.md").symlink_to(real)
code, _, err = run(*global_args(self.claude, self.codex, "--apply"))
self.assertEqual(code, 2)
self.assertIn("symlink", err)
self.assertEqual(real.read_text(), "x")
self.assertFalse((self.codex / "AGENTS.md").exists())
def test_differing_skill_needs_force_and_is_backed_up(self):
skill = self.claude / "skills" / "lean-review" / "SKILL.md"
skill.parent.mkdir(parents=True)
skill.write_text("custom")
code, _, err = run(*global_args(self.claude, self.codex, "--apply"))
self.assertEqual(code, 2)
self.assertIn("--force", err)
self.assertEqual(skill.read_text(), "custom")
self.assertFalse((self.claude / "CLAUDE.md").exists())
run(*global_args(self.claude, self.codex, "--apply", "--force"))
self.assertEqual(len(list(skill.parent.glob("SKILL.md.bak-*"))), 1)
def test_malformed_markers_are_refused(self):
(self.claude / "CLAUDE.md").write_text(f"{lean.BEGIN}\nno end\n")
code, _, err = run(*global_args(self.claude, self.codex, "--apply"))
self.assertEqual(code, 2)
self.assertIn("malformed", err)
def test_missing_home_is_refused_not_created(self):
missing = Path(self._tmp.name) / "nope"
code, _, err = run(*global_args(self.claude, missing, "--apply"))
self.assertEqual(code, 2)
self.assertFalse(missing.exists())
self.assertIn("not an existing directory", err)
def test_non_utf8_instruction_file_is_refused(self):
(self.codex / "AGENTS.md").write_bytes(b"\xff\xfe\x00bad")
code, _, err = run(*global_args(self.claude, self.codex, "--apply"))
self.assertEqual(code, 2)
self.assertEqual((self.codex / "AGENTS.md").read_bytes(), b"\xff\xfe\x00bad")
def test_codex_home_environment_is_honoured(self):
os.environ["CODEX_HOME"] = str(self.codex)
self.addCleanup(os.environ.pop, "CODEX_HOME", None)
run("global", "--claude-home", str(self.claude), "--apply")
self.assertTrue((self.codex / "AGENTS.md").is_file())
def test_existing_file_mode_is_preserved(self):
(self.codex / "AGENTS.md").write_text("x\n")
os.chmod(self.codex / "AGENTS.md", 0o640)
run(*global_args(self.claude, self.codex, "--apply"))
self.assertEqual((self.codex / "AGENTS.md").stat().st_mode & 0o777, 0o640)
class ProjectInstallTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.project = Path(self._tmp.name)
def test_installs_agents_and_claude_import(self):
code, _, _ = run("project", str(self.project), "--apply")
self.assertEqual(code, 0)
self.assertEqual((self.project / "CLAUDE.md").read_text().strip(), "@AGENTS.md")
self.assertTrue((self.project / "AGENTS.md").is_file())
def test_existing_project_file_is_not_overwritten_without_force(self):
(self.project / "AGENTS.md").write_text("mine")
code, _, err = run("project", str(self.project), "--apply")
self.assertEqual(code, 2)
self.assertEqual((self.project / "AGENTS.md").read_text(), "mine")
self.assertFalse((self.project / "CLAUDE.md").exists())
run("project", str(self.project), "--apply", "--force")
self.assertEqual(len(list(self.project.glob("AGENTS.md.bak-*"))), 1)
def test_repeat_is_idempotent(self):
run("project", str(self.project), "--apply")
_, out, _ = run("project", str(self.project), "--apply")
self.assertNotIn("update", out)
self.assertEqual(list(self.project.glob("*.bak-*")), [])
def test_invalid_target_is_refused(self):
code, _, err = run("project", str(self.project / "missing"), "--apply")
self.assertEqual(code, 2)
class ShippedContentTest(unittest.TestCase):
def test_shared_files_stay_lean(self):
for rel, ceiling in [("lean/global/instructions.md", 25), ("lean/project/AGENTS.md", 30), ("AGENTS.md", 30)]:
self.assertLessEqual(len((ROOT / rel).read_text().splitlines()), ceiling, rel)
def test_claude_md_imports_agents_md(self):
for rel in ("CLAUDE.md", "lean/project/CLAUDE.md"):
self.assertEqual((ROOT / rel).read_text().strip(), "@AGENTS.md")
def test_skill_has_frontmatter_and_no_write_tools(self):
text = (ROOT / "lean/skills/lean-review/SKILL.md").read_text()
self.assertTrue(text.startswith("---\nname: lean-review\n"))
self.assertNotIn("Edit", text.split("---")[1])
self.assertNotIn("Write", text.split("---")[1])
def test_shipped_content_has_no_secret_like_values(self):
for path in (ROOT / "lean").rglob("*.md"):
self.assertNotRegex(path.read_text(), r"(?i)(sk-[a-z0-9]{10,}|AKIA[A-Z0-9]{12,}|https?://\d)")
if __name__ == "__main__":
unittest.main()