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,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()
|