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