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,582 @@
1
+ """Tests for adapter.toml + adapter.schema.json (T1b).
2
+
3
+ Verifies:
4
+ - adapter.toml validates against adapter.schema.json (AC 1).
5
+ - Every (5 standard primitives × 6 adapters) = 30 standard pairs present;
6
+ kiro-ide and kiro-cli each add kiro-ide-hook = 32 total (RFC-0022).
7
+ - The mode enum in adapter.schema.json contains exactly the seven RFC-0001 modes;
8
+ unknown modes are rejected (AC 2).
9
+ - Every projection entry carries an on-conflict value from the legal set,
10
+ matching the per-mode default — except for degraded-info-log and dropped
11
+ which are no-write/no-output and carry no on-conflict (AC 2).
12
+ - hook-wiring primitive's source-path is .apm/hook-wiring/.
13
+ - command primitive's source-path is .apm/commands/; Claude Code projects
14
+ direct-file; Copilot/Codex/Kiro-family are dropped.
15
+ - frontmatter-mapping table for kiro-ide-agent-frontmatter-v0.9 validates
16
+ against schema (renamed from kiro-agent-frontmatter-v0.9 in T1);
17
+ frontmatter-default table for copilot-instruction validates and is
18
+ structurally distinct.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import tomllib
25
+ import unittest
26
+ from pathlib import Path
27
+
28
+ REPO_ROOT = Path(__file__).resolve().parents[5]
29
+ CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
30
+ SCHEMA_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.schema.json"
31
+
32
+ # All seven RFC-0001 projection modes.
33
+ SEVEN_RFC_MODES = {
34
+ "direct-directory",
35
+ "direct-file",
36
+ "merge-json",
37
+ "instruction-file",
38
+ "managed-block-inline",
39
+ "degraded-info-log",
40
+ "dropped",
41
+ }
42
+
43
+ # Legal on-conflict values.
44
+ LEGAL_ON_CONFLICT = {
45
+ "prompt-then-preserve",
46
+ "prompt-then-overwrite",
47
+ "preserve-outside-block",
48
+ "merge-managed-key-only",
49
+ "overwrite-without-prompt",
50
+ }
51
+
52
+ # Per-mode default on-conflict values (no-write modes have no on-conflict).
53
+ MODE_DEFAULT_ON_CONFLICT = {
54
+ "direct-directory": "prompt-then-preserve",
55
+ "direct-file": "prompt-then-preserve",
56
+ "merge-json": "merge-managed-key-only",
57
+ "instruction-file": "prompt-then-overwrite",
58
+ "managed-block-inline": "preserve-outside-block",
59
+ # degraded-info-log and dropped carry no on-conflict
60
+ }
61
+
62
+ # Modes that are no-write / no-output — they do not require an on-conflict.
63
+ NO_WRITE_MODES = {"degraded-info-log", "dropped"}
64
+
65
+ # All five primitive names.
66
+ ALL_PRIMITIVES = {"skill", "agent", "hook-body", "hook-wiring", "command"}
67
+
68
+ # All reference adapter names (RFC-0022: kiro-ide and kiro-cli added; kiro retained as alias).
69
+ ALL_ADAPTERS = {"claude-code", "kiro", "kiro-ide", "kiro-cli", "copilot", "codex"}
70
+
71
+ # Extra primitives that are kiro-specific and OK to declare in kiro-family
72
+ # adapter blocks without failing the "no extra primitives" check.
73
+ KIRO_EXTRA_PRIMITIVES = frozenset({"kiro-ide-hook"})
74
+
75
+
76
+ def _load_contract() -> dict:
77
+ return tomllib.loads(CONTRACT_PATH.read_bytes().decode("utf-8"))
78
+
79
+
80
+ def _load_schema() -> dict:
81
+ return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
82
+
83
+
84
+ class ContractSchemaValidationTests(unittest.TestCase):
85
+ """adapter.toml must validate against adapter.schema.json (AC 1)."""
86
+
87
+ def test_contract_validates_against_schema(self) -> None:
88
+ from agentbundle.build.validate import validate
89
+
90
+ contract = _load_contract()
91
+ schema = _load_schema()
92
+ errors = validate(contract, schema)
93
+ self.assertEqual(
94
+ errors,
95
+ [],
96
+ f"adapter.toml failed schema validation:\n" + "\n".join(errors),
97
+ )
98
+
99
+
100
+ class AllPairsEnumeratedTests(unittest.TestCase):
101
+ """Every (primitive × adapter) pair must be present — no missing, no extra."""
102
+
103
+ def setUp(self) -> None:
104
+ self.contract = _load_contract()
105
+
106
+ def test_all_adapters_present(self) -> None:
107
+ adapters = set(self.contract["adapter"].keys())
108
+ self.assertEqual(adapters, ALL_ADAPTERS, f"adapter keys differ: {adapters}")
109
+
110
+ def test_every_primitive_covered_per_adapter(self) -> None:
111
+ """Every (primitive × adapter) pair must be declared in *some* form.
112
+
113
+ Under v0.3 (RFC-0005), kiro's `hook-wiring` no longer appears in the
114
+ legacy `projection` array — it lives in the new
115
+ `projections.<primitive>` table. The coverage union walks both forms.
116
+
117
+ RFC-0022: kiro-family adapters (kiro, kiro-ide, kiro-cli) may
118
+ additionally declare `kiro-ide-hook` in their `projections` table;
119
+ this is a known extra and is not flagged.
120
+ """
121
+ missing: list[str] = []
122
+ extra: list[str] = []
123
+ for adapter_name, adapter_block in self.contract["adapter"].items():
124
+ array_form = {p["primitive"] for p in adapter_block.get("projection", [])}
125
+ table_form = set(adapter_block.get("projections", {}).keys())
126
+ primitives_in_adapter = array_form | table_form
127
+ for prim in ALL_PRIMITIVES:
128
+ if prim not in primitives_in_adapter:
129
+ missing.append(f"({prim}, {adapter_name})")
130
+ for prim in primitives_in_adapter:
131
+ if prim not in ALL_PRIMITIVES and prim not in KIRO_EXTRA_PRIMITIVES:
132
+ extra.append(f"({prim}, {adapter_name})")
133
+ self.assertEqual(missing, [], f"missing pairs: {missing}")
134
+ self.assertEqual(extra, [], f"extra unknown primitives: {extra}")
135
+
136
+ def test_exactly_twenty_pairs_total(self) -> None:
137
+ """Count (primitive × adapter) pairs across array + table forms.
138
+
139
+ Pairs that appear in BOTH forms (the transitional hook-body declarations
140
+ on claude-code/kiro and the claude-code hook-wiring legacy entry that
141
+ coexists with its v0.3 table) count once per adapter, matching the
142
+ "primitive coverage" semantic.
143
+
144
+ RFC-0022 T1: kiro-ide added (+5 standard + 1 kiro-ide-hook = +6),
145
+ kiro-cli carries kiro-ide-hook dropped (+6). Plus kiro-ide adds
146
+ hook-wiring dropped to its array_form, which is already counted.
147
+ Total: 5 (claude-code) + 5 (kiro) + 6 (kiro-ide) + 6 (kiro-cli)
148
+ + 5 (copilot) + 5 (codex) = 32. Class name preserved.
149
+ """
150
+ total = 0
151
+ for adapter_block in self.contract["adapter"].values():
152
+ array_form = {p["primitive"] for p in adapter_block.get("projection", [])}
153
+ table_form = set(adapter_block.get("projections", {}).keys())
154
+ total += len(array_form | table_form)
155
+ self.assertEqual(total, 32, f"expected 32 pairs total, got {total}")
156
+
157
+
158
+ class ModeEnumTests(unittest.TestCase):
159
+ """adapter.schema.json mode enum: seven RFC-0001 modes plus the two
160
+ v0.3 additions from RFC-0005 (`user-merge-json`, `merge-into-agent-json`)
161
+ plus the v0.8 addition from docs/specs/dropped-primitives-coverage
162
+ (`codex-agent-toml`)."""
163
+
164
+ def test_mode_enum_contains_expected_modes(self) -> None:
165
+ schema = _load_schema()
166
+ # Navigate to the mode enum inside projection items.
167
+ projection_items = (
168
+ schema["properties"]["adapter"]["additionalProperties"]["properties"][
169
+ "projection"
170
+ ]["items"]
171
+ )
172
+ mode_enum = set(projection_items["properties"]["mode"]["enum"])
173
+ expected = SEVEN_RFC_MODES | {
174
+ "user-merge-json",
175
+ "merge-into-agent-json",
176
+ "codex-agent-toml",
177
+ # docs/specs/copilot-full-parity (v0.10): copilot agent + hook-wiring
178
+ # modes admitted at every `dropped`-enumerating site.
179
+ "copilot-agent-md",
180
+ "copilot-hooks-json",
181
+ }
182
+ self.assertEqual(
183
+ mode_enum,
184
+ expected,
185
+ f"schema mode enum differs from RFC-0001+RFC-0005+v0.8+v0.10 set: {mode_enum}",
186
+ )
187
+
188
+ def test_schema_rejects_unknown_mode(self) -> None:
189
+ from agentbundle.build.validate import validate
190
+
191
+ schema = _load_schema()
192
+ bad_contract = {
193
+ "contract": {"version": "0.1"},
194
+ "primitive": {
195
+ "skill": {"source-path": ".apm/skills/"},
196
+ "agent": {"source-path": ".apm/agents/"},
197
+ "hook-body": {"source-path": ".apm/hooks/"},
198
+ "hook-wiring": {"source-path": ".apm/hook-wiring/"},
199
+ "command": {"source-path": ".apm/commands/"},
200
+ },
201
+ "adapter": {
202
+ "claude-code": {
203
+ "projection": [
204
+ {
205
+ "primitive": "skill",
206
+ "mode": "not-a-real-mode",
207
+ "target-path": ".claude/skills/",
208
+ "on-conflict": "prompt-then-preserve",
209
+ }
210
+ ]
211
+ }
212
+ },
213
+ }
214
+ errors = validate(bad_contract, schema)
215
+ self.assertTrue(errors, "schema accepted an unknown projection mode")
216
+
217
+
218
+ class OnConflictTests(unittest.TestCase):
219
+ """Every write-mode projection must carry a legal on-conflict matching the default."""
220
+
221
+ def setUp(self) -> None:
222
+ self.contract = _load_contract()
223
+
224
+ def test_write_mode_projections_carry_on_conflict(self) -> None:
225
+ for adapter_name, adapter_block in self.contract["adapter"].items():
226
+ for projection in adapter_block["projection"]:
227
+ mode = projection["mode"]
228
+ if mode in NO_WRITE_MODES:
229
+ # no-write modes must NOT be required to carry on-conflict
230
+ continue
231
+ self.assertIn(
232
+ "on-conflict",
233
+ projection,
234
+ f"({projection['primitive']}, {adapter_name}) mode={mode} missing on-conflict",
235
+ )
236
+
237
+ def test_on_conflict_values_are_legal(self) -> None:
238
+ for adapter_name, adapter_block in self.contract["adapter"].items():
239
+ for projection in adapter_block["projection"]:
240
+ if "on-conflict" in projection:
241
+ self.assertIn(
242
+ projection["on-conflict"],
243
+ LEGAL_ON_CONFLICT,
244
+ f"({projection['primitive']}, {adapter_name}) illegal on-conflict: "
245
+ f"{projection['on-conflict']!r}",
246
+ )
247
+
248
+ def test_on_conflict_matches_mode_default(self) -> None:
249
+ for adapter_name, adapter_block in self.contract["adapter"].items():
250
+ for projection in adapter_block["projection"]:
251
+ mode = projection["mode"]
252
+ if mode in NO_WRITE_MODES:
253
+ continue
254
+ expected = MODE_DEFAULT_ON_CONFLICT.get(mode)
255
+ if expected is None:
256
+ continue # mode has no default; explicit override required
257
+ self.assertEqual(
258
+ projection.get("on-conflict"),
259
+ expected,
260
+ f"({projection['primitive']}, {adapter_name}) mode={mode}: "
261
+ f"expected on-conflict={expected!r}, got {projection.get('on-conflict')!r}",
262
+ )
263
+
264
+ def test_no_write_modes_have_no_on_conflict(self) -> None:
265
+ for adapter_name, adapter_block in self.contract["adapter"].items():
266
+ for projection in adapter_block["projection"]:
267
+ mode = projection["mode"]
268
+ if mode in NO_WRITE_MODES:
269
+ self.assertNotIn(
270
+ "on-conflict",
271
+ projection,
272
+ f"({projection['primitive']}, {adapter_name}) mode={mode} "
273
+ f"should not carry on-conflict",
274
+ )
275
+
276
+
277
+ class SourcePathTests(unittest.TestCase):
278
+ """Primitive source-path values must match the spec."""
279
+
280
+ def setUp(self) -> None:
281
+ self.contract = _load_contract()
282
+
283
+ def test_hook_wiring_source_path(self) -> None:
284
+ self.assertEqual(
285
+ self.contract["primitive"]["hook-wiring"]["source-path"],
286
+ ".apm/hook-wiring/",
287
+ )
288
+
289
+ def test_command_source_path(self) -> None:
290
+ self.assertEqual(
291
+ self.contract["primitive"]["command"]["source-path"],
292
+ ".apm/commands/",
293
+ )
294
+
295
+
296
+ class CommandProjectionTests(unittest.TestCase):
297
+ """command primitive: Claude Code = direct-file; Kiro/Copilot/Codex = dropped."""
298
+
299
+ def setUp(self) -> None:
300
+ self.contract = _load_contract()
301
+
302
+ def _projection_for(self, adapter: str, primitive: str) -> dict:
303
+ for p in self.contract["adapter"][adapter]["projection"]:
304
+ if p["primitive"] == primitive:
305
+ return p
306
+ self.fail(f"projection for ({primitive}, {adapter}) not found")
307
+
308
+ def test_claude_code_command_is_direct_file(self) -> None:
309
+ proj = self._projection_for("claude-code", "command")
310
+ self.assertEqual(proj["mode"], "direct-file")
311
+ self.assertEqual(proj["target-path"], ".claude/commands/")
312
+
313
+ def test_kiro_command_is_dropped(self) -> None:
314
+ proj = self._projection_for("kiro", "command")
315
+ self.assertEqual(proj["mode"], "dropped")
316
+
317
+ def test_copilot_command_is_dropped(self) -> None:
318
+ proj = self._projection_for("copilot", "command")
319
+ self.assertEqual(proj["mode"], "dropped")
320
+
321
+ def test_codex_command_is_dropped(self) -> None:
322
+ proj = self._projection_for("codex", "command")
323
+ self.assertEqual(proj["mode"], "dropped")
324
+
325
+
326
+ class FrontmatterTableTests(unittest.TestCase):
327
+ """frontmatter-mapping and frontmatter-default tables validate and are distinct."""
328
+
329
+ def setUp(self) -> None:
330
+ self.contract = _load_contract()
331
+ self.schema = _load_schema()
332
+
333
+ def test_kiro_frontmatter_mapping_present(self) -> None:
334
+ mapping = self.contract.get("frontmatter-mapping", {})
335
+ self.assertIn(
336
+ "kiro-ide-agent-frontmatter-v0.9",
337
+ mapping,
338
+ "frontmatter-mapping.kiro-ide-agent-frontmatter-v0.9 not found in contract",
339
+ )
340
+
341
+ def test_kiro_frontmatter_mapping_validates_against_schema(self) -> None:
342
+ from agentbundle.build.validate import validate
343
+
344
+ errors = validate(self.contract, self.schema)
345
+ self.assertEqual(
346
+ errors,
347
+ [],
348
+ f"contract with frontmatter-mapping failed validation:\n"
349
+ + "\n".join(errors),
350
+ )
351
+
352
+ def test_copilot_frontmatter_default_present(self) -> None:
353
+ defaults = self.contract.get("frontmatter-default", {})
354
+ self.assertIn(
355
+ "copilot-instruction",
356
+ defaults,
357
+ "frontmatter-default.copilot-instruction not found in contract",
358
+ )
359
+
360
+ def test_copilot_frontmatter_default_has_apply_to(self) -> None:
361
+ default = self.contract["frontmatter-default"]["copilot-instruction"]
362
+ self.assertIn("applyTo", default)
363
+ self.assertEqual(default["applyTo"], "**")
364
+
365
+ def test_frontmatter_mapping_and_default_are_structurally_distinct(self) -> None:
366
+ # mapping = rewrite rules (nested objects with rename/normalize/default fields)
367
+ # default = inject-when-missing (flat string→string map)
368
+ mapping = self.contract.get("frontmatter-mapping", {})
369
+ defaults = self.contract.get("frontmatter-default", {})
370
+
371
+ # They must be under different top-level keys.
372
+ self.assertNotEqual(
373
+ set(mapping.keys()).intersection(set(defaults.keys())),
374
+ set(mapping.keys()),
375
+ "frontmatter-mapping and frontmatter-default share the same sub-keys",
376
+ )
377
+
378
+ # frontmatter-mapping entries are nested objects (rewrite rules).
379
+ for key, mapping_table in mapping.items():
380
+ self.assertIsInstance(
381
+ mapping_table,
382
+ dict,
383
+ f"frontmatter-mapping.{key} should be a dict of rewrite rules",
384
+ )
385
+ for field, rule in mapping_table.items():
386
+ self.assertIsInstance(
387
+ rule,
388
+ dict,
389
+ f"frontmatter-mapping.{key}.{field} should be a dict (rewrite rule)",
390
+ )
391
+
392
+ # frontmatter-default entries are flat string→string maps.
393
+ for key, default_table in defaults.items():
394
+ self.assertIsInstance(
395
+ default_table,
396
+ dict,
397
+ f"frontmatter-default.{key} should be a dict",
398
+ )
399
+ for field, value in default_table.items():
400
+ self.assertIsInstance(
401
+ value,
402
+ str,
403
+ f"frontmatter-default.{key}.{field} should be a string",
404
+ )
405
+
406
+
407
+ class ContractV05Tests(unittest.TestCase):
408
+ """T2 (apm-install-route-parity AC9): contract-version assertion.
409
+
410
+ Originally pinned v0.5; bumped to v0.6 by RFC-0011, v0.7 by
411
+ RFC-0013 / credential-broker-contract, v0.8 by
412
+ docs/specs/dropped-primitives-coverage. Class name preserved to avoid
413
+ needless diff churn against the next bump.
414
+ """
415
+
416
+ def setUp(self) -> None:
417
+ self.contract = _load_contract()
418
+ self.schema = _load_schema()
419
+
420
+ def test_contract_version_is_v05(self) -> None:
421
+ """tomllib.loads of adapter.toml returns contract.version == "0.10"
422
+ (bumped from "0.9" by docs/specs/copilot-full-parity: copilot agent +
423
+ hook-wiring flip `dropped`→native modes, scope-table user capability,
424
+ skill user target, hook-body retarget). Class name preserved to avoid churn.
425
+ """
426
+ self.assertEqual(
427
+ self.contract["contract"]["version"],
428
+ "0.10",
429
+ "adapter.toml [contract] version must be '0.10' after copilot-full-parity",
430
+ )
431
+
432
+ def test_claude_code_install_routes_includes_apm(self) -> None:
433
+ """[adapter."claude-code"] carries install-routes == ["cli", "claude-plugins", "apm"]."""
434
+ routes = self.contract["adapter"]["claude-code"].get("install-routes")
435
+ self.assertEqual(
436
+ routes,
437
+ ["cli", "claude-plugins", "apm"],
438
+ f"expected install-routes=['cli', 'claude-plugins', 'apm'], got {routes!r}",
439
+ )
440
+
441
+ def test_other_adapters_have_no_install_routes(self) -> None:
442
+ """kiro-family, Copilot, and Codex do not declare install-routes (regression
443
+ guard: the v0.4 → v0.5 bump must not silently extend the field's surface to
444
+ those adapters; per-adapter optionality / default ['cli'] on read is unchanged).
445
+ RFC-0022: kiro-ide and kiro-cli added to the checked set."""
446
+ for adapter_name in ("kiro", "kiro-ide", "kiro-cli", "copilot", "codex"):
447
+ adapter_block = self.contract["adapter"].get(adapter_name, {})
448
+ self.assertNotIn(
449
+ "install-routes",
450
+ adapter_block,
451
+ f"adapter '{adapter_name}' must not carry install-routes (only claude-code does)",
452
+ )
453
+
454
+ def test_adapter_schema_accepts_apm_enum_value(self) -> None:
455
+ """Round-trip: the v0.5 contract validates; "apm" is admitted; a value
456
+ outside the three-value enum is rejected."""
457
+ from agentbundle.build.validate import validate
458
+
459
+ # Full contract validates (includes "apm" on install-routes).
460
+ errors = validate(self.contract, self.schema)
461
+ self.assertEqual(
462
+ errors,
463
+ [],
464
+ f"adapter.toml with apm install-route failed schema validation:\n"
465
+ + "\n".join(errors),
466
+ )
467
+
468
+ # Omitting install-routes is also valid (field is optional).
469
+ minimal_contract = {
470
+ "contract": {"version": "0.5"},
471
+ "primitive": {
472
+ "skill": {"source-path": ".apm/skills/"},
473
+ "agent": {"source-path": ".apm/agents/"},
474
+ "hook-body": {"source-path": ".apm/hooks/"},
475
+ "hook-wiring": {"source-path": ".apm/hook-wiring/"},
476
+ "command": {"source-path": ".apm/commands/"},
477
+ },
478
+ "adapter": {
479
+ "claude-code": {}
480
+ },
481
+ }
482
+ errors = validate(minimal_contract, self.schema)
483
+ self.assertEqual(
484
+ errors,
485
+ [],
486
+ f"adapter without install-routes (optional) should validate:\n"
487
+ + "\n".join(errors),
488
+ )
489
+
490
+ # install-routes value outside the enum must be rejected (regression guard
491
+ # for the enum extension: adding "apm" must not have widened the field to
492
+ # any string).
493
+ bad_contract = {
494
+ "contract": {"version": "0.5"},
495
+ "primitive": {
496
+ "skill": {"source-path": ".apm/skills/"},
497
+ "agent": {"source-path": ".apm/agents/"},
498
+ "hook-body": {"source-path": ".apm/hooks/"},
499
+ "hook-wiring": {"source-path": ".apm/hook-wiring/"},
500
+ "command": {"source-path": ".apm/commands/"},
501
+ },
502
+ "adapter": {
503
+ "claude-code": {
504
+ "install-routes": ["foo"],
505
+ }
506
+ },
507
+ }
508
+ errors = validate(bad_contract, self.schema)
509
+ self.assertTrue(
510
+ errors,
511
+ "schema must reject install-routes value outside the three-value enum",
512
+ )
513
+
514
+ # install-routes as a string (not array) must still be rejected.
515
+ bad_contract_str = {
516
+ "contract": {"version": "0.5"},
517
+ "primitive": {
518
+ "skill": {"source-path": ".apm/skills/"},
519
+ "agent": {"source-path": ".apm/agents/"},
520
+ "hook-body": {"source-path": ".apm/hooks/"},
521
+ "hook-wiring": {"source-path": ".apm/hook-wiring/"},
522
+ "command": {"source-path": ".apm/commands/"},
523
+ },
524
+ "adapter": {
525
+ "claude-code": {
526
+ "install-routes": "cli",
527
+ }
528
+ },
529
+ }
530
+ errors = validate(bad_contract_str, self.schema)
531
+ self.assertTrue(
532
+ errors,
533
+ "schema must reject install-routes as a string (must be an array)",
534
+ )
535
+ DATA_CONTRACT_PATH = (
536
+ REPO_ROOT
537
+ / "packages"
538
+ / "agentbundle"
539
+ / "agentbundle"
540
+ / "_data"
541
+ / "adapter.toml"
542
+ )
543
+ SEED_AGENTS_MD_PATH = REPO_ROOT / "packs" / "core" / "seeds" / "AGENTS.md"
544
+
545
+
546
+ class TestCodexSkillDirectDirectory(unittest.TestCase):
547
+ """RFC-0009 / codex-native-skills contract flip.
548
+
549
+ AC1: Codex `skill` is `direct-directory` projecting to
550
+ `.agents/skills/` with `on-conflict = "prompt-then-preserve"`;
551
+ no managed-block delimiter keys remain on the entry.
552
+ AC2: `docs/contracts/adapter.toml` and the bundled `_data/adapter.toml`
553
+ are byte-identical.
554
+ AC15: The seed AGENTS.md no longer carries the legacy delimiter pair.
555
+ """
556
+
557
+ def test_codex_skill_projection_is_direct_directory(self) -> None:
558
+ contract = tomllib.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
559
+ codex_entries = contract["adapter"]["codex"]["projection"]
560
+ skill_entries = [e for e in codex_entries if e["primitive"] == "skill"]
561
+ self.assertEqual(len(skill_entries), 1)
562
+ entry = skill_entries[0]
563
+ self.assertEqual(entry["mode"], "direct-directory")
564
+ self.assertEqual(entry["target-path"], ".agents/skills/")
565
+ self.assertEqual(entry["on-conflict"], "prompt-then-preserve")
566
+ self.assertNotIn("managed-block-delimiter-start", entry)
567
+ self.assertNotIn("managed-block-delimiter-end", entry)
568
+
569
+ def test_contract_files_byte_identical(self) -> None:
570
+ self.assertEqual(
571
+ CONTRACT_PATH.read_bytes(),
572
+ DATA_CONTRACT_PATH.read_bytes(),
573
+ )
574
+
575
+ def test_seed_agents_md_has_no_legacy_delimiters(self) -> None:
576
+ text = SEED_AGENTS_MD_PATH.read_text(encoding="utf-8")
577
+ self.assertNotIn("<!-- agent-skills:start -->", text)
578
+ self.assertNotIn("<!-- agent-skills:end -->", text)
579
+
580
+
581
+ if __name__ == "__main__":
582
+ unittest.main()