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,608 @@
|
|
|
1
|
+
"""Kiro adapter — underlying JSON projection shared by kiro-cli and the kiro alias.
|
|
2
|
+
|
|
3
|
+
RFC-0022: the `kiro` contract adapter is a deprecated alias for `kiro-ide`.
|
|
4
|
+
This module (`kiro.py`) now serves as the shared implementation layer used by:
|
|
5
|
+
- `kiro_cli.py` — CLI target, JSON agents with CLI short-name tool tokens.
|
|
6
|
+
- `kiro_ide.py` — imports `_split_frontmatter`, `_apply_mapping`, and the
|
|
7
|
+
direct-file helpers; overrides agent projection to emit `.md`.
|
|
8
|
+
- `kiro` alias — deprecated alias that calls `kiro_ide.project`.
|
|
9
|
+
|
|
10
|
+
Per RFC-0005 § Build-pipeline ordering invariant, primitives project in
|
|
11
|
+
the fixed order **`hook-body` → `agent` → `hook-wiring` → `command` →
|
|
12
|
+
`skill`** within each pack. The order matters because Kiro's
|
|
13
|
+
`merge-into-agent-json` projection reads the agent JSON the agent
|
|
14
|
+
primitive's projection wrote — agents must land first.
|
|
15
|
+
|
|
16
|
+
When used directly (for the kiro alias / kiro-cli path), agents project as
|
|
17
|
+
`.kiro/agents/<name>.json`. The `kiro-ide-agent-frontmatter-v0.9` mapping
|
|
18
|
+
table (renamed from `kiro-agent-frontmatter-v0.9` in T1) is reinterpreted as
|
|
19
|
+
*frontmatter-key → JSON-field* rather than *frontmatter → frontmatter*.
|
|
20
|
+
|
|
21
|
+
Hook-wiring projection delegates to
|
|
22
|
+
`agentbundle.build.projections.merge_into_agent_json` per RFC-0005.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import shutil
|
|
29
|
+
import sys
|
|
30
|
+
import tomllib
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Iterator
|
|
33
|
+
|
|
34
|
+
from agentbundle.build.projections.merge_into_agent_json import (
|
|
35
|
+
project as merge_into_agent_json_project,
|
|
36
|
+
)
|
|
37
|
+
from agentbundle.build.projections.kiro_ide_hook import (
|
|
38
|
+
project as kiro_ide_hook_project,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Phase order from RFC-0005 § Build-pipeline ordering invariant.
|
|
43
|
+
# `agent` precedes `hook-wiring` so `merge-into-agent-json` finds the
|
|
44
|
+
# agent JSON in place. `command` and `skill` land last; their position
|
|
45
|
+
# relative to wiring is free (neither reads the agent JSON during
|
|
46
|
+
# projection), so the predictable trailing position keeps the phases
|
|
47
|
+
# uniform across adapters.
|
|
48
|
+
from agentbundle.build.phase_order import PHASE_ORDER as _PHASE_ORDER
|
|
49
|
+
from agentbundle.build.projections.direct_directory import sweep_orphans
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _iter_primitives(contract: dict) -> Iterator[str]:
|
|
53
|
+
"""Yield Kiro's projected primitive names in phase order.
|
|
54
|
+
|
|
55
|
+
Walks both the legacy `[[adapter.kiro.projection]]` array (v0.2
|
|
56
|
+
primitives that didn't migrate to the new shape) and the v0.3
|
|
57
|
+
`[adapter.kiro.projections.<primitive>]` table form (hook-body and
|
|
58
|
+
hook-wiring per RFC-0005). Skipped: primitives whose mode is
|
|
59
|
+
`dropped` — they have no projection work.
|
|
60
|
+
|
|
61
|
+
Returns an iterator in PHASE_ORDER so callers (project,
|
|
62
|
+
test_pipeline_phase_order) get a deterministic sequence.
|
|
63
|
+
"""
|
|
64
|
+
adapter_block = contract["adapter"]["kiro"]
|
|
65
|
+
array_form = {entry["primitive"]: entry for entry in adapter_block.get("projection", [])}
|
|
66
|
+
table_form = adapter_block.get("projections", {}) if isinstance(adapter_block.get("projections"), dict) else {}
|
|
67
|
+
|
|
68
|
+
for primitive_name in _PHASE_ORDER:
|
|
69
|
+
if primitive_name in array_form:
|
|
70
|
+
mode = array_form[primitive_name].get("mode")
|
|
71
|
+
if mode == "dropped":
|
|
72
|
+
continue
|
|
73
|
+
yield primitive_name
|
|
74
|
+
elif primitive_name in table_form:
|
|
75
|
+
rule = table_form[primitive_name]
|
|
76
|
+
effective_mode = rule.get("mode")
|
|
77
|
+
if isinstance(effective_mode, dict):
|
|
78
|
+
effective_mode = effective_mode.get("repo")
|
|
79
|
+
if effective_mode == "dropped":
|
|
80
|
+
continue
|
|
81
|
+
yield primitive_name
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def project(pack_path: Path, contract: dict, output_root: Path) -> None:
|
|
85
|
+
"""Single-pack convenience wrapper. Delegates to `project_packs`."""
|
|
86
|
+
project_packs([pack_path], contract, output_root)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def project_packs(pack_paths: list[Path], contract: dict, output_root: Path) -> None:
|
|
90
|
+
"""Project every pack in `pack_paths` in order, then run the
|
|
91
|
+
shared orphan-sweep post-pass on the `skill` target directory.
|
|
92
|
+
|
|
93
|
+
Same-name collision rule: pack source order as supplied here; the
|
|
94
|
+
last pack's `<name>` overwrites earlier packs' (`_project_direct_directory`
|
|
95
|
+
`rmtree`s the destination before `copytree`). The orphan sweep
|
|
96
|
+
observes the union of source skill names across the call's pack
|
|
97
|
+
list (not per-pack) so a pack shipping a subset can co-exist with
|
|
98
|
+
another that ships the union complement.
|
|
99
|
+
"""
|
|
100
|
+
for pack_path in pack_paths:
|
|
101
|
+
_project_single(pack_path, contract, output_root)
|
|
102
|
+
_sweep_skill_orphans(pack_paths, contract, output_root)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Mirror of claude_code.py:_skill_direct_directory_target — keep in sync.
|
|
106
|
+
# A shared helper is barred by the spec's `Never do` boundary (no
|
|
107
|
+
# expansion of projections/direct_directory.py beyond `sweep_orphans`).
|
|
108
|
+
def _skill_direct_directory_target(contract: dict, output_root: Path) -> Path | None:
|
|
109
|
+
adapter_block = contract["adapter"]["kiro"]
|
|
110
|
+
for entry in adapter_block.get("projection", []):
|
|
111
|
+
if entry.get("primitive") == "skill" and entry.get("mode") == "direct-directory":
|
|
112
|
+
return output_root / entry["target-path"].rstrip("/")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _sweep_skill_orphans(pack_paths: list[Path], contract: dict, output_root: Path) -> None:
|
|
117
|
+
target_dir = _skill_direct_directory_target(contract, output_root)
|
|
118
|
+
if target_dir is None:
|
|
119
|
+
return
|
|
120
|
+
skill_source_path = contract["primitive"]["skill"]["source-path"].rstrip("/")
|
|
121
|
+
expected_names: set[str] = set()
|
|
122
|
+
for pack_path in pack_paths:
|
|
123
|
+
source_dir = pack_path / skill_source_path
|
|
124
|
+
if not source_dir.exists():
|
|
125
|
+
continue
|
|
126
|
+
for entry in source_dir.iterdir():
|
|
127
|
+
if entry.is_dir():
|
|
128
|
+
expected_names.add(entry.name)
|
|
129
|
+
sweep_orphans(target_dir, expected_names)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _project_single(pack_path: Path, contract: dict, output_root: Path) -> None:
|
|
133
|
+
"""Project *pack_path* into *output_root* per Kiro's contract rules.
|
|
134
|
+
|
|
135
|
+
Iteration is phase-ordered (see `_iter_primitives`). For each
|
|
136
|
+
primitive in the contract, dispatch on mode:
|
|
137
|
+
|
|
138
|
+
- `direct-directory` → recursive copy
|
|
139
|
+
- `direct-file` (agent) → markdown frontmatter + body → JSON
|
|
140
|
+
- `direct-file` (other) → byte-for-byte file copy
|
|
141
|
+
- `merge-into-agent-json` → delegate to the v0.3 projection module
|
|
142
|
+
- `dropped` → no-op (filtered at iter time)
|
|
143
|
+
"""
|
|
144
|
+
adapter_block = contract["adapter"]["kiro"]
|
|
145
|
+
array_form = {entry["primitive"]: entry for entry in adapter_block.get("projection", [])}
|
|
146
|
+
table_form = adapter_block.get("projections", {}) if isinstance(adapter_block.get("projections"), dict) else {}
|
|
147
|
+
|
|
148
|
+
for primitive_name in _iter_primitives(contract):
|
|
149
|
+
primitive = contract["primitive"][primitive_name]
|
|
150
|
+
source_dir = pack_path / primitive["source-path"].rstrip("/")
|
|
151
|
+
if not source_dir.exists():
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if primitive_name in array_form:
|
|
155
|
+
rule = array_form[primitive_name]
|
|
156
|
+
_dispatch_array_form(primitive_name, source_dir, output_root, rule, contract)
|
|
157
|
+
else:
|
|
158
|
+
rule = table_form[primitive_name]
|
|
159
|
+
_dispatch_table_form(
|
|
160
|
+
primitive_name, source_dir, output_root, rule, pack_path, contract,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _dispatch_array_form(
|
|
165
|
+
primitive_name: str,
|
|
166
|
+
source_dir: Path,
|
|
167
|
+
output_root: Path,
|
|
168
|
+
rule: dict,
|
|
169
|
+
contract: dict,
|
|
170
|
+
) -> None:
|
|
171
|
+
mode = rule["mode"]
|
|
172
|
+
if mode == "direct-directory":
|
|
173
|
+
_project_direct_directory(source_dir, output_root / rule["target-path"].rstrip("/"))
|
|
174
|
+
elif mode == "direct-file":
|
|
175
|
+
if primitive_name == "agent":
|
|
176
|
+
_project_agent_as_json(source_dir, output_root, rule, contract)
|
|
177
|
+
else:
|
|
178
|
+
_project_direct_file(source_dir, output_root, rule["target-path"])
|
|
179
|
+
else:
|
|
180
|
+
raise ValueError(f"kiro: unhandled array-form mode {mode!r} for {primitive_name}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _dispatch_table_form(
|
|
184
|
+
primitive_name: str,
|
|
185
|
+
source_dir: Path,
|
|
186
|
+
output_root: Path,
|
|
187
|
+
rule: dict,
|
|
188
|
+
pack_path: Path,
|
|
189
|
+
contract: dict,
|
|
190
|
+
) -> None:
|
|
191
|
+
mode = rule.get("mode")
|
|
192
|
+
# `mode` may be a string or a scope-map per RFC-0005; at build time
|
|
193
|
+
# we project the repo-scope shape (the user-scope path is resolved
|
|
194
|
+
# at install time by T8b). For string-or-scope-map fields, prefer
|
|
195
|
+
# the repo branch.
|
|
196
|
+
effective_mode = mode["repo"] if isinstance(mode, dict) else mode
|
|
197
|
+
|
|
198
|
+
if primitive_name == "hook-wiring" and effective_mode == "merge-into-agent-json":
|
|
199
|
+
_project_hook_wiring_to_agent_json(source_dir, output_root, rule, pack_path)
|
|
200
|
+
elif primitive_name == "hook-body" and effective_mode == "direct-file":
|
|
201
|
+
# Resolve the scope-conditional target. The build pipeline
|
|
202
|
+
# writes the repo-scope shape; user-scope projection is T8b's
|
|
203
|
+
# install-time concern.
|
|
204
|
+
target = rule.get("target")
|
|
205
|
+
if isinstance(target, dict):
|
|
206
|
+
target_template = target.get("repo")
|
|
207
|
+
else:
|
|
208
|
+
target_template = target
|
|
209
|
+
if target_template:
|
|
210
|
+
_project_direct_file_template(source_dir, output_root, target_template)
|
|
211
|
+
elif primitive_name == "kiro-ide-hook" and effective_mode == "direct-file":
|
|
212
|
+
# RFC-0005 v0.4 — IDE event hooks via the kiro-ide-hook primitive.
|
|
213
|
+
# Delegate the file-walk, JSON-parse, and `${hook-body:<name>}`
|
|
214
|
+
# expansion to the dedicated projection module so the wiring
|
|
215
|
+
# here stays mechanical.
|
|
216
|
+
_project_kiro_ide_hook(source_dir, output_root, rule, contract, pack_path)
|
|
217
|
+
else:
|
|
218
|
+
# Other table-form modes (or scope-only declarations the legacy
|
|
219
|
+
# array still owns) — silent skip.
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Agent .md → .json reform
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _project_agent_as_json(
|
|
229
|
+
source_dir: Path,
|
|
230
|
+
output_root: Path,
|
|
231
|
+
rule: dict,
|
|
232
|
+
contract: dict,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Read `.apm/agents/<name>.md` and emit `<output>/.kiro/agents/<name>.json`.
|
|
235
|
+
|
|
236
|
+
Source layout: `.apm/agents/<name>.md` with YAML-style frontmatter
|
|
237
|
+
(`---` fence) + markdown body. Output layout: JSON object with
|
|
238
|
+
Kiro's documented fields per
|
|
239
|
+
https://kiro.dev/docs/cli/custom-agents/configuration-reference/:
|
|
240
|
+
|
|
241
|
+
- `name`: derived from the source filename (without `.md`),
|
|
242
|
+
or overridden by a `name` frontmatter field if present.
|
|
243
|
+
- `description`: frontmatter `description` (renamed per the
|
|
244
|
+
contract's `kiro-ide-agent-frontmatter-v0.9` mapping).
|
|
245
|
+
- `tools`: frontmatter `tools` normalized to a list.
|
|
246
|
+
- `model`: frontmatter `model`, if declared.
|
|
247
|
+
- `prompt`: the markdown body after the closing `---` fence.
|
|
248
|
+
|
|
249
|
+
The mapping table on the contract retains its `rename` / `normalize`
|
|
250
|
+
grammar; what changes from v0.2 is the *emission* — JSON instead
|
|
251
|
+
of frontmatter-with-body markdown — which closes the spec/Kiro-docs
|
|
252
|
+
drift RFC-0005's "observed-but-not-publicly-documented" drawback
|
|
253
|
+
flagged.
|
|
254
|
+
"""
|
|
255
|
+
target_dir = output_root / rule["target-path"].rstrip("/")
|
|
256
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
mapping_name = rule.get("frontmatter-mapping")
|
|
258
|
+
mapping = (
|
|
259
|
+
contract.get("frontmatter-mapping", {}).get(mapping_name, {})
|
|
260
|
+
if mapping_name
|
|
261
|
+
else {}
|
|
262
|
+
)
|
|
263
|
+
for entry in sorted(source_dir.iterdir()):
|
|
264
|
+
if not (entry.is_file() and entry.suffix == ".md"):
|
|
265
|
+
continue
|
|
266
|
+
frontmatter, body = _split_frontmatter(entry.read_text(encoding="utf-8"))
|
|
267
|
+
rewritten = _apply_mapping(frontmatter, mapping)
|
|
268
|
+
agent_name = rewritten.get("name") or entry.stem
|
|
269
|
+
agent_json: dict[str, Any] = {"name": agent_name}
|
|
270
|
+
# Preserve any rewritten fields that aren't `name` (already
|
|
271
|
+
# placed above). Iterate sorted for deterministic output.
|
|
272
|
+
# An explicit `prompt` in frontmatter wins over the
|
|
273
|
+
# body-derived prompt — pack authors writing Kiro JSON
|
|
274
|
+
# directly may put the prompt there per the published
|
|
275
|
+
# reference, and silently overwriting their value would be
|
|
276
|
+
# data loss.
|
|
277
|
+
for key in sorted(rewritten.keys()):
|
|
278
|
+
if key == "name":
|
|
279
|
+
continue
|
|
280
|
+
agent_json[key] = rewritten[key]
|
|
281
|
+
if "prompt" not in agent_json:
|
|
282
|
+
# Body fallback: the markdown body becomes the agent's
|
|
283
|
+
# prompt when frontmatter doesn't declare one.
|
|
284
|
+
prompt = body.rstrip("\n")
|
|
285
|
+
if prompt:
|
|
286
|
+
agent_json["prompt"] = prompt
|
|
287
|
+
destination = target_dir / (entry.stem + ".json")
|
|
288
|
+
destination.write_text(
|
|
289
|
+
json.dumps(agent_json, indent=2, sort_keys=False) + "\n",
|
|
290
|
+
encoding="utf-8",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# Hook-wiring → agent JSON merge
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _project_hook_wiring_to_agent_json(
|
|
300
|
+
source_dir: Path,
|
|
301
|
+
output_root: Path,
|
|
302
|
+
rule: dict,
|
|
303
|
+
pack_path: Path,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""For each `.apm/hook-wiring/<name>.toml`, merge into the resolved
|
|
306
|
+
agent JSON at `<output>/.kiro/agents/<attach-to-agent>.json`.
|
|
307
|
+
|
|
308
|
+
The agent JSON is **guaranteed to exist** at this point — the
|
|
309
|
+
phase-order invariant ensures agent projection ran first
|
|
310
|
+
(`_iter_primitives` yields `agent` before `hook-wiring`). If the
|
|
311
|
+
wiring TOML's `attach-to-agent` names an agent the pack didn't
|
|
312
|
+
ship, the merge module refuses with the RFC-0005 `internal:` text.
|
|
313
|
+
|
|
314
|
+
Pack-side validation already refused malformed wiring TOMLs at
|
|
315
|
+
`validate` time (T2's `check_kiro_wiring`), so by the time we
|
|
316
|
+
reach this code path, every wiring TOML has a same-pack agent
|
|
317
|
+
target. Build-time defense-in-depth: re-check `attach-to-agent`
|
|
318
|
+
against shipped agents and skip silently if the field is missing.
|
|
319
|
+
"""
|
|
320
|
+
pack_name = pack_path.name
|
|
321
|
+
|
|
322
|
+
target_template = rule.get("target")
|
|
323
|
+
if isinstance(target_template, dict):
|
|
324
|
+
target_template = target_template.get("repo")
|
|
325
|
+
if not target_template:
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
# Group wiring TOMLs by their `attach-to-agent` so we can call
|
|
329
|
+
# merge-into-agent-json once per agent. The merge module takes a
|
|
330
|
+
# batch of `wiring_tomls` for one target file.
|
|
331
|
+
wiring_by_agent: dict[str, dict[str, dict]] = {}
|
|
332
|
+
for entry in sorted(source_dir.iterdir()):
|
|
333
|
+
if not (entry.is_file() and entry.suffix == ".toml"):
|
|
334
|
+
continue
|
|
335
|
+
try:
|
|
336
|
+
body = tomllib.loads(entry.read_text(encoding="utf-8"))
|
|
337
|
+
except tomllib.TOMLDecodeError:
|
|
338
|
+
continue
|
|
339
|
+
attach = body.get("attach-to-agent") if isinstance(body, dict) else None
|
|
340
|
+
if not isinstance(attach, str):
|
|
341
|
+
continue
|
|
342
|
+
wiring_by_agent.setdefault(attach, {})[entry.stem] = body
|
|
343
|
+
|
|
344
|
+
for attach_to_agent, wiring_tomls in wiring_by_agent.items():
|
|
345
|
+
resolved = target_template.replace("<attach-to-agent>", attach_to_agent)
|
|
346
|
+
target_path = output_root / resolved.lstrip("/")
|
|
347
|
+
# Let AgentJsonRefusal propagate. RFC-0005 names the reachable
|
|
348
|
+
# cases — missing agent (pipeline-ordering invariant violation),
|
|
349
|
+
# unparseable JSON, wrong-shape `hooks` — all of which are bugs
|
|
350
|
+
# at build time, not adopter-fixable conditions. Silently
|
|
351
|
+
# swallowing them would let `make build` produce
|
|
352
|
+
# silently-incomplete artifacts; the existing pipeline shape
|
|
353
|
+
# is fail-fast, and that's the right shape here too.
|
|
354
|
+
merge_into_agent_json_project(target_path, pack_name, wiring_tomls)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# Existing helpers (preserved from v0.2 with small adjustments)
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _project_direct_directory(source_dir: Path, target_dir: Path) -> None:
|
|
363
|
+
for entry in sorted(source_dir.iterdir()):
|
|
364
|
+
# Defense-in-depth — `lint-packs` rejects packs that ship
|
|
365
|
+
# symlinks, but a direct `project_packs` caller bypasses
|
|
366
|
+
# that gate. A symlink at the skill-root level would be
|
|
367
|
+
# dereferenced by `copytree`.
|
|
368
|
+
if entry.is_symlink():
|
|
369
|
+
continue
|
|
370
|
+
if entry.is_dir():
|
|
371
|
+
destination = target_dir / entry.name
|
|
372
|
+
# Spec § Never do — `shutil.rmtree` is barred against
|
|
373
|
+
# any entry whose `is_symlink()` is true. If a previous
|
|
374
|
+
# run left a symlink at the destination path, unlink it.
|
|
375
|
+
if destination.is_symlink():
|
|
376
|
+
destination.unlink()
|
|
377
|
+
elif destination.exists():
|
|
378
|
+
shutil.rmtree(destination)
|
|
379
|
+
shutil.copytree(entry, destination, symlinks=True)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _project_direct_file(source_dir: Path, output_root: Path, target_prefix: str) -> None:
|
|
383
|
+
target_dir = output_root / target_prefix.rstrip("/")
|
|
384
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
385
|
+
for entry in sorted(source_dir.iterdir()):
|
|
386
|
+
if entry.is_file():
|
|
387
|
+
destination = target_dir / entry.name
|
|
388
|
+
shutil.copy2(entry, destination, follow_symlinks=False)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _project_direct_file_template(
|
|
392
|
+
source_dir: Path,
|
|
393
|
+
output_root: Path,
|
|
394
|
+
target_template: str,
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Project each file under *source_dir* to a path derived from
|
|
397
|
+
*target_template* by substituting `<name>` with the filename's
|
|
398
|
+
basename.
|
|
399
|
+
|
|
400
|
+
The v0.3 contract introduces `target = "tools/hooks/<name>.{sh,py}"`-
|
|
401
|
+
style templates that preserve the source extension. The braces are
|
|
402
|
+
illustrative; the actual output uses the source file's actual
|
|
403
|
+
extension (`.sh` or `.py`)."""
|
|
404
|
+
for entry in sorted(source_dir.iterdir()):
|
|
405
|
+
if not entry.is_file():
|
|
406
|
+
continue
|
|
407
|
+
# Replace `<name>.{sh,py}` (or any `.{...}` choice block) with
|
|
408
|
+
# the actual filename. The simplest substitution: replace the
|
|
409
|
+
# whole `<name>.{...}` template with `<actual filename>`.
|
|
410
|
+
resolved = target_template
|
|
411
|
+
if "<name>" in resolved:
|
|
412
|
+
# Strip everything from `<name>` onward and re-append the
|
|
413
|
+
# source filename — the source's actual extension wins.
|
|
414
|
+
prefix, _, _suffix = resolved.partition("<name>")
|
|
415
|
+
resolved = prefix + entry.name
|
|
416
|
+
target_path = output_root / resolved.lstrip("/")
|
|
417
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
shutil.copy2(entry, target_path, follow_symlinks=False)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _split_frontmatter(text: str) -> tuple[dict, str]:
|
|
422
|
+
lines = text.splitlines(keepends=True)
|
|
423
|
+
if not lines or not lines[0].startswith("---"):
|
|
424
|
+
return {}, text
|
|
425
|
+
end_index = None
|
|
426
|
+
for index in range(1, len(lines)):
|
|
427
|
+
if lines[index].startswith("---"):
|
|
428
|
+
end_index = index
|
|
429
|
+
break
|
|
430
|
+
if end_index is None:
|
|
431
|
+
return {}, text
|
|
432
|
+
frontmatter_lines = lines[1:end_index]
|
|
433
|
+
body = "".join(lines[end_index + 1 :])
|
|
434
|
+
return _parse_frontmatter(frontmatter_lines), body
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _parse_frontmatter(lines: list[str]) -> dict[str, Any]:
|
|
438
|
+
result: dict[str, Any] = {}
|
|
439
|
+
for line in lines:
|
|
440
|
+
stripped = line.rstrip("\n")
|
|
441
|
+
if not stripped.strip() or stripped.lstrip().startswith("#"):
|
|
442
|
+
continue
|
|
443
|
+
if ":" not in stripped:
|
|
444
|
+
continue
|
|
445
|
+
key, _, value = stripped.partition(":")
|
|
446
|
+
value = value.strip()
|
|
447
|
+
if value.startswith("[") and value.endswith("]"):
|
|
448
|
+
items = [item.strip() for item in value[1:-1].split(",") if item.strip()]
|
|
449
|
+
result[key.strip()] = items
|
|
450
|
+
else:
|
|
451
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
452
|
+
value.startswith("'") and value.endswith("'")
|
|
453
|
+
):
|
|
454
|
+
value = value[1:-1]
|
|
455
|
+
result[key.strip()] = value
|
|
456
|
+
return result
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _apply_mapping(frontmatter: dict[str, Any], mapping: dict) -> dict[str, Any]:
|
|
460
|
+
"""Apply the contract's `kiro-ide-agent-frontmatter-v0.9` rename /
|
|
461
|
+
normalize / values / default rules. Interpreted as
|
|
462
|
+
*markdown-frontmatter → JSON-field*.
|
|
463
|
+
|
|
464
|
+
`normalize = "to-list"` on a string splits on commas, strips
|
|
465
|
+
whitespace, and drops empties — the human-frontmatter convention
|
|
466
|
+
pack authors use (`tools: Read, Grep, Glob, Bash`) that YAML
|
|
467
|
+
itself parses as a single scalar.
|
|
468
|
+
|
|
469
|
+
`values` translates a scalar source value through the declared
|
|
470
|
+
alias map. A source value not in the map drops the field from
|
|
471
|
+
the rewritten output (rather than emitting an unknown identifier
|
|
472
|
+
the consumer would reject); a stderr line surfaces the drop at
|
|
473
|
+
build time so a pack-author typo (`opsus` for `opus`) doesn't
|
|
474
|
+
silently ship a default-model agent.
|
|
475
|
+
|
|
476
|
+
`values` composes with `normalize = "to-list"`: `to-list` runs
|
|
477
|
+
first, then `values` translates each element of the resulting list
|
|
478
|
+
(collapsing duplicates, preserving order, dropping unmapped tokens
|
|
479
|
+
with a warning). This is how the `tools` field maps Claude Code
|
|
480
|
+
tool names (`Read`, `Grep`, `Bash`, …) onto Kiro tool ids
|
|
481
|
+
(`read_file`, `grep_search`, `execute_bash`, …); the same `values`
|
|
482
|
+
map still applies to a scalar field like `model`."""
|
|
483
|
+
rewritten: dict[str, Any] = {}
|
|
484
|
+
for source_key, value in frontmatter.items():
|
|
485
|
+
rule = mapping.get(source_key, {})
|
|
486
|
+
new_key = rule.get("rename", source_key)
|
|
487
|
+
normalize = rule.get("normalize")
|
|
488
|
+
if normalize == "to-list":
|
|
489
|
+
if isinstance(value, list):
|
|
490
|
+
pass
|
|
491
|
+
elif isinstance(value, str):
|
|
492
|
+
value = [item.strip() for item in value.split(",") if item.strip()]
|
|
493
|
+
else:
|
|
494
|
+
value = [value]
|
|
495
|
+
values_map = rule.get("values")
|
|
496
|
+
if isinstance(values_map, dict):
|
|
497
|
+
if isinstance(value, list) and normalize == "to-list":
|
|
498
|
+
# Per-element translation for a declared list field (`tools`
|
|
499
|
+
# after `to-list`). Gated on `normalize == "to-list"` so a
|
|
500
|
+
# scalar field that merely *parsed* as a list (e.g. a
|
|
501
|
+
# malformed `model: [opus]`) still takes the scalar miss
|
|
502
|
+
# branch and drops. Each source token maps through the values
|
|
503
|
+
# map; an unmapped token drops with a stderr warning (it would
|
|
504
|
+
# match no Kiro tool id/tag downstream and silently yield an
|
|
505
|
+
# empty tool set). Order is preserved and duplicates collapse
|
|
506
|
+
# — e.g. `Read, Grep, Glob` all map to the `read` tag, so the
|
|
507
|
+
# output carries a single `read`.
|
|
508
|
+
mapped: list = []
|
|
509
|
+
for item in value:
|
|
510
|
+
if item in values_map:
|
|
511
|
+
translated = values_map[item]
|
|
512
|
+
if translated not in mapped:
|
|
513
|
+
mapped.append(translated)
|
|
514
|
+
else:
|
|
515
|
+
print(
|
|
516
|
+
f"kiro: dropping {new_key} entry {item!r} — not in "
|
|
517
|
+
f"contract values map for source key {source_key!r}",
|
|
518
|
+
file=sys.stderr,
|
|
519
|
+
)
|
|
520
|
+
value = mapped
|
|
521
|
+
elif isinstance(value, str) and value in values_map:
|
|
522
|
+
value = values_map[value]
|
|
523
|
+
else:
|
|
524
|
+
print(
|
|
525
|
+
f"kiro: dropping {new_key}={value!r} — not in contract "
|
|
526
|
+
f"values map for source key {source_key!r}",
|
|
527
|
+
file=sys.stderr,
|
|
528
|
+
)
|
|
529
|
+
continue
|
|
530
|
+
rewritten[new_key] = value
|
|
531
|
+
for source_key, rule in mapping.items():
|
|
532
|
+
default_value = rule.get("default")
|
|
533
|
+
new_key = rule.get("rename", source_key)
|
|
534
|
+
if new_key not in rewritten and default_value is not None:
|
|
535
|
+
rewritten[new_key] = default_value
|
|
536
|
+
return rewritten
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ---------------------------------------------------------------------------
|
|
540
|
+
# kiro-ide-hook dispatch (RFC-0005 v0.4)
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _project_kiro_ide_hook(
|
|
545
|
+
source_dir: Path,
|
|
546
|
+
output_root: Path,
|
|
547
|
+
rule: dict,
|
|
548
|
+
contract: dict,
|
|
549
|
+
pack_path: Path,
|
|
550
|
+
) -> None:
|
|
551
|
+
"""Dispatch ``.apm/kiro-ide-hooks/`` through the dedicated projector.
|
|
552
|
+
|
|
553
|
+
The kiro adapter holds onto the contract dict so the same-pack
|
|
554
|
+
hook-body target directory can be looked up here (the projector
|
|
555
|
+
needs it for ``${hook-body:<name>}`` resolution and shouldn't have
|
|
556
|
+
to re-parse the contract itself). Pre-v0.4 contracts don't reach
|
|
557
|
+
this code path — ``_iter_primitives`` won't yield
|
|
558
|
+
``kiro-ide-hook`` until the v0.4 contract declares it.
|
|
559
|
+
"""
|
|
560
|
+
# Target template from the rule (.kiro/hooks/<pack>/<name>.kiro.hook
|
|
561
|
+
# at v0.4 per the RFC's lean).
|
|
562
|
+
target = rule.get("target")
|
|
563
|
+
if isinstance(target, dict):
|
|
564
|
+
target_template = target.get("repo")
|
|
565
|
+
else:
|
|
566
|
+
target_template = target
|
|
567
|
+
if not target_template:
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
hook_body_target_dir = _resolve_kiro_hook_body_target_dir(contract)
|
|
571
|
+
|
|
572
|
+
kiro_ide_hook_project(
|
|
573
|
+
pack_path,
|
|
574
|
+
output_root,
|
|
575
|
+
target_template=target_template,
|
|
576
|
+
hook_body_target_dir=hook_body_target_dir,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _resolve_kiro_hook_body_target_dir(contract: dict) -> str:
|
|
581
|
+
"""Resolve where same-pack hook-bodies project to under the kiro
|
|
582
|
+
adapter, used for ``${hook-body:<name>}`` substitution.
|
|
583
|
+
|
|
584
|
+
Prefers the legacy ``[[adapter.kiro.projection]]`` array entry per
|
|
585
|
+
the v0.3 ``adapter.toml`` comment "the legacy entries remain
|
|
586
|
+
authoritative". Falls back to the v0.3 table form's
|
|
587
|
+
``[adapter.kiro.projections.hook-body].target.repo`` if no array
|
|
588
|
+
entry exists, stripping the trailing filename pattern (e.g.
|
|
589
|
+
``"tools/hooks/<name>.{sh,py}"`` → ``"tools/hooks"``). Final
|
|
590
|
+
fallback: the documented default ``"tools/hooks"``.
|
|
591
|
+
"""
|
|
592
|
+
adapter_block = contract.get("adapter", {}).get("kiro", {})
|
|
593
|
+
array_form = {
|
|
594
|
+
entry["primitive"]: entry
|
|
595
|
+
for entry in adapter_block.get("projection", [])
|
|
596
|
+
if isinstance(entry, dict)
|
|
597
|
+
}
|
|
598
|
+
if "hook-body" in array_form:
|
|
599
|
+
return array_form["hook-body"].get("target-path", "tools/hooks/").rstrip("/")
|
|
600
|
+
|
|
601
|
+
projections = adapter_block.get("projections", {})
|
|
602
|
+
hook_body_rule = projections.get("hook-body", {}) if isinstance(projections, dict) else {}
|
|
603
|
+
target = hook_body_rule.get("target")
|
|
604
|
+
if isinstance(target, dict):
|
|
605
|
+
target = target.get("repo", "")
|
|
606
|
+
if isinstance(target, str) and "/" in target:
|
|
607
|
+
return target.rsplit("/", 1)[0]
|
|
608
|
+
return "tools/hooks"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""kiro-cli adapter — projects primitives for the `kiro` terminal binary.
|
|
2
|
+
|
|
3
|
+
Targets the `kiro` CLI, not the Kiro IDE. Key differences from kiro-ide:
|
|
4
|
+
- Agents project as `.json` with CLI short-name tool tokens
|
|
5
|
+
(`read`, `grep`, `glob`, `write`, `shell`, `web_fetch`, `web_search`).
|
|
6
|
+
- hook-wiring is retained via `merge-into-agent-json` (same as the
|
|
7
|
+
legacy `kiro` adapter).
|
|
8
|
+
- kiro-ide-hook is dropped (IDE-only primitive).
|
|
9
|
+
|
|
10
|
+
Projection logic is identical to the kiro adapter — the only difference
|
|
11
|
+
is the adapter contract block (`kiro-cli`) and frontmatter mapping table
|
|
12
|
+
(`kiro-cli-agent-frontmatter-v1.0`). This module adapts the contract so
|
|
13
|
+
kiro.py's projection functions run unchanged, rather than duplicating them.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from agentbundle.build.adapters import kiro as _kiro
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def project(pack_path: Path, contract: dict, output_root: Path) -> None:
|
|
24
|
+
"""Single-pack convenience wrapper. Delegates to `project_packs`."""
|
|
25
|
+
project_packs([pack_path], contract, output_root)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def project_packs(pack_paths: list[Path], contract: dict, output_root: Path) -> None:
|
|
29
|
+
"""Project every pack in `pack_paths` using the kiro-cli adapter block.
|
|
30
|
+
|
|
31
|
+
Adapts the contract so kiro.py's projection functions read from
|
|
32
|
+
`[adapter.kiro-cli]` rather than `[adapter.kiro]`. The frontmatter
|
|
33
|
+
mapping table reference in the adapter block (`kiro-cli-agent-
|
|
34
|
+
frontmatter-v1.0`) is preserved so CLI short-name tool tokens are
|
|
35
|
+
emitted instead of the IDE ids.
|
|
36
|
+
"""
|
|
37
|
+
adapted = _adapt_contract(contract)
|
|
38
|
+
_kiro.project_packs(pack_paths, adapted, output_root)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _adapt_contract(contract: dict) -> dict:
|
|
42
|
+
"""Return a shallow copy of *contract* where `adapter["kiro"]` is
|
|
43
|
+
replaced by `adapter["kiro-cli"]`.
|
|
44
|
+
|
|
45
|
+
This lets kiro.py's projection functions (which key on the "kiro"
|
|
46
|
+
adapter block) run unchanged for the kiro-cli target without
|
|
47
|
+
duplicating the projection logic.
|
|
48
|
+
"""
|
|
49
|
+
adapted_adapter = dict(contract["adapter"])
|
|
50
|
+
adapted_adapter["kiro"] = contract["adapter"]["kiro-cli"]
|
|
51
|
+
adapted = dict(contract)
|
|
52
|
+
adapted["adapter"] = adapted_adapter
|
|
53
|
+
return adapted
|