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,176 @@
1
+ """T1 (incompatible-hook-event-drop): construction tests for
2
+ ``_load_pack_hook_wiring_safely``.
3
+
4
+ Five tests covering the three refusal paths (hook-wiring symlink, agent
5
+ symlink, TOML parse failure) and the two happy-path shapes (clean pack,
6
+ missing hook-wiring directory).
7
+
8
+ These tests are written before the implementation exists (TDD red phase).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tempfile
15
+ import textwrap
16
+ import unittest
17
+ from pathlib import Path
18
+
19
+
20
+ def _make_pack(
21
+ root: Path,
22
+ pack_name: str,
23
+ *,
24
+ wiring: dict[str, str] | None = None,
25
+ agents: list[str] | None = None,
26
+ wiring_symlinks: list[str] | None = None,
27
+ agent_symlinks: list[str] | None = None,
28
+ ) -> Path:
29
+ """Build a minimal pack fixture on disk.
30
+
31
+ - ``wiring``: stem → TOML body strings written under `.apm/hook-wiring/`.
32
+ - ``agents``: stems written as `.md` files under `.apm/agents/`.
33
+ - ``wiring_symlinks``: stems that become symlinks under `.apm/hook-wiring/`.
34
+ - ``agent_symlinks``: stems that become symlinks under `.apm/agents/`.
35
+ """
36
+ pack = root / pack_name
37
+ (pack / ".apm" / "hook-wiring").mkdir(parents=True, exist_ok=True)
38
+ (pack / ".apm" / "agents").mkdir(parents=True, exist_ok=True)
39
+
40
+ for stem, body in (wiring or {}).items():
41
+ (pack / ".apm" / "hook-wiring" / f"{stem}.toml").write_text(
42
+ body, encoding="utf-8"
43
+ )
44
+
45
+ for stem in agents or []:
46
+ (pack / ".apm" / "agents" / f"{stem}.md").write_text(
47
+ f"# {stem}\n", encoding="utf-8"
48
+ )
49
+
50
+ # Create dangling symlinks to simulate the security-rail case.
51
+ for stem in wiring_symlinks or []:
52
+ target_path = pack / ".apm" / "hook-wiring" / f"{stem}.toml"
53
+ # Point at a non-existent target so it's definitely a symlink.
54
+ os.symlink("/nonexistent/target.toml", target_path)
55
+
56
+ for stem in agent_symlinks or []:
57
+ target_path = pack / ".apm" / "agents" / f"{stem}.md"
58
+ os.symlink("/nonexistent/target.md", target_path)
59
+
60
+ return pack
61
+
62
+
63
+ class TestLoadPackHookWiringSafely(unittest.TestCase):
64
+ """Construction tests for ``_load_pack_hook_wiring_safely``."""
65
+
66
+ def _import_helper(self):
67
+ from agentbundle.build.scope_rails import _load_pack_hook_wiring_safely
68
+ return _load_pack_hook_wiring_safely
69
+
70
+ def test_returns_loaded_tomls_when_clean(self) -> None:
71
+ """Happy path: valid hook-wiring + agents returns the (wiring_tomls, agent_basenames) tuple."""
72
+ helper = self._import_helper()
73
+ with tempfile.TemporaryDirectory() as raw:
74
+ tmp = Path(raw)
75
+ pack = _make_pack(
76
+ tmp,
77
+ "clean-pack",
78
+ wiring={
79
+ "session-start": textwrap.dedent("""
80
+ attach-to-agent = "work-loop"
81
+
82
+ [[hooks.SessionStart]]
83
+ command = "python3 tools/hooks/session-start.py"
84
+ """).strip()
85
+ },
86
+ agents=["work-loop"],
87
+ )
88
+ result = helper(pack, "clean-pack")
89
+ # Should return a tuple, not a string.
90
+ self.assertIsInstance(result, tuple)
91
+ wiring_tomls, agent_basenames = result
92
+ self.assertIsInstance(wiring_tomls, dict)
93
+ self.assertIsInstance(agent_basenames, set)
94
+ # The parsed TOML dict should contain the "session-start" key.
95
+ self.assertIn("session-start", wiring_tomls)
96
+ # The agents set should contain the work-loop agent.
97
+ self.assertIn("work-loop", agent_basenames)
98
+
99
+ def test_returns_refusal_string_on_hook_wiring_symlink(self) -> None:
100
+ """Security rail: a symlink under .apm/hook-wiring/ returns a refusal string."""
101
+ helper = self._import_helper()
102
+ with tempfile.TemporaryDirectory() as raw:
103
+ tmp = Path(raw)
104
+ pack = _make_pack(
105
+ tmp,
106
+ "symlink-wiring-pack",
107
+ wiring_symlinks=["malicious"],
108
+ agents=["some-agent"],
109
+ )
110
+ result = helper(pack, "symlink-wiring-pack")
111
+ self.assertIsInstance(result, str)
112
+ self.assertIn("symlink-wiring-pack", result)
113
+ self.assertIn("hook-wiring entry is a symlink", result)
114
+
115
+ def test_returns_refusal_string_on_toml_parse_failure(self) -> None:
116
+ """Correctness rail: a malformed TOML returns a refusal string."""
117
+ helper = self._import_helper()
118
+ with tempfile.TemporaryDirectory() as raw:
119
+ tmp = Path(raw)
120
+ pack = _make_pack(
121
+ tmp,
122
+ "bad-toml-pack",
123
+ wiring={"broken": "this is not = valid = toml ["},
124
+ agents=["some-agent"],
125
+ )
126
+ result = helper(pack, "bad-toml-pack")
127
+ self.assertIsInstance(result, str)
128
+ self.assertIn("failed to parse", result)
129
+
130
+ def test_returns_refusal_string_on_agent_symlink(self) -> None:
131
+ """Security rail: a symlink under .apm/agents/ returns a refusal string."""
132
+ helper = self._import_helper()
133
+ with tempfile.TemporaryDirectory() as raw:
134
+ tmp = Path(raw)
135
+ pack = _make_pack(
136
+ tmp,
137
+ "symlink-agent-pack",
138
+ wiring={
139
+ "on-prompt": textwrap.dedent("""
140
+ attach-to-agent = "work-loop"
141
+
142
+ [[hooks.userPromptSubmit]]
143
+ command = "x"
144
+ """).strip()
145
+ },
146
+ agent_symlinks=["work-loop"],
147
+ )
148
+ result = helper(pack, "symlink-agent-pack")
149
+ self.assertIsInstance(result, str)
150
+ self.assertIn("symlink-agent-pack", result)
151
+ self.assertIn("agent entry is a symlink", result)
152
+
153
+ def test_empty_hook_wiring_dir_returns_empty_tuple(self) -> None:
154
+ """Happy path: .apm/ exists but no hook-wiring/ subdir returns ({}, set()).
155
+
156
+ The plan specifies returning ({}, set()) for the uniform-type-signature
157
+ case — the helper short-circuits before agent discovery when there is
158
+ nothing to load from hook-wiring.
159
+ """
160
+ helper = self._import_helper()
161
+ with tempfile.TemporaryDirectory() as raw:
162
+ tmp = Path(raw)
163
+ pack = tmp / "no-wiring-pack"
164
+ # Only create .apm/ with agents — no hook-wiring/ subdir.
165
+ (pack / ".apm" / "agents").mkdir(parents=True, exist_ok=True)
166
+ (pack / ".apm" / "agents" / "work-loop.md").write_text("# work-loop\n", encoding="utf-8")
167
+ result = helper(pack, "no-wiring-pack")
168
+ self.assertIsInstance(result, tuple)
169
+ wiring_tomls, agent_basenames = result
170
+ self.assertEqual(wiring_tomls, {})
171
+ # Per the plan: ({}, set()) — short-circuits before agent discovery.
172
+ self.assertEqual(agent_basenames, set())
173
+
174
+
175
+ if __name__ == "__main__":
176
+ unittest.main()
@@ -0,0 +1,265 @@
1
+ """Tests for pack.schema.json (T1c).
2
+
3
+ Verifies:
4
+ - pack.schema.json accepts a governance-extras recommended-on-core
5
+ example (AC 3).
6
+ - pack.schema.json rejects a pack.toml missing [pack].
7
+ - pack.schema.json rejects a pack.toml whose [pack.adaptation]
8
+ infer-from value is a non-string.
9
+ - pack.schema.json accepts a pack.toml without [pack.dependencies.required]
10
+ (required array is optional).
11
+ - pack.schema.json accepts [pack.seeds] entries that are relative-path
12
+ strings, and rejects an absolute path or a non-string entry.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import tomllib
19
+ import unittest
20
+ from pathlib import Path
21
+
22
+ REPO_ROOT = Path(__file__).resolve().parents[5]
23
+ PACK_SCHEMA_PATH = (
24
+ REPO_ROOT / "docs" / "contracts" / "pack.schema.json"
25
+ )
26
+
27
+
28
+ def _load_schema() -> dict:
29
+ return json.loads(PACK_SCHEMA_PATH.read_text(encoding="utf-8"))
30
+
31
+
32
+ def _parse_toml(toml_text: str) -> dict:
33
+ return tomllib.loads(toml_text)
34
+
35
+
36
+ class PackSchemaAcceptsValidExamplesTests(unittest.TestCase):
37
+ """pack.schema.json accepts well-formed pack.toml structures."""
38
+
39
+ def test_accepts_governance_extras_recommended_on_core(self) -> None:
40
+ """Modeled on RFC-0001's governance-extras recommended-on-core example.
41
+
42
+ Verifies AC 3: the schema accepts [pack], [pack.dependencies] with a
43
+ recommended array of {catalogue, pack, version} objects.
44
+ """
45
+ from agentbundle.build.validate import validate
46
+
47
+ schema = _load_schema()
48
+ toml_text = """
49
+ [pack]
50
+ name = "governance-extras"
51
+ version = "0.1.0"
52
+ description = "RFC/ADR ceremony skills (new-rfc, new-adr, update-conventions)."
53
+
54
+ [pack.dependencies]
55
+ recommended = [
56
+ { catalogue = "agent-ready-repo", pack = "core", version = ">=0.1.0" },
57
+ ]
58
+ """
59
+ instance = _parse_toml(toml_text)
60
+ errors = validate(instance, schema)
61
+ self.assertEqual(
62
+ errors,
63
+ [],
64
+ f"schema rejected valid governance-extras example:\n" + "\n".join(errors),
65
+ )
66
+
67
+ def test_accepts_pack_without_dependencies_required(self) -> None:
68
+ """A pack.toml without [pack.dependencies.required] is valid.
69
+
70
+ The required array is optional — missing-optional != malformed.
71
+ """
72
+ from agentbundle.build.validate import validate
73
+
74
+ schema = _load_schema()
75
+ toml_text = """
76
+ [pack]
77
+ name = "governance-extras"
78
+ version = "0.1.0"
79
+ description = "RFC/ADR ceremony skills."
80
+
81
+ [pack.dependencies]
82
+ recommended = [
83
+ { catalogue = "agent-ready-repo", pack = "core", version = ">=0.1.0" },
84
+ ]
85
+ """
86
+ instance = _parse_toml(toml_text)
87
+ errors = validate(instance, schema)
88
+ self.assertEqual(
89
+ errors,
90
+ [],
91
+ f"schema rejected pack without dependencies.required:\n"
92
+ + "\n".join(errors),
93
+ )
94
+
95
+ def test_accepts_pack_without_any_dependencies(self) -> None:
96
+ """A pack.toml with no [pack.dependencies] section at all is valid."""
97
+ from agentbundle.build.validate import validate
98
+
99
+ schema = _load_schema()
100
+ toml_text = """
101
+ [pack]
102
+ name = "core"
103
+ version = "0.1.0"
104
+ description = "Core agent skills."
105
+ """
106
+ instance = _parse_toml(toml_text)
107
+ errors = validate(instance, schema)
108
+ self.assertEqual(
109
+ errors,
110
+ [],
111
+ f"schema rejected pack without any dependencies:\n" + "\n".join(errors),
112
+ )
113
+
114
+ def test_accepts_seeds_with_relative_paths(self) -> None:
115
+ """[pack.seeds] entries that are relative-path strings are accepted.
116
+
117
+ Verifies the [pack.seeds] shape clause of AC 3.
118
+ """
119
+ from agentbundle.build.validate import validate
120
+
121
+ schema = _load_schema()
122
+ toml_text = """
123
+ [pack]
124
+ name = "core"
125
+ version = "0.1.0"
126
+ description = "Core agent skills."
127
+ seeds = ["AGENTS.md", "docs/CHARTER.md", "docs/CONVENTIONS.md"]
128
+ """
129
+ instance = _parse_toml(toml_text)
130
+ errors = validate(instance, schema)
131
+ self.assertEqual(
132
+ errors,
133
+ [],
134
+ f"schema rejected valid relative-path seeds:\n" + "\n".join(errors),
135
+ )
136
+
137
+
138
+ class PackSchemaRejectsInvalidExamplesTests(unittest.TestCase):
139
+ """pack.schema.json rejects malformed pack.toml structures."""
140
+
141
+ def test_rejects_missing_pack_section(self) -> None:
142
+ """A pack.toml without a [pack] section is rejected."""
143
+ from agentbundle.build.validate import validate
144
+
145
+ schema = _load_schema()
146
+ # Instance missing the required "pack" key entirely.
147
+ instance = {"metadata": {"name": "orphan"}}
148
+ errors = validate(instance, schema)
149
+ self.assertTrue(
150
+ errors,
151
+ "schema accepted a document missing the required [pack] section",
152
+ )
153
+ self.assertTrue(
154
+ any("pack" in e for e in errors),
155
+ f"error message should mention 'pack'; got: {errors}",
156
+ )
157
+
158
+ def test_rejects_adaptation_infer_from_non_string(self) -> None:
159
+ """[pack.adaptation] infer-from must be a string; non-string is rejected.
160
+
161
+ Shape-only check; the semantic set of legal values lives in the
162
+ adapt-to-project skill (out of scope here).
163
+ """
164
+ from agentbundle.build.validate import validate
165
+
166
+ schema = _load_schema()
167
+ toml_text = """
168
+ [pack]
169
+ name = "core"
170
+ version = "0.1.0"
171
+
172
+ [pack.adaptation]
173
+ infer-from = 42
174
+ """
175
+ instance = _parse_toml(toml_text)
176
+ errors = validate(instance, schema)
177
+ self.assertTrue(
178
+ errors,
179
+ "schema accepted [pack.adaptation] infer-from as a non-string (integer)",
180
+ )
181
+
182
+ def test_rejects_adaptation_substitution_infer_from_non_string(self) -> None:
183
+ """infer-from inside a substitutions entry must be a string."""
184
+ from agentbundle.build.validate import validate
185
+
186
+ schema = _load_schema()
187
+ # Build the instance directly because TOML inline-table syntax
188
+ # would require tomllib to parse the mixed-type array, which is
189
+ # not the goal here — this verifies the schema constraint itself.
190
+ instance = {
191
+ "pack": {
192
+ "name": "core",
193
+ "version": "0.1.0",
194
+ "adaptation": {
195
+ "substitutions": [
196
+ {"marker": "<adapt:project-name>", "infer-from": 99}
197
+ ]
198
+ },
199
+ }
200
+ }
201
+ errors = validate(instance, schema)
202
+ self.assertTrue(
203
+ errors,
204
+ "schema accepted substitutions[].infer-from as non-string (integer)",
205
+ )
206
+
207
+ def test_rejects_seeds_with_absolute_path(self) -> None:
208
+ """A seeds entry starting with '/' is an absolute path and must be rejected.
209
+
210
+ Verifies the [pack.seeds] shape clause of AC 3.
211
+ """
212
+ from agentbundle.build.validate import validate
213
+
214
+ schema = _load_schema()
215
+ toml_text = """
216
+ [pack]
217
+ name = "core"
218
+ version = "0.1.0"
219
+ seeds = ["/etc/foo"]
220
+ """
221
+ instance = _parse_toml(toml_text)
222
+ errors = validate(instance, schema)
223
+ self.assertTrue(
224
+ errors,
225
+ "schema accepted an absolute path in seeds (must start with non-'/')",
226
+ )
227
+
228
+ def test_rejects_seeds_with_non_string_entry(self) -> None:
229
+ """A seeds entry that is not a string (e.g. an inline table) is rejected.
230
+
231
+ Verifies the [pack.seeds] shape clause of AC 3.
232
+ """
233
+ from agentbundle.build.validate import validate
234
+
235
+ schema = _load_schema()
236
+ # Cannot express an inline-table in a seeds array via TOML easily
237
+ # for this validator, so construct the instance dict directly.
238
+ instance = {
239
+ "pack": {
240
+ "name": "core",
241
+ "version": "0.1.0",
242
+ "seeds": [{"path": "AGENTS.md"}], # dict, not string
243
+ }
244
+ }
245
+ errors = validate(instance, schema)
246
+ self.assertTrue(
247
+ errors,
248
+ "schema accepted a non-string (dict) entry in seeds",
249
+ )
250
+
251
+
252
+ class PackSchemaLoadsTests(unittest.TestCase):
253
+ """Smoke test: the schema file loads and has the expected top-level shape."""
254
+
255
+ def test_schema_loads(self) -> None:
256
+ schema = _load_schema()
257
+ self.assertEqual(schema.get("type"), "object")
258
+
259
+ def test_schema_requires_pack(self) -> None:
260
+ schema = _load_schema()
261
+ self.assertIn("pack", schema.get("required", []))
262
+
263
+
264
+ if __name__ == "__main__":
265
+ unittest.main()
@@ -0,0 +1,258 @@
1
+ """T3 tests for `[pack.install] allowed-adapters` validation
2
+ (RFC-0011 / pack-allowed-adapters AC3, AC7, AC22).
3
+
4
+ Two surfaces under test:
5
+
6
+ - the JSONSchema admits the optional `allowed-adapters` field (shape
7
+ check; the schema does NOT hardcode the adapter enum);
8
+ - the Python cross-field check `_validate_allowed_adapters` enforces
9
+ contract-shipped + user-scope-capable membership.
10
+
11
+ Plus the `_kiro_target_adapters` literal-gate fix: v0.6 packs no longer
12
+ silently skip the rail.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import tomllib
19
+ import unittest
20
+ from pathlib import Path
21
+
22
+ from agentbundle.commands.validate import (
23
+ _kiro_target_adapters,
24
+ _validate_allowed_adapters,
25
+ )
26
+
27
+ REPO_ROOT = Path(__file__).resolve().parents[5]
28
+ SCHEMA_PATH = (
29
+ REPO_ROOT
30
+ / "packages"
31
+ / "agentbundle"
32
+ / "agentbundle"
33
+ / "_data"
34
+ / "pack.schema.json"
35
+ )
36
+
37
+
38
+ def _load_schema() -> dict:
39
+ return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
40
+
41
+
42
+ def _v06_pack(install: dict | None = None, pack_extras: dict | None = None) -> dict:
43
+ """Build a minimal valid v0.6 pack dict for schema validation."""
44
+ pack = {
45
+ "name": "demo",
46
+ "version": "0.1.0",
47
+ "adapter-contract": {"version": "0.6"},
48
+ }
49
+ if install is not None:
50
+ pack["install"] = install
51
+ if pack_extras:
52
+ pack.update(pack_extras)
53
+ return {"pack": pack}
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Schema-shape tests (JSONSchema admits the field)
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ class TestSchemaShapeAllowedAdapters(unittest.TestCase):
62
+ def test_allowed_adapters_is_optional(self) -> None:
63
+ from agentbundle.build.validate import validate
64
+
65
+ # v0.6 pack omitting allowed-adapters: schema accepts.
66
+ pack = _v06_pack(install={"default-scope": "repo"})
67
+ errors = validate(pack, _load_schema())
68
+ self.assertEqual(errors, [])
69
+
70
+ def test_allowed_adapters_array_of_strings(self) -> None:
71
+ from agentbundle.build.validate import validate
72
+
73
+ pack = _v06_pack(
74
+ install={
75
+ "default-scope": "user",
76
+ "allowed-scopes": ["user"],
77
+ "allowed-adapters": ["claude-code", "kiro", "codex"],
78
+ }
79
+ )
80
+ errors = validate(pack, _load_schema())
81
+ self.assertEqual(errors, [])
82
+
83
+ def test_allowed_adapters_empty_array_refused_by_schema(self) -> None:
84
+ from agentbundle.build.validate import validate
85
+
86
+ pack = _v06_pack(
87
+ install={
88
+ "default-scope": "repo",
89
+ "allowed-adapters": [],
90
+ }
91
+ )
92
+ errors = validate(pack, _load_schema())
93
+ self.assertTrue(errors)
94
+
95
+ def test_allowed_adapters_duplicates_refused_by_cross_field(self) -> None:
96
+ # The bundled validator doesn't enforce JSONSchema uniqueItems;
97
+ # the cross-field check catches duplicates with a refuse message.
98
+ pack = _v06_pack(
99
+ install={
100
+ "default-scope": "repo",
101
+ "allowed-adapters": ["claude-code", "claude-code"],
102
+ }
103
+ )
104
+ msg = _validate_allowed_adapters(pack)
105
+ self.assertIsNotNone(msg)
106
+ self.assertIn("duplicate", msg)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Cross-field check (`_validate_allowed_adapters`)
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ class TestValidateAllowedAdaptersCrossField(unittest.TestCase):
115
+ def test_returns_none_when_field_absent(self) -> None:
116
+ pack = _v06_pack(install={"default-scope": "repo"})
117
+ self.assertIsNone(_validate_allowed_adapters(pack))
118
+
119
+ def test_repo_only_pack_admits_copilot(self) -> None:
120
+ """Copilot is shipped but lacks user-scope; a repo-only pack
121
+ legitimately declares it."""
122
+ pack = _v06_pack(
123
+ install={
124
+ "default-scope": "repo",
125
+ "allowed-adapters": ["copilot"],
126
+ }
127
+ )
128
+ self.assertIsNone(_validate_allowed_adapters(pack))
129
+
130
+ def test_user_scope_pack_accepts_copilot(self) -> None:
131
+ # RFC-0024 / docs/specs/copilot-full-parity: copilot is now a
132
+ # user-scope-capable adapter (`[adapter.copilot.scope].user`), so a
133
+ # user-scope pack declaring it is **accepted** — the inverse of the
134
+ # repo-only refusal RFC-0012 recorded. (`research`, a user-scope-default
135
+ # pack, ships exactly this.)
136
+ pack = _v06_pack(
137
+ install={
138
+ "default-scope": "user",
139
+ "allowed-scopes": ["user"],
140
+ "allowed-adapters": ["copilot"],
141
+ }
142
+ )
143
+ msg = _validate_allowed_adapters(pack)
144
+ self.assertIsNone(msg, f"copilot should be accepted at user scope: {msg}")
145
+
146
+ def test_unknown_adapter_refused(self) -> None:
147
+ pack = _v06_pack(
148
+ install={
149
+ "default-scope": "repo",
150
+ "allowed-adapters": ["windsurf"],
151
+ }
152
+ )
153
+ msg = _validate_allowed_adapters(pack)
154
+ self.assertIsNotNone(msg)
155
+ self.assertIn("'windsurf'", msg)
156
+ self.assertIn("not a shipped adapter", msg)
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # `_kiro_target_adapters` literal-gate fix (semantic predicate)
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ class TestKiroTargetAdaptersV06Gate(unittest.TestCase):
165
+ def _make_pack_tree(self, tmp_path: Path, *, with_agents: bool, with_wiring: bool):
166
+ apm = tmp_path / ".apm"
167
+ apm.mkdir(parents=True)
168
+ if with_agents:
169
+ (apm / "agents").mkdir()
170
+ (apm / "agents" / "a.md").write_text("dummy", encoding="utf-8")
171
+ if with_wiring:
172
+ (apm / "hook-wiring").mkdir()
173
+ (apm / "hook-wiring" / "w.toml").write_text("event = 'PreToolUse'\n", encoding="utf-8")
174
+ return tmp_path
175
+
176
+ def test_v06_pack_with_on_disk_shape_no_allowed_adapters_fires_rail(self) -> None:
177
+ """The case the round-1 literal `version != \"0.3\"` gate broke:
178
+ a v0.6 pack shipping agents + wiring without allowed-adapters
179
+ should still fire the rail through the on-disk inference path."""
180
+ import tempfile
181
+
182
+ with tempfile.TemporaryDirectory() as td:
183
+ pack_path = self._make_pack_tree(
184
+ Path(td), with_agents=True, with_wiring=True
185
+ )
186
+ pack_data = _v06_pack(install={"default-scope": "repo"})
187
+ result = _kiro_target_adapters(pack_data, pack_path)
188
+ self.assertEqual(result, {"kiro"})
189
+
190
+ def test_v06_pack_excluding_kiro_returns_empty(self) -> None:
191
+ import tempfile
192
+
193
+ with tempfile.TemporaryDirectory() as td:
194
+ pack_path = self._make_pack_tree(
195
+ Path(td), with_agents=True, with_wiring=True
196
+ )
197
+ pack_data = _v06_pack(
198
+ install={
199
+ "default-scope": "user",
200
+ "allowed-scopes": ["user"],
201
+ "allowed-adapters": ["claude-code"],
202
+ }
203
+ )
204
+ result = _kiro_target_adapters(pack_data, pack_path)
205
+ self.assertEqual(result, set())
206
+
207
+ def test_v06_pack_including_kiro_returns_kiro(self) -> None:
208
+ import tempfile
209
+
210
+ with tempfile.TemporaryDirectory() as td:
211
+ pack_path = self._make_pack_tree(
212
+ Path(td), with_agents=False, with_wiring=False # not even on-disk
213
+ )
214
+ pack_data = _v06_pack(
215
+ install={
216
+ "default-scope": "user",
217
+ "allowed-scopes": ["user"],
218
+ "allowed-adapters": ["kiro"],
219
+ }
220
+ )
221
+ result = _kiro_target_adapters(pack_data, pack_path)
222
+ self.assertEqual(result, {"kiro"})
223
+
224
+ def test_v03_pack_unchanged(self) -> None:
225
+ import tempfile
226
+
227
+ with tempfile.TemporaryDirectory() as td:
228
+ pack_path = self._make_pack_tree(
229
+ Path(td), with_agents=True, with_wiring=True
230
+ )
231
+ pack_data = {
232
+ "pack": {
233
+ "name": "demo",
234
+ "adapter-contract": {"version": "0.3"},
235
+ }
236
+ }
237
+ result = _kiro_target_adapters(pack_data, pack_path)
238
+ self.assertEqual(result, {"kiro"})
239
+
240
+ def test_v02_pack_skips_rail(self) -> None:
241
+ import tempfile
242
+
243
+ with tempfile.TemporaryDirectory() as td:
244
+ pack_path = self._make_pack_tree(
245
+ Path(td), with_agents=True, with_wiring=True
246
+ )
247
+ pack_data = {
248
+ "pack": {
249
+ "name": "demo",
250
+ "adapter-contract": {"version": "0.2"},
251
+ }
252
+ }
253
+ result = _kiro_target_adapters(pack_data, pack_path)
254
+ self.assertEqual(result, set())
255
+
256
+
257
+ if __name__ == "__main__":
258
+ unittest.main()