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,224 @@
1
+ """Tests for the RFC-0004 v0.2 `[scope]` table on the adapter contract.
2
+
3
+ Verifies AC #14 (RFC-0004) for the distribution-adapters spec:
4
+ - adapter.schema.json accepts a well-formed [adapter.<name>.scope] block.
5
+ - adapter.schema.json rejects each malformed `allowed-prefixes.user` shape
6
+ enumerated in the spec: ["/"], [""], ["../"], [".."],
7
+ ["no-trailing-slash"], ["/begins-with-slash/"], and [].
8
+ - adapter.toml validates against the v0.2 schema with [contract] version =
9
+ "0.2" and the two-prefix [adapter."claude-code".scope] block.
10
+ - The other three reference adapters (kiro, copilot, codex) omit the
11
+ optional [scope] block and remain valid.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ import json
18
+ import tomllib
19
+ import unittest
20
+ from pathlib import Path
21
+
22
+ REPO_ROOT = Path(__file__).resolve().parents[5]
23
+ CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
24
+ SCHEMA_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.schema.json"
25
+
26
+
27
+ def _load_schema() -> dict:
28
+ return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
29
+
30
+
31
+ def _load_contract() -> dict:
32
+ return tomllib.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
33
+
34
+
35
+ class ContractVersionTests(unittest.TestCase):
36
+ """Contract version: bumped to 0.2 by RFC-0004, then to 0.3 by RFC-0005,
37
+ then to 0.4 by RFC-0008 (T2 / spec claude-plugins-install-route), then to
38
+ 0.5 by RFC-0010 (T2 / spec apm-install-route-parity), then to 0.6 by
39
+ RFC-0011 / pack-allowed-adapters (codex user-scope table), then to 0.7
40
+ by RFC-0012 / repo-scope-per-adapter-projection (every adapter declares
41
+ `allowed-prefixes.repo`; copilot gains a scope table) and RFC-0013 /
42
+ credential-broker-contract (governance bump) co-residing at v0.7, then
43
+ to 0.8 by docs/specs/dropped-primitives-coverage (codex agent +
44
+ hook-wiring move from `dropped` to first-class projections)."""
45
+
46
+ def test_contract_version_is_0_5(self) -> None:
47
+ # Class/method name preserved; exact version lives in test_contract.py.
48
+ # Assert >= 0.8 so v0.8 scope features survive future bumps. Compare as
49
+ # (major, minor) tuples — a string compare breaks at v0.10 ("0.10" < "0.8"
50
+ # lexically).
51
+ contract = _load_contract()
52
+ version = tuple(int(part) for part in contract["contract"]["version"].split("."))
53
+ self.assertGreaterEqual(version, (0, 8))
54
+
55
+
56
+ class ClaudeCodeScopeBlockTests(unittest.TestCase):
57
+ """The Claude Code adapter declares the v0.2 [scope] block."""
58
+
59
+ def test_claude_code_scope_present(self) -> None:
60
+ contract = _load_contract()
61
+ scope = contract["adapter"]["claude-code"].get("scope")
62
+ self.assertIsNotNone(scope, "Claude Code [scope] block missing")
63
+ self.assertEqual(scope["repo"], ".")
64
+ self.assertEqual(scope["user"], "~")
65
+
66
+ def test_claude_code_allowed_prefixes_user_two_entries(self) -> None:
67
+ """Two prefixes ship: projected primitives + CLI infrastructure."""
68
+ contract = _load_contract()
69
+ prefixes = (
70
+ contract["adapter"]["claude-code"]["scope"]["allowed-prefixes"]["user"]
71
+ )
72
+ self.assertEqual(prefixes, [".claude/", ".agentbundle/"])
73
+
74
+ def test_contract_validates_against_schema(self) -> None:
75
+ from agentbundle.build.validate import validate
76
+
77
+ errors = validate(_load_contract(), _load_schema())
78
+ self.assertEqual(errors, [], f"v0.2 contract did not validate: {errors}")
79
+
80
+
81
+ class OtherAdaptersOmitScopeTests(unittest.TestCase):
82
+ """v0.3 (RFC-0005) adds a `[scope]` table to Kiro alongside Claude
83
+ Code's existing one; v0.6 (RFC-0011) adds one to Codex; v0.7
84
+ (RFC-0012) adds one to Copilot — every shipped adapter now carries
85
+ a `[scope]` table at v0.7."""
86
+
87
+ def test_copilot_has_scope_per_rfc_0012(self) -> None:
88
+ contract = _load_contract()
89
+ scope = contract["adapter"]["copilot"].get("scope")
90
+ self.assertIsNotNone(scope, "copilot [scope] block missing")
91
+ self.assertEqual(scope["repo"], ".")
92
+ # v0.10 (RFC-0024 / copilot-full-parity): copilot is now a full-parity,
93
+ # user-scope-capable adapter. Repo prefixes cover the three projected
94
+ # primitive homes under `.github/`; the legacy `tools/hooks/` prefix is
95
+ # gone (hook-body retargeted to `.github/hooks/`).
96
+ self.assertEqual(
97
+ scope["allowed-prefixes"]["repo"],
98
+ [".github/instructions/", ".github/agents/", ".github/hooks/"],
99
+ )
100
+ # User scope: `~/.copilot/{agents,instructions,hooks}/` + `.agentbundle/`
101
+ # (the install state-file home, same as every other user-capable adapter).
102
+ self.assertEqual(scope["user"], "~")
103
+ self.assertEqual(
104
+ scope["allowed-prefixes"]["user"],
105
+ [
106
+ ".copilot/agents/",
107
+ ".copilot/instructions/",
108
+ ".copilot/hooks/",
109
+ ".agentbundle/",
110
+ ],
111
+ )
112
+
113
+ def test_codex_has_scope_per_rfc_0011(self) -> None:
114
+ contract = _load_contract()
115
+ scope = contract["adapter"]["codex"].get("scope")
116
+ self.assertIsNotNone(scope, "Codex [scope] block missing (RFC-0011)")
117
+ self.assertEqual(scope["repo"], ".")
118
+ self.assertEqual(scope["user"], "~")
119
+ # v0.8 (dropped-primitives-coverage) adds `.codex/` to allow
120
+ # codex agent + hook-wiring projection.
121
+ self.assertEqual(
122
+ scope["allowed-prefixes"]["user"],
123
+ [".agents/skills/", ".codex/", ".agentbundle/"],
124
+ )
125
+
126
+ def test_kiro_has_scope_per_rfc_0005(self) -> None:
127
+ contract = _load_contract()
128
+ scope = contract["adapter"]["kiro"].get("scope")
129
+ self.assertIsNotNone(scope, "Kiro [scope] block missing")
130
+ self.assertEqual(scope["repo"], ".")
131
+ self.assertEqual(scope["user"], "~")
132
+
133
+ def test_contract_minus_claude_code_scope_still_valid(self) -> None:
134
+ from agentbundle.build.validate import validate
135
+
136
+ contract = _load_contract()
137
+ contract["adapter"]["claude-code"].pop("scope", None)
138
+ errors = validate(contract, _load_schema())
139
+ self.assertEqual(errors, [], f"validate rejected scope-less contract: {errors}")
140
+
141
+
142
+ class AllowedPrefixesRejectionTests(unittest.TestCase):
143
+ """`allowed-prefixes.<scope>` constraints — every bad shape is rejected."""
144
+
145
+ def _validate_with_prefixes(self, prefixes: list[str]) -> list[str]:
146
+ from agentbundle.build.validate import validate
147
+
148
+ schema = _load_schema()
149
+ contract = _load_contract()
150
+ contract["adapter"]["claude-code"]["scope"]["allowed-prefixes"]["user"] = list(prefixes)
151
+ return validate(contract, schema)
152
+
153
+ def test_rejects_root_only(self) -> None:
154
+ errors = self._validate_with_prefixes(["/"])
155
+ self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['/']")
156
+
157
+ def test_rejects_empty_string(self) -> None:
158
+ errors = self._validate_with_prefixes([""])
159
+ self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['']")
160
+
161
+ def test_rejects_dotdot_slash(self) -> None:
162
+ errors = self._validate_with_prefixes(["../"])
163
+ self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['../']")
164
+
165
+ def test_rejects_bare_dotdot(self) -> None:
166
+ errors = self._validate_with_prefixes([".."])
167
+ self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['..']")
168
+
169
+ def test_rejects_no_trailing_slash(self) -> None:
170
+ errors = self._validate_with_prefixes(["no-trailing-slash"])
171
+ self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['no-trailing-slash']")
172
+
173
+ def test_rejects_leading_slash(self) -> None:
174
+ errors = self._validate_with_prefixes(["/begins-with-slash/"])
175
+ self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['/begins-with-slash/']")
176
+
177
+ def test_rejects_empty_array(self) -> None:
178
+ errors = self._validate_with_prefixes([])
179
+ self.assertTrue(errors, "schema accepted allowed-prefixes.user = []")
180
+
181
+ def test_rejects_dotdot_in_middle(self) -> None:
182
+ """Defence in depth: `..` as an interior segment is also rejected."""
183
+ errors = self._validate_with_prefixes([".claude/../etc/"])
184
+ self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['.claude/../etc/']")
185
+
186
+ def test_accepts_nested_path(self) -> None:
187
+ """A nested path like `.claude/skills/` is legal — non-empty, trailing /."""
188
+ errors = self._validate_with_prefixes([".claude/skills/"])
189
+ self.assertEqual(errors, [], f"schema rejected nested path: {errors}")
190
+
191
+
192
+ class StdlibValidatorExtensionsTests(unittest.TestCase):
193
+ """Cross-keyword tests for the validator extensions T10 needs."""
194
+
195
+ def test_min_items_rejects_short_array(self) -> None:
196
+ from agentbundle.build.validate import validate
197
+
198
+ schema = {"type": "array", "minItems": 1}
199
+ self.assertTrue(validate([], schema))
200
+ self.assertEqual(validate(["a"], schema), [])
201
+
202
+ def test_pattern_rejects_dotdot_segment(self) -> None:
203
+ from agentbundle.build.validate import validate
204
+
205
+ # The exact pattern shipped on allowed-prefixes.<scope>.items.
206
+ pattern = r"^((?!\.\.(\/|$))[^/]+/)+$"
207
+ schema = {"type": "string", "pattern": pattern}
208
+ for bad in ("../", "..", "/", "", "no-slash", "/abs/"):
209
+ with self.subTest(value=bad):
210
+ self.assertTrue(
211
+ validate(bad, schema),
212
+ f"pattern accepted forbidden value: {bad!r}",
213
+ )
214
+ for good in (".claude/", ".agentbundle/", "deep/nested/path/"):
215
+ with self.subTest(value=good):
216
+ self.assertEqual(
217
+ validate(good, schema),
218
+ [],
219
+ f"pattern rejected legal value: {good!r}",
220
+ )
221
+
222
+
223
+ if __name__ == "__main__":
224
+ unittest.main()
@@ -0,0 +1,191 @@
1
+ """Tests for adapter-contract v0.7 (RFC-0012 / repo-scope-per-adapter-projection
2
+ and RFC-0013 / credential-broker-contract co-residing at v0.7).
3
+
4
+ Verifies the T1 edits landed:
5
+
6
+ - ``[contract] version == "0.7"`` in both the runtime data file
7
+ (`_data/adapter.toml`) and the docs mirror
8
+ (`docs/contracts/adapter.toml`). The two files must stay byte-
9
+ aligned per the v0.3-schema sync test; this module pins the
10
+ version on both as belt-and-braces (AC1 for both RFCs).
11
+ - **RFC-0012 surface:**
12
+ * ``[adapter.copilot.scope]`` exists with ``repo = "."``,
13
+ ``allowed-prefixes.repo`` enumerating the per-IDE skill /
14
+ hook-body targets, and NO ``user`` key (Copilot is admissible
15
+ at repo scope only).
16
+ * Every shipped adapter declares ``allowed-prefixes.repo`` as a
17
+ non-empty list of trailing-slash strings.
18
+ * Schema validator refuses fixtures that omit the ``repo`` key
19
+ or ``allowed-prefixes.repo`` from any adapter's scope table.
20
+ - **RFC-0013 surface:**
21
+ * Each user-scope-capable adapter (`claude-code`, `kiro`, `codex`)
22
+ still carries `.agentbundle/` in `allowed-prefixes.user`
23
+ (non-regression — the prefix is what `metadata.auth: creds`
24
+ writes its credential cache under).
25
+ - Property-based ``allowed-prefixes.user`` invariants for every
26
+ user-scope-capable adapter (header-comment edits and
27
+ list-order changes don't trip the assertion).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import copy
33
+ import json
34
+ import tomllib
35
+ import unittest
36
+ from pathlib import Path
37
+
38
+ REPO_ROOT = Path(__file__).resolve().parents[5]
39
+ DATA_CONTRACT_PATH = (
40
+ REPO_ROOT / "packages" / "agentbundle" / "agentbundle" / "_data" / "adapter.toml"
41
+ )
42
+ DOCS_CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
43
+ DATA_SCHEMA_PATH = (
44
+ REPO_ROOT
45
+ / "packages"
46
+ / "agentbundle"
47
+ / "agentbundle"
48
+ / "_data"
49
+ / "adapter.schema.json"
50
+ )
51
+
52
+
53
+ def _version_tuple(contract: dict) -> tuple[int, ...]:
54
+ """(major, minor) tuple — a string compare breaks at v0.10 ("0.10"<"0.8")."""
55
+ return tuple(int(part) for part in contract["contract"]["version"].split("."))
56
+
57
+
58
+ class TestContractV07(unittest.TestCase):
59
+ def setUp(self) -> None:
60
+ self.contract = tomllib.loads(DATA_CONTRACT_PATH.read_text(encoding="utf-8"))
61
+ self.docs_contract = tomllib.loads(
62
+ DOCS_CONTRACT_PATH.read_text(encoding="utf-8")
63
+ )
64
+
65
+ def test_contract_version_is_07(self) -> None:
66
+ """RFC-0012 + RFC-0013 + dropped-primitives-coverage co-residing at v0.8.
67
+
68
+ Name preserved to keep the diff small; the v0.7 invariants below
69
+ remain load-bearing post-v0.8 bump because dropped-primitives-coverage
70
+ only widens codex's projection table (it does not regress the v0.7
71
+ ``allowed-prefixes`` / scope-table contracts pinned in this module).
72
+ """
73
+ # Exact version check lives in test_contract.py; assert >= 0.8 (v0.7/v0.8
74
+ # features must survive future bumps — validated by invariant tests below).
75
+ # Compare as (major, minor) tuples — a string compare breaks at v0.10.
76
+ self.assertGreaterEqual(_version_tuple(self.contract), (0, 8))
77
+
78
+ def test_docs_contract_version_is_07(self) -> None:
79
+ """docs mirror stays in sync (byte-identical to _data/ adapter.toml)."""
80
+ self.assertGreaterEqual(_version_tuple(self.docs_contract), (0, 8))
81
+
82
+ def test_copilot_scope_table_shape(self) -> None:
83
+ copilot_scope = self.contract["adapter"]["copilot"].get("scope")
84
+ self.assertIsNotNone(copilot_scope, "copilot scope table missing")
85
+ self.assertEqual(copilot_scope["repo"], ".")
86
+ # v0.10 (RFC-0024 / copilot-full-parity) supersedes-in-part the v0.7
87
+ # repo-only copilot scope: copilot is now user-scope-capable with three
88
+ # `.github/` repo prefixes (the legacy `tools/hooks/` prefix is gone).
89
+ self.assertEqual(
90
+ copilot_scope["allowed-prefixes"]["repo"],
91
+ [".github/instructions/", ".github/agents/", ".github/hooks/"],
92
+ )
93
+ self.assertEqual(copilot_scope["user"], "~")
94
+ self.assertEqual(
95
+ copilot_scope["allowed-prefixes"]["user"],
96
+ [
97
+ ".copilot/agents/",
98
+ ".copilot/instructions/",
99
+ ".copilot/hooks/",
100
+ ".agentbundle/",
101
+ ],
102
+ )
103
+
104
+ def test_every_adapter_has_allowed_prefixes_repo(self) -> None:
105
+ for name, block in self.contract["adapter"].items():
106
+ with self.subTest(adapter=name):
107
+ scope = block.get("scope")
108
+ self.assertIsNotNone(
109
+ scope, f"adapter {name!r} has no scope table at v0.7"
110
+ )
111
+ repo_prefixes = scope.get("allowed-prefixes", {}).get("repo")
112
+ self.assertIsInstance(
113
+ repo_prefixes,
114
+ list,
115
+ f"adapter {name!r} missing allowed-prefixes.repo",
116
+ )
117
+ self.assertTrue(
118
+ repo_prefixes,
119
+ f"adapter {name!r} allowed-prefixes.repo is empty",
120
+ )
121
+ for entry in repo_prefixes:
122
+ self.assertTrue(
123
+ entry.endswith("/"),
124
+ f"adapter {name!r} prefix {entry!r} must end with '/'",
125
+ )
126
+
127
+ def test_existing_user_prefixes_invariants(self) -> None:
128
+ """Property-based assertion — every user-scope-capable adapter's
129
+ prefix list still carries its load-bearing entries. RFC-0013's
130
+ `.agentbundle/` non-regression rolls into this same shape."""
131
+ cc = self.contract["adapter"]["claude-code"]["scope"]
132
+ cc_user = cc["allowed-prefixes"]["user"]
133
+ self.assertIn(".claude/", cc_user)
134
+ self.assertIn(".agentbundle/", cc_user)
135
+
136
+ kiro = self.contract["adapter"]["kiro"]["scope"]
137
+ kiro_user = kiro["allowed-prefixes"]["user"]
138
+ self.assertIn(".kiro/", kiro_user)
139
+ self.assertIn(".agentbundle/", kiro_user)
140
+
141
+ codex = self.contract["adapter"]["codex"]["scope"]
142
+ codex_user = codex["allowed-prefixes"]["user"]
143
+ self.assertIn(".agents/skills/", codex_user)
144
+ self.assertIn(".agentbundle/", codex_user)
145
+
146
+ def test_all_user_scope_adapters_carry_agentbundle_prefix(self) -> None:
147
+ """RFC-0013 AC2 — `.agentbundle/` is the credential-cache root
148
+ every user-scope-capable adapter must admit."""
149
+ for adapter in ("claude-code", "kiro", "codex"):
150
+ with self.subTest(adapter=adapter):
151
+ prefixes = (
152
+ self.contract["adapter"][adapter]["scope"]
153
+ ["allowed-prefixes"]["user"]
154
+ )
155
+ self.assertIn(
156
+ ".agentbundle/", prefixes,
157
+ f"{adapter} user-scope allowed-prefixes must include "
158
+ f"'.agentbundle/' (got {prefixes!r})",
159
+ )
160
+
161
+ def test_schema_refuses_repo_omission(self) -> None:
162
+ """RFC-0012 AC4 — fixture contract with the ``repo`` key removed
163
+ from any adapter's scope table fails validation."""
164
+ from agentbundle.build.validate import validate
165
+
166
+ schema = json.loads(DATA_SCHEMA_PATH.read_text(encoding="utf-8"))
167
+ broken = copy.deepcopy(self.contract)
168
+ del broken["adapter"]["claude-code"]["scope"]["repo"]
169
+ errors = validate(broken, schema)
170
+ self.assertTrue(
171
+ errors,
172
+ "schema accepted a scope table missing the 'repo' key",
173
+ )
174
+
175
+ def test_schema_refuses_allowed_prefixes_repo_omission(self) -> None:
176
+ """RFC-0012 AC4 — fixture contract with ``allowed-prefixes.repo``
177
+ removed from any adapter's scope table fails validation."""
178
+ from agentbundle.build.validate import validate
179
+
180
+ schema = json.loads(DATA_SCHEMA_PATH.read_text(encoding="utf-8"))
181
+ broken = copy.deepcopy(self.contract)
182
+ del broken["adapter"]["kiro"]["scope"]["allowed-prefixes"]["repo"]
183
+ errors = validate(broken, schema)
184
+ self.assertTrue(
185
+ errors,
186
+ "schema accepted a scope table missing allowed-prefixes.repo",
187
+ )
188
+
189
+
190
+ if __name__ == "__main__":
191
+ unittest.main()
@@ -0,0 +1,230 @@
1
+ """Tests for adapter-contract v0.8 (docs/specs/dropped-primitives-coverage).
2
+
3
+ Verifies the T1 contract edits:
4
+
5
+ - ``[contract] version == "0.8"`` in both the runtime data file
6
+ (`_data/adapter.toml`) and the docs mirror.
7
+ - Codex `agent` projection: ``mode == "codex-agent-toml"``,
8
+ ``target-path == ".codex/agents/"``,
9
+ ``frontmatter-mapping == "codex-agent-frontmatter-v0.8"``.
10
+ - Codex `hook-wiring` projection: ``mode == "merge-json"``,
11
+ ``target-path == ".codex/hooks.json"``, ``managed-key == "hooks"``.
12
+ - Codex `command` projection: stays `dropped` (no upstream target).
13
+ - ``[adapter.codex.scope].allowed-prefixes.repo`` and ``.user`` each
14
+ include ``".codex/"``.
15
+ - ``[frontmatter-mapping."codex-agent-frontmatter-v0.8"]`` declares
16
+ per-key sub-tables for ``name`` and ``description``; no ``body``
17
+ sub-table (body-to-``developer_instructions`` is a mode-level
18
+ convention, not a rename rule).
19
+ - Schema admits ``"codex-agent-toml"`` at every site that currently
20
+ enumerates ``"dropped"``.
21
+ - Schema validates the v0.8 contract end-to-end.
22
+ - Property invariants for claude-code (all 5 primitives projected) and
23
+ kiro (4 of 5 with `command: dropped`) survive the codex changes
24
+ untouched. Copilot's 3 dropped entries unchanged.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import tomllib
31
+ import unittest
32
+ from pathlib import Path
33
+
34
+ REPO_ROOT = Path(__file__).resolve().parents[5]
35
+ DATA_CONTRACT_PATH = (
36
+ REPO_ROOT / "packages" / "agentbundle" / "agentbundle" / "_data" / "adapter.toml"
37
+ )
38
+ DOCS_CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
39
+ DATA_SCHEMA_PATH = (
40
+ REPO_ROOT
41
+ / "packages"
42
+ / "agentbundle"
43
+ / "agentbundle"
44
+ / "_data"
45
+ / "adapter.schema.json"
46
+ )
47
+
48
+
49
+ def _codex_projection(contract: dict, primitive: str) -> dict:
50
+ """Find the [[adapter.codex.projection]] entry for ``primitive``."""
51
+ for entry in contract["adapter"]["codex"]["projection"]:
52
+ if entry["primitive"] == primitive:
53
+ return entry
54
+ raise AssertionError(f"codex projection for primitive {primitive!r} not found")
55
+
56
+
57
+ def _version_tuple(contract: dict) -> tuple[int, ...]:
58
+ """(major, minor) tuple — a string compare breaks at v0.10 ("0.10"<"0.8")."""
59
+ return tuple(int(part) for part in contract["contract"]["version"].split("."))
60
+
61
+
62
+ class TestContractV08(unittest.TestCase):
63
+ def setUp(self) -> None:
64
+ self.contract = tomllib.loads(DATA_CONTRACT_PATH.read_text(encoding="utf-8"))
65
+ self.docs_contract = tomllib.loads(
66
+ DOCS_CONTRACT_PATH.read_text(encoding="utf-8")
67
+ )
68
+ self.schema = json.loads(DATA_SCHEMA_PATH.read_text(encoding="utf-8"))
69
+
70
+ def test_contract_version_is_08(self) -> None:
71
+ # v0.8 features are present; exact version check lives in test_contract.py.
72
+ # The v0.8 features (codex-agent-toml, codex-agent-frontmatter-v0.8) must
73
+ # survive future bumps — verify via the codex-specific tests below.
74
+ # Compare as (major, minor) tuples — a string compare breaks at v0.10.
75
+ version = _version_tuple(self.contract)
76
+ self.assertGreaterEqual(version, (0, 8), "contract version must be >= 0.8")
77
+
78
+ def test_docs_contract_version_is_08(self) -> None:
79
+ version = _version_tuple(self.docs_contract)
80
+ self.assertGreaterEqual(version, (0, 8), "docs contract version must be >= 0.8")
81
+
82
+ def test_codex_agent_projection(self) -> None:
83
+ entry = _codex_projection(self.contract, "agent")
84
+ self.assertEqual(entry["mode"], "codex-agent-toml")
85
+ self.assertEqual(entry["target-path"], ".codex/agents/")
86
+ self.assertEqual(
87
+ entry["frontmatter-mapping"], "codex-agent-frontmatter-v0.8"
88
+ )
89
+ self.assertEqual(entry["on-conflict"], "prompt-then-preserve")
90
+
91
+ def test_codex_hook_wiring_projection(self) -> None:
92
+ entry = _codex_projection(self.contract, "hook-wiring")
93
+ self.assertEqual(entry["mode"], "merge-json")
94
+ self.assertEqual(entry["target-path"], ".codex/hooks.json")
95
+ self.assertEqual(entry["managed-key"], "hooks")
96
+ self.assertEqual(entry["on-conflict"], "merge-managed-key-only")
97
+
98
+ def test_codex_command_still_dropped(self) -> None:
99
+ entry = _codex_projection(self.contract, "command")
100
+ self.assertEqual(entry["mode"], "dropped")
101
+
102
+ def test_codex_skill_projection_unchanged(self) -> None:
103
+ """Non-regression — codex skill projection at `.agents/skills/` unchanged."""
104
+ entry = _codex_projection(self.contract, "skill")
105
+ self.assertEqual(entry["mode"], "direct-directory")
106
+ self.assertEqual(entry["target-path"], ".agents/skills/")
107
+
108
+ def test_codex_hook_body_projection_unchanged(self) -> None:
109
+ """Non-regression — codex hook-body projection at `tools/hooks/` unchanged."""
110
+ entry = _codex_projection(self.contract, "hook-body")
111
+ self.assertEqual(entry["mode"], "direct-file")
112
+ self.assertEqual(entry["target-path"], "tools/hooks/")
113
+
114
+ def test_codex_allowed_prefixes_includes_codex_dir(self) -> None:
115
+ scope = self.contract["adapter"]["codex"]["scope"]
116
+ self.assertIn(".codex/", scope["allowed-prefixes"]["repo"])
117
+ self.assertIn(".codex/", scope["allowed-prefixes"]["user"])
118
+
119
+ def test_codex_allowed_prefixes_preserves_existing_entries(self) -> None:
120
+ """Non-regression — `.agents/skills/`, `.agentbundle/`, `tools/hooks/` still present."""
121
+ repo = self.contract["adapter"]["codex"]["scope"]["allowed-prefixes"]["repo"]
122
+ user = self.contract["adapter"]["codex"]["scope"]["allowed-prefixes"]["user"]
123
+ for entry in (".agents/skills/", ".agentbundle/"):
124
+ self.assertIn(entry, repo)
125
+ self.assertIn(entry, user)
126
+ self.assertIn("tools/hooks/", repo)
127
+
128
+ def test_codex_frontmatter_mapping_table(self) -> None:
129
+ mapping = self.contract["frontmatter-mapping"]["codex-agent-frontmatter-v0.8"]
130
+ # Required per-key sub-tables.
131
+ self.assertIn("name", mapping)
132
+ self.assertEqual(mapping["name"]["rename"], "name")
133
+ self.assertIn("description", mapping)
134
+ self.assertEqual(mapping["description"]["rename"], "description")
135
+ # No `body` sub-table — body-to-`developer_instructions` is a
136
+ # mode-level convention per spec AC4, not a frontmatter rename.
137
+ self.assertNotIn("body", mapping)
138
+ self.assertNotIn("developer_instructions", mapping)
139
+
140
+ def test_schema_admits_codex_agent_toml_mode_at_every_dropped_site(self) -> None:
141
+ """Walk the schema; every enum array containing "dropped" must also
142
+ contain "codex-agent-toml". Discovered dynamically so a future schema
143
+ edit that adds a fifth enum site doesn't silently drift this AC."""
144
+
145
+ def walk(node, path):
146
+ if isinstance(node, dict):
147
+ for k, v in node.items():
148
+ if k == "enum" and isinstance(v, list) and "dropped" in v:
149
+ self.assertIn(
150
+ "codex-agent-toml",
151
+ v,
152
+ f"schema enum at {path} admits 'dropped' but not "
153
+ f"'codex-agent-toml': {v!r}",
154
+ )
155
+ walk(v, f"{path}.{k}")
156
+ elif isinstance(node, list):
157
+ for i, v in enumerate(node):
158
+ walk(v, f"{path}[{i}]")
159
+
160
+ walk(self.schema, "$")
161
+
162
+ def test_schema_loads_v08_contract(self) -> None:
163
+ """End-to-end: load schema, validate v0.8 contract; no errors. This
164
+ is load-bearing — it pins that codex's new `codex-agent-toml` mode
165
+ and the codex `merge-json` reuse both validate at the codex array
166
+ site (the projection-mode enum is global across adapters, so
167
+ `merge-json` already validates without per-site enum edits)."""
168
+ from agentbundle.build.validate import validate
169
+
170
+ errors = validate(self.contract, self.schema)
171
+ self.assertEqual(
172
+ errors,
173
+ [],
174
+ "v0.8 adapter.toml failed schema validation:\n" + "\n".join(errors),
175
+ )
176
+
177
+ def test_claude_code_unchanged_post_v08(self) -> None:
178
+ """Property invariant — claude-code projects all 5 primitives."""
179
+ primitives = {
180
+ entry["primitive"]: entry
181
+ for entry in self.contract["adapter"]["claude-code"]["projection"]
182
+ }
183
+ self.assertEqual(
184
+ set(primitives), {"skill", "agent", "hook-body", "hook-wiring", "command"}
185
+ )
186
+ for primitive, entry in primitives.items():
187
+ self.assertNotEqual(
188
+ entry["mode"],
189
+ "dropped",
190
+ f"claude-code {primitive} unexpectedly dropped at v0.8",
191
+ )
192
+
193
+ def test_kiro_unchanged_post_v08(self) -> None:
194
+ """Property invariant — kiro projects 4 of 5 primitives; command dropped."""
195
+ primitives = {
196
+ entry["primitive"]: entry
197
+ for entry in self.contract["adapter"]["kiro"]["projection"]
198
+ }
199
+ self.assertEqual(primitives["command"]["mode"], "dropped")
200
+ for primitive in ("skill", "agent", "hook-body"):
201
+ self.assertNotEqual(
202
+ primitives[primitive]["mode"],
203
+ "dropped",
204
+ f"kiro {primitive} unexpectedly dropped at v0.8",
205
+ )
206
+
207
+ def test_copilot_unchanged_post_v08(self) -> None:
208
+ """Property invariant — at v0.10 (RFC-0024 / copilot-full-parity) copilot
209
+ projects 4 of 5 primitives; only `command` stays dropped (the v0.8
210
+ three-dropped state is superseded — agent + hook-wiring flipped to
211
+ native modes)."""
212
+ primitives = {
213
+ entry["primitive"]: entry
214
+ for entry in self.contract["adapter"]["copilot"]["projection"]
215
+ }
216
+ self.assertEqual(
217
+ primitives["command"]["mode"],
218
+ "dropped",
219
+ "copilot command should still be dropped (copilot-cli#618/#1113)",
220
+ )
221
+ for primitive in ("skill", "agent", "hook-body", "hook-wiring"):
222
+ self.assertNotEqual(
223
+ primitives[primitive]["mode"],
224
+ "dropped",
225
+ f"copilot {primitive} should project natively at v0.10",
226
+ )
227
+
228
+
229
+ if __name__ == "__main__":
230
+ unittest.main()