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,16 @@
|
||||
# AI development scaffold
|
||||
|
||||
Shared instructions for Claude Code and Codex working on this repo (`CLAUDE.md` imports this file).
|
||||
|
||||
## Layout
|
||||
|
||||
- `lean/` - lean default: global rules block, project template, `lean-review` skill.
|
||||
- `scripts/lean-install.py` - installer (dry-run by default). Tests: `python3 -m unittest discover -s tests`.
|
||||
- `.agents/`, `.claude/`, `.codex/` - legacy full library (agents, skills, hooks). Regenerate adapters with `python3 scripts/sync-agent-integrations.py`, verify with `--check`. Do not hand-edit generated files.
|
||||
- `docs/lean-setup.md` - design notes, sources, adopted/rejected patterns.
|
||||
|
||||
## Rules
|
||||
|
||||
- Run the unit tests and `python3 scripts/sync-agent-integrations.py --check` before finishing.
|
||||
- Installer tests must use temporary homes only; never touch real `~/.claude` or `CODEX_HOME`.
|
||||
- Keep the installer stdlib-only. Keep `lean/` files short.
|
||||
@@ -12,24 +12,23 @@ A comprehensive Claude Code configuration for professional software development
|
||||
- **Security** - Automatic secret detection and blocking
|
||||
- **Model Strategy** - Opus for planning, Sonnet for execution (`opusplan`)
|
||||
|
||||
## Quick Start
|
||||
## Quick Start (lean default)
|
||||
|
||||
```bash
|
||||
# 1. Clone this repo
|
||||
git clone https://github.com/yourusername/ai-development-scaffold.git
|
||||
|
||||
# 2. Backup existing Claude config (if any)
|
||||
mv ~/.claude ~/.claude.backup
|
||||
|
||||
# 3. Deploy
|
||||
cp -r ai-development-scaffold/.claude/* ~/.claude/
|
||||
chmod +x ~/.claude/hooks/*.sh
|
||||
|
||||
# 4. Verify
|
||||
ls ~/.claude/
|
||||
# Should show: CLAUDE.md, settings.json, agents/, skills/, hooks/
|
||||
git clone https://git.kinec.tech/kinec.tech/ai-development-scaffold.git
|
||||
cd ai-development-scaffold
|
||||
python3 scripts/lean-install.py global # dry-run: shows planned changes
|
||||
python3 scripts/lean-install.py global --apply # merge rules into ~/.claude/CLAUDE.md and $CODEX_HOME/AGENTS.md
|
||||
python3 scripts/lean-install.py project /path/to/repo --apply # optional per-project AGENTS.md + CLAUDE.md
|
||||
python3 -m unittest discover -s tests # verify the installer
|
||||
```
|
||||
|
||||
Existing files are merged, not replaced (timestamped backup first); settings and credentials are never touched. Details, sources and rejected patterns: [docs/lean-setup.md](docs/lean-setup.md).
|
||||
|
||||
### Optional: full library
|
||||
|
||||
The sections below describe the full agent/skill/hook library. Install pieces selectively (see [docs/lean-setup.md](docs/lean-setup.md#optional-full-library)); do not move or overwrite `~/.claude`.
|
||||
|
||||
## Documentation
|
||||
|
||||
See [.claude/README.md](.claude/README.md) for:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Lean setup
|
||||
|
||||
Default install is small: a marked rules block in the user-level instruction files, an optional `lean-review` skill for Claude, and a project template. The legacy library (`.claude/`, `.agents/`, `.codex/`) is untouched and stays opt-in.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python3 scripts/lean-install.py global # dry-run: shows what would change
|
||||
python3 scripts/lean-install.py global --apply # ~/.claude/CLAUDE.md, $CODEX_HOME/AGENTS.md, lean-review skill
|
||||
python3 scripts/lean-install.py project /path/to/repo --apply # AGENTS.md + CLAUDE.md (@AGENTS.md)
|
||||
python3 scripts/lean-install.py uninstall --apply # remove only the marked block
|
||||
```
|
||||
|
||||
Options: `--claude-home`, `--codex-home` (defaults `$CLAUDE_CONFIG_DIR`/`~/.claude`, `$CODEX_HOME`/`~/.codex`), `--skip-claude`, `--skip-codex`, `--skip-skill`, `--force`.
|
||||
|
||||
Safety: dry-run unless `--apply`; the whole plan is validated before any write; an existing file is changed only by adding or replacing the marked block, after a `*.bak-<timestamp>` copy (never overwriting an earlier backup); symlinked targets, non-UTF-8 files, malformed markers and missing homes are refused; differing skill/project files need `--force`; settings, `config.toml` and `auth.json` are never read or written; no shell rc edits. Restore by copying a backup back over the file.
|
||||
|
||||
Codex reads `$CODEX_HOME/AGENTS.override.md` before `AGENTS.md`; an override file makes the installed global block inactive.
|
||||
|
||||
## Optional full library
|
||||
|
||||
Copy pieces by hand rather than wholesale: pick agents from `.claude/agents/` (or `.codex/agents/`), skills from `.agents/skills/`, and merge hook entries from `.claude/settings.json` into your own settings after backing yours up. Hooks there reference `~/.claude/hooks/`, so only enable them once the scripts are copied there. Never replace `~/.claude` or its credentials.
|
||||
|
||||
## Sources and decisions
|
||||
|
||||
Official: [Claude Code best practices](https://code.claude.com/docs/en/best-practices), [Codex AGENTS.md guide](https://developers.openai.com/codex/guides/agents-md) (redirects to learn.chatgpt.com/docs/agent-configuration/agents-md).
|
||||
Comparison material: Migration Accelerator `CLAUDE.md` (public GitHub sample `J-Bland/migration-accelerator`; the private original on git.kinec.tech was unavailable, so this is a mirror and may differ) and HyperFactory `AGENTS.md` / `diff-reviewer`.
|
||||
|
||||
Adopted:
|
||||
- Compact always-loaded files; detail on demand via skills and links (official; MA/HF both enforce a size budget).
|
||||
- Verification the agent can run, reported with real output (Claude best practices).
|
||||
- Explore/plan only for substantive changes (Claude best practices).
|
||||
- One shared `AGENTS.md`, with `CLAUDE.md` importing it via `@AGENTS.md`; global Codex file lives in the actual `CODEX_HOME`, project files layer root-down (Codex guide).
|
||||
- Evidence rule and one consolidated diff review: a finding needs a concrete failing input and a failed refutation, existing guards are checked first (HyperFactory diff-reviewer).
|
||||
- Security review only when the diff warrants it.
|
||||
|
||||
Rejected: mandatory refactor agent after every green; stop-hook loops; full role inventories; `xhigh`/1M-context defaults; enabling every MCP or LSP plugin; broad global permissions; hooks pointing at paths that may not exist; multi-model review loops; changing model or provider settings.
|
||||
@@ -0,0 +1,10 @@
|
||||
## Working rules (lean-scaffold)
|
||||
|
||||
- Verify before claiming done: run the project's tests, linter and type check, and report the actual result. If a check cannot run, say so.
|
||||
- Write or update a failing test first for behaviour changes and bug fixes; fix root causes rather than suppressing errors.
|
||||
- Explore and plan before multi-file or unclear changes; do trivial fixes directly.
|
||||
- Keep diffs scoped to the request. Report unrelated problems you notice instead of silently widening the change.
|
||||
- Never hardcode or print secrets, tokens or endpoints. Never commit `.env` files.
|
||||
- Do not push, force-push, merge, deploy, or delete data unless asked. Show destructive or outward-facing commands before running them.
|
||||
- Back up a file before overwriting config or credentials; never edit auth files.
|
||||
- Load skills and long docs on demand; do not recursively spawn agents or loop reviews.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Project instructions
|
||||
|
||||
Shared by Claude Code (via `CLAUDE.md`) and Codex. Keep this file short: add a line only if removing it would cause a mistake.
|
||||
|
||||
## Commands
|
||||
|
||||
- Test: `TODO`
|
||||
- Lint/format: `TODO`
|
||||
- Type check: `TODO`
|
||||
|
||||
## Rules
|
||||
|
||||
- Run the commands above after each change; report real output, not assumptions.
|
||||
- Behaviour changes and bug fixes start with a failing test.
|
||||
- Before reporting a defect in existing code, check callers and guards that could already prevent it; report only with a concrete failing input.
|
||||
- Before finishing, review your own diff once; use the `lean-review` skill for a consolidated review.
|
||||
- Use security review only for auth, secrets, input parsing, or dependency changes.
|
||||
- Layer detail in nested `AGENTS.md` / docs and link to it; do not inline it here.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: lean-review
|
||||
description: One consolidated, evidence-based review of the current diff (correctness, failure handling, tests, security only when warranted). Use on demand before finishing or opening a PR.
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Read, Grep, Glob, Bash(git diff:*), Bash(git status:*), Bash(git log:*)
|
||||
---
|
||||
|
||||
# Lean diff review
|
||||
|
||||
Read-only. Review `git diff` against the base branch (or the target the user names) once, in a single pass. Do not spawn other agents.
|
||||
|
||||
1. Run the project's tests/lint/type check if not already run; note results.
|
||||
2. For each suspicion, it is only a candidate. Try to refute it: read callers, look for existing guards, confirm the path is reachable.
|
||||
3. Report only candidates you could not refute, each with: file:line, a concrete failing input or state, and the wrong result. Drop refuted ones; list unresolved ones separately with what you checked.
|
||||
4. Check: wrong value captured or boundary arithmetic; failures swallowed or missing timeouts on external calls; tests that pass without exercising the change; new behaviour without a test.
|
||||
5. Add a security section only if the diff touches auth, secrets, input parsing, or dependencies.
|
||||
|
||||
Output at most 10 findings, worst first, then a one-line verdict. No style nits.
|
||||
@@ -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())
|
||||
@@ -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