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,688 @@
|
|
|
1
|
+
"""`agentbundle validate` subcommand.
|
|
2
|
+
|
|
3
|
+
Validates a pack directory's ``pack.toml`` against ``pack.schema.json``,
|
|
4
|
+
enforces the six-recipe enumeration, and applies the spec-version gate.
|
|
5
|
+
With ``--strict``, runs conformance fixtures via the F-build render
|
|
6
|
+
pipeline when they are present; warns and exits 0 when absent (v1 ship
|
|
7
|
+
state — F-conformance deferred to v1.1 per RFC-0003).
|
|
8
|
+
|
|
9
|
+
Exit codes:
|
|
10
|
+
0 — pack is schema-valid (and conformance fixtures pass if --strict).
|
|
11
|
+
1 — any schema error, unknown recipe, version mismatch, or conformance
|
|
12
|
+
failure; one-line stderr reason printed for each failure.
|
|
13
|
+
|
|
14
|
+
Usage (wired by cli.py):
|
|
15
|
+
args.pack_path — path to a pack directory containing pack.toml.
|
|
16
|
+
args.strict — bool; run conformance fixtures when present.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
import tomllib
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from agentbundle.commands import _drop_warning
|
|
27
|
+
from agentbundle.commands._common import check_spec_version_gate
|
|
28
|
+
|
|
29
|
+
# Stdlib only — no third-party deps.
|
|
30
|
+
|
|
31
|
+
# Six-recipe enumerated set from the sibling distribution-adapters spec.
|
|
32
|
+
VALID_RECIPES: frozenset[str] = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"per-pack-claude-plugin",
|
|
35
|
+
"per-pack-apm-package",
|
|
36
|
+
"marketplace",
|
|
37
|
+
"per-pack-overlay",
|
|
38
|
+
"composite-agents-md",
|
|
39
|
+
"composite-marketplace",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Location of pack.schema.json relative to the repo root. The schema is
|
|
44
|
+
# bundled in docs/contracts/ and is also bundled at
|
|
45
|
+
# agentbundle/_data/pack.schema.json for zipapp use.
|
|
46
|
+
_HERE = Path(__file__).resolve().parent
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _schema_path() -> Path:
|
|
50
|
+
"""Locate pack.schema.json — bundled copy preferred, dev fallback."""
|
|
51
|
+
bundled = _HERE.parent / "_data" / "pack.schema.json"
|
|
52
|
+
if bundled.exists():
|
|
53
|
+
return bundled
|
|
54
|
+
# Dev fallback: walk up from agentbundle/ package to repo root.
|
|
55
|
+
repo_root = _HERE.parent.parent.parent.parent
|
|
56
|
+
return repo_root / "docs" / "contracts" / "pack.schema.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _conformance_fixtures_dir() -> Path:
|
|
60
|
+
"""Return the expected path for conformance fixtures."""
|
|
61
|
+
# packages/agentbundle/tests/fixtures/conformance/
|
|
62
|
+
pkg_root = _HERE.parent.parent.parent
|
|
63
|
+
return pkg_root / "tests" / "fixtures" / "conformance"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run(args) -> int:
|
|
67
|
+
"""Entry point called by the CLI dispatcher. Returns exit code."""
|
|
68
|
+
pack_path = Path(args.pack_path)
|
|
69
|
+
strict: bool = getattr(args, "strict", False)
|
|
70
|
+
|
|
71
|
+
# ── 1. Locate and load pack.toml ──────────────────────────────────────
|
|
72
|
+
pack_toml_path = pack_path / "pack.toml"
|
|
73
|
+
if not pack_toml_path.exists():
|
|
74
|
+
print(
|
|
75
|
+
f"validate: pack.toml not found at {pack_toml_path}",
|
|
76
|
+
file=sys.stderr,
|
|
77
|
+
)
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
raw_toml = pack_toml_path.read_text(encoding="utf-8")
|
|
82
|
+
pack_data = tomllib.loads(raw_toml)
|
|
83
|
+
except tomllib.TOMLDecodeError as exc:
|
|
84
|
+
print(
|
|
85
|
+
f"validate: pack.toml is not valid TOML: {exc}",
|
|
86
|
+
file=sys.stderr,
|
|
87
|
+
)
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
# ── 2. Spec-version gate ──────────────────────────────────────────────
|
|
91
|
+
gate = check_spec_version_gate(pack_data)
|
|
92
|
+
if gate is not None:
|
|
93
|
+
return gate
|
|
94
|
+
|
|
95
|
+
# ── 3. Schema validation ──────────────────────────────────────────────
|
|
96
|
+
schema_path = _schema_path()
|
|
97
|
+
if not schema_path.exists():
|
|
98
|
+
print(
|
|
99
|
+
f"validate: pack.schema.json not found at {schema_path}",
|
|
100
|
+
file=sys.stderr,
|
|
101
|
+
)
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
105
|
+
|
|
106
|
+
# Import validate_instance from F-build (library-first).
|
|
107
|
+
from agentbundle.build.validate import validate as validate_instance
|
|
108
|
+
|
|
109
|
+
errors = validate_instance(pack_data, schema)
|
|
110
|
+
if errors:
|
|
111
|
+
# RFC-0004 § *Install-scope dimension* names a specific stderr
|
|
112
|
+
# text for the cross-field invariant: `pack <name>: default-scope
|
|
113
|
+
# '<requested>' not in allowed-scopes <declared-set>`. The
|
|
114
|
+
# schema's `contains` failure on `$.pack.install.allowed-scopes`
|
|
115
|
+
# is the structural form of that violation; surface it with the
|
|
116
|
+
# spec-named text instead of the generic schema message so
|
|
117
|
+
# adopters get the actionable line.
|
|
118
|
+
if _is_default_scope_invariant_violation(pack_data, errors[0]):
|
|
119
|
+
pack_name = pack_data.get("pack", {}).get("name", pack_path.name)
|
|
120
|
+
install = pack_data.get("pack", {}).get("install", {})
|
|
121
|
+
requested = install.get("default-scope")
|
|
122
|
+
allowed = install.get("allowed-scopes", [])
|
|
123
|
+
print(
|
|
124
|
+
f"validate: pack {pack_name}: default-scope {requested!r} "
|
|
125
|
+
f"not in allowed-scopes {allowed}",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
return 1
|
|
129
|
+
# One-line stderr: first error only (spec says "one-line reason").
|
|
130
|
+
print(
|
|
131
|
+
f"validate: schema error — {errors[0]}",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
return 1
|
|
135
|
+
|
|
136
|
+
# ── 4. Recipe gate ────────────────────────────────────────────────────
|
|
137
|
+
recipes = _extract_recipes(pack_data)
|
|
138
|
+
for recipe in recipes:
|
|
139
|
+
if recipe not in VALID_RECIPES:
|
|
140
|
+
print(
|
|
141
|
+
f"validate: unknown recipe {recipe!r}; "
|
|
142
|
+
f"valid recipes are {sorted(VALID_RECIPES)}",
|
|
143
|
+
file=sys.stderr,
|
|
144
|
+
)
|
|
145
|
+
return 1
|
|
146
|
+
|
|
147
|
+
# ── 4a. Allowed-adapters cross-field check (RFC-0011 / AC3, AC22) ─────
|
|
148
|
+
# The schema admits `allowed-adapters` as `array<string>` but doesn't
|
|
149
|
+
# hardcode the adapter enum (it's contract-derived). Validate here:
|
|
150
|
+
# - every entry must be in the bundled contract's shipped-adapter set;
|
|
151
|
+
# - when "user" ∈ allowed-scopes, every entry must additionally be in
|
|
152
|
+
# the user-scope-capable subset (declare an `[adapter.<name>.scope]`
|
|
153
|
+
# table). The refuse-and-explain messages match the wording pinned
|
|
154
|
+
# in spec § *Always do*.
|
|
155
|
+
aa_refusal = _validate_allowed_adapters(pack_data)
|
|
156
|
+
if aa_refusal is not None:
|
|
157
|
+
print(f"validate: pack.toml: {aa_refusal}", file=sys.stderr)
|
|
158
|
+
return 1
|
|
159
|
+
|
|
160
|
+
# ── 4b. User-scope refusal rails (RFC-0004 A/B/C) ─────────────────────
|
|
161
|
+
# Rails fire only when the pack declares "user" ∈ allowed-scopes. The
|
|
162
|
+
# rails run *after* schema validation so we know `[pack.install]`'s
|
|
163
|
+
# shape (when present) is well-formed before we read it. v0.1 packs
|
|
164
|
+
# have implied `allowed-scopes = ["repo"]`, so the rails are
|
|
165
|
+
# vacuously satisfied — `_allowed_scopes` returns `["repo"]` for
|
|
166
|
+
# them.
|
|
167
|
+
from agentbundle.build.scope_rails import run_all as run_scope_rails
|
|
168
|
+
|
|
169
|
+
allowed = _allowed_scopes(pack_data)
|
|
170
|
+
user_scope_hooks = _user_scope_hooks_opt_in(pack_data)
|
|
171
|
+
rail_refusal = run_scope_rails(pack_path, allowed, user_scope_hooks)
|
|
172
|
+
if rail_refusal is not None:
|
|
173
|
+
pack_name = (
|
|
174
|
+
pack_data.get("pack", {}).get("name") or pack_path.name
|
|
175
|
+
)
|
|
176
|
+
print(
|
|
177
|
+
f"validate: {pack_name}: {rail_refusal}",
|
|
178
|
+
file=sys.stderr,
|
|
179
|
+
)
|
|
180
|
+
return 1
|
|
181
|
+
|
|
182
|
+
# ── 4c/4d. Kiro hook-wiring rails (RFC-0005, T2 / T6) ────────────────
|
|
183
|
+
# Rails 4c (attach-to-agent) and 4d (event-vocabulary) are now merged
|
|
184
|
+
# into a single dispatch that swallows the compatibility-only refusals
|
|
185
|
+
# (missing attach-to-agent, out-of-vocab event) while preserving
|
|
186
|
+
# exit-1 for security (symlink) and correctness (parse-fail,
|
|
187
|
+
# unknown-agent) violations.
|
|
188
|
+
#
|
|
189
|
+
# Spec: docs/specs/incompatible-hook-event-drop AC1–AC5, AC6b.
|
|
190
|
+
from agentbundle.build.scope_rails import _load_pack_hook_wiring_safely
|
|
191
|
+
|
|
192
|
+
target_adapters = _kiro_target_adapters(pack_data, pack_path)
|
|
193
|
+
pack_name = pack_data.get("pack", {}).get("name") or pack_path.name
|
|
194
|
+
|
|
195
|
+
if "kiro" in target_adapters:
|
|
196
|
+
# 1. Safe-load: security + correctness refusals (AC3, AC3b, AC4).
|
|
197
|
+
loaded = _load_pack_hook_wiring_safely(pack_path, pack_name)
|
|
198
|
+
if isinstance(loaded, str):
|
|
199
|
+
print(f"validate: {loaded}", file=sys.stderr)
|
|
200
|
+
return 1
|
|
201
|
+
wiring_tomls, agent_basenames = loaded
|
|
202
|
+
|
|
203
|
+
# 2. Unknown-agent refusal (AC4b) — discriminated from input data,
|
|
204
|
+
# NOT from inspecting check_kiro_attach_to_agent's refusal text,
|
|
205
|
+
# which is bytewise identical for missing-vs-unknown subcases per
|
|
206
|
+
# scope_rails.py:333-337 (load-bearing per round-2 review).
|
|
207
|
+
#
|
|
208
|
+
# Condition: attach is not None AND is a string AND not in
|
|
209
|
+
# agent_basenames — matches spec AC4b exactly.
|
|
210
|
+
# Empty string is preserved as "unknown agent" (kept refusal,
|
|
211
|
+
# exit 1) to match today's helper behavior at scope_rails.py:332
|
|
212
|
+
# — `"" not in agent_basenames` is True, so today's helper
|
|
213
|
+
# refuses on attach = ""; this spec preserves that.
|
|
214
|
+
for stem, body in sorted(wiring_tomls.items()):
|
|
215
|
+
attach = body.get("attach-to-agent") if isinstance(body, dict) else None
|
|
216
|
+
if (
|
|
217
|
+
attach is not None
|
|
218
|
+
and isinstance(attach, str)
|
|
219
|
+
and attach not in agent_basenames
|
|
220
|
+
):
|
|
221
|
+
# Refusal text matches check_kiro_attach_to_agent's
|
|
222
|
+
# pinned wording byte-for-byte (RFC-0005:474). Single
|
|
223
|
+
# source of truth: the helper composes the same string;
|
|
224
|
+
# a future RFC-0005 wording change must update both.
|
|
225
|
+
refusal = (
|
|
226
|
+
f"pack {pack_name}'s hook-wiring {stem}.toml "
|
|
227
|
+
f"does not declare 'attach-to-agent' (or names an unknown "
|
|
228
|
+
f"agent); required for kiro projection"
|
|
229
|
+
)
|
|
230
|
+
print(f"validate: {refusal}", file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
|
|
233
|
+
# 3. Compatibility drops (missing-attach OR out-of-vocab event) —
|
|
234
|
+
# flow to the shared enumerator + info-line emit. Single source
|
|
235
|
+
# of truth with the install side (AC6b).
|
|
236
|
+
contract = _load_adapter_contract()
|
|
237
|
+
info_drops = _drop_warning.enumerate_event_dropped_wirings(
|
|
238
|
+
pack_path, "kiro", contract,
|
|
239
|
+
)
|
|
240
|
+
if info_drops:
|
|
241
|
+
info = _drop_warning.format_drop_message(
|
|
242
|
+
mode="validate_info",
|
|
243
|
+
pack_name=pack_name,
|
|
244
|
+
adapter="kiro",
|
|
245
|
+
dropped_counts={},
|
|
246
|
+
compatible_types=[],
|
|
247
|
+
event_drops=info_drops,
|
|
248
|
+
)
|
|
249
|
+
print(info) # stdout per AC2 + adopter direction
|
|
250
|
+
|
|
251
|
+
# ── 4e. kiro-ide-hook validate rail (RFC-0005 v0.4, T-C2) ────────────
|
|
252
|
+
# Fires whenever the pack ships `.apm/kiro-ide-hooks/` content. The
|
|
253
|
+
# rail's `target_adapters` heuristic differs from `_kiro_target_adapters`
|
|
254
|
+
# — kiro-ide-hook needs no agent (file-event triggers fire
|
|
255
|
+
# independent of agent runtime; cf. § Pack-side schema), so a
|
|
256
|
+
# pack with kiro-ide-hooks but no `.apm/agents/` still targets
|
|
257
|
+
# kiro. Cheapest heuristic: presence of the source directory with
|
|
258
|
+
# at least one *.kiro.hook file.
|
|
259
|
+
if _dir_has_any_kiro_ide_hook(pack_path / ".apm" / "kiro-ide-hooks"):
|
|
260
|
+
from agentbundle.build.scope_rails import check_kiro_ide_hook
|
|
261
|
+
|
|
262
|
+
ide_event_vocab, ide_action_vocab = _kiro_ide_hook_vocabularies()
|
|
263
|
+
ide_hook_refusal = check_kiro_ide_hook(
|
|
264
|
+
pack_path=pack_path,
|
|
265
|
+
pack_name=pack_name,
|
|
266
|
+
target_adapters=("kiro", "kiro-ide"), # "kiro" alias + canonical name
|
|
267
|
+
ide_event_vocabulary=ide_event_vocab,
|
|
268
|
+
ide_action_vocabulary=ide_action_vocab,
|
|
269
|
+
)
|
|
270
|
+
if ide_hook_refusal is not None:
|
|
271
|
+
print(f"validate: {ide_hook_refusal}", file=sys.stderr)
|
|
272
|
+
return 1
|
|
273
|
+
|
|
274
|
+
# ── 5. Strict / conformance mode ─────────────────────────────────────
|
|
275
|
+
if strict:
|
|
276
|
+
conformance_dir = _conformance_fixtures_dir()
|
|
277
|
+
if not conformance_dir.exists():
|
|
278
|
+
print(
|
|
279
|
+
"--strict conformance fixtures not present — skipping",
|
|
280
|
+
file=sys.stderr,
|
|
281
|
+
)
|
|
282
|
+
# Exit 0 on schema portion (v1 carve-out).
|
|
283
|
+
return 0
|
|
284
|
+
# Conformance fixtures present — run them.
|
|
285
|
+
rc = _run_conformance(pack_path, conformance_dir)
|
|
286
|
+
if rc != 0:
|
|
287
|
+
return rc
|
|
288
|
+
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _is_default_scope_invariant_violation(pack_data: dict, first_error: str) -> bool:
|
|
293
|
+
"""Return True when the first schema error is the cross-field invariant.
|
|
294
|
+
|
|
295
|
+
The schema's `if`/`then` block for `default-scope ∈ allowed-scopes`
|
|
296
|
+
surfaces as a `contains` failure on `$.pack.install.allowed-scopes`.
|
|
297
|
+
We also confirm the pack actually has the shape that triggered the
|
|
298
|
+
error (default-scope declared, allowed-scopes declared, default not
|
|
299
|
+
in allowed) so we don't mis-attribute an unrelated `contains`
|
|
300
|
+
failure to this rule.
|
|
301
|
+
"""
|
|
302
|
+
install = pack_data.get("pack", {}).get("install")
|
|
303
|
+
if not isinstance(install, dict):
|
|
304
|
+
return False
|
|
305
|
+
default = install.get("default-scope")
|
|
306
|
+
allowed = install.get("allowed-scopes")
|
|
307
|
+
if not isinstance(default, str) or not isinstance(allowed, list):
|
|
308
|
+
return False
|
|
309
|
+
if default in allowed:
|
|
310
|
+
return False
|
|
311
|
+
# Match the validator's error path heuristically.
|
|
312
|
+
return (
|
|
313
|
+
"pack.install.allowed-scopes" in first_error
|
|
314
|
+
or "allowed-scopes" in first_error
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _user_scope_hooks_opt_in(pack_data: dict) -> bool:
|
|
319
|
+
"""Return True iff the pack declares ``[pack.install] user-scope-hooks = true``.
|
|
320
|
+
|
|
321
|
+
RFC-0005 § Rail B — user-scope lift: the flag is the consent
|
|
322
|
+
gesture that lets a pack ship hook-shaped primitives at user scope.
|
|
323
|
+
Absent or non-boolean → False (rail still refuses the pack).
|
|
324
|
+
"""
|
|
325
|
+
pack = pack_data.get("pack", {})
|
|
326
|
+
if not isinstance(pack, dict):
|
|
327
|
+
return False
|
|
328
|
+
install = pack.get("install", {})
|
|
329
|
+
if not isinstance(install, dict):
|
|
330
|
+
return False
|
|
331
|
+
flag = install.get("user-scope-hooks")
|
|
332
|
+
return flag is True
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _kiro_target_adapters(pack_data: dict, pack_path: Path) -> set[str]:
|
|
336
|
+
"""Resolve the target-adapter set for the kiro hook-wiring rail.
|
|
337
|
+
|
|
338
|
+
Two paths:
|
|
339
|
+
|
|
340
|
+
- v0.6+ packs declaring ``allowed-adapters`` use the field as the
|
|
341
|
+
source of truth: ``"kiro" in allowed-adapters`` ⇒ ``{"kiro"}``,
|
|
342
|
+
else ``set()``. The rail is a no-op for v0.6+ packs that
|
|
343
|
+
deliberately exclude kiro.
|
|
344
|
+
|
|
345
|
+
- All other hook-wiring-capable contract versions (v0.3+, per
|
|
346
|
+
the ``contract_supports_hook_wiring`` predicate that replaces
|
|
347
|
+
the round-1 literal ``version != "0.3"`` gate) fall through
|
|
348
|
+
to on-disk inference: pack ships both ``.apm/agents/`` and
|
|
349
|
+
``.apm/hook-wiring/`` ⇒ ``{"kiro"}``. Pre-hook-wiring
|
|
350
|
+
contracts (v0.1, v0.2) skip the rail.
|
|
351
|
+
|
|
352
|
+
Returns ``{"kiro"}`` when the rail should fire; empty set
|
|
353
|
+
(rail no-op) otherwise.
|
|
354
|
+
"""
|
|
355
|
+
from agentbundle.scope import contract_supports_hook_wiring
|
|
356
|
+
|
|
357
|
+
pack = pack_data.get("pack", {})
|
|
358
|
+
if not isinstance(pack, dict):
|
|
359
|
+
return set()
|
|
360
|
+
contract = pack.get("adapter-contract")
|
|
361
|
+
if not isinstance(contract, dict):
|
|
362
|
+
return set()
|
|
363
|
+
version = contract.get("version")
|
|
364
|
+
if not contract_supports_hook_wiring(version):
|
|
365
|
+
return set()
|
|
366
|
+
|
|
367
|
+
# v0.6+ declarative early-return from allowed-adapters.
|
|
368
|
+
install = pack.get("install")
|
|
369
|
+
if isinstance(install, dict):
|
|
370
|
+
allowed = install.get("allowed-adapters")
|
|
371
|
+
if isinstance(allowed, list):
|
|
372
|
+
allowed_strs = [s for s in allowed if isinstance(s, str)]
|
|
373
|
+
return {"kiro"} if "kiro" in allowed_strs else set()
|
|
374
|
+
|
|
375
|
+
# Heuristic: kiro projection requires a same-pack agent. A pack
|
|
376
|
+
# with wiring but no agents has nothing to attach to.
|
|
377
|
+
agents_dir = pack_path / ".apm" / "agents"
|
|
378
|
+
wiring_dir = pack_path / ".apm" / "hook-wiring"
|
|
379
|
+
if not _dir_has_any_file(agents_dir, ".md"):
|
|
380
|
+
return set()
|
|
381
|
+
if not _dir_has_any_file(wiring_dir, ".toml"):
|
|
382
|
+
return set()
|
|
383
|
+
return {"kiro"}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _dir_has_any_file(directory: Path, suffix: str) -> bool:
|
|
387
|
+
"""Return True if *directory* exists and contains at least one file
|
|
388
|
+
with *suffix*. Symlinks are ignored — the kiro rail consumes them
|
|
389
|
+
through `check_kiro_wiring`, which mirrors rail C's symlink refusal."""
|
|
390
|
+
if not directory.exists():
|
|
391
|
+
return False
|
|
392
|
+
for entry in directory.iterdir():
|
|
393
|
+
if entry.is_file() and entry.suffix == suffix:
|
|
394
|
+
return True
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _dir_has_any_kiro_ide_hook(directory: Path) -> bool:
|
|
399
|
+
"""``.kiro.hook`` is a compound extension; ``Path.suffix`` only
|
|
400
|
+
returns ``.hook``, so the generic helper above misses it. A
|
|
401
|
+
dedicated check keeps the call site readable and pins the
|
|
402
|
+
compound-extension assumption in one place."""
|
|
403
|
+
if not directory.exists():
|
|
404
|
+
return False
|
|
405
|
+
for entry in directory.iterdir():
|
|
406
|
+
if entry.is_file() and entry.name.endswith(".kiro.hook"):
|
|
407
|
+
return True
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _kiro_ide_hook_vocabularies() -> tuple[list[str] | None, list[str] | None]:
|
|
412
|
+
"""Resolve the kiro adapter's ``ide-event-vocabulary`` and
|
|
413
|
+
``ide-action-vocabulary`` from the bundled contract.
|
|
414
|
+
|
|
415
|
+
Returns (None, None) when the contract pre-dates v0.4 (the
|
|
416
|
+
``[adapter.kiro.projections.kiro-ide-hook]`` table doesn't exist),
|
|
417
|
+
which makes checks 2 and 3 of the validate rail no-ops — the
|
|
418
|
+
rail's checks 1 / 4 / 5 (required fields, malformed placeholder,
|
|
419
|
+
unresolvable placeholder) still fire because they're vocabulary-
|
|
420
|
+
independent.
|
|
421
|
+
|
|
422
|
+
Load-at-call-time discipline: the contract file is the source of
|
|
423
|
+
truth; no module-level cache so a test-time swap is visible
|
|
424
|
+
immediately.
|
|
425
|
+
"""
|
|
426
|
+
from agentbundle.build.contract import load as load_contract
|
|
427
|
+
|
|
428
|
+
here = Path(__file__).resolve().parent
|
|
429
|
+
bundled = here.parent / "_data" / "adapter.toml"
|
|
430
|
+
if bundled.exists():
|
|
431
|
+
contract_path = bundled
|
|
432
|
+
else:
|
|
433
|
+
contract_path = here.parent.parent.parent.parent / "docs" / "contracts" / "adapter.toml"
|
|
434
|
+
if not contract_path.exists():
|
|
435
|
+
return None, None
|
|
436
|
+
contract = load_contract(contract_path)
|
|
437
|
+
adapters = contract.get("adapter", {})
|
|
438
|
+
# Prefer the kiro-ide block (v0.9+); fall back to kiro alias (pre-T1).
|
|
439
|
+
kiro = adapters.get("kiro-ide") or adapters.get("kiro") or {}
|
|
440
|
+
projections = kiro.get("projections", {}) if isinstance(kiro, dict) else {}
|
|
441
|
+
rule = projections.get("kiro-ide-hook", {}) if isinstance(projections, dict) else {}
|
|
442
|
+
|
|
443
|
+
def _as_string_list(value: object) -> list[str] | None:
|
|
444
|
+
if isinstance(value, list):
|
|
445
|
+
return [str(v) for v in value if isinstance(v, str)]
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
_as_string_list(rule.get("ide-event-vocabulary")) if isinstance(rule, dict) else None,
|
|
450
|
+
_as_string_list(rule.get("ide-action-vocabulary")) if isinstance(rule, dict) else None,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _load_adapter_contract() -> dict:
|
|
455
|
+
"""Load the bundled adapter contract dict.
|
|
456
|
+
|
|
457
|
+
Same load-at-call-time discipline as
|
|
458
|
+
``_kiro_ide_hook_vocabularies`` — the contract file is the source of
|
|
459
|
+
truth; no module-level cache so a test-time swap is visible.
|
|
460
|
+
|
|
461
|
+
Returns an empty dict when neither the bundled nor the dev-checkout
|
|
462
|
+
path exists (keeps the enumeration rail a no-op rather than crashing).
|
|
463
|
+
"""
|
|
464
|
+
from agentbundle.build.contract import load as load_contract
|
|
465
|
+
|
|
466
|
+
here = Path(__file__).resolve().parent
|
|
467
|
+
bundled = here.parent / "_data" / "adapter.toml"
|
|
468
|
+
if bundled.exists():
|
|
469
|
+
contract_path = bundled
|
|
470
|
+
else:
|
|
471
|
+
contract_path = (
|
|
472
|
+
here.parent.parent.parent.parent / "docs" / "contracts" / "adapter.toml"
|
|
473
|
+
)
|
|
474
|
+
if not contract_path.exists():
|
|
475
|
+
return {}
|
|
476
|
+
return load_contract(contract_path)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _validate_allowed_adapters(pack_data: dict) -> str | None:
|
|
480
|
+
"""Cross-field check for ``[pack.install] allowed-adapters``
|
|
481
|
+
(RFC-0011 substrate; RFC-0012 widens to fire at both scopes).
|
|
482
|
+
|
|
483
|
+
Returns None when the field is absent / valid; returns a
|
|
484
|
+
refuse-and-explain string suitable for printing under the
|
|
485
|
+
``validate: pack.toml: ...`` prefix on violation. Reads the
|
|
486
|
+
bundled adapter contract for the shipped + user-scope-capable
|
|
487
|
+
sets; if the contract doesn't ship a value the pack declares,
|
|
488
|
+
that's the publisher-vs-installer drift case. **The shipped
|
|
489
|
+
check fires at both scopes** (RFC-0012); the user-scope-capability
|
|
490
|
+
subcheck is **scope-conditional** — fires only when the pack's
|
|
491
|
+
resolved scope is user (so a Copilot-bearing pack at
|
|
492
|
+
``default-scope = "repo"`` admits cleanly).
|
|
493
|
+
"""
|
|
494
|
+
import tomllib
|
|
495
|
+
|
|
496
|
+
from agentbundle.scope import (
|
|
497
|
+
shipped_adapters_from_contract,
|
|
498
|
+
user_scope_capable_adapters_from_contract,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
pack = pack_data.get("pack", {})
|
|
502
|
+
if not isinstance(pack, dict):
|
|
503
|
+
return None
|
|
504
|
+
install = pack.get("install")
|
|
505
|
+
if not isinstance(install, dict):
|
|
506
|
+
return None
|
|
507
|
+
declared = install.get("allowed-adapters")
|
|
508
|
+
if not isinstance(declared, list):
|
|
509
|
+
return None
|
|
510
|
+
declared_strs = [s for s in declared if isinstance(s, str)]
|
|
511
|
+
if not declared_strs:
|
|
512
|
+
return None
|
|
513
|
+
if len(declared_strs) != len(set(declared_strs)):
|
|
514
|
+
return "[pack.install] allowed-adapters contains duplicate values"
|
|
515
|
+
|
|
516
|
+
shipped = shipped_adapters_from_contract()
|
|
517
|
+
user_capable = user_scope_capable_adapters_from_contract()
|
|
518
|
+
user_eligible = "user" in _allowed_scopes(pack_data)
|
|
519
|
+
|
|
520
|
+
for name in declared_strs:
|
|
521
|
+
if name not in shipped:
|
|
522
|
+
return (
|
|
523
|
+
f"[pack.install] allowed-adapters contains {name!r}, "
|
|
524
|
+
f"which is not a shipped adapter under the bundled "
|
|
525
|
+
f"contract"
|
|
526
|
+
)
|
|
527
|
+
if user_eligible and name not in user_capable:
|
|
528
|
+
# Read the bundled contract version once so the message
|
|
529
|
+
# tracks RFC-0012's v0.7 bump without per-spec edits.
|
|
530
|
+
from agentbundle.build.main import _read_bundled
|
|
531
|
+
|
|
532
|
+
cv = (
|
|
533
|
+
tomllib.loads(_read_bundled("adapter.toml"))
|
|
534
|
+
.get("contract", {})
|
|
535
|
+
.get("version", "?")
|
|
536
|
+
)
|
|
537
|
+
return (
|
|
538
|
+
f"[pack.install] allowed-adapters contains {name!r}, "
|
|
539
|
+
f"which does not declare a user-scope root in the v{cv} "
|
|
540
|
+
f"adapter contract"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _allowed_scopes(pack_data: dict) -> list[str]:
|
|
547
|
+
"""Return the pack's resolved allowed-scopes list.
|
|
548
|
+
|
|
549
|
+
Resolution mirrors RFC-0004 § *v0.1 vs v0.2 contract acceptance*:
|
|
550
|
+
|
|
551
|
+
- v0.1 packs (declared version "0.1", or no `[pack.adapter-contract]`)
|
|
552
|
+
get the implied `["repo"]`. Any stray `[pack.install]` table is
|
|
553
|
+
ignored.
|
|
554
|
+
- v0.2 packs read `[pack.install].allowed-scopes` when present; when
|
|
555
|
+
only `default-scope` is declared, the implied default is
|
|
556
|
+
`[default-scope]`.
|
|
557
|
+
|
|
558
|
+
The cross-field `default-scope ∈ allowed-scopes` invariant is owned
|
|
559
|
+
by the schema; we trust the schema's verdict here and only resolve
|
|
560
|
+
the list.
|
|
561
|
+
"""
|
|
562
|
+
pack = pack_data.get("pack", {})
|
|
563
|
+
if not isinstance(pack, dict):
|
|
564
|
+
return ["repo"]
|
|
565
|
+
contract_version = (
|
|
566
|
+
pack.get("adapter-contract", {}).get("version")
|
|
567
|
+
if isinstance(pack.get("adapter-contract"), dict)
|
|
568
|
+
else None
|
|
569
|
+
)
|
|
570
|
+
# v0.2 introduced `[pack.install]`; v0.3 added `user-scope-hooks`;
|
|
571
|
+
# v0.6 added `allowed-adapters`. Treat any contract version >= 0.2
|
|
572
|
+
# as carrying the install table. The legacy v0.1 path (and any pack
|
|
573
|
+
# without an adapter-contract declaration) stays repo-only.
|
|
574
|
+
if contract_version is None or contract_version == "0.1":
|
|
575
|
+
return ["repo"]
|
|
576
|
+
install = pack.get("install", {})
|
|
577
|
+
if not isinstance(install, dict):
|
|
578
|
+
return ["repo"]
|
|
579
|
+
allowed = install.get("allowed-scopes")
|
|
580
|
+
if isinstance(allowed, list) and allowed:
|
|
581
|
+
return [s for s in allowed if isinstance(s, str)]
|
|
582
|
+
default = install.get("default-scope")
|
|
583
|
+
if isinstance(default, str):
|
|
584
|
+
return [default]
|
|
585
|
+
return ["repo"]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _extract_recipes(pack_data: dict) -> list[str]:
|
|
589
|
+
"""Return the list of recipe names the pack declares, if any.
|
|
590
|
+
|
|
591
|
+
The schema allows a pack to declare recipes at ``[pack].recipes``
|
|
592
|
+
(a list of strings). Returns an empty list if the field is absent or
|
|
593
|
+
the pack table is missing.
|
|
594
|
+
"""
|
|
595
|
+
pack_table = pack_data.get("pack", {})
|
|
596
|
+
if not isinstance(pack_table, dict):
|
|
597
|
+
return []
|
|
598
|
+
recipes = pack_table.get("recipes", [])
|
|
599
|
+
if not isinstance(recipes, list):
|
|
600
|
+
return []
|
|
601
|
+
return [str(r) for r in recipes if isinstance(r, str)]
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _run_conformance(pack_path: Path, conformance_dir: Path) -> int:
|
|
605
|
+
"""Run each conformance fixture and assert the expected output tree.
|
|
606
|
+
|
|
607
|
+
Each fixture is a subdirectory under ``conformance_dir`` containing:
|
|
608
|
+
- ``expected/`` — the expected rendered output tree.
|
|
609
|
+
|
|
610
|
+
We call ``render.render_pack_to_dir`` and compare file trees.
|
|
611
|
+
Returns 0 if all fixtures pass; 1 on first mismatch (with stderr).
|
|
612
|
+
"""
|
|
613
|
+
import tempfile
|
|
614
|
+
|
|
615
|
+
from agentbundle.render import render_pack_to_dir
|
|
616
|
+
|
|
617
|
+
fixture_dirs = sorted(
|
|
618
|
+
d for d in conformance_dir.iterdir() if d.is_dir()
|
|
619
|
+
)
|
|
620
|
+
if not fixture_dirs:
|
|
621
|
+
print(
|
|
622
|
+
"--strict: conformance directory present but empty — skipping",
|
|
623
|
+
file=sys.stderr,
|
|
624
|
+
)
|
|
625
|
+
return 0
|
|
626
|
+
|
|
627
|
+
for fixture in fixture_dirs:
|
|
628
|
+
expected_dir = fixture / "expected"
|
|
629
|
+
if not expected_dir.exists():
|
|
630
|
+
print(
|
|
631
|
+
f"--strict: fixture {fixture.name!r} has no expected/ tree; skipping",
|
|
632
|
+
file=sys.stderr,
|
|
633
|
+
)
|
|
634
|
+
continue
|
|
635
|
+
|
|
636
|
+
with tempfile.TemporaryDirectory() as raw:
|
|
637
|
+
actual_dir = Path(raw)
|
|
638
|
+
try:
|
|
639
|
+
render_pack_to_dir(pack_path, actual_dir)
|
|
640
|
+
except Exception as exc:
|
|
641
|
+
print(
|
|
642
|
+
f"--strict: render failed for fixture {fixture.name!r}: {exc}",
|
|
643
|
+
file=sys.stderr,
|
|
644
|
+
)
|
|
645
|
+
return 1
|
|
646
|
+
|
|
647
|
+
mismatch = _diff_trees(expected_dir, actual_dir)
|
|
648
|
+
if mismatch:
|
|
649
|
+
print(
|
|
650
|
+
f"--strict: conformance failure in fixture {fixture.name!r}: "
|
|
651
|
+
+ mismatch,
|
|
652
|
+
file=sys.stderr,
|
|
653
|
+
)
|
|
654
|
+
return 1
|
|
655
|
+
|
|
656
|
+
return 0
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _diff_trees(expected: Path, actual: Path) -> str:
|
|
660
|
+
"""Return a one-line description of the first difference, or '' if identical."""
|
|
661
|
+
expected_files = _tree_files(expected)
|
|
662
|
+
actual_files = _tree_files(actual)
|
|
663
|
+
|
|
664
|
+
only_in_expected = expected_files.keys() - actual_files.keys()
|
|
665
|
+
if only_in_expected:
|
|
666
|
+
first = sorted(only_in_expected)[0]
|
|
667
|
+
return f"file missing from actual: {first}"
|
|
668
|
+
|
|
669
|
+
only_in_actual = actual_files.keys() - expected_files.keys()
|
|
670
|
+
if only_in_actual:
|
|
671
|
+
first = sorted(only_in_actual)[0]
|
|
672
|
+
return f"unexpected file in actual: {first}"
|
|
673
|
+
|
|
674
|
+
for relpath in sorted(expected_files):
|
|
675
|
+
if expected_files[relpath] != actual_files[relpath]:
|
|
676
|
+
return f"content differs: {relpath}"
|
|
677
|
+
|
|
678
|
+
return ""
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _tree_files(root: Path) -> dict[str, bytes]:
|
|
682
|
+
"""Return all files under ``root`` as a dict of relpath → bytes."""
|
|
683
|
+
out: dict[str, bytes] = {}
|
|
684
|
+
for path in sorted(root.rglob("*")):
|
|
685
|
+
if path.is_file():
|
|
686
|
+
relpath = path.relative_to(root).as_posix()
|
|
687
|
+
out[relpath] = path.read_bytes()
|
|
688
|
+
return out
|