diff --git a/src/agent_rules_kit/cli.py b/src/agent_rules_kit/cli.py index 21481a7..35ec628 100644 --- a/src/agent_rules_kit/cli.py +++ b/src/agent_rules_kit/cli.py @@ -10,6 +10,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.redaction import redact_secret_like_values OUTPUT_FORMATS = ("console", "json", "markdown") @@ -46,6 +47,22 @@ def build_parser() -> argparse.ArgumentParser: help="Output format. Defaults to console.", ) + init_parser = subparsers.add_parser( + "init", + help="Plan baseline agent instruction files without writing by default.", + ) + init_parser.add_argument( + "repository", + nargs="?", + default=".", + help="Repository root to inspect. Defaults to the current directory.", + ) + init_parser.add_argument( + "--dry-run", + action="store_true", + help="Preview planned file changes without modifying files.", + ) + return parser @@ -61,6 +78,9 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command == "check": 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) + parser.print_help() return 0 @@ -110,6 +130,33 @@ 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) + return 2 + + try: + plan = build_init_plan(repository_root) + 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 + + +def _print_init_dry_run(plan: InitPlan) -> None: + print(f"agent-rules-kit init: {redact_secret_like_values(plan.repository)}") + print("Mode: dry-run") + print("No files will be modified.") + print("Planned file actions:") + + for file_item in plan.files: + path = redact_secret_like_values(file_item.path) + reason = redact_secret_like_values(file_item.reason) + print(f"- {path} [{file_item.action.value}] - {reason}") + + 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 new file mode 100644 index 0000000..24aefd8 --- /dev/null +++ b/src/agent_rules_kit/init_plan.py @@ -0,0 +1,70 @@ +"""Read-only init planning for agent instruction files.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + + +class InitPlanAction(StrEnum): + """Supported dry-run init actions.""" + + CREATE = "create" + SKIP_EXISTING = "skip-existing" + + +@dataclass(frozen=True, slots=True) +class PlannedInitFile: + """A file action planned by init dry-run.""" + + path: str + action: InitPlanAction + reason: str + + +@dataclass(frozen=True, slots=True) +class InitPlan: + """Read-only init plan for a repository.""" + + repository: str + files: tuple[PlannedInitFile, ...] + + +def build_init_plan(root: Path | str) -> InitPlan: + """Build a read-only init plan without modifying files.""" + root_path = Path(root) + + if not root_path.exists(): + raise ValueError(f"repository root does not exist: {root_path}") + if not root_path.is_dir(): + raise ValueError(f"repository root is not a directory: {root_path}") + + target_path = "AGENTS.md" + candidate = root_path / target_path + + if candidate.exists(): + action = InitPlanAction.SKIP_EXISTING + reason = "file already exists" + else: + action = InitPlanAction.CREATE + reason = "baseline agent instruction file would be created" + + return InitPlan( + repository=str(root_path), + files=( + PlannedInitFile( + path=target_path, + action=action, + reason=reason, + ), + ), + ) + + +__all__ = [ + "InitPlan", + "InitPlanAction", + "PlannedInitFile", + "build_init_plan", +] diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e7b9de..8e63271 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,7 @@ import io import json +import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout from pathlib import Path @@ -269,6 +270,84 @@ def test_check_markdown_redacts_secret_like_repository_values(self) -> None: self.assertNotIn(secret_like_path.name, text) self.assertIn("- Status: error", text) + def test_init_dry_run_plans_agents_file_creation_without_writing(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), "--dry-run"]) + + self.assertEqual(exit_code, 0) + self.assertFalse((repository / "AGENTS.md").exists()) + + text = output.getvalue() + + self.assertIn("agent-rules-kit init:", text) + self.assertIn("Mode: dry-run", text) + 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: + 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), "--dry-run"]) + + self.assertEqual(exit_code, 0) + self.assertEqual( + agents_file.read_text(encoding="utf-8"), + "existing instructions\n", + ) + + text = output.getvalue() + + self.assertIn("- AGENTS.md [skip-existing] - file already exists", text) + + def test_init_requires_dry_run_until_write_mode_exists(self) -> None: + output = io.StringIO() + + with tempfile.TemporaryDirectory() as temporary_directory: + with redirect_stderr(output): + exit_code = main(["init", temporary_directory]) + + self.assertEqual(exit_code, 2) + self.assertIn("ERROR: init currently requires --dry-run.", output.getvalue()) + + def test_init_dry_run_returns_two_for_invalid_repository_root(self) -> None: + output = io.StringIO() + + with redirect_stderr(output): + exit_code = main( + [ + "init", + str(FIXTURE_ROOT / "missing-repo"), + "--dry-run", + ] + ) + + self.assertEqual(exit_code, 2) + self.assertIn("ERROR: repository root does not exist:", output.getvalue()) + + def test_init_dry_run_redacts_secret_like_repository_values(self) -> None: + output = io.StringIO() + secret_like_path = FIXTURE_ROOT / ("sk-" + ("A" * 24)) + + with redirect_stderr(output): + exit_code = main(["init", str(secret_like_path), "--dry-run"]) + + text = output.getvalue() + + self.assertEqual(exit_code, 2) + self.assertIn("[REDACTED]", text) + self.assertNotIn(secret_like_path.name, text) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_init_plan.py b/tests/test_init_plan.py new file mode 100644 index 0000000..8daf6e0 --- /dev/null +++ b/tests/test_init_plan.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from agent_rules_kit.init_plan import InitPlanAction, build_init_plan + + +class InitPlanTests(unittest.TestCase): + def test_build_init_plan_marks_agents_file_for_creation_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + + plan = build_init_plan(repository) + + self.assertEqual(plan.repository, str(repository)) + self.assertEqual(plan.files[0].path, "AGENTS.md") + 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: + 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") + + plan = build_init_plan(repository) + + self.assertEqual(plan.files[0].path, "AGENTS.md") + self.assertEqual(plan.files[0].action, InitPlanAction.SKIP_EXISTING) + self.assertEqual( + agents_file.read_text(encoding="utf-8"), + "existing instructions\n", + ) + + def test_build_init_plan_rejects_missing_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + missing_root = Path(temporary_directory) / "missing" + + with self.assertRaises(ValueError): + build_init_plan(missing_root) + + def test_build_init_plan_rejects_file_root(self) -> None: + with tempfile.NamedTemporaryFile() as temporary_file: + with self.assertRaises(ValueError): + build_init_plan(temporary_file.name) + + +if __name__ == "__main__": + unittest.main()