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.
- agentbundle/__init__.py +14 -0
- agentbundle/__main__.py +5 -0
- agentbundle/_data/adapter.schema.json +270 -0
- agentbundle/_data/adapter.toml +584 -0
- agentbundle/_data/install-marker.py +1099 -0
- agentbundle/_data/pack.schema.json +152 -0
- agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
- agentbundle/_data/plugin-manifest.schema.json +18 -0
- agentbundle/build/__init__.py +206 -0
- agentbundle/build/__main__.py +8 -0
- agentbundle/build/adapter_root_bins.py +336 -0
- agentbundle/build/adapters/__init__.py +46 -0
- agentbundle/build/adapters/claude_code.py +142 -0
- agentbundle/build/adapters/codex.py +227 -0
- agentbundle/build/adapters/copilot.py +149 -0
- agentbundle/build/adapters/kiro.py +608 -0
- agentbundle/build/adapters/kiro_cli.py +53 -0
- agentbundle/build/adapters/kiro_ide.py +275 -0
- agentbundle/build/contract.py +20 -0
- agentbundle/build/lint_packs.py +555 -0
- agentbundle/build/main.py +596 -0
- agentbundle/build/phase_order.py +40 -0
- agentbundle/build/projections/__init__.py +13 -0
- agentbundle/build/projections/codex_agent_toml.py +232 -0
- agentbundle/build/projections/copilot_agent_md.py +206 -0
- agentbundle/build/projections/copilot_hooks_json.py +142 -0
- agentbundle/build/projections/direct_directory.py +41 -0
- agentbundle/build/projections/hook_id.py +27 -0
- agentbundle/build/projections/kiro_ide_hook.py +256 -0
- agentbundle/build/projections/merge_into_agent_json.py +264 -0
- agentbundle/build/projections/merge_json.py +58 -0
- agentbundle/build/projections/user_merge_json.py +324 -0
- agentbundle/build/scope_rails.py +728 -0
- agentbundle/build/self_host.py +1486 -0
- agentbundle/build/shared_libs.py +309 -0
- agentbundle/build/target_resolver.py +85 -0
- agentbundle/build/tests/__init__.py +0 -0
- agentbundle/build/tests/test_adapter_claude_code.py +275 -0
- agentbundle/build/tests/test_adapter_codex.py +699 -0
- agentbundle/build/tests/test_adapter_copilot.py +91 -0
- agentbundle/build/tests/test_adapter_kiro.py +449 -0
- agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
- agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
- agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
- agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
- agentbundle/build/tests/test_build_ships_seeds.py +78 -0
- agentbundle/build/tests/test_contract.py +582 -0
- agentbundle/build/tests/test_contract_scope.py +224 -0
- agentbundle/build/tests/test_contract_v07.py +191 -0
- agentbundle/build/tests/test_contract_v08.py +230 -0
- agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
- agentbundle/build/tests/test_end_to_end_build.py +227 -0
- agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
- agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
- agentbundle/build/tests/test_lint_packs.py +703 -0
- agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
- agentbundle/build/tests/test_pack_schema.py +265 -0
- agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
- agentbundle/build/tests/test_pack_schema_install.py +305 -0
- agentbundle/build/tests/test_pipeline.py +272 -0
- agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
- agentbundle/build/tests/test_projections_merge_json.py +148 -0
- agentbundle/build/tests/test_scope_rails.py +398 -0
- agentbundle/build/tests/test_security.py +97 -0
- agentbundle/build/tests/test_self_host_check.py +2100 -0
- agentbundle/build/tests/test_shared_libs_projection.py +415 -0
- agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
- agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
- agentbundle/build/tests/test_validate.py +250 -0
- agentbundle/build/validate.py +141 -0
- agentbundle/catalogue.py +164 -0
- agentbundle/cli.py +486 -0
- agentbundle/commands/__init__.py +5 -0
- agentbundle/commands/_common.py +174 -0
- agentbundle/commands/_drop_warning.py +329 -0
- agentbundle/commands/adapt.py +343 -0
- agentbundle/commands/config.py +125 -0
- agentbundle/commands/diff.py +211 -0
- agentbundle/commands/init_state.py +279 -0
- agentbundle/commands/install.py +3026 -0
- agentbundle/commands/list_packs.py +170 -0
- agentbundle/commands/list_targets.py +23 -0
- agentbundle/commands/reconcile.py +161 -0
- agentbundle/commands/render.py +165 -0
- agentbundle/commands/scaffold.py +69 -0
- agentbundle/commands/uninstall.py +294 -0
- agentbundle/commands/upgrade.py +699 -0
- agentbundle/commands/validate.py +688 -0
- agentbundle/config.py +747 -0
- agentbundle/render.py +123 -0
- agentbundle/safety.py +633 -0
- agentbundle/scope.py +319 -0
- agentbundle/user_config.py +284 -0
- agentbundle/version.py +49 -0
- agentbundle-0.2.0.dist-info/METADATA +37 -0
- agentbundle-0.2.0.dist-info/RECORD +99 -0
- agentbundle-0.2.0.dist-info/WHEEL +5 -0
- agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
- 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()
|