From 08eecd69cc2f8efe548a0066f815c88e5aa809fe Mon Sep 17 00:00:00 2001 From: CoderDeltaLAN Date: Tue, 9 Jun 2026 07:12:27 +0100 Subject: [PATCH] feat: add init write backup --- src/agent_rules_kit/cli.py | 44 +++++++++++-- src/agent_rules_kit/init_plan.py | 10 +-- src/agent_rules_kit/init_write.py | 106 ++++++++++++++++++++++++++++++ tests/test_cli.py | 79 ++++++++++++++++++++-- tests/test_init_plan.py | 4 +- tests/test_init_write.py | 75 +++++++++++++++++++++ 6 files changed, 301 insertions(+), 17 deletions(-) create mode 100644 src/agent_rules_kit/init_write.py create mode 100644 tests/test_init_write.py diff --git a/src/agent_rules_kit/cli.py b/src/agent_rules_kit/cli.py index 35ec628..ee604c0 100644 --- a/src/agent_rules_kit/cli.py +++ b/src/agent_rules_kit/cli.py @@ -11,6 +11,7 @@ from agent_rules_kit import __version__ from agent_rules_kit.discovery import InstructionFile, discover_instruction_files from agent_rules_kit.init_plan import InitPlan, build_init_plan +from agent_rules_kit.init_write import InitWriteResult, write_init_files from agent_rules_kit.redaction import redact_secret_like_values OUTPUT_FORMATS = ("console", "json", "markdown") @@ -62,6 +63,11 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Preview planned file changes without modifying files.", ) + init_parser.add_argument( + "--write", + action="store_true", + help="Write baseline files, backing up existing files first.", + ) return parser @@ -79,7 +85,11 @@ def main(argv: Sequence[str] | None = None) -> int: return _run_check(Path(args.repository), output_format=args.format) if args.command == "init": - return _run_init(Path(args.repository), dry_run=args.dry_run) + return _run_init( + Path(args.repository), + dry_run=args.dry_run, + write=args.write, + ) parser.print_help() return 0 @@ -130,18 +140,26 @@ def _print_console_check( return 0 -def _run_init(repository_root: Path, *, dry_run: bool) -> int: - if not dry_run: - print("ERROR: init currently requires --dry-run.", file=sys.stderr) +def _run_init(repository_root: Path, *, dry_run: bool, write: bool) -> int: + if dry_run and write: + print("ERROR: init accepts only one mode: --dry-run or --write.", file=sys.stderr) + return 2 + + if not dry_run and not write: + print("ERROR: init currently requires --dry-run or --write.", file=sys.stderr) return 2 try: - plan = build_init_plan(repository_root) + if dry_run: + plan = build_init_plan(repository_root) + _print_init_dry_run(plan) + else: + result = write_init_files(repository_root) + _print_init_write(result) except ValueError as error: print(f"ERROR: {redact_secret_like_values(str(error))}", file=sys.stderr) return 2 - _print_init_dry_run(plan) return 0 @@ -157,6 +175,20 @@ def _print_init_dry_run(plan: InitPlan) -> None: print(f"- {path} [{file_item.action.value}] - {reason}") +def _print_init_write(result: InitWriteResult) -> None: + print(f"agent-rules-kit init: {redact_secret_like_values(result.repository)}") + print("Mode: write") + print("Files modified:") + + for file_item in result.files: + path = redact_secret_like_values(file_item.path) + if file_item.backup_path is None: + print(f"- {path} [{file_item.action.value}]") + else: + backup_path = redact_secret_like_values(file_item.backup_path) + print(f"- {path} [{file_item.action.value}] - backup: {backup_path}") + + def _build_check_payload( repository_root: Path, instruction_files: tuple[InstructionFile, ...], diff --git a/src/agent_rules_kit/init_plan.py b/src/agent_rules_kit/init_plan.py index 24aefd8..2854e9c 100644 --- a/src/agent_rules_kit/init_plan.py +++ b/src/agent_rules_kit/init_plan.py @@ -8,15 +8,15 @@ class InitPlanAction(StrEnum): - """Supported dry-run init actions.""" + """Supported init actions.""" CREATE = "create" - SKIP_EXISTING = "skip-existing" + BACKUP_AND_REPLACE = "backup-and-replace" @dataclass(frozen=True, slots=True) class PlannedInitFile: - """A file action planned by init dry-run.""" + """A file action planned by init.""" path: str action: InitPlanAction @@ -44,8 +44,8 @@ def build_init_plan(root: Path | str) -> InitPlan: candidate = root_path / target_path if candidate.exists(): - action = InitPlanAction.SKIP_EXISTING - reason = "file already exists" + action = InitPlanAction.BACKUP_AND_REPLACE + reason = "existing file would be backed up before replacement" else: action = InitPlanAction.CREATE reason = "baseline agent instruction file would be created" diff --git a/src/agent_rules_kit/init_write.py b/src/agent_rules_kit/init_write.py new file mode 100644 index 0000000..92d371f --- /dev/null +++ b/src/agent_rules_kit/init_write.py @@ -0,0 +1,106 @@ +"""Explicit write mode for agent instruction init.""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path + +from agent_rules_kit.init_plan import InitPlanAction, build_init_plan + +BASELINE_AGENTS_CONTENT = """# Agent Instructions + +This repository uses baseline agent instructions generated by agent-rules-kit. + +- Read the repository before changing files. +- Do not run destructive commands unless explicitly requested. +- Do not add secrets, tokens, credentials, or private data. +""" + + +@dataclass(frozen=True, slots=True) +class WrittenInitFile: + """A file action performed by init write mode.""" + + path: str + action: InitPlanAction + backup_path: str | None + + +@dataclass(frozen=True, slots=True) +class InitWriteResult: + """Result of explicit init write mode.""" + + repository: str + files: tuple[WrittenInitFile, ...] + + +def write_init_files(root: Path | str) -> InitWriteResult: + """Write baseline init files, backing up existing files before replacement.""" + plan = build_init_plan(root) + root_path = Path(root) + written_files: list[WrittenInitFile] = [] + + for planned_file in plan.files: + target = root_path / planned_file.path + backup_path: Path | None = None + + if planned_file.action == InitPlanAction.BACKUP_AND_REPLACE: + backup_path = _next_backup_path(target) + shutil.copy2(target, backup_path) + + _write_text_atomic(target, BASELINE_AGENTS_CONTENT) + + written_files.append( + WrittenInitFile( + path=planned_file.path, + action=planned_file.action, + backup_path=( + backup_path.relative_to(root_path).as_posix() + if backup_path is not None + else None + ), + ) + ) + + return InitWriteResult( + repository=plan.repository, + files=tuple(written_files), + ) + + +def _write_text_atomic(target: Path, content: str) -> None: + temporary_path = _next_available_path( + target.with_name(f".{target.name}.agent-rules-kit.tmp") + ) + + try: + temporary_path.write_text(content, encoding="utf-8") + temporary_path.replace(target) + finally: + if temporary_path.exists(): + temporary_path.unlink() + + +def _next_backup_path(target: Path) -> Path: + return _next_available_path(target.with_name(f"{target.name}.agent-rules-kit.bak")) + + +def _next_available_path(candidate: Path) -> Path: + if not candidate.exists(): + return candidate + + for index in range(1, 1000): + indexed_candidate = candidate.with_name(f"{candidate.name}.{index}") + if not indexed_candidate.exists(): + return indexed_candidate + + raise RuntimeError(f"could not find available backup path for: {candidate}") + + +__all__ = [ + "BASELINE_AGENTS_CONTENT", + "InitWriteResult", + "WrittenInitFile", + "write_init_files", +] diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e63271..f8a250d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -289,7 +289,7 @@ def test_init_dry_run_plans_agents_file_creation_without_writing(self) -> None: self.assertIn("No files will be modified.", text) self.assertIn("- AGENTS.md [create]", text) - def test_init_dry_run_skips_existing_agents_file_without_writing(self) -> None: + def test_init_dry_run_plans_backup_before_replace_without_writing(self) -> None: output = io.StringIO() with tempfile.TemporaryDirectory() as temporary_directory: @@ -308,9 +308,10 @@ def test_init_dry_run_skips_existing_agents_file_without_writing(self) -> None: text = output.getvalue() - self.assertIn("- AGENTS.md [skip-existing] - file already exists", text) + self.assertIn("- AGENTS.md [backup-and-replace]", text) + self.assertIn("existing file would be backed up before replacement", text) - def test_init_requires_dry_run_until_write_mode_exists(self) -> None: + def test_init_requires_explicit_mode(self) -> None: output = io.StringIO() with tempfile.TemporaryDirectory() as temporary_directory: @@ -318,7 +319,10 @@ def test_init_requires_dry_run_until_write_mode_exists(self) -> None: exit_code = main(["init", temporary_directory]) self.assertEqual(exit_code, 2) - self.assertIn("ERROR: init currently requires --dry-run.", output.getvalue()) + self.assertIn( + "ERROR: init currently requires --dry-run or --write.", + output.getvalue(), + ) def test_init_dry_run_returns_two_for_invalid_repository_root(self) -> None: output = io.StringIO() @@ -348,6 +352,73 @@ def test_init_dry_run_redacts_secret_like_repository_values(self) -> None: self.assertIn("[REDACTED]", text) self.assertNotIn(secret_like_path.name, text) + def test_init_rejects_dry_run_and_write_together(self) -> None: + output = io.StringIO() + + with tempfile.TemporaryDirectory() as temporary_directory: + with redirect_stderr(output): + exit_code = main( + [ + "init", + temporary_directory, + "--dry-run", + "--write", + ] + ) + + self.assertEqual(exit_code, 2) + self.assertIn( + "ERROR: init accepts only one mode: --dry-run or --write.", + output.getvalue(), + ) + + def test_init_write_creates_agents_file(self) -> None: + output = io.StringIO() + + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + + with redirect_stdout(output): + exit_code = main(["init", str(repository), "--write"]) + + agents_file = repository / "AGENTS.md" + + self.assertEqual(exit_code, 0) + self.assertTrue(agents_file.exists()) + self.assertIn("# Agent Instructions", agents_file.read_text(encoding="utf-8")) + + text = output.getvalue() + + self.assertIn("Mode: write", text) + self.assertIn("- AGENTS.md [create]", text) + + def test_init_write_backs_up_existing_agents_file_before_replacing(self) -> None: + output = io.StringIO() + + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + agents_file = repository / "AGENTS.md" + agents_file.write_text("existing instructions\n", encoding="utf-8") + + with redirect_stdout(output): + exit_code = main(["init", str(repository), "--write"]) + + backup_file = repository / "AGENTS.md.agent-rules-kit.bak" + + self.assertEqual(exit_code, 0) + self.assertTrue(backup_file.exists()) + self.assertEqual( + backup_file.read_text(encoding="utf-8"), + "existing instructions\n", + ) + self.assertIn("# Agent Instructions", agents_file.read_text(encoding="utf-8")) + + text = output.getvalue() + + self.assertIn("Mode: write", text) + self.assertIn("- AGENTS.md [backup-and-replace]", text) + self.assertIn("backup: AGENTS.md.agent-rules-kit.bak", text) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_init_plan.py b/tests/test_init_plan.py index 8daf6e0..2eecdb4 100644 --- a/tests/test_init_plan.py +++ b/tests/test_init_plan.py @@ -19,7 +19,7 @@ def test_build_init_plan_marks_agents_file_for_creation_when_missing(self) -> No self.assertEqual(plan.files[0].action, InitPlanAction.CREATE) self.assertFalse((repository / "AGENTS.md").exists()) - def test_build_init_plan_marks_existing_agents_file_as_skip_existing(self) -> None: + def test_build_init_plan_marks_existing_agents_file_as_backup_and_replace(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: repository = Path(temporary_directory) agents_file = repository / "AGENTS.md" @@ -28,7 +28,7 @@ def test_build_init_plan_marks_existing_agents_file_as_skip_existing(self) -> No plan = build_init_plan(repository) self.assertEqual(plan.files[0].path, "AGENTS.md") - self.assertEqual(plan.files[0].action, InitPlanAction.SKIP_EXISTING) + self.assertEqual(plan.files[0].action, InitPlanAction.BACKUP_AND_REPLACE) self.assertEqual( agents_file.read_text(encoding="utf-8"), "existing instructions\n", diff --git a/tests/test_init_write.py b/tests/test_init_write.py new file mode 100644 index 0000000..ada13cb --- /dev/null +++ b/tests/test_init_write.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from agent_rules_kit.init_plan import InitPlanAction +from agent_rules_kit.init_write import BASELINE_AGENTS_CONTENT, write_init_files + + +class InitWriteTests(unittest.TestCase): + def test_write_init_files_creates_agents_file_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + + result = write_init_files(repository) + + agents_file = repository / "AGENTS.md" + + self.assertEqual(result.repository, str(repository)) + self.assertEqual(result.files[0].path, "AGENTS.md") + self.assertEqual(result.files[0].action, InitPlanAction.CREATE) + self.assertEqual(result.files[0].backup_path, None) + self.assertEqual(agents_file.read_text(encoding="utf-8"), BASELINE_AGENTS_CONTENT) + + def test_write_init_files_backs_up_existing_agents_file_before_replacing(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + agents_file = repository / "AGENTS.md" + agents_file.write_text("existing instructions\n", encoding="utf-8") + + result = write_init_files(repository) + + backup_file = repository / "AGENTS.md.agent-rules-kit.bak" + + self.assertEqual(result.files[0].path, "AGENTS.md") + self.assertEqual(result.files[0].action, InitPlanAction.BACKUP_AND_REPLACE) + self.assertEqual(result.files[0].backup_path, "AGENTS.md.agent-rules-kit.bak") + self.assertEqual( + backup_file.read_text(encoding="utf-8"), + "existing instructions\n", + ) + self.assertEqual(agents_file.read_text(encoding="utf-8"), BASELINE_AGENTS_CONTENT) + + def test_write_init_files_does_not_overwrite_existing_backup(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + agents_file = repository / "AGENTS.md" + first_backup = repository / "AGENTS.md.agent-rules-kit.bak" + + agents_file.write_text("existing instructions\n", encoding="utf-8") + first_backup.write_text("older backup\n", encoding="utf-8") + + result = write_init_files(repository) + + second_backup = repository / "AGENTS.md.agent-rules-kit.bak.1" + + self.assertEqual(result.files[0].backup_path, "AGENTS.md.agent-rules-kit.bak.1") + self.assertEqual(first_backup.read_text(encoding="utf-8"), "older backup\n") + self.assertEqual( + second_backup.read_text(encoding="utf-8"), + "existing instructions\n", + ) + self.assertEqual(agents_file.read_text(encoding="utf-8"), BASELINE_AGENTS_CONTENT) + + def test_write_init_files_rejects_missing_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + missing_root = Path(temporary_directory) / "missing" + + with self.assertRaises(ValueError): + write_init_files(missing_root) + + +if __name__ == "__main__": + unittest.main()