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,596 @@
1
+ """Build pipeline: recipe loader, pack discovery, adapter dispatch,
2
+ marketplace aggregation.
3
+
4
+ Recipes live next to this module under `recipes/`. Each recipe carries
5
+ a `type` (`per-pack` | `aggregate` | `overlay` | `composite`) that
6
+ determines how the pipeline interprets it. RFC-0001 ships the first
7
+ three (per-pack-claude-plugin, per-pack-apm-package, marketplace); the
8
+ other three (per-pack-overlay, composite-agents-md, composite-marketplace)
9
+ are consumed by T7's self-host writer.
10
+
11
+ Pack discovery globs the configured `--packs-dir` for subdirectories
12
+ whose `pack.toml` validates. Pack-internal name collisions (two
13
+ primitives with the same local name inside a single pack) are rejected
14
+ before any adapter runs, with a stderr message naming both source
15
+ paths.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import shutil
22
+ import sys
23
+ import tomllib
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Iterable
27
+
28
+ from agentbundle.build.adapters import ADAPTERS
29
+ from agentbundle.build.contract import load as load_contract
30
+ from agentbundle.build.validate import validate as validate_instance
31
+
32
+ PACKAGE_ROOT = Path(__file__).resolve().parent
33
+ RECIPES_DIR = PACKAGE_ROOT / "recipes"
34
+ REPO_ROOT = PACKAGE_ROOT.parent.parent.parent.parent
35
+
36
+
37
+ def _bundled_or_repo(name: str) -> Path:
38
+ """Locate a data file shipped under both `agentbundle/_data/` and
39
+ `<repo>/docs/contracts/`.
40
+
41
+ Prefer the bundled copy when present on disk (works in a `pip install`
42
+ and a dev checkout); fall back to the repo path for dev checkouts
43
+ whose `_data/` hasn't been synced. Inside a `zipapp` neither path is
44
+ a real filesystem location — callers should use `_read_bundled` to
45
+ get the text content instead of trying to open the returned Path.
46
+ """
47
+ bundled = PACKAGE_ROOT.parent / "_data" / name
48
+ if bundled.exists():
49
+ return bundled
50
+ return REPO_ROOT / "docs" / "contracts" / name
51
+
52
+
53
+ def _read_bundled(name: str) -> str:
54
+ """Read a packaged data file, transparently handling the zipapp case.
55
+
56
+ Resolution order:
57
+ 1. `<package>/_data/<name>` via `importlib.resources` — works for
58
+ filesystem installs AND inside a `zipapp` archive.
59
+ 2. `<repo>/docs/contracts/<name>` — dev fallback for source trees
60
+ whose `_data/` hasn't been populated.
61
+ """
62
+ try:
63
+ from importlib.resources import files
64
+
65
+ resource = files("agentbundle").joinpath(f"_data/{name}")
66
+ if resource.is_file():
67
+ return resource.read_text(encoding="utf-8")
68
+ except (FileNotFoundError, ModuleNotFoundError):
69
+ pass
70
+ return (REPO_ROOT / "docs" / "contracts" / name).read_text(encoding="utf-8")
71
+
72
+
73
+ CONTRACT_PATH = _bundled_or_repo("adapter.toml")
74
+ PACK_SCHEMA_PATH = _bundled_or_repo("pack.schema.json")
75
+ PLUGIN_MANIFEST_SCHEMA_PATH = _bundled_or_repo("plugin-manifest.schema.json")
76
+ PRIMITIVE_DIRS = ("skills", "agents", "hooks", "hook-wiring", "commands")
77
+
78
+ # The canonical SessionStart hook command synthesised into each derived
79
+ # plugin.json (claude-plugins route). Shell-exec contract (AC9 sub-assertion):
80
+ # when CLAUDE_PLUGIN_ROOT is substituted the double-quoted path survives
81
+ # spaces. The trailing `--install-route claude-plugins` flag is required by
82
+ # the writer's argparse (apm-install-route-parity AC2/AC8); the build
83
+ # pipeline and the projected command stay coupled at projection time via
84
+ # `make build` so a refreshed writer always ships next to a refreshed
85
+ # command — see RFC-0010 / spec apm-install-route-parity §Rollout.
86
+ _SESSION_START_COMMAND = (
87
+ 'python3 "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/scripts/install-marker.py"'
88
+ ' --install-route claude-plugins'
89
+ )
90
+
91
+ # The canonical APM-route SessionStart hook command synthesised into each
92
+ # derived dist/apm/<pack>/.apm/hooks/install-marker.json. APM's HookIntegrator
93
+ # rewrites ${PLUGIN_ROOT} to per-target tokens (${CLAUDE_PLUGIN_ROOT},
94
+ # ${CURSOR_PLUGIN_ROOT}, …); the writer's data-directory shim resolves the
95
+ # hash-file location per spec AC3 precedence.
96
+ _SESSION_START_COMMAND_APM = (
97
+ 'python3 "${PLUGIN_ROOT}/.apm/hooks/install-marker.py"'
98
+ ' --install-route apm'
99
+ )
100
+
101
+ # JSON shape emitted into dist/apm/<pack>/.apm/hooks/install-marker.json
102
+ # (spec AC7). Authored as a Python dict so json.dumps controls indentation.
103
+ _APM_INSTALL_MARKER_HOOK_JSON = {
104
+ "hooks": {
105
+ "SessionStart": [
106
+ {
107
+ "hooks": [
108
+ {
109
+ "type": "command",
110
+ "command": _SESSION_START_COMMAND_APM,
111
+ "timeout": 10,
112
+ }
113
+ ]
114
+ }
115
+ ]
116
+ }
117
+ }
118
+
119
+
120
+ def _read_install_marker_template() -> bytes:
121
+ """Read the canonical install-marker.py template as bytes.
122
+
123
+ Resolution order (mirrors _read_bundled pattern):
124
+ 1. `<package>/_data/install-marker.py` via importlib.resources — works
125
+ for filesystem installs AND inside a zipapp archive.
126
+ 2. `<repo>/packages/agentbundle/templates/install-marker.py` — dev
127
+ fallback for source trees.
128
+ """
129
+ try:
130
+ from importlib.resources import files
131
+
132
+ resource = files("agentbundle").joinpath("_data/install-marker.py")
133
+ if resource.is_file():
134
+ return resource.read_bytes()
135
+ except (FileNotFoundError, ModuleNotFoundError):
136
+ pass
137
+ return (REPO_ROOT / "packages" / "agentbundle" / "templates" / "install-marker.py").read_bytes()
138
+
139
+
140
+ def validate_derived_plugin_manifest_dict(manifest: dict, label: str = "<derived>") -> None:
141
+ """Validate an in-memory derived plugin manifest dict against the derived schema.
142
+
143
+ Call this BEFORE writing to disk so a synthesis bug does not land a
144
+ malformed plugin.json in dist/ (Blocker-3: pre-write validation).
145
+ """
146
+ schema = json.loads(_read_bundled("plugin-manifest.derived.schema.json"))
147
+ errors = validate_instance(manifest, schema)
148
+ if errors:
149
+ raise ValueError(
150
+ f"derived plugin manifest {label} failed schema: "
151
+ + "; ".join(errors)
152
+ )
153
+
154
+
155
+ def validate_derived_plugin_manifest(plugin_json_path: Path) -> None:
156
+ """Validate a derived .claude-plugin/plugin.json (with synthesised hooks) against derived schema.
157
+
158
+ Defence-in-depth: also available as validate_derived_plugin_manifest_dict
159
+ for pre-write validation before the file is written to disk.
160
+ """
161
+ manifest = json.loads(plugin_json_path.read_text(encoding="utf-8"))
162
+ validate_derived_plugin_manifest_dict(manifest, label=str(plugin_json_path))
163
+
164
+ # The three RFC-0001 recipes that plain `make build` invokes.
165
+ # RFC-0002 recipes (per-pack-overlay, composite-agents-md,
166
+ # composite-marketplace) fire only under --self.
167
+ DEFAULT_RECIPES = (
168
+ "per-pack-claude-plugin",
169
+ "per-pack-apm-package",
170
+ "marketplace",
171
+ )
172
+
173
+
174
+ @dataclass
175
+ class Recipe:
176
+ name: str
177
+ type: str
178
+ adapter: str | None
179
+ output_subdir: str | None
180
+ input_subdir: str | None
181
+ output_file: str | None
182
+ units: list[str]
183
+ fragment_path: str | None
184
+ manifest_path: str | None
185
+
186
+
187
+ @dataclass
188
+ class Pack:
189
+ name: str
190
+ path: Path
191
+
192
+
193
+ def load_recipe(name: str, recipes_dir: Path = RECIPES_DIR) -> Recipe:
194
+ """Load a recipe by name.
195
+
196
+ Tries the filesystem first (dev/install case), then falls back to
197
+ `importlib.resources` (zipapp case where the package contents live
198
+ inside a `.pyz` archive that `Path.exists()` cannot traverse).
199
+ """
200
+ recipe_path = recipes_dir / f"{name}.toml"
201
+ if recipe_path.exists():
202
+ return _parse_recipe_text(recipe_path.read_text(encoding="utf-8"))
203
+ # Zipapp fallback: read via importlib.resources.
204
+ try:
205
+ from importlib.resources import files
206
+
207
+ resource = files("agentbundle.build").joinpath(f"recipes/{name}.toml")
208
+ if resource.is_file():
209
+ return _parse_recipe_text(resource.read_text(encoding="utf-8"))
210
+ except (FileNotFoundError, ModuleNotFoundError):
211
+ pass
212
+ raise FileNotFoundError(f"recipe {name!r} not found at {recipe_path}")
213
+
214
+
215
+ def load_recipe_from_path(path: Path) -> Recipe:
216
+ return _parse_recipe(path)
217
+
218
+
219
+ def _parse_recipe(path: Path) -> Recipe:
220
+ return _parse_recipe_text(path.read_text(encoding="utf-8"))
221
+
222
+
223
+ def _parse_recipe_text(toml_text: str) -> Recipe:
224
+ data = tomllib.loads(toml_text)
225
+ body = data["recipe"]
226
+ return Recipe(
227
+ name=body["name"],
228
+ type=body["type"],
229
+ adapter=body.get("adapter"),
230
+ output_subdir=body.get("output-subdir"),
231
+ input_subdir=body.get("input-subdir"),
232
+ output_file=body.get("output-file"),
233
+ units=body.get("units", []),
234
+ fragment_path=body.get("fragment-path"),
235
+ manifest_path=body.get("manifest-path"),
236
+ )
237
+
238
+
239
+ def discover_packs(packs_dir: Path) -> list[Pack]:
240
+ if not packs_dir.exists():
241
+ return []
242
+ packs: list[Pack] = []
243
+ for entry in sorted(packs_dir.iterdir()):
244
+ if entry.is_dir() and (entry / "pack.toml").exists():
245
+ validate_pack_metadata(entry / "pack.toml")
246
+ packs.append(Pack(name=entry.name, path=entry))
247
+ return packs
248
+
249
+
250
+ def validate_pack_metadata(pack_toml_path: Path) -> None:
251
+ """Validate a pack.toml against pack.schema.json. Raise on errors."""
252
+ metadata = tomllib.loads(pack_toml_path.read_text(encoding="utf-8"))
253
+ schema = json.loads(_read_bundled("pack.schema.json"))
254
+ errors = validate_instance(metadata, schema)
255
+ if errors:
256
+ raise ValueError(
257
+ f"pack metadata at {pack_toml_path} failed schema: "
258
+ + "; ".join(errors)
259
+ )
260
+
261
+
262
+ def validate_plugin_manifest(plugin_json_path: Path) -> None:
263
+ """Validate a per-pack .claude-plugin/plugin.json against schema."""
264
+ manifest = json.loads(plugin_json_path.read_text(encoding="utf-8"))
265
+ schema = json.loads(_read_bundled("plugin-manifest.schema.json"))
266
+ errors = validate_instance(manifest, schema)
267
+ if errors:
268
+ raise ValueError(
269
+ f"plugin manifest at {plugin_json_path} failed schema: "
270
+ + "; ".join(errors)
271
+ )
272
+
273
+
274
+ def validate_pack_uniqueness(pack: Pack) -> None:
275
+ """Raise if a pack has two primitives with the same local name.
276
+
277
+ The local name is the stem for most primitives, except `hooks` where
278
+ `.sh` and `.py` are both legal (the spec § Hook extensions makes both
279
+ valid in `packs/<pack>/.apm/hooks/`) — so for hooks we key by the
280
+ full filename so `baz.sh` and `baz.py` coexist.
281
+ """
282
+ apm_root = pack.path / ".apm"
283
+ if not apm_root.exists():
284
+ return
285
+ seen: dict[str, Path] = {}
286
+ for primitive_dir_name in PRIMITIVE_DIRS:
287
+ primitive_dir = apm_root / primitive_dir_name
288
+ if not primitive_dir.exists():
289
+ continue
290
+ for child in primitive_dir.iterdir():
291
+ local_name = child.name if primitive_dir_name == "hooks" else child.stem
292
+ key = f"{primitive_dir_name}:{local_name}"
293
+ if key in seen:
294
+ raise ValueError(
295
+ f"pack {pack.name!r}: duplicate primitive {key!r} — "
296
+ f"{seen[key]} and {child}"
297
+ )
298
+ seen[key] = child
299
+
300
+
301
+ def run_recipe(
302
+ recipe: Recipe,
303
+ packs: Iterable[Pack],
304
+ output_dir: Path,
305
+ contract: dict,
306
+ ) -> dict:
307
+ """Execute a recipe and return a description of what it produced."""
308
+ packs_list = list(packs)
309
+ for pack in packs_list:
310
+ validate_pack_uniqueness(pack)
311
+
312
+ if recipe.type == "per-pack":
313
+ return _run_per_pack(recipe, packs_list, output_dir, contract)
314
+ if recipe.type == "aggregate":
315
+ return _run_aggregate(recipe, output_dir)
316
+ if recipe.type == "overlay":
317
+ return _run_overlay(recipe, packs_list)
318
+ if recipe.type == "composite":
319
+ return _run_composite(recipe, packs_list)
320
+ raise ValueError(f"unknown recipe type {recipe.type!r}")
321
+
322
+
323
+ def _assert_under(target: Path, base: Path) -> None:
324
+ """Refuse if `target.resolve()` would escape `base.resolve()`.
325
+
326
+ Defense-in-depth against traversal in recipe `output-subdir` and
327
+ contract `target-path` values. Repo-owned today; the CLI accepts
328
+ external recipe paths via `--recipe path.toml`, so this guard is
329
+ load-bearing the moment an operator points the CLI at untrusted TOML.
330
+ """
331
+ base_resolved = base.resolve()
332
+ target_resolved = target.resolve()
333
+ try:
334
+ target_resolved.relative_to(base_resolved)
335
+ except ValueError as exc:
336
+ raise ValueError(
337
+ f"refusing to write outside output root: {target_resolved} not under {base_resolved}"
338
+ ) from exc
339
+
340
+
341
+ def _run_per_pack(
342
+ recipe: Recipe, packs: list[Pack], output_dir: Path, contract: dict
343
+ ) -> dict:
344
+ if recipe.adapter == "apm":
345
+ return _run_per_pack_apm(recipe, packs, output_dir)
346
+ if recipe.adapter not in ADAPTERS:
347
+ raise ValueError(f"unknown adapter target {recipe.adapter!r}")
348
+ if recipe.adapter not in contract["adapter"]:
349
+ raise ValueError(
350
+ f"adapter {recipe.adapter!r} declared in recipe but not in contract"
351
+ )
352
+ project = ADAPTERS[recipe.adapter]
353
+ produced: dict[str, str] = {}
354
+ for pack in packs:
355
+ try:
356
+ _run_per_pack_single(
357
+ pack, recipe, project, output_dir, contract, produced
358
+ )
359
+ except Exception as exc:
360
+ # Concern-9: surface the pack name so the operator knows which pack failed.
361
+ raise RuntimeError(f"pack {pack.name!r}: {exc}") from exc
362
+ return {"recipe": recipe.name, "type": recipe.type, "produced": produced}
363
+
364
+
365
+ def _run_per_pack_single(
366
+ pack: Pack,
367
+ recipe: Recipe,
368
+ project,
369
+ output_dir: Path,
370
+ contract: dict,
371
+ produced: dict[str, str],
372
+ ) -> None:
373
+ """Execute the derivation pipeline for a single pack."""
374
+ per_pack_output = output_dir / recipe.output_subdir / pack.name
375
+ _assert_under(per_pack_output, output_dir)
376
+ # Transactional cleanup (Blocker-4): remove any prior partial or
377
+ # crashed build so phantom files do not survive into this build.
378
+ if per_pack_output.exists():
379
+ shutil.rmtree(per_pack_output)
380
+ per_pack_output.mkdir(parents=True, exist_ok=True)
381
+ project(pack.path, contract, per_pack_output)
382
+ plugin_manifest = pack.path / ".claude-plugin" / "plugin.json"
383
+ if plugin_manifest.exists():
384
+ # Validate source-tree manifest against the source schema
385
+ # (forbids hooks; additionalProperties: false ensures any stray
386
+ # hooks block is caught here before synthesis).
387
+ validate_plugin_manifest(plugin_manifest)
388
+ destination = per_pack_output / ".claude-plugin" / "plugin.json"
389
+ destination.parent.mkdir(parents=True, exist_ok=True)
390
+ # Load, splice in synthesised SessionStart hook, re-serialise.
391
+ derived = json.loads(plugin_manifest.read_text(encoding="utf-8"))
392
+ derived["hooks"] = {
393
+ "SessionStart": [{"command": _SESSION_START_COMMAND}]
394
+ }
395
+ # Validate the derived manifest IN MEMORY before writing to disk
396
+ # (Blocker-3: pre-write validation so a synthesis bug never lands
397
+ # a malformed plugin.json in dist/).
398
+ validate_derived_plugin_manifest_dict(
399
+ derived, label=str(destination)
400
+ )
401
+ destination.write_text(
402
+ json.dumps(derived, indent=2, sort_keys=False) + "\n",
403
+ encoding="utf-8",
404
+ )
405
+ # Defence-in-depth: re-validate the written file against the schema
406
+ # to catch any serialise/parse divergence introduced by json.dumps.
407
+ validate_derived_plugin_manifest(destination)
408
+
409
+ # Project pack.toml verbatim (writer reads it for name/version/allowed-scopes).
410
+ pack_toml_src = pack.path / "pack.toml"
411
+ if pack_toml_src.exists():
412
+ shutil.copy2(pack_toml_src, per_pack_output / "pack.toml", follow_symlinks=False)
413
+
414
+ # Project the canonical install-marker.py writer into scripts/.
415
+ scripts_dir = per_pack_output / ".claude-plugin" / "scripts"
416
+ scripts_dir.mkdir(parents=True, exist_ok=True)
417
+ (scripts_dir / "install-marker.py").write_bytes(_read_install_marker_template())
418
+
419
+ # Issue #190: ship the pack's seeds/ inside the plugin artifact so the
420
+ # governance content travels with the pack on the Claude-plugin route
421
+ # (RFC-0001 §281-284). symlinks=True preserves a seed symlink as a
422
+ # symlink rather than dereferencing the build host's file into dist/
423
+ # at build time — matching the APM recipe's copytree posture.
424
+ seeds_src = pack.path / "seeds"
425
+ if seeds_src.is_dir():
426
+ shutil.copytree(seeds_src, per_pack_output / "seeds", symlinks=True)
427
+
428
+ produced[pack.name] = str(per_pack_output)
429
+
430
+
431
+ def _run_per_pack_apm(recipe: Recipe, packs: list[Pack], output_dir: Path) -> dict:
432
+ produced: dict[str, str] = {}
433
+ writer_bytes = _read_install_marker_template()
434
+ for pack in packs:
435
+ per_pack_output = output_dir / recipe.output_subdir / pack.name
436
+ _assert_under(per_pack_output, output_dir)
437
+ # Transactional cleanup: remove any prior partial or crashed build
438
+ # so phantom files do not survive into this build (mirrors the
439
+ # claude-plugins derivation rail).
440
+ if per_pack_output.exists():
441
+ shutil.rmtree(per_pack_output)
442
+ per_pack_output.mkdir(parents=True, exist_ok=True)
443
+ pack_metadata = tomllib.loads((pack.path / "pack.toml").read_text(encoding="utf-8"))
444
+ (per_pack_output / "apm.yml").write_text(
445
+ _render_apm_yml(pack_metadata.get("pack", {})),
446
+ encoding="utf-8",
447
+ )
448
+ apm_source = pack.path / ".apm"
449
+ if apm_source.exists():
450
+ apm_dest = per_pack_output / ".apm"
451
+ if apm_dest.exists():
452
+ shutil.rmtree(apm_dest)
453
+ # symlinks=True preserves symlinks as symlinks rather than
454
+ # dereferencing them — a pack containing a symlink to /etc/passwd
455
+ # cannot exfiltrate the target into the published dist/ tree.
456
+ shutil.copytree(apm_source, apm_dest, symlinks=True)
457
+
458
+ # apm-install-route-parity T4 / AC11: project install-marker
459
+ # artifacts (writer + JSON hook) and pack.toml into the per-pack
460
+ # output. The writer is byte-identical to the canonical template
461
+ # — drift gate (AC16) enforces this at make build-check.
462
+ hooks_dir = per_pack_output / ".apm" / "hooks"
463
+ hooks_dir.mkdir(parents=True, exist_ok=True)
464
+ (hooks_dir / "install-marker.py").write_bytes(writer_bytes)
465
+ (hooks_dir / "install-marker.json").write_text(
466
+ json.dumps(_APM_INSTALL_MARKER_HOOK_JSON, indent=2) + "\n",
467
+ encoding="utf-8",
468
+ )
469
+
470
+ # Project pack.toml verbatim. The writer reads it for
471
+ # name/version/allowed-scopes — same role as in the claude-plugins
472
+ # derivation (spec AC11 c).
473
+ pack_toml_src = pack.path / "pack.toml"
474
+ if pack_toml_src.exists():
475
+ shutil.copy2(
476
+ pack_toml_src,
477
+ per_pack_output / "pack.toml",
478
+ follow_symlinks=False,
479
+ )
480
+
481
+ # Issue #190 / RFC-0001 §595: ship the pack's seeds/ inside the APM
482
+ # package so the governance content travels with the pack on the APM
483
+ # route. symlinks=True preserves a seed symlink as a symlink rather
484
+ # than dereferencing the build host's file into dist/ at build time.
485
+ seeds_src = pack.path / "seeds"
486
+ if seeds_src.is_dir():
487
+ shutil.copytree(seeds_src, per_pack_output / "seeds", symlinks=True)
488
+
489
+ produced[pack.name] = str(per_pack_output)
490
+ return {"recipe": recipe.name, "type": recipe.type, "produced": produced}
491
+
492
+
493
+ def _render_apm_yml(pack_metadata: dict) -> str:
494
+ """Render the per-pack APM package metadata.
495
+
496
+ Stdlib-only — no PyYAML. Values are JSON-encoded scalars (YAML is
497
+ a JSON superset, so a JSON-quoted string is always a valid YAML
498
+ scalar). This blocks YAML-key injection from a pack name or
499
+ description containing newlines or YAML control characters.
500
+ """
501
+ lines = [
502
+ f"name: {json.dumps(pack_metadata.get('name', ''))}",
503
+ f"version: {json.dumps(pack_metadata.get('version', '0.0.0'))}",
504
+ ]
505
+ description = pack_metadata.get("description")
506
+ if description:
507
+ lines.append(f"description: {json.dumps(description)}")
508
+ return "\n".join(lines) + "\n"
509
+
510
+
511
+ def _run_aggregate(recipe: Recipe, output_dir: Path) -> dict:
512
+ input_dir = output_dir / recipe.input_subdir
513
+ _assert_under(input_dir, output_dir)
514
+ entries: list[dict] = []
515
+ if input_dir.exists():
516
+ for plugin_dir in sorted(input_dir.iterdir()):
517
+ manifest = plugin_dir / ".claude-plugin" / "plugin.json"
518
+ if manifest.exists():
519
+ entries.append(json.loads(manifest.read_text(encoding="utf-8")))
520
+ output_path = output_dir / recipe.output_file
521
+ _assert_under(output_path, output_dir)
522
+ output_path.parent.mkdir(parents=True, exist_ok=True)
523
+ output_path.write_text(
524
+ json.dumps({"plugins": entries}, indent=2, sort_keys=True) + "\n",
525
+ encoding="utf-8",
526
+ )
527
+ return {"recipe": recipe.name, "type": recipe.type, "entries": len(entries)}
528
+
529
+
530
+ def _run_overlay(recipe: Recipe, packs: list[Pack]) -> dict:
531
+ expansion = {
532
+ pack.name: [str(pack.path / unit.rstrip("/")) for unit in recipe.units]
533
+ for pack in packs
534
+ }
535
+ return {"recipe": recipe.name, "type": recipe.type, "expansion": expansion}
536
+
537
+
538
+ def _run_composite(recipe: Recipe, packs: list[Pack]) -> dict:
539
+ composed: list[str] = []
540
+ for pack in packs:
541
+ target = pack.path / (recipe.fragment_path or recipe.manifest_path or "")
542
+ if target.exists():
543
+ composed.append(str(target))
544
+ return {"recipe": recipe.name, "type": recipe.type, "composed": composed}
545
+
546
+
547
+ def run_default_build(
548
+ packs_dir: Path, output_dir: Path, contract: dict | None = None
549
+ ) -> list[dict]:
550
+ """Run the three RFC-0001 recipes — what plain `make build` invokes."""
551
+ if contract is None:
552
+ contract = tomllib.loads(_read_bundled("adapter.toml"))
553
+ packs = discover_packs(packs_dir)
554
+ results: list[dict] = []
555
+ for recipe_name in DEFAULT_RECIPES:
556
+ recipe = load_recipe(recipe_name)
557
+ results.append(run_recipe(recipe, packs, output_dir, contract))
558
+ return results
559
+
560
+
561
+ def cmd_build(args) -> int:
562
+ """argparse entrypoint for the `build` subcommand."""
563
+ output_dir = Path(args.output_dir).resolve()
564
+ packs_dir = Path(args.packs_dir).resolve()
565
+ try:
566
+ contract = tomllib.loads(_read_bundled("adapter.toml"))
567
+ except Exception as exc:
568
+ print(f"build: failed to load contract: {exc}", file=sys.stderr)
569
+ return 1
570
+
571
+ if args.recipe:
572
+ try:
573
+ if "/" in args.recipe or args.recipe.endswith(".toml"):
574
+ recipe = load_recipe_from_path(Path(args.recipe))
575
+ else:
576
+ recipe = load_recipe(args.recipe)
577
+ except FileNotFoundError as exc:
578
+ print(f"build: {exc}", file=sys.stderr)
579
+ return 1
580
+ try:
581
+ packs = discover_packs(packs_dir)
582
+ if args.pack:
583
+ packs = [p for p in packs if p.name == args.pack]
584
+ run_recipe(recipe, packs, output_dir, contract)
585
+ except ValueError as exc:
586
+ print(f"build: {exc}", file=sys.stderr)
587
+ return 1
588
+ return 0
589
+
590
+ # Default `build` (no --recipe): run the three RFC-0001 recipes.
591
+ try:
592
+ run_default_build(packs_dir, output_dir, contract)
593
+ except ValueError as exc:
594
+ print(f"build: {exc}", file=sys.stderr)
595
+ return 1
596
+ return 0
@@ -0,0 +1,40 @@
1
+ """Build-pipeline phase order (RFC-0005 § Build-pipeline ordering invariant).
2
+
3
+ Single source of truth for the order primitives project within each
4
+ pack: ``hook-body`` → ``agent`` → ``hook-wiring`` → ``kiro-ide-hook``
5
+ → ``command`` → ``skill``.
6
+
7
+ Two real dependencies drive the order:
8
+
9
+ 1. **hook-wiring ← agent.** Kiro's ``merge-into-agent-json``
10
+ projection reads the agent JSON the agent projection wrote.
11
+ 2. **kiro-ide-hook ← hook-body** (RFC-0005 § Substitution rules,
12
+ v0.4). The ``kiro-ide-hook`` projector expands
13
+ ``${hook-body:<name>}`` placeholders in ``then.command`` to
14
+ the projected hook-body path. The hook-body files must already
15
+ exist (or at least be enumerable) when the substitution runs.
16
+
17
+ Every other ordering — ``hook-body`` → ``agent``, ``hook-wiring`` →
18
+ ``kiro-ide-hook``, ``command`` and ``skill`` relative to anything
19
+ else — is a **tiebreak**, not a dependency. The strict serial order
20
+ above is the picked tiebreak, pinned for *operational* determinism
21
+ (log ordering, partial-state-on-failure semantics, rollback
22
+ target). RFC-0005 § Substitution rules → *Why serial rather than
23
+ DAG-parallel* spells this out.
24
+
25
+ Each reference adapter (``claude_code``, ``kiro``, ``copilot``,
26
+ ``codex``) imports ``PHASE_ORDER`` from this module so a future
27
+ contract revision changes one line, not four.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+
33
+ PHASE_ORDER: tuple[str, ...] = (
34
+ "hook-body",
35
+ "agent",
36
+ "hook-wiring",
37
+ "kiro-ide-hook",
38
+ "command",
39
+ "skill",
40
+ )
@@ -0,0 +1,13 @@
1
+ """Projection-mode implementations for v0.3 user-scope hook handling.
2
+
3
+ Each module here implements one projection ``mode`` value from the
4
+ adapter contract: ``user_merge_json`` (Claude Code user scope —
5
+ shared-file merge into ``~/.claude/settings.json``) and
6
+ ``merge_into_agent_json`` (Kiro repo + user scope — merge into the
7
+ pack-owned agent JSON). Shared id-synthesis lives in ``hook_id``.
8
+
9
+ The pipeline (T7) wires these in; the CLI install / uninstall surface
10
+ (T8b) drives them. Per RFC-0005 § Pipeline ordering invariant, the
11
+ build pipeline must project ``agent`` files before any wiring merges
12
+ run — that ordering is the pipeline's concern, not these modules'.
13
+ """