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