diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..65ca10a --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index 3d7777e..b542661 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/lean-setup.md b/docs/lean-setup.md new file mode 100644 index 0000000..4219e61 --- /dev/null +++ b/docs/lean-setup.md @@ -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-` 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. diff --git a/lean/global/instructions.md b/lean/global/instructions.md new file mode 100644 index 0000000..8f65952 --- /dev/null +++ b/lean/global/instructions.md @@ -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. diff --git a/lean/project/AGENTS.md b/lean/project/AGENTS.md new file mode 100644 index 0000000..7ac6a00 --- /dev/null +++ b/lean/project/AGENTS.md @@ -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. diff --git a/lean/project/CLAUDE.md b/lean/project/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/lean/project/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/lean/skills/lean-review/SKILL.md b/lean/skills/lean-review/SKILL.md new file mode 100644 index 0000000..cec77ae --- /dev/null +++ b/lean/skills/lean-review/SKILL.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. diff --git a/scripts/lean-install.py b/scripts/lean-install.py new file mode 100644 index 0000000..2a65a4f --- /dev/null +++ b/scripts/lean-install.py @@ -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 = "" +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()) diff --git a/tests/test_lean_install.py b/tests/test_lean_install.py new file mode 100644 index 0000000..87794a7 --- /dev/null +++ b/tests/test_lean_install.py @@ -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()