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,91 @@
1
+ """Tests for the Copilot adapter (T4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from agentbundle.build.adapters.copilot import project
10
+ from agentbundle.build.contract import load as load_contract
11
+
12
+ REPO_ROOT = Path(__file__).resolve().parents[5]
13
+ CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
14
+
15
+
16
+ def _seed_pack(root: Path) -> Path:
17
+ pack = root / "pack"
18
+ (pack / ".apm" / "skills" / "foo").mkdir(parents=True)
19
+ (pack / ".apm" / "skills" / "foo" / "SKILL.md").write_text(
20
+ "---\ndescription: foo skill\n---\nfoo body\n",
21
+ encoding="utf-8",
22
+ )
23
+
24
+ (pack / ".apm" / "agents").mkdir(parents=True)
25
+ (pack / ".apm" / "agents" / "bar.md").write_text("agent body\n", encoding="utf-8")
26
+
27
+ (pack / ".apm" / "hooks").mkdir(parents=True)
28
+ (pack / ".apm" / "hooks" / "baz.sh").write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
29
+ (pack / ".apm" / "hooks" / "baz.py").write_text("print('hi')\n", encoding="utf-8")
30
+
31
+ (pack / ".apm" / "hook-wiring").mkdir(parents=True)
32
+ (pack / ".apm" / "hook-wiring" / "baz.toml").write_text("[hooks]\n", encoding="utf-8")
33
+
34
+ (pack / ".apm" / "commands").mkdir(parents=True)
35
+ (pack / ".apm" / "commands" / "qux.md").write_text("# qux\n", encoding="utf-8")
36
+ return pack
37
+
38
+
39
+ class CopilotAdapterTests(unittest.TestCase):
40
+ @classmethod
41
+ def setUpClass(cls) -> None:
42
+ cls.contract = load_contract(CONTRACT_PATH)
43
+
44
+ def test_skill_projects_with_applyTo_default(self) -> None:
45
+ with tempfile.TemporaryDirectory() as tmp:
46
+ tmp_path = Path(tmp)
47
+ pack = _seed_pack(tmp_path)
48
+ out = tmp_path / "out"
49
+ project(pack, self.contract, out)
50
+ output_path = out / ".github" / "instructions" / "foo.instructions.md"
51
+ self.assertTrue(output_path.exists())
52
+ text = output_path.read_text(encoding="utf-8")
53
+ self.assertIn("applyTo", text)
54
+ self.assertIn("**", text)
55
+
56
+ def test_agent_projects_agent_md(self) -> None:
57
+ # v0.10 (copilot-full-parity): agent flips dropped → copilot-agent-md.
58
+ with tempfile.TemporaryDirectory() as tmp:
59
+ tmp_path = Path(tmp)
60
+ pack = _seed_pack(tmp_path)
61
+ out = tmp_path / "out"
62
+ project(pack, self.contract, out)
63
+ self.assertTrue((out / ".github" / "agents" / "bar.agent.md").exists())
64
+
65
+ def test_hook_body_lands_in_github_hooks(self) -> None:
66
+ # v0.10: hook-body retargets tools/hooks/ → .github/hooks/ (repo-relpaths).
67
+ with tempfile.TemporaryDirectory() as tmp:
68
+ tmp_path = Path(tmp)
69
+ pack = _seed_pack(tmp_path)
70
+ out = tmp_path / "out"
71
+ project(pack, self.contract, out)
72
+ self.assertTrue((out / ".github" / "hooks" / "baz.sh").exists())
73
+ self.assertTrue((out / ".github" / "hooks" / "baz.py").exists())
74
+ # No legacy tools/hooks/ output remains for copilot.
75
+ self.assertFalse((out / "tools" / "hooks").exists())
76
+
77
+ def test_hook_wiring_projects_per_file_json_command_dropped(self) -> None:
78
+ # v0.10: hook-wiring flips dropped → copilot-hooks-json (one <name>.json
79
+ # per source file); command stays dropped.
80
+ with tempfile.TemporaryDirectory() as tmp:
81
+ tmp_path = Path(tmp)
82
+ pack = _seed_pack(tmp_path)
83
+ out = tmp_path / "out"
84
+ project(pack, self.contract, out)
85
+ self.assertTrue((out / ".github" / "hooks" / "baz.json").exists())
86
+ self.assertFalse(any(out.rglob("settings.local.json")))
87
+ self.assertFalse(any(out.rglob("qux.md")))
88
+
89
+
90
+ if __name__ == "__main__":
91
+ unittest.main()
@@ -0,0 +1,449 @@
1
+ """Tests for the Kiro adapter (T3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import tempfile
8
+ import unittest
9
+ from contextlib import redirect_stderr
10
+ from pathlib import Path
11
+
12
+ from agentbundle.build.adapters.kiro import project, project_packs
13
+ from agentbundle.build.contract import load as load_contract
14
+
15
+ REPO_ROOT = Path(__file__).resolve().parents[5]
16
+ CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
17
+
18
+
19
+ def _seed_pack(root: Path) -> Path:
20
+ pack = root / "pack"
21
+ (pack / ".apm" / "skills" / "foo").mkdir(parents=True)
22
+ (pack / ".apm" / "skills" / "foo" / "SKILL.md").write_text(
23
+ "# foo skill\n",
24
+ encoding="utf-8",
25
+ )
26
+
27
+ (pack / ".apm" / "agents").mkdir(parents=True)
28
+ (pack / ".apm" / "agents" / "bar.md").write_text(
29
+ "---\nname: bar\ntools: Read\n---\nagent body\n",
30
+ encoding="utf-8",
31
+ )
32
+
33
+ (pack / ".apm" / "hooks").mkdir(parents=True)
34
+ (pack / ".apm" / "hooks" / "baz.sh").write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
35
+ (pack / ".apm" / "hooks" / "baz.py").write_text("print('hi')\n", encoding="utf-8")
36
+
37
+ (pack / ".apm" / "hook-wiring").mkdir(parents=True)
38
+ (pack / ".apm" / "hook-wiring" / "baz.toml").write_text(
39
+ '[hooks]\nbaz = "tools/hooks/baz.sh"\n',
40
+ encoding="utf-8",
41
+ )
42
+
43
+ (pack / ".apm" / "commands").mkdir(parents=True)
44
+ (pack / ".apm" / "commands" / "qux.md").write_text("# qux\n", encoding="utf-8")
45
+ return pack
46
+
47
+
48
+ class KiroAdapterTests(unittest.TestCase):
49
+ @classmethod
50
+ def setUpClass(cls) -> None:
51
+ cls.contract = load_contract(CONTRACT_PATH)
52
+
53
+ def test_skill_projects_to_kiro_skills(self) -> None:
54
+ with tempfile.TemporaryDirectory() as tmp:
55
+ tmp_path = Path(tmp)
56
+ pack = _seed_pack(tmp_path)
57
+ out = tmp_path / "out"
58
+ project(pack, self.contract, out)
59
+ self.assertTrue((out / ".kiro" / "skills" / "foo" / "SKILL.md").exists())
60
+
61
+ def test_agent_projects_as_json_per_kiro_schema(self) -> None:
62
+ """RFC-0005 / T7: Kiro agents are JSON files per the documented
63
+ Kiro schema (https://kiro.dev/docs/cli/custom-agents/configuration-reference/),
64
+ not markdown-with-frontmatter as v0.2 used to project. The
65
+ `kiro-ide-agent-frontmatter-v0.9` mapping table is reinterpreted as
66
+ *markdown-frontmatter → JSON-field* — the rename / to-list
67
+ normalize semantics carry over to JSON emission."""
68
+ import json
69
+
70
+ with tempfile.TemporaryDirectory() as tmp:
71
+ tmp_path = Path(tmp)
72
+ pack = _seed_pack(tmp_path)
73
+ out = tmp_path / "out"
74
+ project(pack, self.contract, out)
75
+ # File is .json, not .md.
76
+ agent_json_path = out / ".kiro" / "agents" / "bar.json"
77
+ self.assertTrue(agent_json_path.exists(), "agent projected as .md instead of .json")
78
+ self.assertFalse(
79
+ (out / ".kiro" / "agents" / "bar.md").exists(),
80
+ "stale .md projection left behind",
81
+ )
82
+ data = json.loads(agent_json_path.read_text(encoding="utf-8"))
83
+ # `tools: Read` source normalises to a list (`to-list`) then
84
+ # maps onto the Kiro `read_file` tool id (`values`).
85
+ self.assertEqual(data["tools"], ["read_file"])
86
+ # `name` from filename (or frontmatter); body becomes prompt.
87
+ self.assertEqual(data["name"], "bar")
88
+ self.assertEqual(data.get("prompt", "").strip(), "agent body")
89
+
90
+ def test_model_alias_translates_to_kiro_id(self) -> None:
91
+ """Source `model: opus` (Claude Code's friendly alias) translates
92
+ to Kiro's documented model ID per the contract's values map.
93
+ Kiro CLI rejects an unknown identifier — emitting the alias
94
+ verbatim would break the agent at load time."""
95
+ with tempfile.TemporaryDirectory() as tmp:
96
+ tmp_path = Path(tmp)
97
+ pack = tmp_path / "pack"
98
+ (pack / ".apm" / "agents").mkdir(parents=True)
99
+ (pack / ".apm" / "agents" / "opus-agent.md").write_text(
100
+ "---\nname: opus-agent\nmodel: opus\n---\nbody\n",
101
+ encoding="utf-8",
102
+ )
103
+ (pack / ".apm" / "agents" / "sonnet-agent.md").write_text(
104
+ "---\nname: sonnet-agent\nmodel: sonnet\n---\nbody\n",
105
+ encoding="utf-8",
106
+ )
107
+ (pack / ".apm" / "agents" / "haiku-agent.md").write_text(
108
+ "---\nname: haiku-agent\nmodel: haiku\n---\nbody\n",
109
+ encoding="utf-8",
110
+ )
111
+ out = tmp_path / "out"
112
+ project(pack, self.contract, out)
113
+ opus = json.loads((out / ".kiro" / "agents" / "opus-agent.json").read_text(encoding="utf-8"))
114
+ sonnet = json.loads((out / ".kiro" / "agents" / "sonnet-agent.json").read_text(encoding="utf-8"))
115
+ haiku = json.loads((out / ".kiro" / "agents" / "haiku-agent.json").read_text(encoding="utf-8"))
116
+ self.assertEqual(opus["model"], "claude-opus-4.6")
117
+ self.assertEqual(sonnet["model"], "claude-sonnet-4.5")
118
+ self.assertEqual(haiku["model"], "claude-haiku-4.5")
119
+
120
+ def test_model_unknown_alias_drops_field_and_warns(self) -> None:
121
+ """A source `model` value not in the contract's values map is
122
+ dropped from the JSON output. Kiro then falls back to its CLI
123
+ default with a warning rather than refusing the agent. The
124
+ adapter also emits a stderr warning at build time so a
125
+ pack-author typo (`opsus` for `opus`) surfaces before the
126
+ agent ships."""
127
+ with tempfile.TemporaryDirectory() as tmp:
128
+ tmp_path = Path(tmp)
129
+ pack = tmp_path / "pack"
130
+ (pack / ".apm" / "agents").mkdir(parents=True)
131
+ (pack / ".apm" / "agents" / "mystery.md").write_text(
132
+ "---\nname: mystery\nmodel: gpt-5-turbo\n---\nbody\n",
133
+ encoding="utf-8",
134
+ )
135
+ out = tmp_path / "out"
136
+ buf = io.StringIO()
137
+ with redirect_stderr(buf):
138
+ project(pack, self.contract, out)
139
+ data = json.loads((out / ".kiro" / "agents" / "mystery.json").read_text(encoding="utf-8"))
140
+ self.assertNotIn("model", data)
141
+ stderr = buf.getvalue()
142
+ self.assertIn("dropping model=", stderr)
143
+ self.assertIn("gpt-5-turbo", stderr)
144
+
145
+ def test_model_absent_in_source_absent_in_output(self) -> None:
146
+ """An agent with no `model` frontmatter produces no `model`
147
+ field in the JSON — regression check that the values rule
148
+ doesn't inject anything for an absent source key."""
149
+ with tempfile.TemporaryDirectory() as tmp:
150
+ tmp_path = Path(tmp)
151
+ pack = tmp_path / "pack"
152
+ (pack / ".apm" / "agents").mkdir(parents=True)
153
+ (pack / ".apm" / "agents" / "no-model.md").write_text(
154
+ "---\nname: no-model\n---\nbody\n",
155
+ encoding="utf-8",
156
+ )
157
+ out = tmp_path / "out"
158
+ project(pack, self.contract, out)
159
+ data = json.loads((out / ".kiro" / "agents" / "no-model.json").read_text(encoding="utf-8"))
160
+ self.assertNotIn("model", data)
161
+
162
+ def test_model_non_scalar_value_drops_field(self) -> None:
163
+ """A non-string `model` value (e.g. a YAML flow-sequence
164
+ `model: [opus]`) takes the values-miss branch and drops the
165
+ field — `values` is defined to translate scalar source values
166
+ only, so non-scalar input is intentionally rejected rather
167
+ than smuggled through as a literal list."""
168
+ with tempfile.TemporaryDirectory() as tmp:
169
+ tmp_path = Path(tmp)
170
+ pack = tmp_path / "pack"
171
+ (pack / ".apm" / "agents").mkdir(parents=True)
172
+ (pack / ".apm" / "agents" / "list-model.md").write_text(
173
+ "---\nname: list-model\nmodel: [opus]\n---\nbody\n",
174
+ encoding="utf-8",
175
+ )
176
+ out = tmp_path / "out"
177
+ buf = io.StringIO()
178
+ with redirect_stderr(buf):
179
+ project(pack, self.contract, out)
180
+ data = json.loads((out / ".kiro" / "agents" / "list-model.json").read_text(encoding="utf-8"))
181
+ self.assertNotIn("model", data)
182
+
183
+ def test_tools_comma_string_splits_to_list(self) -> None:
184
+ """Pack authors write `tools: Read, Grep, Glob, Bash` —
185
+ Claude Code's frontmatter convention, not YAML flow syntax —
186
+ and the kiro projection must split on commas, then map each
187
+ Claude Code name onto its Kiro tool id: Read→read_file,
188
+ Grep→grep_search, Glob→file_search, Bash→execute_bash."""
189
+ with tempfile.TemporaryDirectory() as tmp:
190
+ tmp_path = Path(tmp)
191
+ pack = tmp_path / "pack"
192
+ (pack / ".apm" / "agents").mkdir(parents=True)
193
+ (pack / ".apm" / "agents" / "multi.md").write_text(
194
+ "---\nname: multi\ntools: Read, Grep, Glob, Bash\n---\nbody\n",
195
+ encoding="utf-8",
196
+ )
197
+ out = tmp_path / "out"
198
+ project(pack, self.contract, out)
199
+ data = json.loads((out / ".kiro" / "agents" / "multi.json").read_text(encoding="utf-8"))
200
+ self.assertEqual(
201
+ data["tools"], ["read_file", "grep_search", "file_search", "execute_bash"]
202
+ )
203
+
204
+ def test_tools_single_token_one_element_list(self) -> None:
205
+ with tempfile.TemporaryDirectory() as tmp:
206
+ tmp_path = Path(tmp)
207
+ pack = tmp_path / "pack"
208
+ (pack / ".apm" / "agents").mkdir(parents=True)
209
+ (pack / ".apm" / "agents" / "one.md").write_text(
210
+ "---\nname: one\ntools: Read\n---\nbody\n",
211
+ encoding="utf-8",
212
+ )
213
+ out = tmp_path / "out"
214
+ project(pack, self.contract, out)
215
+ data = json.loads((out / ".kiro" / "agents" / "one.json").read_text(encoding="utf-8"))
216
+ self.assertEqual(data["tools"], ["read_file"])
217
+
218
+ def test_tools_bracketed_list_preserved(self) -> None:
219
+ """A YAML flow-sequence `tools: [Read, Grep]` is parsed as a
220
+ list by `_parse_frontmatter`; the to-list normalize must not
221
+ re-wrap or re-split it, and each element still maps to its
222
+ Kiro tool id."""
223
+ with tempfile.TemporaryDirectory() as tmp:
224
+ tmp_path = Path(tmp)
225
+ pack = tmp_path / "pack"
226
+ (pack / ".apm" / "agents").mkdir(parents=True)
227
+ (pack / ".apm" / "agents" / "bracketed.md").write_text(
228
+ "---\nname: bracketed\ntools: [Read, Grep]\n---\nbody\n",
229
+ encoding="utf-8",
230
+ )
231
+ out = tmp_path / "out"
232
+ project(pack, self.contract, out)
233
+ data = json.loads((out / ".kiro" / "agents" / "bracketed.json").read_text(encoding="utf-8"))
234
+ self.assertEqual(data["tools"], ["read_file", "grep_search"])
235
+
236
+ def test_tools_web_search_maps_to_web_tag(self) -> None:
237
+ """`WebSearch` has no granular Kiro tool id, so it maps to the
238
+ `web` tag; `WebFetch` maps to the granular `web_fetch` id. Order
239
+ is preserved."""
240
+ with tempfile.TemporaryDirectory() as tmp:
241
+ tmp_path = Path(tmp)
242
+ pack = tmp_path / "pack"
243
+ (pack / ".apm" / "agents").mkdir(parents=True)
244
+ (pack / ".apm" / "agents" / "web.md").write_text(
245
+ "---\nname: web\ntools: Read, WebFetch, WebSearch\n---\nbody\n",
246
+ encoding="utf-8",
247
+ )
248
+ out = tmp_path / "out"
249
+ project(pack, self.contract, out)
250
+ data = json.loads((out / ".kiro" / "agents" / "web.json").read_text(encoding="utf-8"))
251
+ self.assertEqual(data["tools"], ["read_file", "web_fetch", "web"])
252
+
253
+ def test_tools_unmapped_token_drops_with_warning(self) -> None:
254
+ """A Claude Code tool name absent from the values map (e.g.
255
+ `NotebookEdit`) drops from the output with a stderr warning,
256
+ rather than emitting a token Kiro can't resolve."""
257
+ with tempfile.TemporaryDirectory() as tmp:
258
+ tmp_path = Path(tmp)
259
+ pack = tmp_path / "pack"
260
+ (pack / ".apm" / "agents").mkdir(parents=True)
261
+ (pack / ".apm" / "agents" / "nb.md").write_text(
262
+ "---\nname: nb\ntools: Read, NotebookEdit\n---\nbody\n",
263
+ encoding="utf-8",
264
+ )
265
+ out = tmp_path / "out"
266
+ stderr = io.StringIO()
267
+ with redirect_stderr(stderr):
268
+ project(pack, self.contract, out)
269
+ data = json.loads((out / ".kiro" / "agents" / "nb.json").read_text(encoding="utf-8"))
270
+ self.assertEqual(data["tools"], ["read_file"])
271
+ self.assertIn("NotebookEdit", stderr.getvalue())
272
+
273
+ def test_hook_wiring_array_entry_removed(self) -> None:
274
+ """AC2: the legacy `degraded-info-log` kiro hook-wiring entry is gone."""
275
+ kiro_array_primitives = {
276
+ entry["primitive"]
277
+ for entry in self.contract["adapter"]["kiro"].get("projection", [])
278
+ }
279
+ self.assertNotIn(
280
+ "hook-wiring",
281
+ kiro_array_primitives,
282
+ "legacy kiro hook-wiring projection array entry still present",
283
+ )
284
+
285
+ def test_hook_wiring_no_info_log_emitted(self) -> None:
286
+ """No info-log fires for kiro hook-wiring under v0.3 (the legacy
287
+ `degraded-info-log` array entry that produced it has been removed).
288
+ The v0.3 `merge-into-agent-json` projection is wired up in T5/T6 —
289
+ this test pins the regression of the prior runtime behaviour."""
290
+ with tempfile.TemporaryDirectory() as tmp:
291
+ tmp_path = Path(tmp)
292
+ pack = _seed_pack(tmp_path)
293
+ out = tmp_path / "out"
294
+ buf = io.StringIO()
295
+ with redirect_stderr(buf):
296
+ project(pack, self.contract, out)
297
+ self.assertNotIn(
298
+ "hook-wiring",
299
+ buf.getvalue(),
300
+ "kiro adapter emitted a hook-wiring info-log after AC2 removal",
301
+ )
302
+
303
+ def test_hook_body_extensions_preserved(self) -> None:
304
+ with tempfile.TemporaryDirectory() as tmp:
305
+ tmp_path = Path(tmp)
306
+ pack = _seed_pack(tmp_path)
307
+ out = tmp_path / "out"
308
+ project(pack, self.contract, out)
309
+ self.assertTrue((out / "tools" / "hooks" / "baz.sh").exists())
310
+ self.assertTrue((out / "tools" / "hooks" / "baz.py").exists())
311
+
312
+ def test_command_dropped(self) -> None:
313
+ with tempfile.TemporaryDirectory() as tmp:
314
+ tmp_path = Path(tmp)
315
+ pack = _seed_pack(tmp_path)
316
+ out = tmp_path / "out"
317
+ project(pack, self.contract, out)
318
+ # No command output.
319
+ self.assertFalse(any(out.rglob("commands")))
320
+
321
+
322
+ def _seed_minimal_pack(root: Path, name: str, skill_name: str, body: str) -> Path:
323
+ pack = root / name
324
+ skill_dir = pack / ".apm" / "skills" / skill_name
325
+ skill_dir.mkdir(parents=True)
326
+ (skill_dir / "SKILL.md").write_text(body, encoding="utf-8")
327
+ return pack
328
+
329
+
330
+ class ProjectPacksTests(unittest.TestCase):
331
+ @classmethod
332
+ def setUpClass(cls) -> None:
333
+ cls.contract = load_contract(CONTRACT_PATH)
334
+
335
+ def test_project_packs_iterates_in_order(self) -> None:
336
+ with tempfile.TemporaryDirectory() as tmp:
337
+ tmp_path = Path(tmp)
338
+ pack_a = _seed_minimal_pack(tmp_path, "pack-a", "skill-a", "# a\n")
339
+ pack_b = _seed_minimal_pack(tmp_path, "pack-b", "skill-b", "# b\n")
340
+ out = tmp_path / "out"
341
+
342
+ project_packs([pack_a, pack_b], self.contract, out)
343
+
344
+ self.assertTrue((out / ".kiro" / "skills" / "skill-a" / "SKILL.md").is_file())
345
+ self.assertTrue((out / ".kiro" / "skills" / "skill-b" / "SKILL.md").is_file())
346
+
347
+ def test_single_pack_project_delegates_to_project_packs(self) -> None:
348
+ with tempfile.TemporaryDirectory() as tmp:
349
+ tmp_path = Path(tmp)
350
+ pack = _seed_minimal_pack(tmp_path, "pack", "skill-x", "# x\n")
351
+ out_a = tmp_path / "out-a"
352
+ out_b = tmp_path / "out-b"
353
+
354
+ project(pack, self.contract, out_a)
355
+ project_packs([pack], self.contract, out_b)
356
+
357
+ self.assertEqual(
358
+ (out_a / ".kiro" / "skills" / "skill-x" / "SKILL.md").read_bytes(),
359
+ (out_b / ".kiro" / "skills" / "skill-x" / "SKILL.md").read_bytes(),
360
+ )
361
+
362
+ def test_same_name_last_wins(self) -> None:
363
+ with tempfile.TemporaryDirectory() as tmp:
364
+ tmp_path = Path(tmp)
365
+ pack_a = _seed_minimal_pack(
366
+ tmp_path, "pack-a", "same-name", "# pack-a\nPACK_A_SENTINEL\n",
367
+ )
368
+ pack_b = _seed_minimal_pack(
369
+ tmp_path, "pack-b", "same-name", "# pack-b\nPACK_B_SENTINEL\n",
370
+ )
371
+ out = tmp_path / "out"
372
+
373
+ project_packs([pack_a, pack_b], self.contract, out)
374
+ body = (out / ".kiro" / "skills" / "same-name" / "SKILL.md").read_text(
375
+ encoding="utf-8",
376
+ )
377
+ self.assertIn("PACK_B_SENTINEL", body)
378
+ self.assertNotIn("PACK_A_SENTINEL", body)
379
+
380
+ def test_same_name_last_wins_reversed(self) -> None:
381
+ with tempfile.TemporaryDirectory() as tmp:
382
+ tmp_path = Path(tmp)
383
+ pack_a = _seed_minimal_pack(
384
+ tmp_path, "pack-a", "same-name", "# pack-a\nPACK_A_SENTINEL\n",
385
+ )
386
+ pack_b = _seed_minimal_pack(
387
+ tmp_path, "pack-b", "same-name", "# pack-b\nPACK_B_SENTINEL\n",
388
+ )
389
+ out = tmp_path / "out"
390
+
391
+ project_packs([pack_b, pack_a], self.contract, out)
392
+ body = (out / ".kiro" / "skills" / "same-name" / "SKILL.md").read_text(
393
+ encoding="utf-8",
394
+ )
395
+ self.assertIn("PACK_A_SENTINEL", body)
396
+ self.assertNotIn("PACK_B_SENTINEL", body)
397
+
398
+
399
+ def _seed_named_skills_pack(root: Path, pack_name: str, skill_names: list[str]) -> Path:
400
+ pack = root / pack_name
401
+ for skill_name in skill_names:
402
+ skill_dir = pack / ".apm" / "skills" / skill_name
403
+ skill_dir.mkdir(parents=True)
404
+ (skill_dir / "SKILL.md").write_text(
405
+ f"# {skill_name}\nfrom {pack_name}\n",
406
+ encoding="utf-8",
407
+ )
408
+ return pack
409
+
410
+
411
+ class TestKiroOrphanSweep(unittest.TestCase):
412
+ @classmethod
413
+ def setUpClass(cls) -> None:
414
+ cls.contract = load_contract(CONTRACT_PATH)
415
+
416
+ def test_two_stage_shrink(self) -> None:
417
+ # AC19: project {a, b, c} then {a, c} into the same output.
418
+ with tempfile.TemporaryDirectory() as tmp:
419
+ tmp_path = Path(tmp)
420
+ three = _seed_named_skills_pack(tmp_path, "three-skill", ["a", "b", "c"])
421
+ shrink = _seed_named_skills_pack(tmp_path, "two-skill-shrink", ["a", "c"])
422
+ out = tmp_path / "out"
423
+
424
+ project_packs([three], self.contract, out)
425
+ self.assertTrue((out / ".kiro" / "skills" / "b").is_dir())
426
+
427
+ project_packs([shrink], self.contract, out)
428
+ children = {p.name for p in (out / ".kiro" / "skills").iterdir()}
429
+ self.assertEqual(children, {"a", "c"})
430
+
431
+ def test_two_pack_union(self) -> None:
432
+ # AC20 — kiro case.
433
+ with tempfile.TemporaryDirectory() as tmp:
434
+ tmp_path = Path(tmp)
435
+ pack_a = _seed_named_skills_pack(tmp_path, "pack-a", ["a", "b"])
436
+ pack_b = _seed_named_skills_pack(tmp_path, "pack-b", ["b", "c"])
437
+ out = tmp_path / "out"
438
+
439
+ project_packs([pack_a, pack_b], self.contract, out)
440
+ children = {p.name for p in (out / ".kiro" / "skills").iterdir()}
441
+ self.assertEqual(children, {"a", "b", "c"})
442
+
443
+ project_packs([pack_a], self.contract, out)
444
+ children = {p.name for p in (out / ".kiro" / "skills").iterdir()}
445
+ self.assertEqual(children, {"a", "b"})
446
+
447
+
448
+ if __name__ == "__main__":
449
+ unittest.main()
@@ -0,0 +1,105 @@
1
+ """Tests for the kiro deprecated alias (T4 — RFC-0022).
2
+
3
+ The `kiro` adapter is retained as a deprecated alias for `kiro-ide`.
4
+ Packs declaring `allowed-adapters = ["kiro"]` keep working; a
5
+ build-time deprecation warning is emitted.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import tempfile
12
+ import unittest
13
+ import warnings
14
+ from pathlib import Path
15
+
16
+ from agentbundle.build.adapters import ADAPTERS, kiro_ide
17
+ from agentbundle.build.contract import load as load_contract
18
+
19
+ REPO_ROOT = Path(__file__).resolve().parents[5]
20
+ CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
21
+
22
+
23
+ def _seed_pack(root: Path) -> Path:
24
+ pack = root / "pack"
25
+ (pack / ".apm" / "agents").mkdir(parents=True)
26
+ (pack / ".apm" / "agents" / "foo.md").write_text(
27
+ "---\nname: foo\ntools: Read\n---\nbody\n",
28
+ encoding="utf-8",
29
+ )
30
+ return pack
31
+
32
+
33
+ class KiroAliasTests(unittest.TestCase):
34
+ @classmethod
35
+ def setUpClass(cls) -> None:
36
+ cls.contract = load_contract(CONTRACT_PATH)
37
+
38
+ def test_kiro_resolves_to_kiro_ide(self) -> None:
39
+ """ADAPTERS["kiro"] dispatches to kiro-ide's projection logic.
40
+
41
+ Project a minimal pack using both "kiro" and "kiro-ide" entries
42
+ and verify they produce identical output.
43
+ """
44
+ with tempfile.TemporaryDirectory() as tmp:
45
+ tmp_path = Path(tmp)
46
+ pack = _seed_pack(tmp_path)
47
+ out_kiro = tmp_path / "out-kiro"
48
+ out_kiro_ide = tmp_path / "out-kiro-ide"
49
+
50
+ kiro_func = ADAPTERS.get("kiro")
51
+ self.assertIsNotNone(kiro_func, "ADAPTERS must register 'kiro'")
52
+
53
+ with warnings.catch_warnings():
54
+ warnings.simplefilter("ignore", DeprecationWarning)
55
+ kiro_func(pack, self.contract, out_kiro)
56
+
57
+ kiro_ide.project(pack, self.contract, out_kiro_ide)
58
+
59
+ # T1 landed: kiro_ide.project emits .md (gray-matter format).
60
+ alias_json = (out_kiro / ".kiro" / "agents" / "foo.md").read_bytes()
61
+ ide_json = (out_kiro_ide / ".kiro" / "agents" / "foo.md").read_bytes()
62
+ self.assertEqual(
63
+ alias_json,
64
+ ide_json,
65
+ "kiro alias must produce identical output to kiro-ide",
66
+ )
67
+
68
+ def test_kiro_emits_deprecation_warning(self) -> None:
69
+ """Calling the kiro alias emits a DeprecationWarning containing 'kiro-ide'."""
70
+ with tempfile.TemporaryDirectory() as tmp:
71
+ tmp_path = Path(tmp)
72
+ pack = _seed_pack(tmp_path)
73
+ out = tmp_path / "out"
74
+ kiro_func = ADAPTERS["kiro"]
75
+
76
+ with warnings.catch_warnings(record=True) as caught:
77
+ warnings.simplefilter("always")
78
+ kiro_func(pack, self.contract, out)
79
+
80
+ dep_warnings = [
81
+ w for w in caught if issubclass(w.category, DeprecationWarning)
82
+ ]
83
+ self.assertTrue(dep_warnings, "kiro alias must emit a DeprecationWarning")
84
+ messages = " ".join(str(w.message) for w in dep_warnings)
85
+ self.assertIn("kiro-ide", messages, "warning must mention 'kiro-ide'")
86
+
87
+ def test_kiro_alias_in_shipped_for_cli(self) -> None:
88
+ """'kiro' must appear in the shipped adapters derived from the contract.
89
+
90
+ The [adapter.kiro] stub block in adapter.toml ensures the adapter
91
+ key is present, so `_shipped_for_cli` (derived from the contract's
92
+ adapter key set) continues to include 'kiro'.
93
+ """
94
+ from agentbundle.scope import shipped_adapters_from_contract
95
+
96
+ shipped = shipped_adapters_from_contract()
97
+ self.assertIn(
98
+ "kiro",
99
+ shipped,
100
+ "'kiro' must be in shipped adapters (alias stub block keeps it in the contract)",
101
+ )
102
+
103
+
104
+ if __name__ == "__main__":
105
+ unittest.main()