Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/agent_rules_kit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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


Expand All @@ -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

Expand Down Expand Up @@ -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, ...],
Expand Down
70 changes: 70 additions & 0 deletions src/agent_rules_kit/init_plan.py
Original file line number Diff line number Diff line change
@@ -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",
]
79 changes: 79 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io
import json
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
Expand Down Expand Up @@ -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()
51 changes: 51 additions & 0 deletions tests/test_init_plan.py
Original file line number Diff line number Diff line change
@@ -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()
Loading