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,305 @@
1
+ """T11: `pack.schema.json` enforces `[pack.install]` under contract v0.2.
2
+
3
+ Verifies AC #15 (RFC-0004) for the distribution-adapters spec. Six test
4
+ rows from the plan:
5
+
6
+ 1. A v0.2 pack with no [pack.install] table is rejected.
7
+ 2. A v0.2 pack with default-scope="repo", allowed-scopes=["repo"] accepted.
8
+ 3. A v0.2 pack with default-scope="user", allowed-scopes=["repo"] rejected
9
+ by the default-scope ∈ allowed-scopes invariant.
10
+ 4. A v0.2 pack omitting allowed-scopes (only default-scope declared) is
11
+ accepted — the implied `[default-scope]` default lands at CLI
12
+ consumption time, not at schema validation time.
13
+ 5. A v0.1 pack (declares 0.1 or omits the adapter-contract field) without
14
+ [pack.install] is accepted (legacy).
15
+ 6. A v0.1 pack carrying a stray [pack.install] table is accepted — the
16
+ table is ignored at CLI consumption time.
17
+
18
+ The cross-field invariant lives in `pack.schema.json` (jsonschema
19
+ `if`/`then`) so catalogue indexers and third-party validators refuse a
20
+ malformed pack identically.
21
+
22
+ Tests live in a new file (not extending test_pack_schema.py owned by T1c)
23
+ to avoid the merge-conflict pattern the plan calls out for parallel
24
+ worktrees.
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
+ PACK_SCHEMA_PATH = REPO_ROOT / "docs" / "contracts" / "pack.schema.json"
36
+
37
+
38
+ def _load_schema() -> dict:
39
+ return json.loads(PACK_SCHEMA_PATH.read_text(encoding="utf-8"))
40
+
41
+
42
+ def _parse(toml_text: str) -> dict:
43
+ return tomllib.loads(toml_text)
44
+
45
+
46
+ class V02PackInstallRequiredTests(unittest.TestCase):
47
+ """Row 1: v0.2 pack without [pack.install] is rejected."""
48
+
49
+ def test_v02_without_install_rejected(self) -> None:
50
+ from agentbundle.build.validate import validate
51
+
52
+ instance = _parse(
53
+ """
54
+ [pack]
55
+ name = "demo"
56
+ version = "0.1.0"
57
+
58
+ [pack.adapter-contract]
59
+ version = "0.2"
60
+ """
61
+ )
62
+ errors = validate(instance, _load_schema())
63
+ self.assertTrue(errors, "v0.2 pack without [pack.install] was accepted")
64
+ self.assertTrue(
65
+ any("install" in e for e in errors),
66
+ f"error should name 'install'; got: {errors}",
67
+ )
68
+
69
+
70
+ class V02PackInstallValidTests(unittest.TestCase):
71
+ """Row 2: v0.2 pack with a well-formed install table is accepted."""
72
+
73
+ def test_v02_with_valid_install_accepted(self) -> None:
74
+ from agentbundle.build.validate import validate
75
+
76
+ instance = _parse(
77
+ """
78
+ [pack]
79
+ name = "demo"
80
+ version = "0.1.0"
81
+
82
+ [pack.adapter-contract]
83
+ version = "0.2"
84
+
85
+ [pack.install]
86
+ default-scope = "repo"
87
+ allowed-scopes = ["repo"]
88
+ """
89
+ )
90
+ errors = validate(instance, _load_schema())
91
+ self.assertEqual(errors, [], f"valid v0.2 pack rejected: {errors}")
92
+
93
+
94
+ class DefaultInAllowedInvariantTests(unittest.TestCase):
95
+ """Row 3: default-scope ∉ allowed-scopes is rejected."""
96
+
97
+ def test_user_default_with_repo_only_allowed_rejected(self) -> None:
98
+ from agentbundle.build.validate import validate
99
+
100
+ instance = _parse(
101
+ """
102
+ [pack]
103
+ name = "demo"
104
+ version = "0.1.0"
105
+
106
+ [pack.adapter-contract]
107
+ version = "0.2"
108
+
109
+ [pack.install]
110
+ default-scope = "user"
111
+ allowed-scopes = ["repo"]
112
+ """
113
+ )
114
+ errors = validate(instance, _load_schema())
115
+ self.assertTrue(
116
+ errors,
117
+ "default-scope='user' with allowed-scopes=['repo'] was accepted",
118
+ )
119
+
120
+ def test_repo_default_with_user_only_allowed_rejected(self) -> None:
121
+ """Mirror invariant: default-scope='repo' but allowed-scopes=['user']."""
122
+ from agentbundle.build.validate import validate
123
+
124
+ instance = _parse(
125
+ """
126
+ [pack]
127
+ name = "demo"
128
+ version = "0.1.0"
129
+
130
+ [pack.adapter-contract]
131
+ version = "0.2"
132
+
133
+ [pack.install]
134
+ default-scope = "repo"
135
+ allowed-scopes = ["user"]
136
+ """
137
+ )
138
+ errors = validate(instance, _load_schema())
139
+ self.assertTrue(
140
+ errors,
141
+ "default-scope='repo' with allowed-scopes=['user'] was accepted",
142
+ )
143
+
144
+ def test_both_scopes_allowed_with_user_default_accepted(self) -> None:
145
+ """default='user' but allowed=['repo','user'] is fine."""
146
+ from agentbundle.build.validate import validate
147
+
148
+ instance = _parse(
149
+ """
150
+ [pack]
151
+ name = "demo"
152
+ version = "0.1.0"
153
+
154
+ [pack.adapter-contract]
155
+ version = "0.2"
156
+
157
+ [pack.install]
158
+ default-scope = "user"
159
+ allowed-scopes = ["repo", "user"]
160
+ """
161
+ )
162
+ errors = validate(instance, _load_schema())
163
+ self.assertEqual(errors, [], f"valid dual-scope pack rejected: {errors}")
164
+
165
+
166
+ class AllowedScopesOmittedTests(unittest.TestCase):
167
+ """Row 4: omitting allowed-scopes is accepted.
168
+
169
+ The implied `allowed-scopes = [default-scope]` is a CLI consumption-time
170
+ behaviour (per § *Install-scope dimension*); the schema simply does not
171
+ require the field.
172
+ """
173
+
174
+ def test_only_default_scope_accepted(self) -> None:
175
+ from agentbundle.build.validate import validate
176
+
177
+ instance = _parse(
178
+ """
179
+ [pack]
180
+ name = "demo"
181
+ version = "0.1.0"
182
+
183
+ [pack.adapter-contract]
184
+ version = "0.2"
185
+
186
+ [pack.install]
187
+ default-scope = "repo"
188
+ """
189
+ )
190
+ errors = validate(instance, _load_schema())
191
+ self.assertEqual(
192
+ errors,
193
+ [],
194
+ f"pack with only default-scope was rejected: {errors}",
195
+ )
196
+
197
+
198
+ class V01LegacyTests(unittest.TestCase):
199
+ """Rows 5 + 6: v0.1 packs are accepted, with or without a stray install."""
200
+
201
+ def test_v01_no_install_accepted(self) -> None:
202
+ from agentbundle.build.validate import validate
203
+
204
+ instance = _parse(
205
+ """
206
+ [pack]
207
+ name = "demo"
208
+ version = "0.1.0"
209
+
210
+ [pack.adapter-contract]
211
+ version = "0.1"
212
+ """
213
+ )
214
+ errors = validate(instance, _load_schema())
215
+ self.assertEqual(errors, [], f"v0.1 pack rejected: {errors}")
216
+
217
+ def test_v01_omitting_adapter_contract_accepted(self) -> None:
218
+ """A pack omitting [pack.adapter-contract] is treated as v0.1 — accepted."""
219
+ from agentbundle.build.validate import validate
220
+
221
+ instance = _parse(
222
+ """
223
+ [pack]
224
+ name = "demo"
225
+ version = "0.1.0"
226
+ """
227
+ )
228
+ errors = validate(instance, _load_schema())
229
+ self.assertEqual(errors, [], f"adapter-contract-less pack rejected: {errors}")
230
+
231
+ def test_v01_with_stray_install_accepted(self) -> None:
232
+ """A v0.1 pack carrying a stray [pack.install] table is accepted.
233
+
234
+ The schema must not validate the install table when the pack declares
235
+ an older contract version; CLI consumption ignores the table per
236
+ § *Install-scope dimension*. As long as the rest of the pack is
237
+ well-formed, the pack is accepted.
238
+ """
239
+ from agentbundle.build.validate import validate
240
+
241
+ instance = _parse(
242
+ """
243
+ [pack]
244
+ name = "demo"
245
+ version = "0.1.0"
246
+
247
+ [pack.adapter-contract]
248
+ version = "0.1"
249
+
250
+ [pack.install]
251
+ default-scope = "repo"
252
+ allowed-scopes = ["repo"]
253
+ """
254
+ )
255
+ errors = validate(instance, _load_schema())
256
+ self.assertEqual(
257
+ errors,
258
+ [],
259
+ f"v0.1 pack with stray install was rejected: {errors}",
260
+ )
261
+
262
+
263
+ class AllowedScopesShapeTests(unittest.TestCase):
264
+ """Allowed-scopes accepts only the two-value alphabet."""
265
+
266
+ def test_allowed_scopes_unknown_value_rejected(self) -> None:
267
+ from agentbundle.build.validate import validate
268
+
269
+ instance = _parse(
270
+ """
271
+ [pack]
272
+ name = "demo"
273
+ version = "0.1.0"
274
+
275
+ [pack.adapter-contract]
276
+ version = "0.2"
277
+
278
+ [pack.install]
279
+ default-scope = "repo"
280
+ allowed-scopes = ["repo", "global"]
281
+ """
282
+ )
283
+ errors = validate(instance, _load_schema())
284
+ self.assertTrue(
285
+ errors,
286
+ "schema accepted unknown allowed-scopes value 'global'",
287
+ )
288
+
289
+ def test_allowed_scopes_empty_array_rejected(self) -> None:
290
+ from agentbundle.build.validate import validate
291
+
292
+ instance = {
293
+ "pack": {
294
+ "name": "demo",
295
+ "version": "0.1.0",
296
+ "adapter-contract": {"version": "0.2"},
297
+ "install": {"default-scope": "repo", "allowed-scopes": []},
298
+ }
299
+ }
300
+ errors = validate(instance, _load_schema())
301
+ self.assertTrue(errors, "schema accepted allowed-scopes=[]")
302
+
303
+
304
+ if __name__ == "__main__":
305
+ unittest.main()
@@ -0,0 +1,272 @@
1
+ """Tests for the build pipeline (T6) — recipe loading, dispatch,
2
+ pack-internal collision detection, aggregate marketplace, RFC-0002
3
+ recipe expansion shapes, and the empty-pack edge case.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from pathlib import Path
14
+
15
+ from agentbundle.build.contract import load as load_contract
16
+ from agentbundle.build.main import (
17
+ Pack,
18
+ discover_packs,
19
+ load_recipe,
20
+ load_recipe_from_path,
21
+ run_recipe,
22
+ validate_pack_uniqueness,
23
+ )
24
+
25
+ REPO_ROOT = Path(__file__).resolve().parents[5]
26
+ CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
27
+ FIXTURES = Path(__file__).resolve().parent / "fixtures"
28
+
29
+
30
+ def _seed_pack(root: Path, name: str = "demo") -> Path:
31
+ pack = root / name
32
+ (pack / ".apm" / "skills" / "foo").mkdir(parents=True)
33
+ (pack / ".apm" / "skills" / "foo" / "SKILL.md").write_text(
34
+ "---\ndescription: foo\n---\n# foo\n",
35
+ encoding="utf-8",
36
+ )
37
+ (pack / ".apm" / "agents").mkdir(parents=True)
38
+ (pack / ".apm" / "agents" / "bar.md").write_text("---\nname: bar\n---\n", encoding="utf-8")
39
+ (pack / ".apm" / "hooks").mkdir(parents=True)
40
+ (pack / ".apm" / "hooks" / "baz.sh").write_text("#!/bin/sh\n", encoding="utf-8")
41
+ (pack / ".apm" / "commands").mkdir(parents=True)
42
+ (pack / ".apm" / "commands" / "qux.md").write_text("# qux\n", encoding="utf-8")
43
+
44
+ (pack / "pack.toml").write_text(
45
+ f'[pack]\nname = "{name}"\nversion = "0.1.0"\ndescription = "demo pack"\n',
46
+ encoding="utf-8",
47
+ )
48
+ (pack / ".claude-plugin").mkdir(parents=True)
49
+ (pack / ".claude-plugin" / "plugin.json").write_text(
50
+ json.dumps(
51
+ {"name": name, "version": "0.1.0", "description": "demo plugin"}, indent=2
52
+ ),
53
+ encoding="utf-8",
54
+ )
55
+ return pack
56
+
57
+
58
+ class PerPackClaudePluginTests(unittest.TestCase):
59
+ @classmethod
60
+ def setUpClass(cls) -> None:
61
+ cls.contract = load_contract(CONTRACT_PATH)
62
+
63
+ def test_runs_against_single_pack_fixture(self) -> None:
64
+ with tempfile.TemporaryDirectory() as tmp:
65
+ tmp_path = Path(tmp)
66
+ packs_dir = tmp_path / "packs"
67
+ packs_dir.mkdir()
68
+ _seed_pack(packs_dir, "core")
69
+ output_dir = tmp_path / "dist"
70
+ recipe = load_recipe("per-pack-claude-plugin")
71
+ result = run_recipe(recipe, discover_packs(packs_dir), output_dir, self.contract)
72
+ self.assertIn("core", result["produced"])
73
+ self.assertTrue(
74
+ (output_dir / "claude-plugins" / "core" / ".claude-plugin" / "plugin.json").exists()
75
+ )
76
+ self.assertTrue(
77
+ (output_dir / "claude-plugins" / "core" / ".claude" / "skills" / "foo").exists()
78
+ )
79
+
80
+
81
+ class PerPackApmPackageTests(unittest.TestCase):
82
+ @classmethod
83
+ def setUpClass(cls) -> None:
84
+ cls.contract = load_contract(CONTRACT_PATH)
85
+
86
+ def test_produces_apm_yml_and_apm_tree(self) -> None:
87
+ with tempfile.TemporaryDirectory() as tmp:
88
+ tmp_path = Path(tmp)
89
+ packs_dir = tmp_path / "packs"
90
+ packs_dir.mkdir()
91
+ _seed_pack(packs_dir, "core")
92
+ output_dir = tmp_path / "dist"
93
+ recipe = load_recipe("per-pack-apm-package")
94
+ run_recipe(recipe, discover_packs(packs_dir), output_dir, self.contract)
95
+ apm_yml = output_dir / "apm" / "core" / "apm.yml"
96
+ self.assertTrue(apm_yml.exists())
97
+ self.assertIn('name: "core"', apm_yml.read_text(encoding="utf-8"))
98
+ self.assertTrue((output_dir / "apm" / "core" / ".apm" / "skills" / "foo").exists())
99
+
100
+
101
+ class MarketplaceAggregateTests(unittest.TestCase):
102
+ @classmethod
103
+ def setUpClass(cls) -> None:
104
+ cls.contract = load_contract(CONTRACT_PATH)
105
+
106
+ def test_aggregates_all_plugin_jsons(self) -> None:
107
+ with tempfile.TemporaryDirectory() as tmp:
108
+ tmp_path = Path(tmp)
109
+ packs_dir = tmp_path / "packs"
110
+ packs_dir.mkdir()
111
+ _seed_pack(packs_dir, "core")
112
+ _seed_pack(packs_dir, "extras")
113
+ output_dir = tmp_path / "dist"
114
+
115
+ run_recipe(
116
+ load_recipe("per-pack-claude-plugin"),
117
+ discover_packs(packs_dir),
118
+ output_dir,
119
+ self.contract,
120
+ )
121
+ result = run_recipe(
122
+ load_recipe("marketplace"),
123
+ discover_packs(packs_dir),
124
+ output_dir,
125
+ self.contract,
126
+ )
127
+
128
+ marketplace = output_dir / "claude-plugins" / "marketplace.json"
129
+ self.assertTrue(marketplace.exists())
130
+ payload = json.loads(marketplace.read_text(encoding="utf-8"))
131
+ self.assertEqual(len(payload["plugins"]), 2)
132
+ self.assertEqual(result["entries"], 2)
133
+
134
+
135
+ class PackInternalCollisionTests(unittest.TestCase):
136
+ def test_duplicate_skill_name_rejected(self) -> None:
137
+ with tempfile.TemporaryDirectory() as tmp:
138
+ tmp_path = Path(tmp)
139
+ pack_path = _seed_pack(tmp_path / "packs", "core")
140
+ (pack_path / ".apm" / "skills" / "foo").mkdir(exist_ok=True)
141
+ (pack_path / ".apm" / "skills" / "foo.md").write_text("dup\n", encoding="utf-8")
142
+ with self.assertRaises(ValueError) as caught:
143
+ validate_pack_uniqueness(Pack(name="core", path=pack_path))
144
+ self.assertIn("duplicate primitive", str(caught.exception))
145
+
146
+
147
+ class UnknownRecipeTests(unittest.TestCase):
148
+ def test_unknown_recipe_name_exits_non_zero(self) -> None:
149
+ with tempfile.TemporaryDirectory() as tmp:
150
+ result = subprocess.run(
151
+ [
152
+ sys.executable,
153
+ "-m",
154
+ "agentbundle.build",
155
+ "build",
156
+ "--recipe",
157
+ "bogus-recipe",
158
+ "--packs-dir",
159
+ tmp,
160
+ "--output-dir",
161
+ tmp,
162
+ ],
163
+ capture_output=True,
164
+ text=True,
165
+ cwd=REPO_ROOT,
166
+ )
167
+ self.assertNotEqual(result.returncode, 0)
168
+ self.assertIn("bogus-recipe", result.stderr)
169
+
170
+
171
+ class UnknownAdapterTargetTests(unittest.TestCase):
172
+ @classmethod
173
+ def setUpClass(cls) -> None:
174
+ cls.contract = load_contract(CONTRACT_PATH)
175
+
176
+ def test_unknown_target_in_recipe_raises(self) -> None:
177
+ recipe = load_recipe_from_path(FIXTURES / "recipes" / "bogus-target.toml")
178
+ with tempfile.TemporaryDirectory() as tmp:
179
+ tmp_path = Path(tmp)
180
+ packs_dir = tmp_path / "packs"
181
+ packs_dir.mkdir()
182
+ _seed_pack(packs_dir, "core")
183
+ with self.assertRaises(ValueError) as caught:
184
+ run_recipe(recipe, discover_packs(packs_dir), tmp_path / "dist", self.contract)
185
+ self.assertIn("bogus", str(caught.exception))
186
+
187
+
188
+ class Rfc0002RecipeLoadTests(unittest.TestCase):
189
+ """The three RFC-0002 recipe files load and expand to the shapes T7 needs."""
190
+
191
+ @classmethod
192
+ def setUpClass(cls) -> None:
193
+ cls.contract = load_contract(CONTRACT_PATH)
194
+
195
+ def test_per_pack_overlay_expansion_shape(self) -> None:
196
+ recipe = load_recipe("per-pack-overlay")
197
+ self.assertEqual(recipe.type, "overlay")
198
+ with tempfile.TemporaryDirectory() as tmp:
199
+ tmp_path = Path(tmp)
200
+ packs_dir = tmp_path / "packs"
201
+ packs_dir.mkdir()
202
+ _seed_pack(packs_dir, "core")
203
+ result = run_recipe(
204
+ recipe, discover_packs(packs_dir), tmp_path / "dist", self.contract
205
+ )
206
+ self.assertEqual(result["type"], "overlay")
207
+ self.assertIn("core", result["expansion"])
208
+ paths = result["expansion"]["core"]
209
+ self.assertTrue(any(p.endswith(".apm") for p in paths))
210
+ self.assertTrue(any(p.endswith("seeds") for p in paths))
211
+
212
+ def test_composite_agents_md_expansion(self) -> None:
213
+ recipe = load_recipe("composite-agents-md")
214
+ self.assertEqual(recipe.type, "composite")
215
+ with tempfile.TemporaryDirectory() as tmp:
216
+ tmp_path = Path(tmp)
217
+ packs_dir = tmp_path / "packs"
218
+ packs_dir.mkdir()
219
+ pack_one = _seed_pack(packs_dir, "core")
220
+ pack_two = _seed_pack(packs_dir, "extras")
221
+ (pack_one / "seeds").mkdir()
222
+ (pack_one / "seeds" / "AGENTS.fragment.md").write_text("core fragment\n", encoding="utf-8")
223
+ (pack_two / "seeds").mkdir()
224
+ (pack_two / "seeds" / "AGENTS.fragment.md").write_text("extras fragment\n", encoding="utf-8")
225
+ result = run_recipe(
226
+ recipe, discover_packs(packs_dir), tmp_path / "dist", self.contract
227
+ )
228
+ self.assertEqual(len(result["composed"]), 2)
229
+
230
+ def test_composite_marketplace_expansion(self) -> None:
231
+ recipe = load_recipe("composite-marketplace")
232
+ self.assertEqual(recipe.type, "composite")
233
+ with tempfile.TemporaryDirectory() as tmp:
234
+ tmp_path = Path(tmp)
235
+ packs_dir = tmp_path / "packs"
236
+ packs_dir.mkdir()
237
+ _seed_pack(packs_dir, "core")
238
+ _seed_pack(packs_dir, "extras")
239
+ _seed_pack(packs_dir, "monorepo-extras")
240
+ result = run_recipe(
241
+ recipe, discover_packs(packs_dir), tmp_path / "dist", self.contract
242
+ )
243
+ self.assertEqual(len(result["composed"]), 3)
244
+
245
+
246
+ class EmptyPackEdgeCaseTests(unittest.TestCase):
247
+ @classmethod
248
+ def setUpClass(cls) -> None:
249
+ cls.contract = load_contract(CONTRACT_PATH)
250
+
251
+ def test_pack_missing_commands_dir_runs_silently(self) -> None:
252
+ with tempfile.TemporaryDirectory() as tmp:
253
+ tmp_path = Path(tmp)
254
+ packs_dir = tmp_path / "packs"
255
+ packs_dir.mkdir()
256
+ pack = _seed_pack(packs_dir, "minimal")
257
+ (pack / ".apm" / "commands" / "qux.md").unlink()
258
+ (pack / ".apm" / "commands").rmdir()
259
+ output_dir = tmp_path / "dist"
260
+ run_recipe(
261
+ load_recipe("per-pack-claude-plugin"),
262
+ discover_packs(packs_dir),
263
+ output_dir,
264
+ self.contract,
265
+ )
266
+ self.assertFalse(
267
+ (output_dir / "claude-plugins" / "minimal" / ".claude" / "commands").exists()
268
+ )
269
+
270
+
271
+ if __name__ == "__main__":
272
+ unittest.main()