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
44 changes: 38 additions & 6 deletions src/agent_rules_kit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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

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


Expand All @@ -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, ...],
Expand Down
10 changes: 5 additions & 5 deletions src/agent_rules_kit/init_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
106 changes: 106 additions & 0 deletions src/agent_rules_kit/init_write.py
Original file line number Diff line number Diff line change
@@ -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",
]
79 changes: 75 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -308,17 +308,21 @@ 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:
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())
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()
Expand Down Expand Up @@ -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()
4 changes: 2 additions & 2 deletions tests/test_init_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
Loading
Loading