agentbundle 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. agentbundle-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,699 @@
1
+ """Tests for the Codex adapter (T5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+ from unittest import mock
9
+
10
+ from agentbundle.build.adapters import codex
11
+ from agentbundle.build.adapters.codex import (
12
+ _LEGACY_SKILL_BLOCK_END,
13
+ _LEGACY_SKILL_BLOCK_START,
14
+ _splice_managed_block,
15
+ _strip_legacy_skill_block,
16
+ project,
17
+ project_packs,
18
+ )
19
+ from agentbundle.build.contract import load as load_contract
20
+
21
+ REPO_ROOT = Path(__file__).resolve().parents[5]
22
+ CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
23
+
24
+
25
+ def _seed_pack(root: Path, name: str = "pack", skill_prefix: str = "") -> Path:
26
+ pack = root / name
27
+ (pack / ".apm" / "skills" / f"{skill_prefix}foo").mkdir(parents=True)
28
+ (pack / ".apm" / "skills" / f"{skill_prefix}foo" / "SKILL.md").write_text(
29
+ f"---\ndescription: {skill_prefix}foo skill description\n---\n# foo\n",
30
+ encoding="utf-8",
31
+ )
32
+ (pack / ".apm" / "skills" / f"{skill_prefix}alpha").mkdir(parents=True)
33
+ (pack / ".apm" / "skills" / f"{skill_prefix}alpha" / "SKILL.md").write_text(
34
+ f"---\ndescription: {skill_prefix}alpha skill description\n---\n# alpha\n",
35
+ encoding="utf-8",
36
+ )
37
+
38
+ (pack / ".apm" / "agents").mkdir(parents=True)
39
+ (pack / ".apm" / "agents" / "bar.md").write_text("agent body\n", encoding="utf-8")
40
+
41
+ (pack / ".apm" / "hooks").mkdir(parents=True)
42
+ (pack / ".apm" / "hooks" / "baz.sh").write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
43
+ (pack / ".apm" / "hooks" / "baz.py").write_text("print('hi')\n", encoding="utf-8")
44
+
45
+ (pack / ".apm" / "hook-wiring").mkdir(parents=True)
46
+ (pack / ".apm" / "hook-wiring" / "baz.toml").write_text("[hooks]\n", encoding="utf-8")
47
+
48
+ (pack / ".apm" / "commands").mkdir(parents=True)
49
+ (pack / ".apm" / "commands" / "qux.md").write_text("# qux\n", encoding="utf-8")
50
+ return pack
51
+
52
+
53
+ class CodexAdapterTests(unittest.TestCase):
54
+ @classmethod
55
+ def setUpClass(cls) -> None:
56
+ cls.contract = load_contract(CONTRACT_PATH)
57
+
58
+ def test_only_command_dropped_post_v08(self) -> None:
59
+ """v0.8 inverts the pre-bump assertion: codex now projects `agent`
60
+ (via codex-agent-toml) and `hook-wiring` (via merge-json) natively.
61
+ Only `command` stays dropped — codex custom-prompts are deprecated
62
+ upstream in favour of skills (RFC pointer in spec § Assumptions).
63
+
64
+ Renamed from ``test_agent_hook_wiring_command_dropped`` — the
65
+ v0.7 assertion was the inverse of what the v0.8 contract claims
66
+ (AC2). Deliberate spec-driven inversion, not a regression hiding
67
+ behind a test deletion.
68
+ """
69
+ with tempfile.TemporaryDirectory() as tmp:
70
+ tmp_path = Path(tmp)
71
+ pack = _seed_pack(tmp_path)
72
+ out = tmp_path / "out"
73
+ project(pack, self.contract, out)
74
+ # Agent .md no longer appears anywhere (projected as .toml).
75
+ self.assertFalse(any(out.rglob("bar.md")))
76
+ # Command .md still nowhere (codex command stays dropped).
77
+ self.assertFalse(any(out.rglob("qux.md")))
78
+ # Agent IS projected as TOML.
79
+ self.assertTrue((out / ".codex" / "agents" / "bar.toml").exists())
80
+
81
+ def test_codex_agent_projects_via_codex_agent_toml_mode(self) -> None:
82
+ """The pack ships ``.apm/agents/<name>.md``; codex projects each
83
+ as ``.codex/agents/<name>.toml`` with the three expected keys."""
84
+ import tomllib
85
+
86
+ with tempfile.TemporaryDirectory() as tmp:
87
+ tmp_path = Path(tmp)
88
+ pack = tmp_path / "pack"
89
+ (pack / ".apm" / "agents").mkdir(parents=True)
90
+ (pack / ".apm" / "agents" / "bar.md").write_text(
91
+ "---\nname: bar\ndescription: a bar agent\n---\nAgent body.\n",
92
+ encoding="utf-8",
93
+ )
94
+ out = tmp_path / "out"
95
+ project(pack, self.contract, out)
96
+ target = out / ".codex" / "agents" / "bar.toml"
97
+ self.assertTrue(target.exists(), f"expected {target}")
98
+ data = tomllib.loads(target.read_text(encoding="utf-8"))
99
+ self.assertEqual(data["name"], "bar")
100
+ self.assertEqual(data["description"], "a bar agent")
101
+ self.assertIn("developer_instructions", data)
102
+
103
+ def test_codex_hook_wiring_projects_via_merge_json(self) -> None:
104
+ """Pack ships ``.apm/hook-wiring/<name>.toml``; codex projects the
105
+ merged result at ``.codex/hooks.json`` with the ``hooks`` key."""
106
+ import json
107
+
108
+ with tempfile.TemporaryDirectory() as tmp:
109
+ tmp_path = Path(tmp)
110
+ pack = tmp_path / "pack"
111
+ (pack / ".apm" / "hook-wiring").mkdir(parents=True)
112
+ (pack / ".apm" / "hook-wiring" / "wire.toml").write_text(
113
+ '[hooks]\n'
114
+ '"SessionStart" = [{matcher = "*", hooks = [{type = "command", command = "echo hi"}]}]\n',
115
+ encoding="utf-8",
116
+ )
117
+ out = tmp_path / "out"
118
+ project(pack, self.contract, out)
119
+ target = out / ".codex" / "hooks.json"
120
+ self.assertTrue(target.exists(), f"expected {target}")
121
+ data = json.loads(target.read_text(encoding="utf-8"))
122
+ self.assertIn("hooks", data)
123
+ self.assertIn("SessionStart", data["hooks"])
124
+
125
+ def test_codex_command_still_dropped_at_build_time(self) -> None:
126
+ """Fixture pack with one command; assert NO command-shaped output
127
+ anywhere under ``<output>/.codex/`` (mode is `dropped`,
128
+ ``_iter_primitives`` skips it)."""
129
+ with tempfile.TemporaryDirectory() as tmp:
130
+ tmp_path = Path(tmp)
131
+ pack = tmp_path / "pack"
132
+ (pack / ".apm" / "commands").mkdir(parents=True)
133
+ (pack / ".apm" / "commands" / "qux.md").write_text(
134
+ "# qux command\n", encoding="utf-8"
135
+ )
136
+ out = tmp_path / "out"
137
+ project(pack, self.contract, out)
138
+ self.assertFalse(
139
+ any(out.rglob("qux.md")),
140
+ "command projection should be skipped (dropped)",
141
+ )
142
+
143
+ def test_hook_body_extensions_preserved(self) -> None:
144
+ with tempfile.TemporaryDirectory() as tmp:
145
+ tmp_path = Path(tmp)
146
+ pack = _seed_pack(tmp_path)
147
+ out = tmp_path / "out"
148
+ project(pack, self.contract, out)
149
+ self.assertTrue((out / "tools" / "hooks" / "baz.sh").exists())
150
+ self.assertTrue((out / "tools" / "hooks" / "baz.py").exists())
151
+
152
+
153
+ def _seed_two_skill_pack(root: Path, name: str = "two-skill") -> Path:
154
+ """Two skills: one flat, one with nested subdirectories."""
155
+ pack = root / name
156
+ flat = pack / ".apm" / "skills" / "flat"
157
+ flat.mkdir(parents=True)
158
+ (flat / "SKILL.md").write_text(
159
+ "---\ndescription: flat skill\n---\n# flat\nbody\n",
160
+ encoding="utf-8",
161
+ )
162
+
163
+ nested = pack / ".apm" / "skills" / "nested"
164
+ (nested / "scripts").mkdir(parents=True)
165
+ (nested / "references").mkdir(parents=True)
166
+ (nested / "SKILL.md").write_text(
167
+ "---\ndescription: nested skill\n---\n# nested\nbody\n",
168
+ encoding="utf-8",
169
+ )
170
+ (nested / "scripts" / "run.sh").write_text(
171
+ "#!/bin/sh\necho run\n",
172
+ encoding="utf-8",
173
+ )
174
+ (nested / "references" / "notes.md").write_text(
175
+ "# Notes\nReference content.\n",
176
+ encoding="utf-8",
177
+ )
178
+ return pack
179
+
180
+
181
+ def _seed_symlinked_pack(root: Path, name: str = "symlinked") -> Path:
182
+ """Skill body with a relative symlink under references/."""
183
+ pack = root / name
184
+ (pack / ".apm" / "assets").mkdir(parents=True)
185
+ (pack / ".apm" / "assets" / "shared.md").write_text(
186
+ "# Shared\nContent.\n",
187
+ encoding="utf-8",
188
+ )
189
+
190
+ linker = pack / ".apm" / "skills" / "linker"
191
+ (linker / "references").mkdir(parents=True)
192
+ (linker / "SKILL.md").write_text(
193
+ "---\ndescription: linker skill\n---\n# linker\n",
194
+ encoding="utf-8",
195
+ )
196
+ (linker / "references" / "shared.md").symlink_to(Path("../../../assets/shared.md"))
197
+ return pack
198
+
199
+
200
+ def _seed_same_name_pack(root: Path, name: str, body: str) -> Path:
201
+ pack = root / name
202
+ skill_dir = pack / ".apm" / "skills" / "same-name"
203
+ skill_dir.mkdir(parents=True)
204
+ (skill_dir / "SKILL.md").write_text(body, encoding="utf-8")
205
+ return pack
206
+
207
+
208
+ class TestDirectDirectoryProjection(unittest.TestCase):
209
+ """Post-RFC-0009 Codex `skill` projection — `direct-directory` mode."""
210
+
211
+ @classmethod
212
+ def setUpClass(cls) -> None:
213
+ cls.contract = load_contract(CONTRACT_PATH)
214
+
215
+ def test_byte_equal_projection_two_skill(self) -> None:
216
+ # AC3, AC4.
217
+ with tempfile.TemporaryDirectory() as tmp:
218
+ tmp_path = Path(tmp)
219
+ pack = _seed_two_skill_pack(tmp_path)
220
+ out = tmp_path / "out"
221
+
222
+ project_packs([pack], self.contract, out)
223
+
224
+ for rel in (
225
+ "flat/SKILL.md",
226
+ "nested/SKILL.md",
227
+ "nested/scripts/run.sh",
228
+ "nested/references/notes.md",
229
+ ):
230
+ source_bytes = (pack / ".apm" / "skills" / rel).read_bytes()
231
+ projected_bytes = (out / ".agents" / "skills" / rel).read_bytes()
232
+ self.assertEqual(
233
+ projected_bytes,
234
+ source_bytes,
235
+ f"byte mismatch at {rel}",
236
+ )
237
+
238
+ def test_symlink_pass_through(self) -> None:
239
+ # AC5.
240
+ import os
241
+
242
+ with tempfile.TemporaryDirectory() as tmp:
243
+ tmp_path = Path(tmp)
244
+ pack = _seed_symlinked_pack(tmp_path)
245
+ out = tmp_path / "out"
246
+
247
+ project_packs([pack], self.contract, out)
248
+
249
+ projected_link = out / ".agents" / "skills" / "linker" / "references" / "shared.md"
250
+ self.assertTrue(os.path.islink(projected_link))
251
+ self.assertEqual(
252
+ os.readlink(projected_link),
253
+ str(Path("../../../assets/shared.md")),
254
+ )
255
+
256
+ def test_same_name_last_wins(self) -> None:
257
+ # AC6 — Codex case.
258
+ with tempfile.TemporaryDirectory() as tmp:
259
+ tmp_path = Path(tmp)
260
+ pack_a = _seed_same_name_pack(
261
+ tmp_path, "pack-a", "# pack-a\nPACK_A_SENTINEL\n",
262
+ )
263
+ pack_b = _seed_same_name_pack(
264
+ tmp_path, "pack-b", "# pack-b\nPACK_B_SENTINEL\n",
265
+ )
266
+ out = tmp_path / "out"
267
+
268
+ project_packs([pack_a, pack_b], self.contract, out)
269
+ body = (out / ".agents" / "skills" / "same-name" / "SKILL.md").read_text(
270
+ encoding="utf-8",
271
+ )
272
+ self.assertIn("PACK_B_SENTINEL", body)
273
+ self.assertNotIn("PACK_A_SENTINEL", body)
274
+
275
+ def test_top_level_symlink_skill_is_skipped(self) -> None:
276
+ # Defense-in-depth: a malicious pack with `.apm/skills/<name>`
277
+ # as a symlink to a sensitive directory would exfiltrate its
278
+ # contents via `copytree` (the `symlinks=True` flag only
279
+ # governs symlinks *inside* the tree). The adapter must skip
280
+ # symlink entries at the iteration level so the contents do
281
+ # not land in the projection. `lint-packs` already refuses
282
+ # such packs; this is the adapter-layer safety net.
283
+ with tempfile.TemporaryDirectory() as tmp:
284
+ tmp_path = Path(tmp)
285
+ external = tmp_path / "external-secrets"
286
+ external.mkdir()
287
+ (external / "secret.txt").write_text("DO NOT LEAK\n", encoding="utf-8")
288
+
289
+ pack = tmp_path / "pack"
290
+ skills_dir = pack / ".apm" / "skills"
291
+ skills_dir.mkdir(parents=True)
292
+ # Top-level skill entry is a symlink-to-directory.
293
+ (skills_dir / "malicious").symlink_to(external, target_is_directory=True)
294
+ # And a legitimate skill — that one must still project.
295
+ legit = skills_dir / "legit"
296
+ legit.mkdir()
297
+ (legit / "SKILL.md").write_text("# legit\n", encoding="utf-8")
298
+
299
+ out = tmp_path / "out"
300
+
301
+ project_packs([pack], self.contract, out)
302
+
303
+ self.assertFalse((out / ".agents" / "skills" / "malicious").exists())
304
+ self.assertFalse((out / ".agents" / "skills" / "secret.txt").exists())
305
+ self.assertTrue((out / ".agents" / "skills" / "legit" / "SKILL.md").is_file())
306
+ # External directory untouched.
307
+ self.assertEqual(
308
+ (external / "secret.txt").read_text(encoding="utf-8"),
309
+ "DO NOT LEAK\n",
310
+ )
311
+
312
+ def test_destination_symlink_safe_overwrite(self) -> None:
313
+ # Spec § Never do: `shutil.rmtree` is barred against entries
314
+ # whose `is_symlink()` is true. If a previous run left a
315
+ # symlink at `<target>/skills/<name>`, the next projection
316
+ # must unlink it (removing the link, not the target).
317
+ with tempfile.TemporaryDirectory() as tmp:
318
+ tmp_path = Path(tmp)
319
+ pack = _seed_two_skill_pack(tmp_path)
320
+ out = tmp_path / "out"
321
+ target = out / ".agents" / "skills"
322
+ target.mkdir(parents=True)
323
+
324
+ external = tmp_path / "external"
325
+ external.mkdir()
326
+ (external / "anchor").write_text("keep me\n", encoding="utf-8")
327
+ (target / "flat").symlink_to(external, target_is_directory=True)
328
+
329
+ project_packs([pack], self.contract, out)
330
+
331
+ self.assertFalse((target / "flat").is_symlink())
332
+ self.assertTrue((target / "flat" / "SKILL.md").is_file())
333
+ self.assertTrue(external.is_dir())
334
+ self.assertEqual(
335
+ (external / "anchor").read_text(encoding="utf-8"),
336
+ "keep me\n",
337
+ )
338
+
339
+ def test_same_name_last_wins_reversed(self) -> None:
340
+ # AC6 — Codex case, reversed.
341
+ with tempfile.TemporaryDirectory() as tmp:
342
+ tmp_path = Path(tmp)
343
+ pack_a = _seed_same_name_pack(
344
+ tmp_path, "pack-a", "# pack-a\nPACK_A_SENTINEL\n",
345
+ )
346
+ pack_b = _seed_same_name_pack(
347
+ tmp_path, "pack-b", "# pack-b\nPACK_B_SENTINEL\n",
348
+ )
349
+ out = tmp_path / "out"
350
+
351
+ project_packs([pack_b, pack_a], self.contract, out)
352
+ body = (out / ".agents" / "skills" / "same-name" / "SKILL.md").read_text(
353
+ encoding="utf-8",
354
+ )
355
+ self.assertIn("PACK_A_SENTINEL", body)
356
+ self.assertNotIn("PACK_B_SENTINEL", body)
357
+
358
+
359
+ def _seed_named_skills_pack(root: Path, pack_name: str, skill_names: list[str]) -> Path:
360
+ pack = root / pack_name
361
+ for skill_name in skill_names:
362
+ skill_dir = pack / ".apm" / "skills" / skill_name
363
+ skill_dir.mkdir(parents=True)
364
+ (skill_dir / "SKILL.md").write_text(
365
+ f"# {skill_name}\nfrom {pack_name}\n",
366
+ encoding="utf-8",
367
+ )
368
+ return pack
369
+
370
+
371
+ class TestCodexOrphanSweep(unittest.TestCase):
372
+ """T7 — `direct-directory` skill projection runs `sweep_orphans`
373
+ against the union of source skill names across the call's pack list.
374
+ """
375
+
376
+ @classmethod
377
+ def setUpClass(cls) -> None:
378
+ cls.contract = load_contract(CONTRACT_PATH)
379
+
380
+ def test_codex_two_stage_shrink(self) -> None:
381
+ # AC17: project {a, b, c} then {a, c} into the same output.
382
+ with tempfile.TemporaryDirectory() as tmp:
383
+ tmp_path = Path(tmp)
384
+ three = _seed_named_skills_pack(tmp_path, "three-skill", ["a", "b", "c"])
385
+ shrink = _seed_named_skills_pack(tmp_path, "two-skill-shrink", ["a", "c"])
386
+ out = tmp_path / "out"
387
+
388
+ project_packs([three], self.contract, out)
389
+ self.assertTrue((out / ".agents" / "skills" / "b").is_dir())
390
+
391
+ project_packs([shrink], self.contract, out)
392
+ children = {p.name for p in (out / ".agents" / "skills").iterdir()}
393
+ self.assertEqual(children, {"a", "c"})
394
+
395
+ def test_codex_two_pack_union(self) -> None:
396
+ # AC20: pack_a={a,b} + pack_b={b,c} → {a,b,c};
397
+ # then pack_a alone → {a,b}, c removed.
398
+ with tempfile.TemporaryDirectory() as tmp:
399
+ tmp_path = Path(tmp)
400
+ pack_a = _seed_named_skills_pack(tmp_path, "pack-a", ["a", "b"])
401
+ pack_b = _seed_named_skills_pack(tmp_path, "pack-b", ["b", "c"])
402
+ out = tmp_path / "out"
403
+
404
+ project_packs([pack_a, pack_b], self.contract, out)
405
+ children = {p.name for p in (out / ".agents" / "skills").iterdir()}
406
+ self.assertEqual(children, {"a", "b", "c"})
407
+
408
+ project_packs([pack_a], self.contract, out)
409
+ children = {p.name for p in (out / ".agents" / "skills").iterdir()}
410
+ self.assertEqual(children, {"a", "b"})
411
+
412
+ def test_codex_symlink_safe_sweep(self) -> None:
413
+ # AC21: pre-seed a symlink-to-external in the target dir; the
414
+ # sweep removes the symlink but leaves the external dir intact.
415
+ with tempfile.TemporaryDirectory() as tmp:
416
+ tmp_path = Path(tmp)
417
+ pack = _seed_named_skills_pack(tmp_path, "pack", ["a"])
418
+ external = tmp_path / "external"
419
+ external.mkdir()
420
+ (external / "anchor").write_text("keep me\n", encoding="utf-8")
421
+ out = tmp_path / "out"
422
+ target = out / ".agents" / "skills"
423
+ target.mkdir(parents=True)
424
+ link = target / "b"
425
+ link.symlink_to(external, target_is_directory=True)
426
+
427
+ project_packs([pack], self.contract, out)
428
+
429
+ self.assertTrue((target / "a").is_dir())
430
+ self.assertFalse(link.exists())
431
+ self.assertFalse(link.is_symlink())
432
+ self.assertTrue(external.is_dir())
433
+ self.assertEqual(
434
+ (external / "anchor").read_text(encoding="utf-8"),
435
+ "keep me\n",
436
+ )
437
+
438
+
439
+ class TestMigrationStripIntegrated(unittest.TestCase):
440
+ """Codex `project_packs` strips the legacy block from `<output_root>/AGENTS.md`."""
441
+
442
+ @classmethod
443
+ def setUpClass(cls) -> None:
444
+ cls.contract = load_contract(CONTRACT_PATH)
445
+
446
+ def _populated(self) -> str:
447
+ return (
448
+ "# Top\n\nIntroductory prose.\n\n"
449
+ f"{_LEGACY_SKILL_BLOCK_START}\n"
450
+ "- **a** — desc-a\n"
451
+ "- **b** — desc-b\n"
452
+ f"{_LEGACY_SKILL_BLOCK_END}\n"
453
+ "\nClosing prose.\n"
454
+ )
455
+
456
+ def test_happy_path_strips_delimiters_and_preserves_prose(self) -> None:
457
+ # AC10, AC11. The strip's only allowed mutation is removing
458
+ # the legacy delimiter region; outside-delimiter bytes must
459
+ # survive byte-for-byte. Substring `assertIn` would pass on
460
+ # munged surrounding bytes; the concatenation assertion
461
+ # below pins the byte-equality contract AC11(c) names.
462
+ outside_before = "# Top\n\nIntroductory prose.\n\n"
463
+ outside_after = "\nClosing prose.\n"
464
+ populated = (
465
+ f"{outside_before}"
466
+ f"{_LEGACY_SKILL_BLOCK_START}\n"
467
+ f"- **a** — desc-a\n"
468
+ f"- **b** — desc-b\n"
469
+ f"{_LEGACY_SKILL_BLOCK_END}\n"
470
+ f"{outside_after}"
471
+ )
472
+ with tempfile.TemporaryDirectory() as tmp:
473
+ tmp_path = Path(tmp)
474
+ pack = _seed_two_skill_pack(tmp_path)
475
+ out = tmp_path / "out"
476
+ out.mkdir()
477
+ (out / "AGENTS.md").write_text(populated, encoding="utf-8")
478
+
479
+ project_packs([pack], self.contract, out)
480
+
481
+ text = (out / "AGENTS.md").read_text(encoding="utf-8")
482
+ self.assertNotIn(_LEGACY_SKILL_BLOCK_START, text)
483
+ self.assertNotIn(_LEGACY_SKILL_BLOCK_END, text)
484
+ # Byte-for-byte preservation: the outside-delimiter prose
485
+ # appears unchanged, in order, with no munging.
486
+ self.assertIn(outside_before + outside_after, text)
487
+
488
+ def test_already_clean_is_byte_identical(self) -> None:
489
+ # AC12.
490
+ clean = "# Top\n\nNo managed block.\n"
491
+ with tempfile.TemporaryDirectory() as tmp:
492
+ tmp_path = Path(tmp)
493
+ pack = _seed_two_skill_pack(tmp_path)
494
+ out = tmp_path / "out"
495
+ out.mkdir()
496
+ (out / "AGENTS.md").write_text(clean, encoding="utf-8")
497
+
498
+ project_packs([pack], self.contract, out)
499
+
500
+ self.assertEqual(
501
+ (out / "AGENTS.md").read_text(encoding="utf-8"),
502
+ clean,
503
+ )
504
+
505
+ def test_idempotent_across_two_calls(self) -> None:
506
+ # AC13.
507
+ with tempfile.TemporaryDirectory() as tmp:
508
+ tmp_path = Path(tmp)
509
+ pack = _seed_two_skill_pack(tmp_path)
510
+ out = tmp_path / "out"
511
+ out.mkdir()
512
+ (out / "AGENTS.md").write_text(self._populated(), encoding="utf-8")
513
+
514
+ project_packs([pack], self.contract, out)
515
+ first = (out / "AGENTS.md").read_bytes()
516
+ project_packs([pack], self.contract, out)
517
+ second = (out / "AGENTS.md").read_bytes()
518
+ self.assertEqual(first, second)
519
+
520
+ def test_hand_edited_content_between_delimiters_is_lost(self) -> None:
521
+ # AC14.
522
+ sentinel = "<<HAND-EDITED-PRESERVE-ME>>"
523
+ body = (
524
+ "prefix\n"
525
+ f"{_LEGACY_SKILL_BLOCK_START}\n"
526
+ f"{sentinel}\n"
527
+ f"{_LEGACY_SKILL_BLOCK_END}\n"
528
+ "suffix\n"
529
+ )
530
+ with tempfile.TemporaryDirectory() as tmp:
531
+ tmp_path = Path(tmp)
532
+ pack = _seed_two_skill_pack(tmp_path)
533
+ out = tmp_path / "out"
534
+ out.mkdir()
535
+ (out / "AGENTS.md").write_text(body, encoding="utf-8")
536
+
537
+ project_packs([pack], self.contract, out)
538
+
539
+ text = (out / "AGENTS.md").read_text(encoding="utf-8")
540
+ self.assertNotIn(sentinel, text)
541
+
542
+
543
+ class TestMigrationStripPureFunction(unittest.TestCase):
544
+ """Pure-function tests for `_strip_legacy_skill_block`.
545
+
546
+ No filesystem; the strip is a text transform. Integration with
547
+ `project_packs` is covered by T4's tests.
548
+ """
549
+
550
+ OUTSIDE_BEFORE = "# Top\n\nIntroductory prose.\n\n"
551
+ OUTSIDE_AFTER = "\nClosing prose.\n"
552
+
553
+ def _populated(self) -> str:
554
+ return (
555
+ f"{self.OUTSIDE_BEFORE}"
556
+ f"{_LEGACY_SKILL_BLOCK_START}\n"
557
+ f"- **a** — desc-a\n"
558
+ f"- **b** — desc-b\n"
559
+ f"{_LEGACY_SKILL_BLOCK_END}\n"
560
+ f"{self.OUTSIDE_AFTER}"
561
+ )
562
+
563
+ def test_happy_path_strips_delimiters_and_preserves_outside_prose(self) -> None:
564
+ stripped = _strip_legacy_skill_block(self._populated())
565
+ self.assertNotIn(_LEGACY_SKILL_BLOCK_START, stripped)
566
+ self.assertNotIn(_LEGACY_SKILL_BLOCK_END, stripped)
567
+ self.assertIn("# Top\n", stripped)
568
+ self.assertIn("Introductory prose.", stripped)
569
+ self.assertIn("Closing prose.", stripped)
570
+
571
+ def test_already_clean_input_is_byte_identical(self) -> None:
572
+ clean = "# Top\n\nNo managed block here.\n"
573
+ self.assertEqual(_strip_legacy_skill_block(clean), clean)
574
+
575
+ def test_idempotent(self) -> None:
576
+ once = _strip_legacy_skill_block(self._populated())
577
+ twice = _strip_legacy_skill_block(once)
578
+ self.assertEqual(once, twice)
579
+
580
+ def test_non_list_content_between_delimiters_is_lost(self) -> None:
581
+ sentinel = "<<HAND-EDITED-PRESERVE-ME>>"
582
+ text = (
583
+ f"prefix\n"
584
+ f"{_LEGACY_SKILL_BLOCK_START}\n"
585
+ f"{sentinel}\n"
586
+ f"{_LEGACY_SKILL_BLOCK_END}\n"
587
+ f"suffix\n"
588
+ )
589
+ stripped = _strip_legacy_skill_block(text)
590
+ self.assertNotIn(sentinel, stripped)
591
+ self.assertIn("prefix", stripped)
592
+ self.assertIn("suffix", stripped)
593
+
594
+ def test_out_of_order_delimiters_refused(self) -> None:
595
+ # If the adopter pasted the delimiters in reverse order, the
596
+ # splice would otherwise corrupt the file silently. Refuse
597
+ # the input with a named error so the adopter can fix.
598
+ reversed_input = (
599
+ "prefix\n"
600
+ f"{_LEGACY_SKILL_BLOCK_END}\n"
601
+ f"{_LEGACY_SKILL_BLOCK_START}\n"
602
+ "suffix\n"
603
+ )
604
+ with self.assertRaises(ValueError) as caught:
605
+ _strip_legacy_skill_block(reversed_input)
606
+ self.assertIn("appears before", str(caught.exception))
607
+
608
+ def test_splice_managed_block_symbol_still_exists(self) -> None:
609
+ # AC23(i): a future refactor that inlines the splice and deletes
610
+ # the helper symbol breaks this import-and-call assertion.
611
+ self.assertTrue(callable(_splice_managed_block))
612
+
613
+ def test_strip_invokes_splice_managed_block_once(self) -> None:
614
+ # AC23(ii) — deliberate retention test. A refactor that
615
+ # inlines the splice and deletes `_splice_managed_block`
616
+ # breaks the import. A refactor that keeps the symbol but
617
+ # stops calling it from `_strip_legacy_skill_block` makes
618
+ # `call_count == 0`. Either signals the retention contract
619
+ # has been broken before the migration window closes. Do
620
+ # not "simplify" by removing the mock — the mock IS the
621
+ # contract. Patch with `wraps=` so the real function still
622
+ # runs and the strip behaviour is unchanged.
623
+ with mock.patch.object(
624
+ codex,
625
+ "_splice_managed_block",
626
+ wraps=codex._splice_managed_block,
627
+ ) as spy:
628
+ _strip_legacy_skill_block(self._populated())
629
+ self.assertEqual(spy.call_count, 1)
630
+
631
+
632
+ class TestCodexProjectsEveryShippedSkill(unittest.TestCase):
633
+ """AC29 — every skill any in-tree pack ships projects through Codex
634
+ into `.agents/skills/<name>/SKILL.md` (byte-equal to source).
635
+
636
+ The spec text references `dist/codex/` as a notional adopter path;
637
+ in this self-hosting repo, Codex projects to the repo root, so the
638
+ test runs the projection against a `tmp_path` and enumerates
639
+ `packs/*/.apm/skills/`. The sentinel set (`work-loop`, `new-spec`,
640
+ `new-rfc`, `new-adr`) spans multiple packs (core +
641
+ governance-extras), so the test must walk all packs — a core-only
642
+ walk would silently skip `new-rfc` / `new-adr` against the spec's
643
+ explicit sentinel list.
644
+ """
645
+
646
+ @classmethod
647
+ def setUpClass(cls) -> None:
648
+ cls.contract = load_contract(CONTRACT_PATH)
649
+
650
+ def test_every_shipped_skill_projects_with_equal_bytes(self) -> None:
651
+ packs_root = REPO_ROOT / "packs"
652
+ self.assertTrue(packs_root.is_dir())
653
+ pack_paths = sorted(p for p in packs_root.iterdir() if p.is_dir())
654
+
655
+ # Collect every source skill across every pack. Tracks the
656
+ # "winning" source path for same-name collisions so byte-equal
657
+ # comparisons use the last-supplied pack's body (matching
658
+ # AC6).
659
+ winning_source: dict[str, Path] = {}
660
+ for pack_path in pack_paths:
661
+ skills_dir = pack_path / ".apm" / "skills"
662
+ if not skills_dir.is_dir():
663
+ continue
664
+ for entry in skills_dir.iterdir():
665
+ if entry.is_dir():
666
+ winning_source[entry.name] = entry
667
+
668
+ self.assertGreater(len(winning_source), 0)
669
+ for sentinel in ("work-loop", "new-spec", "new-rfc", "new-adr"):
670
+ self.assertIn(
671
+ sentinel,
672
+ winning_source,
673
+ f"sentinel skill {sentinel!r} missing from any in-tree pack",
674
+ )
675
+
676
+ with tempfile.TemporaryDirectory() as tmp:
677
+ tmp_path = Path(tmp)
678
+ project_packs(pack_paths, self.contract, tmp_path)
679
+
680
+ for skill_name, source_skill_dir in winning_source.items():
681
+ projected_skill_md = (
682
+ tmp_path / ".agents" / "skills" / skill_name / "SKILL.md"
683
+ )
684
+ source_skill_md = source_skill_dir / "SKILL.md"
685
+ if not source_skill_md.exists():
686
+ continue
687
+ self.assertTrue(
688
+ projected_skill_md.is_file(),
689
+ f"skill {skill_name!r}: SKILL.md missing in projection",
690
+ )
691
+ self.assertEqual(
692
+ projected_skill_md.read_bytes(),
693
+ source_skill_md.read_bytes(),
694
+ f"skill {skill_name!r}: SKILL.md bytes differ",
695
+ )
696
+
697
+
698
+ if __name__ == "__main__":
699
+ unittest.main()