Skip to content

Commit 3238360

Browse files
feat: add init write backup
Adds explicit init --write support with backup behavior before replacing existing AGENTS.md files.
1 parent e735112 commit 3238360

6 files changed

Lines changed: 301 additions & 17 deletions

File tree

src/agent_rules_kit/cli.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from agent_rules_kit import __version__
1212
from agent_rules_kit.discovery import InstructionFile, discover_instruction_files
1313
from agent_rules_kit.init_plan import InitPlan, build_init_plan
14+
from agent_rules_kit.init_write import InitWriteResult, write_init_files
1415
from agent_rules_kit.redaction import redact_secret_like_values
1516

1617
OUTPUT_FORMATS = ("console", "json", "markdown")
@@ -62,6 +63,11 @@ def build_parser() -> argparse.ArgumentParser:
6263
action="store_true",
6364
help="Preview planned file changes without modifying files.",
6465
)
66+
init_parser.add_argument(
67+
"--write",
68+
action="store_true",
69+
help="Write baseline files, backing up existing files first.",
70+
)
6571

6672
return parser
6773

@@ -79,7 +85,11 @@ def main(argv: Sequence[str] | None = None) -> int:
7985
return _run_check(Path(args.repository), output_format=args.format)
8086

8187
if args.command == "init":
82-
return _run_init(Path(args.repository), dry_run=args.dry_run)
88+
return _run_init(
89+
Path(args.repository),
90+
dry_run=args.dry_run,
91+
write=args.write,
92+
)
8393

8494
parser.print_help()
8595
return 0
@@ -130,18 +140,26 @@ def _print_console_check(
130140
return 0
131141

132142

133-
def _run_init(repository_root: Path, *, dry_run: bool) -> int:
134-
if not dry_run:
135-
print("ERROR: init currently requires --dry-run.", file=sys.stderr)
143+
def _run_init(repository_root: Path, *, dry_run: bool, write: bool) -> int:
144+
if dry_run and write:
145+
print("ERROR: init accepts only one mode: --dry-run or --write.", file=sys.stderr)
146+
return 2
147+
148+
if not dry_run and not write:
149+
print("ERROR: init currently requires --dry-run or --write.", file=sys.stderr)
136150
return 2
137151

138152
try:
139-
plan = build_init_plan(repository_root)
153+
if dry_run:
154+
plan = build_init_plan(repository_root)
155+
_print_init_dry_run(plan)
156+
else:
157+
result = write_init_files(repository_root)
158+
_print_init_write(result)
140159
except ValueError as error:
141160
print(f"ERROR: {redact_secret_like_values(str(error))}", file=sys.stderr)
142161
return 2
143162

144-
_print_init_dry_run(plan)
145163
return 0
146164

147165

@@ -157,6 +175,20 @@ def _print_init_dry_run(plan: InitPlan) -> None:
157175
print(f"- {path} [{file_item.action.value}] - {reason}")
158176

159177

178+
def _print_init_write(result: InitWriteResult) -> None:
179+
print(f"agent-rules-kit init: {redact_secret_like_values(result.repository)}")
180+
print("Mode: write")
181+
print("Files modified:")
182+
183+
for file_item in result.files:
184+
path = redact_secret_like_values(file_item.path)
185+
if file_item.backup_path is None:
186+
print(f"- {path} [{file_item.action.value}]")
187+
else:
188+
backup_path = redact_secret_like_values(file_item.backup_path)
189+
print(f"- {path} [{file_item.action.value}] - backup: {backup_path}")
190+
191+
160192
def _build_check_payload(
161193
repository_root: Path,
162194
instruction_files: tuple[InstructionFile, ...],

src/agent_rules_kit/init_plan.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88

99

1010
class InitPlanAction(StrEnum):
11-
"""Supported dry-run init actions."""
11+
"""Supported init actions."""
1212

1313
CREATE = "create"
14-
SKIP_EXISTING = "skip-existing"
14+
BACKUP_AND_REPLACE = "backup-and-replace"
1515

1616

1717
@dataclass(frozen=True, slots=True)
1818
class PlannedInitFile:
19-
"""A file action planned by init dry-run."""
19+
"""A file action planned by init."""
2020

2121
path: str
2222
action: InitPlanAction
@@ -44,8 +44,8 @@ def build_init_plan(root: Path | str) -> InitPlan:
4444
candidate = root_path / target_path
4545

4646
if candidate.exists():
47-
action = InitPlanAction.SKIP_EXISTING
48-
reason = "file already exists"
47+
action = InitPlanAction.BACKUP_AND_REPLACE
48+
reason = "existing file would be backed up before replacement"
4949
else:
5050
action = InitPlanAction.CREATE
5151
reason = "baseline agent instruction file would be created"

src/agent_rules_kit/init_write.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Explicit write mode for agent instruction init."""
2+
3+
from __future__ import annotations
4+
5+
import shutil
6+
from dataclasses import dataclass
7+
from pathlib import Path
8+
9+
from agent_rules_kit.init_plan import InitPlanAction, build_init_plan
10+
11+
BASELINE_AGENTS_CONTENT = """# Agent Instructions
12+
13+
This repository uses baseline agent instructions generated by agent-rules-kit.
14+
15+
- Read the repository before changing files.
16+
- Do not run destructive commands unless explicitly requested.
17+
- Do not add secrets, tokens, credentials, or private data.
18+
"""
19+
20+
21+
@dataclass(frozen=True, slots=True)
22+
class WrittenInitFile:
23+
"""A file action performed by init write mode."""
24+
25+
path: str
26+
action: InitPlanAction
27+
backup_path: str | None
28+
29+
30+
@dataclass(frozen=True, slots=True)
31+
class InitWriteResult:
32+
"""Result of explicit init write mode."""
33+
34+
repository: str
35+
files: tuple[WrittenInitFile, ...]
36+
37+
38+
def write_init_files(root: Path | str) -> InitWriteResult:
39+
"""Write baseline init files, backing up existing files before replacement."""
40+
plan = build_init_plan(root)
41+
root_path = Path(root)
42+
written_files: list[WrittenInitFile] = []
43+
44+
for planned_file in plan.files:
45+
target = root_path / planned_file.path
46+
backup_path: Path | None = None
47+
48+
if planned_file.action == InitPlanAction.BACKUP_AND_REPLACE:
49+
backup_path = _next_backup_path(target)
50+
shutil.copy2(target, backup_path)
51+
52+
_write_text_atomic(target, BASELINE_AGENTS_CONTENT)
53+
54+
written_files.append(
55+
WrittenInitFile(
56+
path=planned_file.path,
57+
action=planned_file.action,
58+
backup_path=(
59+
backup_path.relative_to(root_path).as_posix()
60+
if backup_path is not None
61+
else None
62+
),
63+
)
64+
)
65+
66+
return InitWriteResult(
67+
repository=plan.repository,
68+
files=tuple(written_files),
69+
)
70+
71+
72+
def _write_text_atomic(target: Path, content: str) -> None:
73+
temporary_path = _next_available_path(
74+
target.with_name(f".{target.name}.agent-rules-kit.tmp")
75+
)
76+
77+
try:
78+
temporary_path.write_text(content, encoding="utf-8")
79+
temporary_path.replace(target)
80+
finally:
81+
if temporary_path.exists():
82+
temporary_path.unlink()
83+
84+
85+
def _next_backup_path(target: Path) -> Path:
86+
return _next_available_path(target.with_name(f"{target.name}.agent-rules-kit.bak"))
87+
88+
89+
def _next_available_path(candidate: Path) -> Path:
90+
if not candidate.exists():
91+
return candidate
92+
93+
for index in range(1, 1000):
94+
indexed_candidate = candidate.with_name(f"{candidate.name}.{index}")
95+
if not indexed_candidate.exists():
96+
return indexed_candidate
97+
98+
raise RuntimeError(f"could not find available backup path for: {candidate}")
99+
100+
101+
__all__ = [
102+
"BASELINE_AGENTS_CONTENT",
103+
"InitWriteResult",
104+
"WrittenInitFile",
105+
"write_init_files",
106+
]

tests/test_cli.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ def test_init_dry_run_plans_agents_file_creation_without_writing(self) -> None:
289289
self.assertIn("No files will be modified.", text)
290290
self.assertIn("- AGENTS.md [create]", text)
291291

292-
def test_init_dry_run_skips_existing_agents_file_without_writing(self) -> None:
292+
def test_init_dry_run_plans_backup_before_replace_without_writing(self) -> None:
293293
output = io.StringIO()
294294

295295
with tempfile.TemporaryDirectory() as temporary_directory:
@@ -308,17 +308,21 @@ def test_init_dry_run_skips_existing_agents_file_without_writing(self) -> None:
308308

309309
text = output.getvalue()
310310

311-
self.assertIn("- AGENTS.md [skip-existing] - file already exists", text)
311+
self.assertIn("- AGENTS.md [backup-and-replace]", text)
312+
self.assertIn("existing file would be backed up before replacement", text)
312313

313-
def test_init_requires_dry_run_until_write_mode_exists(self) -> None:
314+
def test_init_requires_explicit_mode(self) -> None:
314315
output = io.StringIO()
315316

316317
with tempfile.TemporaryDirectory() as temporary_directory:
317318
with redirect_stderr(output):
318319
exit_code = main(["init", temporary_directory])
319320

320321
self.assertEqual(exit_code, 2)
321-
self.assertIn("ERROR: init currently requires --dry-run.", output.getvalue())
322+
self.assertIn(
323+
"ERROR: init currently requires --dry-run or --write.",
324+
output.getvalue(),
325+
)
322326

323327
def test_init_dry_run_returns_two_for_invalid_repository_root(self) -> None:
324328
output = io.StringIO()
@@ -348,6 +352,73 @@ def test_init_dry_run_redacts_secret_like_repository_values(self) -> None:
348352
self.assertIn("[REDACTED]", text)
349353
self.assertNotIn(secret_like_path.name, text)
350354

355+
def test_init_rejects_dry_run_and_write_together(self) -> None:
356+
output = io.StringIO()
357+
358+
with tempfile.TemporaryDirectory() as temporary_directory:
359+
with redirect_stderr(output):
360+
exit_code = main(
361+
[
362+
"init",
363+
temporary_directory,
364+
"--dry-run",
365+
"--write",
366+
]
367+
)
368+
369+
self.assertEqual(exit_code, 2)
370+
self.assertIn(
371+
"ERROR: init accepts only one mode: --dry-run or --write.",
372+
output.getvalue(),
373+
)
374+
375+
def test_init_write_creates_agents_file(self) -> None:
376+
output = io.StringIO()
377+
378+
with tempfile.TemporaryDirectory() as temporary_directory:
379+
repository = Path(temporary_directory)
380+
381+
with redirect_stdout(output):
382+
exit_code = main(["init", str(repository), "--write"])
383+
384+
agents_file = repository / "AGENTS.md"
385+
386+
self.assertEqual(exit_code, 0)
387+
self.assertTrue(agents_file.exists())
388+
self.assertIn("# Agent Instructions", agents_file.read_text(encoding="utf-8"))
389+
390+
text = output.getvalue()
391+
392+
self.assertIn("Mode: write", text)
393+
self.assertIn("- AGENTS.md [create]", text)
394+
395+
def test_init_write_backs_up_existing_agents_file_before_replacing(self) -> None:
396+
output = io.StringIO()
397+
398+
with tempfile.TemporaryDirectory() as temporary_directory:
399+
repository = Path(temporary_directory)
400+
agents_file = repository / "AGENTS.md"
401+
agents_file.write_text("existing instructions\n", encoding="utf-8")
402+
403+
with redirect_stdout(output):
404+
exit_code = main(["init", str(repository), "--write"])
405+
406+
backup_file = repository / "AGENTS.md.agent-rules-kit.bak"
407+
408+
self.assertEqual(exit_code, 0)
409+
self.assertTrue(backup_file.exists())
410+
self.assertEqual(
411+
backup_file.read_text(encoding="utf-8"),
412+
"existing instructions\n",
413+
)
414+
self.assertIn("# Agent Instructions", agents_file.read_text(encoding="utf-8"))
415+
416+
text = output.getvalue()
417+
418+
self.assertIn("Mode: write", text)
419+
self.assertIn("- AGENTS.md [backup-and-replace]", text)
420+
self.assertIn("backup: AGENTS.md.agent-rules-kit.bak", text)
421+
351422

352423
if __name__ == "__main__":
353424
unittest.main()

tests/test_init_plan.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def test_build_init_plan_marks_agents_file_for_creation_when_missing(self) -> No
1919
self.assertEqual(plan.files[0].action, InitPlanAction.CREATE)
2020
self.assertFalse((repository / "AGENTS.md").exists())
2121

22-
def test_build_init_plan_marks_existing_agents_file_as_skip_existing(self) -> None:
22+
def test_build_init_plan_marks_existing_agents_file_as_backup_and_replace(self) -> None:
2323
with tempfile.TemporaryDirectory() as temporary_directory:
2424
repository = Path(temporary_directory)
2525
agents_file = repository / "AGENTS.md"
@@ -28,7 +28,7 @@ def test_build_init_plan_marks_existing_agents_file_as_skip_existing(self) -> No
2828
plan = build_init_plan(repository)
2929

3030
self.assertEqual(plan.files[0].path, "AGENTS.md")
31-
self.assertEqual(plan.files[0].action, InitPlanAction.SKIP_EXISTING)
31+
self.assertEqual(plan.files[0].action, InitPlanAction.BACKUP_AND_REPLACE)
3232
self.assertEqual(
3333
agents_file.read_text(encoding="utf-8"),
3434
"existing instructions\n",

0 commit comments

Comments
 (0)