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,224 @@
|
||||
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()
|
||||
Reference in new issue
Block a user