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,3026 @@
|
|
|
1
|
+
"""``agentbundle install`` — constrained-network pack installer.
|
|
2
|
+
|
|
3
|
+
Per RFC-0004 the install verb is the load-bearing CLI surface for the
|
|
4
|
+
scope dimension. The handler enforces:
|
|
5
|
+
|
|
6
|
+
- **Scope resolution** via :mod:`agentbundle.scope` (CLI flag > pack
|
|
7
|
+
``default-scope`` > built-in ``"repo"``).
|
|
8
|
+
- **Cross-scope conflict** with ``--force`` semantics:
|
|
9
|
+
- Pack already at the *requested* scope → refused (use ``upgrade``).
|
|
10
|
+
- Pack at the *other* scope, no ``--force`` → refused; stderr
|
|
11
|
+
names the other scope and the bypass flag.
|
|
12
|
+
- Pack at the other scope, with ``--force`` → dual-scope install:
|
|
13
|
+
re-confirms the existing scope and installs at the new scope,
|
|
14
|
+
printing two ``installed:`` lines in repo-then-user order.
|
|
15
|
+
- **Pre-flight order**: every scope's preconditions (``~``-expansion,
|
|
16
|
+
Rails A/B/C re-check, path-jail probe) run **before** any write to
|
|
17
|
+
either scope. A user-scope failure after a repo write would leave a
|
|
18
|
+
half-applied install on disk, so we sequence: resolve → check → write.
|
|
19
|
+
- **State-file v0.1 refusal** is delegated to
|
|
20
|
+
:func:`config.load_state(..., for_write=True)`.
|
|
21
|
+
|
|
22
|
+
Tier-1/2/3 classification is unchanged from the pre-RFC-0004 shape;
|
|
23
|
+
both scope roots use :func:`_classify_for_install`. Writes go through
|
|
24
|
+
:func:`safety.write_jailed` with the matching ``scope`` and
|
|
25
|
+
``allowed_prefixes`` so the user-scope jail fires.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import functools
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
import tomllib
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import TYPE_CHECKING, Callable
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
import argparse
|
|
40
|
+
|
|
41
|
+
from agentbundle.config import State
|
|
42
|
+
from agentbundle.safety import Tier
|
|
43
|
+
from agentbundle.user_config import UserConfig
|
|
44
|
+
|
|
45
|
+
# enumerate_event_dropped_wirings is imported at module level so it is
|
|
46
|
+
# patchable from tests (the mock target is
|
|
47
|
+
# ``agentbundle.commands.install.enumerate_event_dropped_wirings``).
|
|
48
|
+
from agentbundle.commands._drop_warning import enumerate_event_dropped_wirings
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class _ScopePlan:
|
|
53
|
+
"""One scope's worth of pre-flight + write data.
|
|
54
|
+
|
|
55
|
+
Computed during pre-flight and consumed at write time. Keeping the
|
|
56
|
+
fields in a dataclass lets the dual-scope path assemble *all*
|
|
57
|
+
plans (and surface any pre-flight failure across either scope)
|
|
58
|
+
before any side-effect runs.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
scope: str
|
|
62
|
+
root: Path # absolute path of the scope's root
|
|
63
|
+
state_path: Path # absolute path of the scope's state file
|
|
64
|
+
allowed_prefixes: list[str] | None
|
|
65
|
+
state: "State" # the loaded state at this scope (read-only mode)
|
|
66
|
+
already_installed: bool
|
|
67
|
+
# `.upstream.<ext>` companion relpaths written during this scope's
|
|
68
|
+
# step-9 projection loop. Threaded to the install marker so the
|
|
69
|
+
# adapt-to-project skill can surface class-2 work without re-walking
|
|
70
|
+
# the tree. Stays empty when nothing collided.
|
|
71
|
+
new_companions: list[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run(args: "argparse.Namespace") -> int:
|
|
75
|
+
"""Entry point for ``agentbundle install``.
|
|
76
|
+
|
|
77
|
+
Returns 0 on success, non-zero on any failure. See module docstring
|
|
78
|
+
for the dual-scope contract.
|
|
79
|
+
"""
|
|
80
|
+
from agentbundle.catalogue import CatalogueError, resolve_catalogue
|
|
81
|
+
from agentbundle.commands._common import check_spec_version_gate
|
|
82
|
+
from agentbundle.config import (
|
|
83
|
+
ConfigError,
|
|
84
|
+
PackState,
|
|
85
|
+
dump_state,
|
|
86
|
+
load_pack_toml,
|
|
87
|
+
load_state,
|
|
88
|
+
)
|
|
89
|
+
from agentbundle.render import render_pack
|
|
90
|
+
from agentbundle import safety, scope as scope_mod
|
|
91
|
+
from agentbundle.build import scope_rails
|
|
92
|
+
|
|
93
|
+
pack_name: str = args.pack
|
|
94
|
+
catalogue_uri: str = args.catalogue
|
|
95
|
+
cli_scope: str | None = getattr(args, "scope", None)
|
|
96
|
+
force: bool = bool(getattr(args, "force", False))
|
|
97
|
+
force_merge: bool = bool(getattr(args, "force_merge", False))
|
|
98
|
+
cli_adapter: str | None = getattr(args, "adapter", None)
|
|
99
|
+
# User-config attached by `cli.py:main()` via args._user_config.
|
|
100
|
+
# Default to None for callers that construct an args namespace by
|
|
101
|
+
# hand (tests) or for any code path that bypasses main(). The
|
|
102
|
+
# pre-flight in `_resolve_target_adapter` no-ops when this is None,
|
|
103
|
+
# so legacy callers see exactly today's behavior.
|
|
104
|
+
user_config: "UserConfig | None" = getattr(args, "_user_config", None)
|
|
105
|
+
output_root = Path(args.output).resolve()
|
|
106
|
+
|
|
107
|
+
# `--force-merge` runtime binding (Step 2's resolved scope is the
|
|
108
|
+
# source of truth — see below). The early check here catches an
|
|
109
|
+
# explicit ``--scope repo``; the resolved-scope check after
|
|
110
|
+
# Step 2 catches the case where the pack defaults to repo scope.
|
|
111
|
+
if force_merge and cli_scope == "repo":
|
|
112
|
+
print(
|
|
113
|
+
"install: --force-merge is bound to user scope; pass --scope user "
|
|
114
|
+
"or omit --force-merge",
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
)
|
|
117
|
+
return 1
|
|
118
|
+
|
|
119
|
+
# RFC-0012 removes the user-scope-only `--adapter` binding —
|
|
120
|
+
# `--adapter` is admitted at both scopes now. The handler-level
|
|
121
|
+
# mutex with `--emit-install-routes` runs after `scope.resolve()`
|
|
122
|
+
# so it consults the resolved scope (matches the existing
|
|
123
|
+
# `force_merge` precedent below).
|
|
124
|
+
#
|
|
125
|
+
# Backward-compat for test fixtures: argparse always sets the
|
|
126
|
+
# attribute (default False) so `hasattr` is the discriminator
|
|
127
|
+
# between "real CLI invocation" (attribute present) and "test
|
|
128
|
+
# fixture with a bare SimpleNamespace" (attribute absent). Test
|
|
129
|
+
# fixtures that pre-date RFC-0012 *at repo scope* implicitly want
|
|
130
|
+
# the legacy dist-tree shape; treating absent-attribute as the
|
|
131
|
+
# "legacy dist-tree" value preserves their assertions while real
|
|
132
|
+
# CLI calls without the flag flow through the new per-IDE
|
|
133
|
+
# projection path. The fallback is scope-dependent because the
|
|
134
|
+
# `emit_install_routes` flag is only meaningful at repo scope —
|
|
135
|
+
# firing the user-scope binding refusal on every legacy test
|
|
136
|
+
# fixture would be a false positive. Note: cli_scope is the raw
|
|
137
|
+
# CLI flag here (Step 2's `requested_scope` resolution hasn't
|
|
138
|
+
# run yet); user-scope tests pass `scope="user"` explicitly so
|
|
139
|
+
# the discriminator is accurate.
|
|
140
|
+
if hasattr(args, "emit_install_routes"):
|
|
141
|
+
emit_install_routes: bool = bool(args.emit_install_routes)
|
|
142
|
+
else:
|
|
143
|
+
# Absent attribute → repo-scope legacy callers want dist-tree
|
|
144
|
+
# shape; user-scope callers want the new path-jail.
|
|
145
|
+
emit_install_routes = cli_scope != "user"
|
|
146
|
+
|
|
147
|
+
# Range-check the CLI-supplied pack name before any I/O. The manifest's
|
|
148
|
+
# `pack.name` is checked by `_assert_pack_metadata_shape` below; this
|
|
149
|
+
# second check covers `args.pack` itself, which becomes a TOML key in
|
|
150
|
+
# `dump_state` and a TOML basic-string value in `_append_install_marker`.
|
|
151
|
+
# Injection is structurally prevented by `_emit_basic_string` /
|
|
152
|
+
# `_toml_key`, but refusing here is the bell-rings-loud companion.
|
|
153
|
+
if not _PACK_NAME_RE.fullmatch(pack_name):
|
|
154
|
+
print(
|
|
155
|
+
f"install: pack {pack_name!r} has invalid name: "
|
|
156
|
+
f"must match ^[a-z0-9][a-z0-9-]*$ per docs/CONVENTIONS.md",
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
)
|
|
159
|
+
return 1
|
|
160
|
+
|
|
161
|
+
# ── Step 1: Resolve catalogue + locate + spec gate ────────────────────────
|
|
162
|
+
try:
|
|
163
|
+
catalogue_dir = resolve_catalogue(catalogue_uri)
|
|
164
|
+
except CatalogueError as exc:
|
|
165
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
166
|
+
return 1
|
|
167
|
+
pack_dir = _locate_pack(catalogue_dir, pack_name)
|
|
168
|
+
if pack_dir is None:
|
|
169
|
+
print(
|
|
170
|
+
f"install: pack {pack_name!r} not found in catalogue at {catalogue_dir}; "
|
|
171
|
+
"expected packs/<pack>/ or <catalogue>/<pack>/",
|
|
172
|
+
file=sys.stderr,
|
|
173
|
+
)
|
|
174
|
+
return 1
|
|
175
|
+
try:
|
|
176
|
+
pack_toml = load_pack_toml(pack_dir / "pack.toml")
|
|
177
|
+
except ConfigError as exc:
|
|
178
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
gate = check_spec_version_gate(pack_toml)
|
|
181
|
+
if gate is not None:
|
|
182
|
+
return gate
|
|
183
|
+
# Defence-in-depth against pack-metadata-driven TOML injection: refuse
|
|
184
|
+
# manifests whose name / version fall outside their canonical grammars
|
|
185
|
+
# before any write. The relpath half of the check runs after
|
|
186
|
+
# `render_pack` (it needs the projection) — see Step 7 below.
|
|
187
|
+
try:
|
|
188
|
+
_assert_pack_metadata_shape(pack_toml)
|
|
189
|
+
except RuntimeError as exc:
|
|
190
|
+
print(str(exc), file=sys.stderr)
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
# ── Step 2: Resolve scope ─────────────────────────────────────────────────
|
|
194
|
+
# RFC-0004 § *v0.1 vs v0.2 contract acceptance*: a stray
|
|
195
|
+
# [pack.install] table on a v0.1 pack is *ignored*. We gate the
|
|
196
|
+
# install table on the declared contract version so a legacy pack
|
|
197
|
+
# carrying `default-scope = "user"` does NOT resolve to user scope.
|
|
198
|
+
# Mirrors validate.py:_allowed_scopes, kept in sync intentionally.
|
|
199
|
+
from agentbundle.config import pack_spec_version
|
|
200
|
+
|
|
201
|
+
# v0.2 introduced `[pack.install]`; v0.3 (RFC-0005) added
|
|
202
|
+
# `user-scope-hooks`; v0.6 (RFC-0011) added `allowed-adapters`.
|
|
203
|
+
# Mirror validate.py:_allowed_scopes — every version >= 0.2 carries
|
|
204
|
+
# the install table. The v0.1 path stays gateless (legacy implied
|
|
205
|
+
# `default-scope = "repo"`).
|
|
206
|
+
_pack_version = pack_spec_version(pack_toml)
|
|
207
|
+
if _pack_version is None or _pack_version == "0.1":
|
|
208
|
+
pack_install = None
|
|
209
|
+
else:
|
|
210
|
+
pack_install = pack_toml.get("pack", {}).get("install")
|
|
211
|
+
try:
|
|
212
|
+
requested_scope = scope_mod.resolve(
|
|
213
|
+
cli_scope, pack_install, pack_name=pack_name
|
|
214
|
+
)
|
|
215
|
+
except scope_mod.ScopeRefused as exc:
|
|
216
|
+
print(
|
|
217
|
+
f"install: {exc.pack_name}: scope {exc.requested!r} not in "
|
|
218
|
+
f"allowed-scopes {exc.allowed}",
|
|
219
|
+
file=sys.stderr,
|
|
220
|
+
)
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
# RFC-0005 § Binding: ``--force-merge`` is bound to user scope only.
|
|
224
|
+
# Gate on the *resolved* scope (the source of truth post-Step 2)
|
|
225
|
+
# so a pack defaulting to repo scope also surfaces the refusal,
|
|
226
|
+
# not just an explicit `--scope repo`.
|
|
227
|
+
if force_merge and requested_scope != "user":
|
|
228
|
+
print(
|
|
229
|
+
"install: --force-merge is bound to user scope; pass --scope user "
|
|
230
|
+
"or omit --force-merge",
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
return 1
|
|
234
|
+
|
|
235
|
+
# RFC-0012 handler-level mutex (after Step 2 so requested_scope is
|
|
236
|
+
# the resolved value, matching install.py:197's force_merge
|
|
237
|
+
# precedent). The mutex consults `requested_scope`, not
|
|
238
|
+
# `args.scope`, so a pack whose `[scope] default-scope = "user"`
|
|
239
|
+
# surfaces the binding correctly when `--scope` is omitted.
|
|
240
|
+
if requested_scope == "user" and emit_install_routes:
|
|
241
|
+
print(
|
|
242
|
+
"install: --emit-install-routes is bound to --scope repo",
|
|
243
|
+
file=sys.stderr,
|
|
244
|
+
)
|
|
245
|
+
return 1
|
|
246
|
+
if (
|
|
247
|
+
requested_scope == "repo"
|
|
248
|
+
and cli_adapter is not None
|
|
249
|
+
and emit_install_routes
|
|
250
|
+
):
|
|
251
|
+
print(
|
|
252
|
+
"install: --adapter and --emit-install-routes are mutually "
|
|
253
|
+
"exclusive at --scope repo",
|
|
254
|
+
file=sys.stderr,
|
|
255
|
+
)
|
|
256
|
+
return 1
|
|
257
|
+
|
|
258
|
+
# ── Step 3: Pre-flight — load state at *both* scopes ──────────────────────
|
|
259
|
+
# Read-only loads (for_write=False) — we do the v0.1 refusal *only*
|
|
260
|
+
# for scopes we're about to write to, after we know which they are.
|
|
261
|
+
# Loading both is cheap and reveals the cross-scope conflict.
|
|
262
|
+
repo_state_path = output_root / ".agentbundle-state.toml"
|
|
263
|
+
user_root: Path | None
|
|
264
|
+
user_state_path: Path | None
|
|
265
|
+
try:
|
|
266
|
+
repo_state = load_state(repo_state_path)
|
|
267
|
+
except ConfigError as exc:
|
|
268
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
269
|
+
return 1
|
|
270
|
+
|
|
271
|
+
# User scope resolution fires when the install itself touches user
|
|
272
|
+
# scope, OR when the installing pack declares
|
|
273
|
+
# `[[pack.dependencies.required]]` (AC17 union-of-scopes resolution
|
|
274
|
+
# requires user_state to be consulted even for repo-only addons —
|
|
275
|
+
# otherwise a `core` install at user scope is invisible to the
|
|
276
|
+
# gate). Defer the expanduser call to avoid raising on adopters
|
|
277
|
+
# with $HOME=/ when neither condition fires.
|
|
278
|
+
_pack_has_required = bool(
|
|
279
|
+
pack_toml.get("pack", {}).get("dependencies", {}).get("required")
|
|
280
|
+
)
|
|
281
|
+
needs_user_state = (
|
|
282
|
+
requested_scope == "user"
|
|
283
|
+
or "user" in _resolved_allowed_scopes(pack_install)
|
|
284
|
+
or _pack_has_required
|
|
285
|
+
)
|
|
286
|
+
user_state = None
|
|
287
|
+
if needs_user_state:
|
|
288
|
+
try:
|
|
289
|
+
user_root = scope_mod.resolve_user_root()
|
|
290
|
+
except scope_mod.UserScopeUnresolvable:
|
|
291
|
+
user_root = None # surface only if we actually need to write
|
|
292
|
+
if user_root is not None:
|
|
293
|
+
user_state_path = user_root / ".agentbundle" / "state.toml"
|
|
294
|
+
try:
|
|
295
|
+
user_state = load_state(user_state_path)
|
|
296
|
+
except ConfigError as exc:
|
|
297
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
298
|
+
return 1
|
|
299
|
+
else:
|
|
300
|
+
user_state_path = None
|
|
301
|
+
else:
|
|
302
|
+
user_root = None
|
|
303
|
+
user_state_path = None
|
|
304
|
+
|
|
305
|
+
# ── Step 3b: Dependency gate — [pack.dependencies.required] ──────────────
|
|
306
|
+
# Resolves required deps against the union of repo + user state (AC17).
|
|
307
|
+
# Gate runs before any write (and before the already-installed check, so
|
|
308
|
+
# dep errors surface even when another early-exit would fire).
|
|
309
|
+
from agentbundle.config import State as _State
|
|
310
|
+
|
|
311
|
+
_effective_user_state: "State" = user_state if user_state is not None else _State()
|
|
312
|
+
try:
|
|
313
|
+
validate_dependencies_required(
|
|
314
|
+
pack_toml, repo_state=repo_state, user_state=_effective_user_state
|
|
315
|
+
)
|
|
316
|
+
except RuntimeError as exc:
|
|
317
|
+
print(str(exc), file=sys.stderr)
|
|
318
|
+
return 1
|
|
319
|
+
|
|
320
|
+
# ── Step 3c: RFC-0012 AC24 in-band detection (repo scope, per-IDE) ──
|
|
321
|
+
# Pre-RFC-0012 state must surface migration messaging *before* the
|
|
322
|
+
# already-installed branch fires its "use 'upgrade'" refusal —
|
|
323
|
+
# otherwise an adopter with stale state would receive misleading
|
|
324
|
+
# advice ("just upgrade") instead of the correct uninstall +
|
|
325
|
+
# reinstall path. Detection is gated to ``--scope repo`` without
|
|
326
|
+
# ``--emit-install-routes`` per spec AC24's narrowed-inference rule
|
|
327
|
+
# (the legacy dist-tree producer must not trigger (b) on its own
|
|
328
|
+
# output). The resolver is lifted here so the (a) trigger has a
|
|
329
|
+
# ``repo_target_adapter`` to compare against ``state.adapter``; the
|
|
330
|
+
# same values are reused downstream and the original computation
|
|
331
|
+
# block below is now a no-op for this code path.
|
|
332
|
+
_pack_allowed_adapters: list[str] | None = None
|
|
333
|
+
if isinstance(pack_install, dict):
|
|
334
|
+
_raw = pack_install.get("allowed-adapters")
|
|
335
|
+
if isinstance(_raw, list):
|
|
336
|
+
_pack_allowed_adapters = [s for s in _raw if isinstance(s, str)]
|
|
337
|
+
_pack_contract_version = pack_spec_version(pack_toml)
|
|
338
|
+
repo_target_adapter: str | None = None
|
|
339
|
+
allowed_prefixes_repo: list[str] | None = None
|
|
340
|
+
if requested_scope == "repo" and not emit_install_routes:
|
|
341
|
+
try:
|
|
342
|
+
repo_target_adapter = _resolve_target_adapter(
|
|
343
|
+
pack_dir,
|
|
344
|
+
scope="repo",
|
|
345
|
+
adapter=cli_adapter,
|
|
346
|
+
allowed_adapters=_pack_allowed_adapters,
|
|
347
|
+
contract_version=_pack_contract_version,
|
|
348
|
+
state_adapter=None,
|
|
349
|
+
command_name="install",
|
|
350
|
+
user_config=user_config,
|
|
351
|
+
)
|
|
352
|
+
except _AdapterResolutionRefused as exc:
|
|
353
|
+
print(str(exc), file=sys.stderr)
|
|
354
|
+
return 1
|
|
355
|
+
allowed_prefixes_repo = _adapter_allowed_prefixes_repo(
|
|
356
|
+
repo_target_adapter
|
|
357
|
+
)
|
|
358
|
+
# Issue #190: render the current per-IDE projection's relpaths so
|
|
359
|
+
# orphan recovery can exclude paths Step 9 companion-protects.
|
|
360
|
+
# Best-effort and deterministic (same inputs as the Step-7 render).
|
|
361
|
+
# On FileNotFoundError/ValueError the orphan filter degrades to off
|
|
362
|
+
# (None) and Step 7 re-renders to surface the canonical error; any
|
|
363
|
+
# other render exception propagates here exactly as it did from
|
|
364
|
+
# Step 7 before this change (no new swallowing).
|
|
365
|
+
_orphan_filter_relpaths: "set[str] | None" = None
|
|
366
|
+
try:
|
|
367
|
+
_, _early_repo_projection = _render_for_repo_scope(
|
|
368
|
+
pack_dir,
|
|
369
|
+
adapter=cli_adapter,
|
|
370
|
+
allowed_adapters=_pack_allowed_adapters,
|
|
371
|
+
contract_version=_pack_contract_version,
|
|
372
|
+
state_adapter=None,
|
|
373
|
+
command_name="install",
|
|
374
|
+
user_config=user_config,
|
|
375
|
+
)
|
|
376
|
+
_orphan_filter_relpaths = set(_early_repo_projection.keys())
|
|
377
|
+
except (FileNotFoundError, ValueError):
|
|
378
|
+
_orphan_filter_relpaths = None
|
|
379
|
+
rc = _classify_pre_rfc0012_state(
|
|
380
|
+
output_root=output_root,
|
|
381
|
+
pack_name=pack_name,
|
|
382
|
+
pack_dir=pack_dir,
|
|
383
|
+
repo_state=repo_state,
|
|
384
|
+
repo_target_adapter=repo_target_adapter,
|
|
385
|
+
allowed_prefixes_repo=allowed_prefixes_repo,
|
|
386
|
+
force=force,
|
|
387
|
+
projection_relpaths=_orphan_filter_relpaths,
|
|
388
|
+
)
|
|
389
|
+
if rc is not None:
|
|
390
|
+
return rc
|
|
391
|
+
|
|
392
|
+
# ``installed_at_*`` is computed AFTER Step 3c because (b)+--force
|
|
393
|
+
# drops the stale state row inside ``_classify_pre_rfc0012_state``
|
|
394
|
+
# so the subsequent install proceeds as a clean reinstall. Computing
|
|
395
|
+
# the flag before detection would cache pre-cleanup state and fire
|
|
396
|
+
# the misleading "use 'upgrade' to change version" refusal at
|
|
397
|
+
# Step 4 even after --force succeeded.
|
|
398
|
+
installed_at_repo = pack_name in repo_state.packs
|
|
399
|
+
installed_at_user = user_state is not None and pack_name in user_state.packs
|
|
400
|
+
|
|
401
|
+
# ── Step 4: Branch on already-installed shape ─────────────────────────────
|
|
402
|
+
# 4a. Already at requested scope → refuse (use 'upgrade'); --force does
|
|
403
|
+
# not bypass this case.
|
|
404
|
+
if (requested_scope == "repo" and installed_at_repo) or (
|
|
405
|
+
requested_scope == "user" and installed_at_user
|
|
406
|
+
):
|
|
407
|
+
print(
|
|
408
|
+
f"install: {pack_name} already installed at {requested_scope}; "
|
|
409
|
+
"use 'upgrade' to change version",
|
|
410
|
+
file=sys.stderr,
|
|
411
|
+
)
|
|
412
|
+
return 1
|
|
413
|
+
|
|
414
|
+
# 4b. Already at the *other* scope, no --force → refuse cross-scope.
|
|
415
|
+
other_scope = "user" if requested_scope == "repo" else "repo"
|
|
416
|
+
other_already = installed_at_user if requested_scope == "repo" else installed_at_repo
|
|
417
|
+
if other_already and not force:
|
|
418
|
+
print(
|
|
419
|
+
f"install: {pack_name} already installed at {other_scope}; "
|
|
420
|
+
"pass --force to install at both",
|
|
421
|
+
file=sys.stderr,
|
|
422
|
+
)
|
|
423
|
+
return 1
|
|
424
|
+
|
|
425
|
+
# ── Step 5: Build the scope plan(s) ───────────────────────────────────────
|
|
426
|
+
# Determine which scopes this run will write to. Dual-scope is the
|
|
427
|
+
# --force-and-pack-already-at-other-scope case; everything else is
|
|
428
|
+
# single-scope. Pre-flight all of them before any write.
|
|
429
|
+
scopes_to_install: list[str] = []
|
|
430
|
+
if force and other_already:
|
|
431
|
+
# Dual-scope path: repo first, then user (spec § *Output*).
|
|
432
|
+
scopes_to_install = ["repo", "user"]
|
|
433
|
+
else:
|
|
434
|
+
scopes_to_install = [requested_scope]
|
|
435
|
+
|
|
436
|
+
# Probe per-adapter scope metadata (for allowed-prefixes at user
|
|
437
|
+
# scope). The Claude Code adapter ships a [scope] block from
|
|
438
|
+
# RFC-0004; RFC-0005's T1 added one to Kiro too; RFC-0011 added
|
|
439
|
+
# one to Codex; RFC-0012 added one to Copilot. Resolve which
|
|
440
|
+
# adapter the user-scope install targets via the six-step (0–5)
|
|
441
|
+
# lookup and use that adapter's `allowed-prefixes.user`.
|
|
442
|
+
# ``_pack_allowed_adapters`` and ``_pack_contract_version`` were
|
|
443
|
+
# lifted to Step 3c above so the AC24 detection block can resolve
|
|
444
|
+
# the repo-target adapter early; reuse the same values here.
|
|
445
|
+
# Only resolve the user-scope target adapter when user scope is in
|
|
446
|
+
# this run's plan. Resolving unconditionally at scope="user" would
|
|
447
|
+
# surface the user-scope-capability subcheck refusal (e.g.
|
|
448
|
+
# `--adapter copilot` against a repo-only install) even though the
|
|
449
|
+
# install would never write to user scope.
|
|
450
|
+
user_target_adapter: str | None = None
|
|
451
|
+
allowed_prefixes_user: list[str] | None = None
|
|
452
|
+
if "user" in scopes_to_install:
|
|
453
|
+
try:
|
|
454
|
+
user_target_adapter = _resolve_target_adapter(
|
|
455
|
+
pack_dir,
|
|
456
|
+
scope="user",
|
|
457
|
+
adapter=cli_adapter,
|
|
458
|
+
allowed_adapters=_pack_allowed_adapters,
|
|
459
|
+
contract_version=_pack_contract_version,
|
|
460
|
+
state_adapter=None, # First install has no prior state here.
|
|
461
|
+
command_name="install",
|
|
462
|
+
user_config=user_config,
|
|
463
|
+
)
|
|
464
|
+
except _AdapterResolutionRefused as exc:
|
|
465
|
+
print(str(exc), file=sys.stderr)
|
|
466
|
+
return 1
|
|
467
|
+
allowed_prefixes_user = _adapter_allowed_prefixes_user(user_target_adapter)
|
|
468
|
+
|
|
469
|
+
# RFC-0012 repo-scope per-IDE resolution: ``repo_target_adapter``
|
|
470
|
+
# and ``allowed_prefixes_repo`` were lifted to Step 3c above so the
|
|
471
|
+
# AC24 detection block (which covers the AC22 orphan-refusal path
|
|
472
|
+
# as trigger (c)) can run before the already-installed branch.
|
|
473
|
+
# No re-resolution here.
|
|
474
|
+
|
|
475
|
+
# RFC-0005 AC25: refuse install --scope user against an adapter
|
|
476
|
+
# that doesn't declare a working user-scope hook-wiring mode. The
|
|
477
|
+
# heuristic above picks kiro/claude-code; this guard catches a
|
|
478
|
+
# contract-misconfiguration regression (e.g. someone strips
|
|
479
|
+
# `user-merge-json` from the contract or sets `dropped`) before
|
|
480
|
+
# any byte is written.
|
|
481
|
+
user_scope_hooks_opt_in = bool(
|
|
482
|
+
isinstance(pack_install, dict)
|
|
483
|
+
and pack_install.get("user-scope-hooks") is True
|
|
484
|
+
)
|
|
485
|
+
if (
|
|
486
|
+
user_scope_hooks_opt_in
|
|
487
|
+
and requested_scope == "user"
|
|
488
|
+
and not _adapter_supports_user_scope_hook_wiring(user_target_adapter)
|
|
489
|
+
):
|
|
490
|
+
print(
|
|
491
|
+
f"install: adapter {user_target_adapter!r} does not declare a "
|
|
492
|
+
f"hook-wiring mode that supports user scope; pack {pack_name} "
|
|
493
|
+
f"requires it",
|
|
494
|
+
file=sys.stderr,
|
|
495
|
+
)
|
|
496
|
+
return 1
|
|
497
|
+
|
|
498
|
+
plans: list[_ScopePlan] = []
|
|
499
|
+
for scope_value in scopes_to_install:
|
|
500
|
+
if scope_value == "repo":
|
|
501
|
+
# RFC-0012: at repo scope without --emit-install-routes,
|
|
502
|
+
# thread the repo-adapter's `allowed-prefixes.repo` into
|
|
503
|
+
# the plan so the path-jail fences each write under the
|
|
504
|
+
# per-IDE directory (`<repo>/.kiro/`, `<repo>/.claude/`,
|
|
505
|
+
# etc.). With --emit-install-routes the legacy dist-tree
|
|
506
|
+
# producer runs and the prefix list stays None.
|
|
507
|
+
plans.append(
|
|
508
|
+
_ScopePlan(
|
|
509
|
+
scope="repo",
|
|
510
|
+
root=output_root,
|
|
511
|
+
state_path=repo_state_path,
|
|
512
|
+
allowed_prefixes=allowed_prefixes_repo,
|
|
513
|
+
state=repo_state,
|
|
514
|
+
already_installed=installed_at_repo,
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
else:
|
|
518
|
+
# User scope: surface unresolvable $HOME *now* so failures
|
|
519
|
+
# land in pre-flight, before any write.
|
|
520
|
+
try:
|
|
521
|
+
user_root_resolved = scope_mod.resolve_user_root()
|
|
522
|
+
except scope_mod.UserScopeUnresolvable:
|
|
523
|
+
print(
|
|
524
|
+
"install: cannot resolve user scope: $HOME unset or invalid",
|
|
525
|
+
file=sys.stderr,
|
|
526
|
+
)
|
|
527
|
+
return 1
|
|
528
|
+
# Print the resolved root to stderr so the adopter sees
|
|
529
|
+
# the destination before any side-effect.
|
|
530
|
+
print(f"install: user scope resolved to {user_root_resolved}", file=sys.stderr)
|
|
531
|
+
# Re-load user state in for-write mode so a v0.1 file fails
|
|
532
|
+
# here (after the resolved-root line so adopters see context).
|
|
533
|
+
try:
|
|
534
|
+
user_state_for_write = load_state(
|
|
535
|
+
user_root_resolved / ".agentbundle" / "state.toml", for_write=True
|
|
536
|
+
)
|
|
537
|
+
except ConfigError as exc:
|
|
538
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
539
|
+
return 1
|
|
540
|
+
plans.append(
|
|
541
|
+
_ScopePlan(
|
|
542
|
+
scope="user",
|
|
543
|
+
root=user_root_resolved,
|
|
544
|
+
state_path=user_root_resolved / ".agentbundle" / "state.toml",
|
|
545
|
+
allowed_prefixes=allowed_prefixes_user,
|
|
546
|
+
state=user_state_for_write,
|
|
547
|
+
already_installed=installed_at_user,
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# If the repo plan is going to be written to (not just a recap),
|
|
552
|
+
# also load the state in for-write mode so v0.1 refusal fires.
|
|
553
|
+
for plan in plans:
|
|
554
|
+
if plan.scope == "repo" and not plan.already_installed:
|
|
555
|
+
try:
|
|
556
|
+
plan.state = load_state(plan.state_path, for_write=True)
|
|
557
|
+
except ConfigError as exc:
|
|
558
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
559
|
+
return 1
|
|
560
|
+
|
|
561
|
+
# Dropped-primitives warning rail (docs/specs/dropped-primitives-
|
|
562
|
+
# coverage T6 / AC10). Pre-write barrier: Step 5's plans are built
|
|
563
|
+
# and both target adapters are resolved by here; Step 6's pre-flight
|
|
564
|
+
# hasn't fired yet; no byte has been written. Emit one warning per
|
|
565
|
+
# (root, pack_name, adapter, scope) where the resolved adapter has
|
|
566
|
+
# any `dropped` mode for a primitive type the pack actually ships.
|
|
567
|
+
# Contract-driven — no hardcoded adapter literals.
|
|
568
|
+
#
|
|
569
|
+
# Dual-scope late-resolution: `repo_target_adapter` is set at Step 3c
|
|
570
|
+
# only when ``requested_scope == "repo"``. When ``requested_scope ==
|
|
571
|
+
# "user"`` AND ``force + other_already`` (Step 4b's dual-scope path),
|
|
572
|
+
# the run writes to repo too but Step 3c didn't resolve. Resolve here
|
|
573
|
+
# so the warning fires for both scopes — without this, the
|
|
574
|
+
# ``--scope user --force`` dual-scope path silently drops the repo-
|
|
575
|
+
# side warning even though the install does land at repo scope.
|
|
576
|
+
if "repo" in scopes_to_install and repo_target_adapter is None:
|
|
577
|
+
try:
|
|
578
|
+
repo_target_adapter = _resolve_target_adapter(
|
|
579
|
+
pack_dir,
|
|
580
|
+
scope="repo",
|
|
581
|
+
adapter=cli_adapter,
|
|
582
|
+
allowed_adapters=_pack_allowed_adapters,
|
|
583
|
+
contract_version=_pack_contract_version,
|
|
584
|
+
state_adapter=None,
|
|
585
|
+
command_name="install",
|
|
586
|
+
user_config=user_config,
|
|
587
|
+
)
|
|
588
|
+
except _AdapterResolutionRefused:
|
|
589
|
+
# Repo resolution failed; defer the actual refusal to the
|
|
590
|
+
# downstream render step which already raises with adopter-
|
|
591
|
+
# facing wording. Silently skipping the warning is
|
|
592
|
+
# acceptable in this corner because the install itself
|
|
593
|
+
# will halt before any byte is written.
|
|
594
|
+
repo_target_adapter = None
|
|
595
|
+
|
|
596
|
+
for plan in plans:
|
|
597
|
+
scope_adapter = (
|
|
598
|
+
repo_target_adapter if plan.scope == "repo" else user_target_adapter
|
|
599
|
+
)
|
|
600
|
+
if scope_adapter is None:
|
|
601
|
+
continue
|
|
602
|
+
_maybe_emit_dropped_warning(
|
|
603
|
+
root=plan.root,
|
|
604
|
+
pack_dir=pack_dir,
|
|
605
|
+
pack_name=pack_name,
|
|
606
|
+
adapter=scope_adapter,
|
|
607
|
+
scope=plan.scope,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# ── Step 6: Pre-flight — rails A/B/C for any user-scope write ────────────
|
|
611
|
+
# Also run the kiro attach-to-agent rail (T2's `check_kiro_wiring`)
|
|
612
|
+
# for user-scope kiro-targeted packs. Catches malformed wiring TOMLs
|
|
613
|
+
# (missing/typo'd `attach-to-agent`, path-traversal payloads) before
|
|
614
|
+
# any render — the build-pipeline error message ("internal: <path>
|
|
615
|
+
# missing") isn't actionable for adopters; this rail surfaces the
|
|
616
|
+
# actual contract violation.
|
|
617
|
+
if any(p.scope == "user" for p in plans) and user_target_adapter == "kiro":
|
|
618
|
+
target_adapters = {"kiro"}
|
|
619
|
+
kiro_refusal = scope_rails.check_kiro_wiring(
|
|
620
|
+
pack_dir, pack_name, target_adapters
|
|
621
|
+
)
|
|
622
|
+
if kiro_refusal is not None:
|
|
623
|
+
print(f"install: {kiro_refusal}", file=sys.stderr)
|
|
624
|
+
return 1
|
|
625
|
+
for plan in plans:
|
|
626
|
+
if plan.scope == "user":
|
|
627
|
+
allowed_scopes = _resolved_allowed_scopes(pack_install)
|
|
628
|
+
# RFC-0005 § Rail B — user-scope lift: the
|
|
629
|
+
# `[pack.install] user-scope-hooks = true` consent gesture
|
|
630
|
+
# threads through to the rail at install time too. Without
|
|
631
|
+
# this, validate and install would disagree on the same
|
|
632
|
+
# pack: validate would accept (post-T3), install would
|
|
633
|
+
# refuse — a surface-mismatch class of bug.
|
|
634
|
+
user_scope_hooks = bool(pack_install.get("user-scope-hooks") is True)
|
|
635
|
+
rail_refusal = scope_rails.run_all(
|
|
636
|
+
pack_dir, allowed_scopes, user_scope_hooks
|
|
637
|
+
)
|
|
638
|
+
if rail_refusal is not None:
|
|
639
|
+
print(
|
|
640
|
+
f"install: {pack_name}: {rail_refusal}",
|
|
641
|
+
file=sys.stderr,
|
|
642
|
+
)
|
|
643
|
+
return 1
|
|
644
|
+
|
|
645
|
+
# ── Step 7: Render projection — per-scope shape ───────────────────────────
|
|
646
|
+
# RFC-0004's user-scope install lands a Claude Code overlay (paths
|
|
647
|
+
# under `.claude/...`), not the dist-tree shape `render_pack`
|
|
648
|
+
# produces. We render twice when the run spans both scopes: once for
|
|
649
|
+
# the dist-tree (consumed by repo-scope writes) and once for the
|
|
650
|
+
# Claude-Code-only projection (consumed by user-scope writes). The
|
|
651
|
+
# dist-tree render is cached for the lifetime of this run; the
|
|
652
|
+
# Claude-Code render uses the same adapter the `make build --self`
|
|
653
|
+
# path uses (the spec §Tier model defines this as the user-scope
|
|
654
|
+
# projection target).
|
|
655
|
+
repo_projection: dict[str, bytes] | None = None
|
|
656
|
+
user_projection: dict[str, bytes] | None = None
|
|
657
|
+
# RFC-0005 § hook-body at user scope: user-scope packs ship hook
|
|
658
|
+
# bodies that project to `<adapter>/hooks/<pack>/` (not the legacy
|
|
659
|
+
# `tools/hooks/`); RFC-0005 § hook-wiring lands the wiring merger
|
|
660
|
+
# against the adopter's settings or pack-owned agent JSON instead
|
|
661
|
+
# of writing the wiring TOML to disk. Both rewrites happen
|
|
662
|
+
# post-render and pre-path-jail.
|
|
663
|
+
# `user_target_adapter` resolved earlier (alongside allowed_prefixes_user).
|
|
664
|
+
try:
|
|
665
|
+
if any(p.scope == "repo" for p in plans):
|
|
666
|
+
if emit_install_routes:
|
|
667
|
+
# Legacy dist-tree producer (RFC-0012 § *CLI surface*'s
|
|
668
|
+
# catalogue-publishing opt-in).
|
|
669
|
+
repo_projection = render_pack(pack_dir)
|
|
670
|
+
else:
|
|
671
|
+
# RFC-0012 default: per-IDE projection at repo scope.
|
|
672
|
+
# `_render_for_repo_scope` returns (adapter, projection);
|
|
673
|
+
# we already resolved the adapter above for the
|
|
674
|
+
# path-jail prefix list, but the helper re-resolves so
|
|
675
|
+
# the caller gets a paired return.
|
|
676
|
+
_resolved_adapter, repo_projection = _render_for_repo_scope(
|
|
677
|
+
pack_dir,
|
|
678
|
+
adapter=cli_adapter,
|
|
679
|
+
allowed_adapters=_pack_allowed_adapters,
|
|
680
|
+
contract_version=_pack_contract_version,
|
|
681
|
+
state_adapter=None,
|
|
682
|
+
command_name="install",
|
|
683
|
+
user_config=user_config,
|
|
684
|
+
)
|
|
685
|
+
if any(p.scope == "user" for p in plans):
|
|
686
|
+
user_projection = _render_for_user_scope(
|
|
687
|
+
pack_dir,
|
|
688
|
+
adapter=cli_adapter,
|
|
689
|
+
allowed_adapters=_pack_allowed_adapters,
|
|
690
|
+
contract_version=_pack_contract_version,
|
|
691
|
+
state_adapter=None,
|
|
692
|
+
command_name="install",
|
|
693
|
+
user_config=user_config,
|
|
694
|
+
)
|
|
695
|
+
user_scope_hooks_enabled = bool(
|
|
696
|
+
isinstance(pack_install, dict)
|
|
697
|
+
and pack_install.get("user-scope-hooks") is True
|
|
698
|
+
)
|
|
699
|
+
if user_scope_hooks_enabled:
|
|
700
|
+
user_projection = _rewrite_user_scope_hook_paths(
|
|
701
|
+
user_projection,
|
|
702
|
+
pack_name=pack_name,
|
|
703
|
+
target_adapter=user_target_adapter,
|
|
704
|
+
)
|
|
705
|
+
if user_target_adapter == "copilot":
|
|
706
|
+
# Copilot's whole prefix diverges at user scope
|
|
707
|
+
# (`.github/…`→`.copilot/…`) for every primitive — not just
|
|
708
|
+
# hooks — so this runs unconditionally for copilot, before
|
|
709
|
+
# the path-jail probe below (RFC-0024 / copilot-full-parity).
|
|
710
|
+
user_projection = _rewrite_copilot_user_scope_paths(
|
|
711
|
+
user_projection
|
|
712
|
+
)
|
|
713
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
714
|
+
print(f"install: render failed for pack {pack_name!r}: {exc}", file=sys.stderr)
|
|
715
|
+
return 1
|
|
716
|
+
|
|
717
|
+
# RFC-0005 § Binding: ``--force-merge`` is Claude-Code-only. The
|
|
718
|
+
# kiro merge target is a pack-owned agent JSON; adopter collision
|
|
719
|
+
# is structurally a non-case. Refuse early once the target adapter
|
|
720
|
+
# is known.
|
|
721
|
+
if force_merge and user_target_adapter != "claude-code" and any(
|
|
722
|
+
p.scope == "user" for p in plans
|
|
723
|
+
):
|
|
724
|
+
print(
|
|
725
|
+
"install: --force-merge applies only to Claude-Code-targeted packs; "
|
|
726
|
+
f"pack {pack_name} resolves to adapter '{user_target_adapter}' at user scope",
|
|
727
|
+
file=sys.stderr,
|
|
728
|
+
)
|
|
729
|
+
return 1
|
|
730
|
+
|
|
731
|
+
# Full re-check including projection relpaths now that `render_pack`
|
|
732
|
+
# has produced the per-scope projection(s). Name + version are
|
|
733
|
+
# idempotently re-validated (already passed once after `load_pack_toml`);
|
|
734
|
+
# the load-bearing addition at this site is the relpath loop, which
|
|
735
|
+
# needs the projection's keys to run. Refuses if a single bad path
|
|
736
|
+
# appears at either scope.
|
|
737
|
+
try:
|
|
738
|
+
for _projection in (repo_projection, user_projection):
|
|
739
|
+
if _projection is not None:
|
|
740
|
+
_assert_pack_metadata_shape(pack_toml, projection=_projection)
|
|
741
|
+
except RuntimeError as exc:
|
|
742
|
+
print(str(exc), file=sys.stderr)
|
|
743
|
+
return 1
|
|
744
|
+
|
|
745
|
+
pack_version: str = pack_toml.get("pack", {}).get("version", "0.0.0")
|
|
746
|
+
|
|
747
|
+
# ── Step 8: Pre-flight — path-jail probe every projected file ─────────────
|
|
748
|
+
# The probe is read-only: assert_under + (for user) prefix check.
|
|
749
|
+
# This catches a pack whose projection rule resolves under
|
|
750
|
+
# ~/Documents/ before any byte is written.
|
|
751
|
+
for plan in plans:
|
|
752
|
+
projection = repo_projection if plan.scope == "repo" else user_projection
|
|
753
|
+
if projection is None:
|
|
754
|
+
continue
|
|
755
|
+
for relpath in projection.keys():
|
|
756
|
+
target = plan.root / relpath
|
|
757
|
+
try:
|
|
758
|
+
safety.assert_under(plan.root, target)
|
|
759
|
+
except safety.PathJailError as exc:
|
|
760
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
761
|
+
return 1
|
|
762
|
+
# Per-prefix probe fires whenever the plan has an
|
|
763
|
+
# allowed_prefixes list (user scope always; repo scope when
|
|
764
|
+
# the per-IDE path is in use — RFC-0012). With
|
|
765
|
+
# `allowed_prefixes=None` the probe is skipped (legacy
|
|
766
|
+
# dist-tree producer at repo scope under
|
|
767
|
+
# --emit-install-routes).
|
|
768
|
+
if plan.allowed_prefixes is not None:
|
|
769
|
+
target_relpath = target.resolve().relative_to(plan.root.resolve()).as_posix()
|
|
770
|
+
prefixes = plan.allowed_prefixes or []
|
|
771
|
+
# Directory-boundary matching only — see safety.py.
|
|
772
|
+
if not any(target_relpath.startswith(p) for p in prefixes):
|
|
773
|
+
print(
|
|
774
|
+
f"install: refusing to write outside allowed prefixes "
|
|
775
|
+
f"for scope {plan.scope!r}: {target.resolve()}",
|
|
776
|
+
file=sys.stderr,
|
|
777
|
+
)
|
|
778
|
+
return 1
|
|
779
|
+
|
|
780
|
+
# ── Step 9: All pre-flight passed — perform writes ────────────────────────
|
|
781
|
+
for plan in plans:
|
|
782
|
+
# Skip the write for the already-installed scope in a dual-scope
|
|
783
|
+
# --force run: the state file is already correct; we re-emit
|
|
784
|
+
# the `installed:` line for the recap, but we don't rewrite.
|
|
785
|
+
if plan.already_installed:
|
|
786
|
+
continue
|
|
787
|
+
|
|
788
|
+
projection = repo_projection if plan.scope == "repo" else user_projection
|
|
789
|
+
if projection is None:
|
|
790
|
+
projection = {}
|
|
791
|
+
|
|
792
|
+
# Reset the PackState for this scope's install.
|
|
793
|
+
prior = plan.state.packs.get(pack_name)
|
|
794
|
+
new_pack_state = PackState(
|
|
795
|
+
installed_version=pack_version,
|
|
796
|
+
source="agent-ready-repo",
|
|
797
|
+
install_route="cli",
|
|
798
|
+
scope=plan.scope,
|
|
799
|
+
primitives=_collect_primitives(pack_dir),
|
|
800
|
+
files={},
|
|
801
|
+
primitive_versions=dict(prior.primitive_versions) if prior else {},
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
for relpath, content in sorted(projection.items()):
|
|
805
|
+
tier = _classify_for_install(
|
|
806
|
+
relpath, plan.root, content, plan.state, pack_name=pack_name,
|
|
807
|
+
)
|
|
808
|
+
if tier is safety.Tier.TIER_2:
|
|
809
|
+
try:
|
|
810
|
+
safety.write_companion(plan.root, relpath, content)
|
|
811
|
+
except safety.PathJailError as exc:
|
|
812
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
813
|
+
return 1
|
|
814
|
+
plan.new_companions.append(
|
|
815
|
+
safety.companion_path(Path(relpath)).as_posix()
|
|
816
|
+
)
|
|
817
|
+
else:
|
|
818
|
+
try:
|
|
819
|
+
safety.write_jailed(
|
|
820
|
+
plan.root,
|
|
821
|
+
relpath,
|
|
822
|
+
content,
|
|
823
|
+
scope=plan.scope,
|
|
824
|
+
allowed_prefixes=plan.allowed_prefixes,
|
|
825
|
+
)
|
|
826
|
+
except safety.PathJailError as exc:
|
|
827
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
828
|
+
return 1
|
|
829
|
+
new_pack_state.files[relpath] = {
|
|
830
|
+
"sha": safety.sha256_bytes(content),
|
|
831
|
+
"from-pack-version": pack_version,
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
# Deliver the pack's seeds (governance docs: AGENTS.md, docs/CHARTER.md,
|
|
835
|
+
# …) into the repo at repo scope. Seeds land at the repo root / docs/,
|
|
836
|
+
# outside the adapter projection prefixes, so they never interact with
|
|
837
|
+
# the orphan scan. Tier-1/2/3 + composition-fragment handling is shared
|
|
838
|
+
# with `scaffold` via `deliver_seeds`; here we also record each delivered
|
|
839
|
+
# seed in state so upgrades give edited seeds Tier-2 companion safety.
|
|
840
|
+
# (RFC-0001 §281-284 / file-safety contract.)
|
|
841
|
+
if plan.scope == "repo":
|
|
842
|
+
seeds_dir = pack_dir / "seeds"
|
|
843
|
+
if seeds_dir.is_dir():
|
|
844
|
+
from agentbundle.commands._common import deliver_seeds
|
|
845
|
+
|
|
846
|
+
try:
|
|
847
|
+
seed_deliveries = deliver_seeds(seeds_dir, plan.root)
|
|
848
|
+
except safety.PathJailError as exc:
|
|
849
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
850
|
+
return 1
|
|
851
|
+
for rec in seed_deliveries:
|
|
852
|
+
new_pack_state.files[rec.relpath] = {
|
|
853
|
+
"sha": safety.sha256_bytes(rec.content),
|
|
854
|
+
"from-pack-version": pack_version,
|
|
855
|
+
}
|
|
856
|
+
if rec.companion_relpath is not None:
|
|
857
|
+
plan.new_companions.append(rec.companion_relpath)
|
|
858
|
+
# Observability: tell the operator that seeds landed and,
|
|
859
|
+
# crucially, when an edited file was preserved as a companion
|
|
860
|
+
# rather than overwritten (the silent-companion diagnosability
|
|
861
|
+
# gap on a brownfield install). stderr so the stdout
|
|
862
|
+
# `installed:` rail stays parseable.
|
|
863
|
+
if seed_deliveries:
|
|
864
|
+
_n_companion = sum(
|
|
865
|
+
1 for r in seed_deliveries if r.action == "companion"
|
|
866
|
+
)
|
|
867
|
+
_summary = (
|
|
868
|
+
f"install: delivered {len(seed_deliveries)} seed(s) "
|
|
869
|
+
f"for pack {pack_name}"
|
|
870
|
+
)
|
|
871
|
+
if _n_companion:
|
|
872
|
+
_summary += (
|
|
873
|
+
f"; {_n_companion} collided with your edits and "
|
|
874
|
+
f"were kept as *.upstream.<ext> companions"
|
|
875
|
+
)
|
|
876
|
+
print(_summary, file=sys.stderr)
|
|
877
|
+
|
|
878
|
+
# RFC-0005 T8b — user-scope hook-wiring merge phase.
|
|
879
|
+
# Runs after file writes (so hook bodies exist where wiring
|
|
880
|
+
# entries reference them via $HOOK_BODY_PATH-style placeholders;
|
|
881
|
+
# T8b's resolver-time substitution is the consumer's concern).
|
|
882
|
+
# Captures (event, id[, target-file]) tuples and writes them
|
|
883
|
+
# to ``hook_wiring_owned`` on the PackState so uninstall can
|
|
884
|
+
# be precise.
|
|
885
|
+
if plan.scope == "user":
|
|
886
|
+
# AC10a — record the resolved adapter unconditionally for
|
|
887
|
+
# every user-scope install (lifted out of the kiro-hook-only
|
|
888
|
+
# branch below). Without this, codex / non-hook claude-code
|
|
889
|
+
# installs silently default the state field, breaking AC25's
|
|
890
|
+
# state-shape assertions and the upgrade-side state-hint
|
|
891
|
+
# short-circuit (AC10b) on subsequent upgrades.
|
|
892
|
+
new_pack_state.adapter = user_target_adapter
|
|
893
|
+
|
|
894
|
+
user_scope_hooks_enabled = bool(
|
|
895
|
+
isinstance(pack_install, dict)
|
|
896
|
+
and pack_install.get("user-scope-hooks") is True
|
|
897
|
+
)
|
|
898
|
+
if user_scope_hooks_enabled:
|
|
899
|
+
try:
|
|
900
|
+
owned_rows = _merge_user_scope_hook_wiring(
|
|
901
|
+
pack_dir=pack_dir,
|
|
902
|
+
pack_name=pack_name,
|
|
903
|
+
target_adapter=user_target_adapter,
|
|
904
|
+
install_root=plan.root,
|
|
905
|
+
force_merge=force_merge,
|
|
906
|
+
)
|
|
907
|
+
except Exception as exc:
|
|
908
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
909
|
+
return 1
|
|
910
|
+
new_pack_state.hook_wiring_owned = owned_rows
|
|
911
|
+
|
|
912
|
+
# The merge phase re-wrote the agent JSON (Kiro) with
|
|
913
|
+
# the hook entries we just merged in. Refresh the
|
|
914
|
+
# state.files SHA so uninstall's Tier-1 check still
|
|
915
|
+
# passes — see ``_refresh_merge_target_shas``.
|
|
916
|
+
_refresh_merge_target_shas(
|
|
917
|
+
pack_state=new_pack_state,
|
|
918
|
+
owned_rows=owned_rows,
|
|
919
|
+
root=plan.root,
|
|
920
|
+
)
|
|
921
|
+
elif plan.scope == "repo" and repo_target_adapter is not None:
|
|
922
|
+
# RFC-0012: record the resolved adapter on every repo-scope
|
|
923
|
+
# per-IDE install. State-hint short-circuit at upgrade time
|
|
924
|
+
# (AC10b parity at repo scope) depends on this. Skipped
|
|
925
|
+
# when `--emit-install-routes` is set — the legacy dist-tree
|
|
926
|
+
# producer has no single adapter to pin.
|
|
927
|
+
new_pack_state.adapter = repo_target_adapter
|
|
928
|
+
|
|
929
|
+
plan.state.packs[pack_name] = new_pack_state
|
|
930
|
+
# Stamp the post-write schema. Always emit the current
|
|
931
|
+
# ``STATE_SCHEMA_VERSION`` (bumped to v0.3 in T8a) so a fresh
|
|
932
|
+
# install never produces a state file pinned at a stale version.
|
|
933
|
+
from agentbundle.config import STATE_SCHEMA_VERSION
|
|
934
|
+
|
|
935
|
+
plan.state.schema_version = STATE_SCHEMA_VERSION
|
|
936
|
+
serialised = dump_state(plan.state)
|
|
937
|
+
# State file is CLI-owned metadata, not pack-projected content.
|
|
938
|
+
# At repo scope the path is `<root>/.agentbundle-state.toml` —
|
|
939
|
+
# a top-level file that wouldn't match any `.agentbundle/`-style
|
|
940
|
+
# prefix. Skip the prefix check (the jail-under-root check still
|
|
941
|
+
# fires) so the state-write isn't blocked by RFC-0012's
|
|
942
|
+
# per-IDE prefix list. At user scope the state file is under
|
|
943
|
+
# `~/.agentbundle/state.toml` which already matches the prefix.
|
|
944
|
+
state_relpath = str(plan.state_path.relative_to(plan.root))
|
|
945
|
+
state_prefixes = plan.allowed_prefixes
|
|
946
|
+
if plan.scope == "repo" and state_relpath == ".agentbundle-state.toml":
|
|
947
|
+
state_prefixes = None
|
|
948
|
+
try:
|
|
949
|
+
safety.write_jailed(
|
|
950
|
+
plan.root,
|
|
951
|
+
state_relpath,
|
|
952
|
+
serialised,
|
|
953
|
+
scope=plan.scope,
|
|
954
|
+
allowed_prefixes=state_prefixes,
|
|
955
|
+
)
|
|
956
|
+
except safety.PathJailError as exc:
|
|
957
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
958
|
+
return 1
|
|
959
|
+
|
|
960
|
+
# ── Step 10: recommends cross-scope warnings (stderr) ─────────────────────
|
|
961
|
+
# Emitted per scope per recommend per spec § *recommends across
|
|
962
|
+
# scopes*. The warning text distinguishes three cases (compatible-
|
|
963
|
+
# present / missing-installable / scope-disjoint). Output goes to
|
|
964
|
+
# stderr so the `installed:` rail on stdout stays parseable.
|
|
965
|
+
recommends = pack_toml.get("pack", {}).get("recommends", [])
|
|
966
|
+
if isinstance(recommends, list):
|
|
967
|
+
for plan in plans:
|
|
968
|
+
# The recommending scope is each plan's scope; a dual-scope
|
|
969
|
+
# --force install emits one warning per scope per recommend.
|
|
970
|
+
for rec in recommends:
|
|
971
|
+
if not isinstance(rec, str):
|
|
972
|
+
continue
|
|
973
|
+
_emit_recommends_warning(
|
|
974
|
+
rec,
|
|
975
|
+
recommending_scope=plan.scope,
|
|
976
|
+
catalogue_dir=catalogue_dir,
|
|
977
|
+
repo_state=repo_state,
|
|
978
|
+
user_state=user_state,
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
# ── Step 11: Write install marker(s) per scope ───────────────────────────
|
|
982
|
+
# Per spec AC19a: after every successful install, append a
|
|
983
|
+
# `[[packs-installed]]` entry to `.adapt-install-marker.toml` at the
|
|
984
|
+
# install's scope root. The file's *path* encodes the scope.
|
|
985
|
+
pack_version = pack_toml.get("pack", {}).get("version", "")
|
|
986
|
+
# Per AC19a: markers are repo-only, so unresolved-markers is computed
|
|
987
|
+
# off the **repo-scope** projection regardless of which scopes the
|
|
988
|
+
# install touched. User-scope marker files always carry [].
|
|
989
|
+
repo_unresolved_markers = (
|
|
990
|
+
_collect_unresolved_markers(repo_projection)
|
|
991
|
+
if repo_projection is not None
|
|
992
|
+
else []
|
|
993
|
+
)
|
|
994
|
+
for plan in plans:
|
|
995
|
+
scope_markers = repo_unresolved_markers if plan.scope == "repo" else []
|
|
996
|
+
try:
|
|
997
|
+
_append_install_marker(
|
|
998
|
+
plan.root,
|
|
999
|
+
plan.scope,
|
|
1000
|
+
pack_name=pack_name,
|
|
1001
|
+
pack_version=pack_version,
|
|
1002
|
+
unresolved_markers=scope_markers,
|
|
1003
|
+
new_companions=plan.new_companions,
|
|
1004
|
+
allowed_prefixes=plan.allowed_prefixes,
|
|
1005
|
+
)
|
|
1006
|
+
except (OSError, safety.PathJailError) as exc:
|
|
1007
|
+
print(f"install: {exc}", file=sys.stderr)
|
|
1008
|
+
return 1
|
|
1009
|
+
|
|
1010
|
+
# ── Step 12: Chained adapt (in-process) ──────────────────────────────────
|
|
1011
|
+
# Per spec AC19b: invoke `agentbundle.commands.adapt.run` in-process
|
|
1012
|
+
# with --values-from <repo>/.adapt-discovery.toml regardless of the
|
|
1013
|
+
# install scope (markers are repo-only). AC19d covers the two
|
|
1014
|
+
# failure modes.
|
|
1015
|
+
repo_plan = next((p for p in plans if p.scope == "repo"), None)
|
|
1016
|
+
repo_root_for_adapt = (
|
|
1017
|
+
repo_plan.root if repo_plan is not None else Path(args.output).resolve()
|
|
1018
|
+
)
|
|
1019
|
+
adapt_rc = _chain_adapt(repo_root_for_adapt)
|
|
1020
|
+
if adapt_rc != 0:
|
|
1021
|
+
# Per AC19d (ii): malformed `.adapt-discovery.toml` causes the
|
|
1022
|
+
# chained adapt to raise; install exits non-zero. The marker
|
|
1023
|
+
# file was already written in step 11 — that's by design.
|
|
1024
|
+
return adapt_rc
|
|
1025
|
+
|
|
1026
|
+
# ── Step 13: Emit installed: lines (repo first, user last) ───────────────
|
|
1027
|
+
# RFC-0011 extends user-scope output with ` via <adapter>` and an
|
|
1028
|
+
# optional ` (other declared adapters: …; use --adapter to override)`
|
|
1029
|
+
# suffix when multiple CLI homes match the pack's allowed-adapters.
|
|
1030
|
+
# RFC-0012 extends repo-scope output with the same `via <adapter>`
|
|
1031
|
+
# shape for per-IDE projection; the `--emit-install-routes` path
|
|
1032
|
+
# emits an `emitted install routes for ...` line instead (no
|
|
1033
|
+
# single adapter to pin). AC21: repo scope carries no "other
|
|
1034
|
+
# declared adapters" suffix (no probe runs).
|
|
1035
|
+
for plan in plans:
|
|
1036
|
+
if plan.scope == "user":
|
|
1037
|
+
line = f"installed: {pack_name} @ user via {user_target_adapter}"
|
|
1038
|
+
if cli_adapter is None and _pack_allowed_adapters:
|
|
1039
|
+
probes = _user_scope_adapter_probes()
|
|
1040
|
+
home = Path.home()
|
|
1041
|
+
populated_others = [
|
|
1042
|
+
a
|
|
1043
|
+
for a in _pack_allowed_adapters
|
|
1044
|
+
if a != user_target_adapter
|
|
1045
|
+
and a in probes
|
|
1046
|
+
and probes[a](home)
|
|
1047
|
+
]
|
|
1048
|
+
if populated_others:
|
|
1049
|
+
line += (
|
|
1050
|
+
f" (other declared adapters: "
|
|
1051
|
+
f"{', '.join(populated_others)}; "
|
|
1052
|
+
f"use --adapter to override)"
|
|
1053
|
+
)
|
|
1054
|
+
print(line)
|
|
1055
|
+
elif plan.scope == "repo" and emit_install_routes:
|
|
1056
|
+
# Dist-tree shape — the two per-pack-emitting recipes
|
|
1057
|
+
# produce `<repo>/claude-plugins/<pack>/` and
|
|
1058
|
+
# `<repo>/apm/<pack>/`. The `marketplace` recipe doesn't
|
|
1059
|
+
# produce a per-pack directory and is excluded from the
|
|
1060
|
+
# route list per RFC-0012 § *Install-time message rail
|
|
1061
|
+
# (repo scope)*.
|
|
1062
|
+
routes = [
|
|
1063
|
+
f"{output_root}/claude-plugins/{pack_name}/",
|
|
1064
|
+
f"{output_root}/apm/{pack_name}/",
|
|
1065
|
+
]
|
|
1066
|
+
# Emit both the new route-list summary AND the legacy
|
|
1067
|
+
# plain-text line. Order: route-list first (the new info)
|
|
1068
|
+
# so adopters reading the tail see the existing
|
|
1069
|
+
# ``installed: <pack> @ repo`` recap last — preserves the
|
|
1070
|
+
# invariant every pre-RFC-0012 integration test asserts
|
|
1071
|
+
# against (last non-empty stdout line is the install
|
|
1072
|
+
# recap).
|
|
1073
|
+
print(
|
|
1074
|
+
f"emitted install routes for {pack_name} at "
|
|
1075
|
+
f"{_format_route_list(routes)}"
|
|
1076
|
+
)
|
|
1077
|
+
print(f"installed: {pack_name} @ repo")
|
|
1078
|
+
elif plan.scope == "repo" and repo_target_adapter is not None:
|
|
1079
|
+
# RFC-0012 per-IDE projection at repo scope.
|
|
1080
|
+
print(
|
|
1081
|
+
f"installed: {pack_name} @ repo via {repo_target_adapter}"
|
|
1082
|
+
)
|
|
1083
|
+
else:
|
|
1084
|
+
# Defensive fallback: matches pre-RFC-0012 wording for any
|
|
1085
|
+
# path the new branches don't capture.
|
|
1086
|
+
print(f"installed: {pack_name} @ {plan.scope}")
|
|
1087
|
+
|
|
1088
|
+
return 0
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
_INBAND_DETECTION_SEEN: set[tuple[str, str]] = set()
|
|
1092
|
+
"""RFC-0012 AC24 once-per-``(root, pack_name)`` short-circuit.
|
|
1093
|
+
|
|
1094
|
+
Process-scoped mutable state. The detection block consults this set; an
|
|
1095
|
+
entry means "we already emitted a migration line for this (root, pack) in
|
|
1096
|
+
this process and any further ``install`` invocation should stay silent."
|
|
1097
|
+
Production ``agentbundle`` CLI invocations are short-lived processes so
|
|
1098
|
+
the set resets naturally; long-running embedders (an MCP shim that loops
|
|
1099
|
+
``install.run`` calls, a test harness) MUST reset via
|
|
1100
|
+
:func:`_clear_inband_detection_seen` between logical sessions or detection
|
|
1101
|
+
will silently skip on the second call."""
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _clear_inband_detection_seen() -> None:
|
|
1105
|
+
"""Reset the once-per-session detection set.
|
|
1106
|
+
|
|
1107
|
+
Public-by-convention (single leading underscore) helper for callers
|
|
1108
|
+
that need to bypass the once-per-process short-circuit — tests, and
|
|
1109
|
+
any long-running embedder restarting an install loop. Prefer this
|
|
1110
|
+
over reaching into :data:`_INBAND_DETECTION_SEEN` directly so the
|
|
1111
|
+
storage shape can change without breaking callers.
|
|
1112
|
+
"""
|
|
1113
|
+
_INBAND_DETECTION_SEEN.clear()
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
# ---------------------------------------------------------------------------
|
|
1117
|
+
# Dropped-primitives warning rail (docs/specs/dropped-primitives-coverage T6)
|
|
1118
|
+
# ---------------------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
_DROPPED_WARNING_SEEN: set[tuple[str, str, str, str]] = set()
|
|
1122
|
+
"""Once-per-``(root, pack_name, adapter, scope)`` short-circuit for the
|
|
1123
|
+
dropped-primitives warning rail (spec AC11).
|
|
1124
|
+
|
|
1125
|
+
The 4-tuple key's `scope` component is load-bearing: dual-scope installs
|
|
1126
|
+
fire one warning per scope where the resolved adapter has dropped modes,
|
|
1127
|
+
each silenceable independently on repeat. See spec AC10/AC11 for the
|
|
1128
|
+
dual-scope contract."""
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _clear_dropped_warning_seen() -> None:
|
|
1132
|
+
"""Reset the once-per-session dropped-warning set.
|
|
1133
|
+
|
|
1134
|
+
Public-by-convention helper for tests + long-running embedders;
|
|
1135
|
+
mirrors :func:`_clear_inband_detection_seen` (PR #141 precedent).
|
|
1136
|
+
"""
|
|
1137
|
+
_DROPPED_WARNING_SEEN.clear()
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _enumerate_dropped_primitives(
|
|
1141
|
+
pack_dir: Path,
|
|
1142
|
+
adapter: str,
|
|
1143
|
+
contract: dict | None = None,
|
|
1144
|
+
) -> dict[str, int]:
|
|
1145
|
+
"""Return ``{primitive-type-name: count}`` for primitives the pack
|
|
1146
|
+
ships AND the adapter projects with ``mode = "dropped"``.
|
|
1147
|
+
|
|
1148
|
+
Counts come from ``<pack_dir>/.apm/<source-dir>/`` (where
|
|
1149
|
+
``<source-dir>`` is the contract's ``primitive.<type>.source-path``
|
|
1150
|
+
last segment — e.g., ``hook-body`` → ``hooks/``). Each entry counts
|
|
1151
|
+
as one primitive only if it matches the type's expected shape
|
|
1152
|
+
(skills/agents/commands are directories or .md files; hook-wiring is
|
|
1153
|
+
.toml; hook-body is any file). Junk files (``.DS_Store``, editor
|
|
1154
|
+
swap files) and stray directories don't inflate the count. Empty
|
|
1155
|
+
mapping when:
|
|
1156
|
+
|
|
1157
|
+
- The adapter has no ``dropped`` entries at all (e.g. claude-code).
|
|
1158
|
+
- The pack ships nothing under any of the adapter's dropped types.
|
|
1159
|
+
"""
|
|
1160
|
+
if contract is None:
|
|
1161
|
+
import tomllib as _tomllib
|
|
1162
|
+
from agentbundle.build.main import _read_bundled
|
|
1163
|
+
|
|
1164
|
+
contract = _tomllib.loads(_read_bundled("adapter.toml"))
|
|
1165
|
+
|
|
1166
|
+
primitives = contract.get("primitive", {})
|
|
1167
|
+
adapter_entries = contract.get("adapter", {}).get(adapter, {}).get("projection", [])
|
|
1168
|
+
out: dict[str, int] = {}
|
|
1169
|
+
for entry in adapter_entries:
|
|
1170
|
+
if entry.get("mode") != "dropped":
|
|
1171
|
+
continue
|
|
1172
|
+
ptype = entry.get("primitive")
|
|
1173
|
+
if not ptype:
|
|
1174
|
+
continue
|
|
1175
|
+
source_path = primitives.get(ptype, {}).get("source-path", "")
|
|
1176
|
+
source_dir = pack_dir / source_path.strip("/")
|
|
1177
|
+
if not source_dir.exists():
|
|
1178
|
+
continue
|
|
1179
|
+
count = _count_primitive_entries(source_dir, ptype)
|
|
1180
|
+
if count > 0:
|
|
1181
|
+
out[ptype] = count
|
|
1182
|
+
return out
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
_JUNK_NAMES = {"Thumbs.db", "desktop.ini"}
|
|
1186
|
+
"""Cross-platform editor / OS artifacts that aren't pack content.
|
|
1187
|
+
Leading-dot files (``.DS_Store``, editor swaps) are caught by the
|
|
1188
|
+
dotfile skip; these are the named exceptions that don't start with a
|
|
1189
|
+
dot but still aren't primitives."""
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _is_junk_name(name: str) -> bool:
|
|
1193
|
+
"""Return True for entries that aren't pack content regardless of type."""
|
|
1194
|
+
if name.startswith("."):
|
|
1195
|
+
return True
|
|
1196
|
+
if name in _JUNK_NAMES:
|
|
1197
|
+
return True
|
|
1198
|
+
# Editor swap / backup suffixes.
|
|
1199
|
+
if name.endswith(("~", ".swp", ".bak")):
|
|
1200
|
+
return True
|
|
1201
|
+
return False
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _count_primitive_entries(source_dir: Path, ptype: str) -> int:
|
|
1205
|
+
"""Count entries in ``source_dir`` that match ``ptype``'s shape.
|
|
1206
|
+
|
|
1207
|
+
Per the bundled contract's primitive layout:
|
|
1208
|
+
- ``skill``: subdirectories (each a skill bundle with SKILL.md).
|
|
1209
|
+
- ``agent``, ``command``: ``.md`` files.
|
|
1210
|
+
- ``hook-body``: ``.sh`` or ``.py`` files (the two shapes the
|
|
1211
|
+
contract's adapters project via direct-file today; a future
|
|
1212
|
+
primitive shape extends this set explicitly).
|
|
1213
|
+
- ``hook-wiring``: ``.toml`` files.
|
|
1214
|
+
|
|
1215
|
+
Junk entries (``.DS_Store``, ``Thumbs.db``, ``desktop.ini``, editor
|
|
1216
|
+
swap/backup files, stray subdirs) are skipped — they would
|
|
1217
|
+
otherwise inflate the warning rail's count.
|
|
1218
|
+
"""
|
|
1219
|
+
count = 0
|
|
1220
|
+
for entry in source_dir.iterdir():
|
|
1221
|
+
if _is_junk_name(entry.name):
|
|
1222
|
+
continue
|
|
1223
|
+
if ptype == "skill":
|
|
1224
|
+
if entry.is_dir():
|
|
1225
|
+
count += 1
|
|
1226
|
+
elif ptype in ("agent", "command"):
|
|
1227
|
+
if entry.is_file() and entry.suffix == ".md":
|
|
1228
|
+
count += 1
|
|
1229
|
+
elif ptype == "hook-wiring":
|
|
1230
|
+
if entry.is_file() and entry.suffix == ".toml":
|
|
1231
|
+
count += 1
|
|
1232
|
+
elif ptype == "hook-body":
|
|
1233
|
+
if entry.is_file() and entry.suffix in (".sh", ".py"):
|
|
1234
|
+
count += 1
|
|
1235
|
+
else:
|
|
1236
|
+
# Unknown primitive type — admit conservatively but only
|
|
1237
|
+
# files (not stray subdirs) so a future contract addition
|
|
1238
|
+
# is surfaced rather than silently filtered.
|
|
1239
|
+
if entry.is_file():
|
|
1240
|
+
count += 1
|
|
1241
|
+
return count
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _enumerate_compatible_primitives(
|
|
1245
|
+
pack_dir: Path,
|
|
1246
|
+
adapter: str,
|
|
1247
|
+
contract: dict | None = None,
|
|
1248
|
+
) -> list[str]:
|
|
1249
|
+
"""Return primitive-type names where ``mode != "dropped"`` AND the
|
|
1250
|
+
pack ships at least one file. Order matches the adapter's projection
|
|
1251
|
+
declaration order for stable output."""
|
|
1252
|
+
if contract is None:
|
|
1253
|
+
import tomllib as _tomllib
|
|
1254
|
+
from agentbundle.build.main import _read_bundled
|
|
1255
|
+
|
|
1256
|
+
contract = _tomllib.loads(_read_bundled("adapter.toml"))
|
|
1257
|
+
|
|
1258
|
+
primitives = contract.get("primitive", {})
|
|
1259
|
+
adapter_entries = contract.get("adapter", {}).get(adapter, {}).get("projection", [])
|
|
1260
|
+
out: list[str] = []
|
|
1261
|
+
for entry in adapter_entries:
|
|
1262
|
+
if entry.get("mode") == "dropped":
|
|
1263
|
+
continue
|
|
1264
|
+
ptype = entry.get("primitive")
|
|
1265
|
+
if not ptype:
|
|
1266
|
+
continue
|
|
1267
|
+
source_path = primitives.get(ptype, {}).get("source-path", "")
|
|
1268
|
+
source_dir = pack_dir / source_path.strip("/")
|
|
1269
|
+
if not source_dir.exists():
|
|
1270
|
+
continue
|
|
1271
|
+
if _count_primitive_entries(source_dir, ptype) > 0:
|
|
1272
|
+
out.append(ptype)
|
|
1273
|
+
return out
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _format_dropped_warning(
|
|
1277
|
+
pack_name: str,
|
|
1278
|
+
adapter: str,
|
|
1279
|
+
dropped_counts: dict[str, int],
|
|
1280
|
+
compatible_types: list[str],
|
|
1281
|
+
) -> str:
|
|
1282
|
+
"""Backward-compat shim — delegates to the shared formatter.
|
|
1283
|
+
|
|
1284
|
+
Thin positional-argument wrapper around
|
|
1285
|
+
:func:`agentbundle.commands._drop_warning.format_drop_message` so
|
|
1286
|
+
existing callers (tests + ``_maybe_emit_dropped_warning``) keep
|
|
1287
|
+
working without modification. T4 of spec incompatible-hook-event-drop
|
|
1288
|
+
moved the canonical implementation to ``_drop_warning.py``; this
|
|
1289
|
+
shim lives here for backward compat.
|
|
1290
|
+
|
|
1291
|
+
Raises:
|
|
1292
|
+
ValueError: when ``dropped_counts`` has no nonzero entries (same
|
|
1293
|
+
contract as the pre-move implementation).
|
|
1294
|
+
"""
|
|
1295
|
+
from agentbundle.commands._drop_warning import format_drop_message
|
|
1296
|
+
|
|
1297
|
+
return format_drop_message(
|
|
1298
|
+
pack_name=pack_name,
|
|
1299
|
+
adapter=adapter,
|
|
1300
|
+
dropped_counts=dropped_counts,
|
|
1301
|
+
compatible_types=compatible_types,
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _maybe_emit_dropped_warning(
|
|
1306
|
+
*,
|
|
1307
|
+
root: Path,
|
|
1308
|
+
pack_dir: Path,
|
|
1309
|
+
pack_name: str,
|
|
1310
|
+
adapter: str,
|
|
1311
|
+
scope: str,
|
|
1312
|
+
) -> None:
|
|
1313
|
+
"""If the pack ships any primitive type the adapter drops, or any
|
|
1314
|
+
hook-wiring file uses an event the adapter doesn't support, emit the
|
|
1315
|
+
warning to stderr. Short-circuits once per
|
|
1316
|
+
``(root, pack_name, adapter, scope)`` per process so repeat calls
|
|
1317
|
+
in the same process stay silent (AC11).
|
|
1318
|
+
|
|
1319
|
+
Covers both the coarse-grained primitive-type drop rail
|
|
1320
|
+
(``_enumerate_dropped_primitives``) and the per-file event-level
|
|
1321
|
+
drop rail (``enumerate_event_dropped_wirings``). The short-circuit
|
|
1322
|
+
key is unchanged — both drop kinds derive from the same inputs, so
|
|
1323
|
+
one warning per scope per process covers both (spec AC9).
|
|
1324
|
+
|
|
1325
|
+
Pre-write barrier: callers invoke this after Step 5's plans-list is
|
|
1326
|
+
built (both target adapters resolved) and before Step 6's pre-flight
|
|
1327
|
+
rails fire / Step 9's writes execute.
|
|
1328
|
+
"""
|
|
1329
|
+
from agentbundle.commands._drop_warning import format_drop_message
|
|
1330
|
+
|
|
1331
|
+
key = (str(root), pack_name, adapter, scope)
|
|
1332
|
+
if key in _DROPPED_WARNING_SEEN:
|
|
1333
|
+
return
|
|
1334
|
+
|
|
1335
|
+
# Load the contract once for both enumerators so we don't hit disk twice.
|
|
1336
|
+
import tomllib as _tomllib
|
|
1337
|
+
from agentbundle.build.main import _read_bundled
|
|
1338
|
+
contract = _tomllib.loads(_read_bundled("adapter.toml"))
|
|
1339
|
+
|
|
1340
|
+
dropped = _enumerate_dropped_primitives(pack_dir, adapter, contract)
|
|
1341
|
+
event_drops = enumerate_event_dropped_wirings(pack_dir, adapter, contract)
|
|
1342
|
+
|
|
1343
|
+
if not dropped and not event_drops:
|
|
1344
|
+
# Adapter has no dropped modes OR pack ships nothing droppable.
|
|
1345
|
+
# Record the no-op so even a "no warning" decision is short-circuited
|
|
1346
|
+
# — a future caller flipping the pack's primitives wouldn't expect
|
|
1347
|
+
# a sudden warning mid-process.
|
|
1348
|
+
_DROPPED_WARNING_SEEN.add(key)
|
|
1349
|
+
return
|
|
1350
|
+
compatible = _enumerate_compatible_primitives(pack_dir, adapter, contract)
|
|
1351
|
+
msg = format_drop_message(
|
|
1352
|
+
pack_name=pack_name,
|
|
1353
|
+
adapter=adapter,
|
|
1354
|
+
dropped_counts=dropped,
|
|
1355
|
+
compatible_types=compatible,
|
|
1356
|
+
event_drops=event_drops,
|
|
1357
|
+
mode="install_warning",
|
|
1358
|
+
)
|
|
1359
|
+
print(msg, file=sys.stderr)
|
|
1360
|
+
_DROPPED_WARNING_SEEN.add(key)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _scan_dist_tree_artifacts(root: Path, pack_name: str) -> list[Path]:
|
|
1364
|
+
"""Return pre-RFC-0012 dist-tree projection files for ``pack_name``.
|
|
1365
|
+
|
|
1366
|
+
Scans ``<root>/claude-plugins/<pack>/`` and ``<root>/apm/<pack>/`` —
|
|
1367
|
+
the two per-pack subtrees the legacy ``per-pack-claude-plugin`` and
|
|
1368
|
+
``per-pack-apm-package`` recipes produce. Other top-level
|
|
1369
|
+
directories (``.claude/`` etc.) belong to AC24 trigger (c)'s
|
|
1370
|
+
``safety.scan_for_pack_artifacts`` scan, not this one.
|
|
1371
|
+
"""
|
|
1372
|
+
out: list[Path] = []
|
|
1373
|
+
for top in ("claude-plugins", "apm"):
|
|
1374
|
+
base = root / top / pack_name
|
|
1375
|
+
if not base.exists():
|
|
1376
|
+
continue
|
|
1377
|
+
for entry in base.rglob("*"):
|
|
1378
|
+
if entry.is_file():
|
|
1379
|
+
out.append(entry)
|
|
1380
|
+
return sorted(out)
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _classify_pre_rfc0012_state(
|
|
1384
|
+
*,
|
|
1385
|
+
output_root: Path,
|
|
1386
|
+
pack_name: str,
|
|
1387
|
+
pack_dir: Path,
|
|
1388
|
+
repo_state: "State",
|
|
1389
|
+
repo_target_adapter: str,
|
|
1390
|
+
allowed_prefixes_repo: list[str],
|
|
1391
|
+
force: bool,
|
|
1392
|
+
projection_relpaths: "set[str] | None" = None,
|
|
1393
|
+
) -> int | None:
|
|
1394
|
+
"""RFC-0012 AC24: in-band detection of pre-RFC-0012 state.
|
|
1395
|
+
|
|
1396
|
+
Triggers evaluated per-pack in precedence ``(b) → (a) → (c)``; only
|
|
1397
|
+
the first match emits. Detection runs once per
|
|
1398
|
+
``(output_root, pack_name)`` per process; subsequent calls
|
|
1399
|
+
short-circuit to silence.
|
|
1400
|
+
|
|
1401
|
+
Returns:
|
|
1402
|
+
- ``None`` — no trigger fired, or ``--force`` cleared the
|
|
1403
|
+
trigger's on-disk shape; caller proceeds with the install.
|
|
1404
|
+
- ``1`` — refused with pinned stderr; caller returns 1.
|
|
1405
|
+
"""
|
|
1406
|
+
from agentbundle import safety
|
|
1407
|
+
|
|
1408
|
+
key = (str(output_root), pack_name)
|
|
1409
|
+
if key in _INBAND_DETECTION_SEEN:
|
|
1410
|
+
return None
|
|
1411
|
+
|
|
1412
|
+
state_row = repo_state.packs.get(pack_name)
|
|
1413
|
+
|
|
1414
|
+
# (b) Shape-mismatch — state row exists AND dist-tree files exist.
|
|
1415
|
+
# Pre-RFC-0012 signal per spec AC24: state.toml carries a row AND
|
|
1416
|
+
# the on-disk shape is the legacy dist-tree (post-RFC-0012 the only
|
|
1417
|
+
# code path producing those files is ``--emit-install-routes``,
|
|
1418
|
+
# which short-circuits before this detection runs).
|
|
1419
|
+
if state_row is not None:
|
|
1420
|
+
dist_tree = _scan_dist_tree_artifacts(output_root, pack_name)
|
|
1421
|
+
if dist_tree:
|
|
1422
|
+
_INBAND_DETECTION_SEEN.add(key)
|
|
1423
|
+
if force:
|
|
1424
|
+
# AC25(vi): --force is the corrective action for (b)'s
|
|
1425
|
+
# cross-invocation false positive — clean the dist-tree
|
|
1426
|
+
# files AND drop the stale state row so the install
|
|
1427
|
+
# proceeds as a clean reinstall. Without the row drop
|
|
1428
|
+
# Step 4 would refuse with "use 'upgrade'", trapping the
|
|
1429
|
+
# adopter in a loop (upgrade at repo scope re-emits the
|
|
1430
|
+
# dist-tree shape today). The caller re-computes
|
|
1431
|
+
# ``installed_at_repo`` after this helper returns; the
|
|
1432
|
+
# on-disk state.toml is rewritten here too because the
|
|
1433
|
+
# per-scope plan loop reloads state from disk at
|
|
1434
|
+
# ``install.py:519`` (``load_state(for_write=True)``) and
|
|
1435
|
+
# an in-memory-only pop would silently resurrect.
|
|
1436
|
+
import shutil
|
|
1437
|
+
|
|
1438
|
+
from agentbundle.config import dump_state
|
|
1439
|
+
|
|
1440
|
+
for top in ("claude-plugins", "apm"):
|
|
1441
|
+
subtree = output_root / top / pack_name
|
|
1442
|
+
if subtree.exists():
|
|
1443
|
+
try:
|
|
1444
|
+
shutil.rmtree(subtree)
|
|
1445
|
+
except OSError:
|
|
1446
|
+
pass
|
|
1447
|
+
repo_state.packs.pop(pack_name, None)
|
|
1448
|
+
state_path = output_root / ".agentbundle-state.toml"
|
|
1449
|
+
if state_path.exists():
|
|
1450
|
+
# Direct write (not ``safety.write_jailed``) because
|
|
1451
|
+
# the path *is* the jail anchor plus a fixed top-
|
|
1452
|
+
# level filename; the durability guarantee comes from
|
|
1453
|
+
# the post-install atomic rewrite at line ~809 a few
|
|
1454
|
+
# hundred milliseconds later. If detection is ever
|
|
1455
|
+
# lifted into a standalone verb, route through
|
|
1456
|
+
# ``safety.write_jailed`` for atomicity.
|
|
1457
|
+
state_path.write_text(
|
|
1458
|
+
dump_state(repo_state), encoding="utf-8"
|
|
1459
|
+
)
|
|
1460
|
+
return None
|
|
1461
|
+
print(
|
|
1462
|
+
f"install: pre-RFC-0012 dist-tree files for pack "
|
|
1463
|
+
f"{pack_name} at "
|
|
1464
|
+
f"{_format_route_list([str(p) for p in dist_tree])} — "
|
|
1465
|
+
f"state recorded but on-disk shape predates per-IDE "
|
|
1466
|
+
f"projection; rerun with --force to clean and reinstall, "
|
|
1467
|
+
f"or delete the listed paths and rerun",
|
|
1468
|
+
file=sys.stderr,
|
|
1469
|
+
)
|
|
1470
|
+
return 1
|
|
1471
|
+
|
|
1472
|
+
# (a) Adapter disagreement — state row exists, no dist-tree
|
|
1473
|
+
# files (so (b) didn't fire), AND resolver's pick disagrees
|
|
1474
|
+
# with the recorded adapter. AC25(iii): the corrective action
|
|
1475
|
+
# is uninstall + reinstall; ``--force`` does NOT clear this.
|
|
1476
|
+
# ``state_row.adapter`` is always a non-empty string per
|
|
1477
|
+
# ``load_state`` (defaults to "claude-code" at read time for
|
|
1478
|
+
# absent / non-string values); no coercion needed.
|
|
1479
|
+
recorded_adapter = state_row.adapter
|
|
1480
|
+
if recorded_adapter != repo_target_adapter:
|
|
1481
|
+
_INBAND_DETECTION_SEEN.add(key)
|
|
1482
|
+
print(
|
|
1483
|
+
f"install: state records adapter "
|
|
1484
|
+
f"{recorded_adapter!r} for pack {pack_name}, but "
|
|
1485
|
+
f"resolver picked {repo_target_adapter!r} — uninstall "
|
|
1486
|
+
f"the pack at repo scope and reinstall to reconcile "
|
|
1487
|
+
f"(cross-adapter install is not supported)",
|
|
1488
|
+
file=sys.stderr,
|
|
1489
|
+
)
|
|
1490
|
+
return 1
|
|
1491
|
+
else:
|
|
1492
|
+
# (c) Orphan recovery — no state row AND per-IDE artifacts
|
|
1493
|
+
# exist under the resolved adapter's allowed-prefixes.repo.
|
|
1494
|
+
# The scan is **per-pack scoped** via ``pack_dir`` +
|
|
1495
|
+
# ``pack_name`` so a third pack's orphan files (left under the
|
|
1496
|
+
# same adapter prefix by a different crashed install) don't
|
|
1497
|
+
# surface here as a false positive — the cross-pack residual
|
|
1498
|
+
# ROADMAP named after PR #141.
|
|
1499
|
+
orphans = safety.scan_for_pack_artifacts(
|
|
1500
|
+
output_root, allowed_prefixes_repo,
|
|
1501
|
+
pack_dir=pack_dir, pack_name=pack_name,
|
|
1502
|
+
)
|
|
1503
|
+
# Canonicalise relpaths (NFC + ``os.path.normcase``) before any
|
|
1504
|
+
# membership test so a case-insensitive filesystem (Windows NTFS,
|
|
1505
|
+
# HFS+) or one that returns paths in a different Unicode normal
|
|
1506
|
+
# form (macOS NFD ↔ NFC) doesn't fail-open. Shared by the
|
|
1507
|
+
# issue-#190 projection filter and the foreign-owned filter below;
|
|
1508
|
+
# both compare on-disk orphan paths against an authored relpath set.
|
|
1509
|
+
import os as _os
|
|
1510
|
+
import unicodedata as _unicodedata
|
|
1511
|
+
|
|
1512
|
+
def _canon_relpath(rel: str) -> str:
|
|
1513
|
+
return _unicodedata.normalize("NFC", _os.path.normcase(rel))
|
|
1514
|
+
|
|
1515
|
+
# Issue #190: a file the *current* projection ships is not an
|
|
1516
|
+
# interrupted-install orphan — it is a path Step 9 companion-
|
|
1517
|
+
# protects (adopter edit → ``*.upstream.<ext>``; identical →
|
|
1518
|
+
# clean Tier-1). Drop those from the orphan set so a first
|
|
1519
|
+
# install over hand-authored primitives proceeds to Step 9
|
|
1520
|
+
# instead of refusing. What remains is the genuine residual:
|
|
1521
|
+
# files under a still-shipped primitive's dir that the current
|
|
1522
|
+
# projection no longer includes (a stale crumb from an older or
|
|
1523
|
+
# interrupted install — or an adopter file the scanner's
|
|
1524
|
+
# primitive-name heuristic happened to match). Canonicalise both
|
|
1525
|
+
# sides so the comparison can't fail-open and leave a projected
|
|
1526
|
+
# path in the unlink set on a case-folding / NFD filesystem.
|
|
1527
|
+
if orphans and projection_relpaths is not None:
|
|
1528
|
+
_canon_projection = {_canon_relpath(r) for r in projection_relpaths}
|
|
1529
|
+
orphans = [
|
|
1530
|
+
p for p in orphans
|
|
1531
|
+
if _canon_relpath(p.relative_to(output_root).as_posix())
|
|
1532
|
+
not in _canon_projection
|
|
1533
|
+
]
|
|
1534
|
+
# The scanner's primitive-name heuristic is best-effort scoping,
|
|
1535
|
+
# not authoritative ownership. When two packs ship primitives
|
|
1536
|
+
# whose names collide (segment-match or stem-match), the
|
|
1537
|
+
# scanner mis-attributes the foreign pack's file as an orphan
|
|
1538
|
+
# of the installing pack — and the --force branch below would
|
|
1539
|
+
# unlink another state-tracked pack's file. ``state.toml`` IS
|
|
1540
|
+
# authoritative: filter out paths claimed by any other pack's
|
|
1541
|
+
# state row before treating the scanner's result as orphans.
|
|
1542
|
+
#
|
|
1543
|
+
# ``state.toml`` is authoritative: filter out paths claimed by any
|
|
1544
|
+
# other pack's state row (compared with the same ``_canon_relpath``
|
|
1545
|
+
# canonicalisation defined above) before treating the scanner's
|
|
1546
|
+
# result as orphans.
|
|
1547
|
+
if orphans:
|
|
1548
|
+
foreign_owned: set[str] = set()
|
|
1549
|
+
for other_name, other_state in repo_state.packs.items():
|
|
1550
|
+
if other_name == pack_name:
|
|
1551
|
+
continue
|
|
1552
|
+
foreign_owned.update(
|
|
1553
|
+
_canon_relpath(rel) for rel in other_state.files.keys()
|
|
1554
|
+
)
|
|
1555
|
+
if foreign_owned:
|
|
1556
|
+
orphans = [
|
|
1557
|
+
p for p in orphans
|
|
1558
|
+
if _canon_relpath(p.relative_to(output_root).as_posix())
|
|
1559
|
+
not in foreign_owned
|
|
1560
|
+
]
|
|
1561
|
+
if orphans:
|
|
1562
|
+
_INBAND_DETECTION_SEEN.add(key)
|
|
1563
|
+
if force:
|
|
1564
|
+
for orphan in orphans:
|
|
1565
|
+
try:
|
|
1566
|
+
orphan.unlink()
|
|
1567
|
+
except OSError:
|
|
1568
|
+
pass
|
|
1569
|
+
return None
|
|
1570
|
+
print(
|
|
1571
|
+
f"install: unrecognized files at projection paths not "
|
|
1572
|
+
f"shipped by pack {pack_name} at "
|
|
1573
|
+
f"{_format_route_list([str(p) for p in orphans])} — these "
|
|
1574
|
+
f"may be left over from an older or interrupted install, or "
|
|
1575
|
+
f"your own files; rerun with --force to remove them and "
|
|
1576
|
+
f"reinstall, or move them aside and rerun",
|
|
1577
|
+
file=sys.stderr,
|
|
1578
|
+
)
|
|
1579
|
+
return 1
|
|
1580
|
+
|
|
1581
|
+
_INBAND_DETECTION_SEEN.add(key)
|
|
1582
|
+
return None
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def _format_route_list(routes: list[str]) -> str:
|
|
1586
|
+
"""Format a list of route paths per RFC-0012 § *Install-time
|
|
1587
|
+
message rail (repo scope)*.
|
|
1588
|
+
|
|
1589
|
+
- ``N=1`` → ``"X"``
|
|
1590
|
+
- ``N=2`` → ``"X and Y"``
|
|
1591
|
+
- ``N>=3`` → ``"X, Y, and Z"`` (serial-comma + final "and")
|
|
1592
|
+
"""
|
|
1593
|
+
if not routes:
|
|
1594
|
+
return ""
|
|
1595
|
+
if len(routes) == 1:
|
|
1596
|
+
return routes[0]
|
|
1597
|
+
if len(routes) == 2:
|
|
1598
|
+
return f"{routes[0]} and {routes[1]}"
|
|
1599
|
+
return ", ".join(routes[:-1]) + f", and {routes[-1]}"
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
_PACK_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
1603
|
+
_PACK_VERSION_RE = re.compile(
|
|
1604
|
+
r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$"
|
|
1605
|
+
)
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def _assert_pack_metadata_shape(
|
|
1609
|
+
pack_toml: dict,
|
|
1610
|
+
*,
|
|
1611
|
+
projection: "dict[str, bytes] | None" = None,
|
|
1612
|
+
) -> None:
|
|
1613
|
+
"""Defence-in-depth: refuse a pack whose manifest or projection
|
|
1614
|
+
relpaths fall outside the canonical TOML-safe grammars.
|
|
1615
|
+
|
|
1616
|
+
The structural fix for pack-metadata-driven TOML injection lives in
|
|
1617
|
+
:func:`config._emit_basic_string`. This validator is the bell-rings-
|
|
1618
|
+
loud companion at the install boundary: it stops the install before
|
|
1619
|
+
any write to either scope's state file. The three checks:
|
|
1620
|
+
|
|
1621
|
+
- ``pack.name`` matches ``^[a-z0-9][a-z0-9-]*$`` per
|
|
1622
|
+
``docs/CONVENTIONS.md``.
|
|
1623
|
+
- ``pack.version`` matches a SemVer-ish grammar
|
|
1624
|
+
``^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$``.
|
|
1625
|
+
``pack.schema.json`` types this as a bare string today; we tighten
|
|
1626
|
+
here because every value that lands in a basic-string position
|
|
1627
|
+
should be regex-shaped, not free-form.
|
|
1628
|
+
- If *projection* is supplied, every relpath contains no ``"``,
|
|
1629
|
+
``\\``, or control character (U+0000..U+001F, U+007F).
|
|
1630
|
+
|
|
1631
|
+
Raises ``RuntimeError`` on the first violation with a message
|
|
1632
|
+
shaped ``install: pack '<name>' has invalid <field>: <reason>`` —
|
|
1633
|
+
callers print ``str(exc)`` to stderr and exit non-zero.
|
|
1634
|
+
"""
|
|
1635
|
+
pack_block = pack_toml.get("pack", {}) if isinstance(pack_toml, dict) else {}
|
|
1636
|
+
name_raw = pack_block.get("name", "") if isinstance(pack_block, dict) else ""
|
|
1637
|
+
version_raw = pack_block.get("version", "") if isinstance(pack_block, dict) else ""
|
|
1638
|
+
|
|
1639
|
+
# `name` is the visible identifier in the error message — if it's
|
|
1640
|
+
# not a string, fall back to the type-name for the operator's sake
|
|
1641
|
+
# but don't interpolate the raw value (which may itself be
|
|
1642
|
+
# adversarial). `<unknown>` matches the placeholder used by
|
|
1643
|
+
# validate_dependencies_required for the same reason.
|
|
1644
|
+
name_for_message = name_raw if isinstance(name_raw, str) else "<unknown>"
|
|
1645
|
+
|
|
1646
|
+
if not isinstance(name_raw, str) or not _PACK_NAME_RE.fullmatch(name_raw):
|
|
1647
|
+
raise RuntimeError(
|
|
1648
|
+
f"install: pack {name_for_message!r} has invalid name: "
|
|
1649
|
+
f"must match ^[a-z0-9][a-z0-9-]*$ per docs/CONVENTIONS.md"
|
|
1650
|
+
)
|
|
1651
|
+
|
|
1652
|
+
if not isinstance(version_raw, str) or not _PACK_VERSION_RE.fullmatch(version_raw):
|
|
1653
|
+
# The raw value is operator-untrusted (it's the attack vector); do
|
|
1654
|
+
# not interpolate it into stderr — that surface can carry ANSI or
|
|
1655
|
+
# other terminal-bound bytes. The operator can `cat pack.toml` to
|
|
1656
|
+
# inspect it themselves.
|
|
1657
|
+
raise RuntimeError(
|
|
1658
|
+
f"install: pack {name_for_message!r} has invalid version: "
|
|
1659
|
+
f"must match ^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$"
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
if projection is not None:
|
|
1663
|
+
for relpath in projection:
|
|
1664
|
+
if not isinstance(relpath, str):
|
|
1665
|
+
raise RuntimeError(
|
|
1666
|
+
f"install: pack {name_for_message!r} has invalid "
|
|
1667
|
+
f"projection relpath: not a string"
|
|
1668
|
+
)
|
|
1669
|
+
# Refuse `"`, `\`, and any control char. Newlines, tabs and
|
|
1670
|
+
# carriage returns are control chars; null bytes too. The
|
|
1671
|
+
# path-jail probe (Step 8 in `run`) catches traversal; this
|
|
1672
|
+
# check catches TOML-grammar bombs that the path-jail would
|
|
1673
|
+
# let through. Same stderr discipline as version: do not
|
|
1674
|
+
# interpolate the raw relpath into the message — only its
|
|
1675
|
+
# length, which is bounded and operator-safe.
|
|
1676
|
+
if any(c == '"' or c == "\\" or ord(c) < 0x20 or ord(c) == 0x7F for c in relpath):
|
|
1677
|
+
raise RuntimeError(
|
|
1678
|
+
f"install: pack {name_for_message!r} has invalid "
|
|
1679
|
+
f"projection relpath (length {len(relpath)}): contains a "
|
|
1680
|
+
f"quote, backslash, or control character"
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def _strip_markdown_code(text: str) -> str:
|
|
1685
|
+
"""Remove fenced code blocks and inline-code spans from Markdown text.
|
|
1686
|
+
|
|
1687
|
+
Issue #190 minor: a `<adapt:NAME>`-shaped token inside a code span is
|
|
1688
|
+
*documentation about* the marker syntax (e.g. the `adapt-to-project`
|
|
1689
|
+
SKILL.md says "for each `<adapt:name>` marker …"), not a live
|
|
1690
|
+
substitution marker. Stripping code before the marker scan keeps such
|
|
1691
|
+
examples from leaking into the install-marker's `unresolved-markers`.
|
|
1692
|
+
Heuristic, not a full CommonMark parser — fences first, then inline spans.
|
|
1693
|
+
"""
|
|
1694
|
+
import re
|
|
1695
|
+
|
|
1696
|
+
# Fenced blocks: a line opening with 3+ backticks/tildes to the matching
|
|
1697
|
+
# close fence (or end-of-text for an unclosed fence).
|
|
1698
|
+
no_fences = re.sub(
|
|
1699
|
+
r"(?ms)^[ \t]*(`{3,}|~{3,}).*?(?:^[ \t]*\1[ \t]*$|\Z)", "", text
|
|
1700
|
+
)
|
|
1701
|
+
# Inline code: a run of N backticks to the next run of exactly N backticks.
|
|
1702
|
+
no_inline = re.sub(r"(`+)(?:.|\n)*?\1", "", no_fences)
|
|
1703
|
+
return no_inline
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def _collect_unresolved_markers(projection: dict) -> list[str]:
|
|
1707
|
+
"""Return sorted, deduplicated list of `<adapt:NAME>` markers found
|
|
1708
|
+
in the projection's byte content. The skill resolves these later;
|
|
1709
|
+
the install marker just enumerates them for the nudge surface.
|
|
1710
|
+
|
|
1711
|
+
Markers inside Markdown code spans/blocks are ignored — those are
|
|
1712
|
+
documentation examples, not live substitution points (issue #190)."""
|
|
1713
|
+
import re
|
|
1714
|
+
|
|
1715
|
+
marker_re = re.compile(r"<adapt:([a-z][a-z0-9-]*)>")
|
|
1716
|
+
seen: set[str] = set()
|
|
1717
|
+
for _relpath, content in projection.items():
|
|
1718
|
+
if isinstance(content, bytes):
|
|
1719
|
+
try:
|
|
1720
|
+
text = content.decode("utf-8", errors="ignore")
|
|
1721
|
+
except Exception:
|
|
1722
|
+
continue
|
|
1723
|
+
else:
|
|
1724
|
+
text = str(content)
|
|
1725
|
+
for name in marker_re.findall(_strip_markdown_code(text)):
|
|
1726
|
+
seen.add(name)
|
|
1727
|
+
return sorted(seen)
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def _append_install_marker(
|
|
1731
|
+
root: Path,
|
|
1732
|
+
scope: str,
|
|
1733
|
+
*,
|
|
1734
|
+
pack_name: str,
|
|
1735
|
+
pack_version: str,
|
|
1736
|
+
unresolved_markers: list[str],
|
|
1737
|
+
new_companions: list[str],
|
|
1738
|
+
allowed_prefixes: list[str] | None,
|
|
1739
|
+
) -> None:
|
|
1740
|
+
"""Append a `[[packs-installed]]` entry to `.adapt-install-marker.toml`
|
|
1741
|
+
at *root* via `os.replace` atomic rename. Repo-scope marker lives
|
|
1742
|
+
at `<repo>/.adapt-install-marker.toml`; user-scope at
|
|
1743
|
+
`<user-root>/.agentbundle/.adapt-install-marker.toml`.
|
|
1744
|
+
|
|
1745
|
+
Per spec AC19a: scope is encoded by the file's location, not as a
|
|
1746
|
+
field — the path is the source of truth.
|
|
1747
|
+
"""
|
|
1748
|
+
import os
|
|
1749
|
+
import tomllib
|
|
1750
|
+
from datetime import datetime, timezone
|
|
1751
|
+
|
|
1752
|
+
from agentbundle import safety
|
|
1753
|
+
|
|
1754
|
+
if scope == "user":
|
|
1755
|
+
# Route through `safety.user_state_path` so the dot-directory
|
|
1756
|
+
# is created with mode 0o700 + symlink/non-directory probe.
|
|
1757
|
+
# The helper returns `<home>/.agentbundle/state.toml`; we sit
|
|
1758
|
+
# the marker next to it.
|
|
1759
|
+
state_path = safety.user_state_path(home=root)
|
|
1760
|
+
marker_path = state_path.parent / ".adapt-install-marker.toml"
|
|
1761
|
+
marker_relpath = ".agentbundle/.adapt-install-marker.toml"
|
|
1762
|
+
else:
|
|
1763
|
+
marker_path = root / ".adapt-install-marker.toml"
|
|
1764
|
+
marker_relpath = ".adapt-install-marker.toml"
|
|
1765
|
+
|
|
1766
|
+
# Read existing entries if present.
|
|
1767
|
+
entries: list[dict] = []
|
|
1768
|
+
if marker_path.exists():
|
|
1769
|
+
try:
|
|
1770
|
+
existing = tomllib.loads(marker_path.read_text(encoding="utf-8"))
|
|
1771
|
+
except Exception as exc:
|
|
1772
|
+
# Spec rail: silent discard would hide prior pack adaptations
|
|
1773
|
+
# from the next session's nudge. Warn explicitly so the
|
|
1774
|
+
# override is auditable; proceed with the fresh entry.
|
|
1775
|
+
print(
|
|
1776
|
+
f"install: warning: existing install marker at {marker_path} "
|
|
1777
|
+
f"is malformed ({exc}); prior entries lost — re-run install "
|
|
1778
|
+
f"for any earlier packs",
|
|
1779
|
+
file=sys.stderr,
|
|
1780
|
+
)
|
|
1781
|
+
existing = {}
|
|
1782
|
+
raw_entries = existing.get("packs-installed", [])
|
|
1783
|
+
if isinstance(raw_entries, list):
|
|
1784
|
+
for e in raw_entries:
|
|
1785
|
+
if not isinstance(e, dict):
|
|
1786
|
+
continue
|
|
1787
|
+
# Defence-in-depth: a CLI-written marker has `installed-at`
|
|
1788
|
+
# as a TOML datetime literal, which `tomllib` parses to a
|
|
1789
|
+
# `datetime.datetime`. A hand-edited or attacker-mediated
|
|
1790
|
+
# marker could carry `installed-at = "...\nphantom = ..."`
|
|
1791
|
+
# (a TOML basic-string in the position) which `tomllib`
|
|
1792
|
+
# parses to a `str` containing real control chars — and
|
|
1793
|
+
# bare re-emission would land phantom TOML structure on
|
|
1794
|
+
# the next install. Drop any entry whose `installed-at`
|
|
1795
|
+
# isn't a `datetime`; warn so the operator can investigate.
|
|
1796
|
+
ts = e.get("installed-at")
|
|
1797
|
+
if not isinstance(ts, datetime):
|
|
1798
|
+
print(
|
|
1799
|
+
f"install: warning: dropping marker entry with non-"
|
|
1800
|
+
f"datetime installed-at at {marker_path} "
|
|
1801
|
+
f"(prior entry will not surface in the next nudge)",
|
|
1802
|
+
file=sys.stderr,
|
|
1803
|
+
)
|
|
1804
|
+
continue
|
|
1805
|
+
# Security Concern 2: type-validate name/version/install-route.
|
|
1806
|
+
# A tampered marker with name=42 (TOML integer) or version=[]
|
|
1807
|
+
# survives the installed-at filter but raises ValueError at
|
|
1808
|
+
# _emit_basic_string time, bricking subsequent installs.
|
|
1809
|
+
_skip_entry = False
|
|
1810
|
+
for _field in ("name", "version"):
|
|
1811
|
+
_val = e.get(_field)
|
|
1812
|
+
if _val is not None and not isinstance(_val, str):
|
|
1813
|
+
_label = e.get("name") if _field != "name" else "<unnamed>"
|
|
1814
|
+
if isinstance(_label, str):
|
|
1815
|
+
_label_str = _label
|
|
1816
|
+
else:
|
|
1817
|
+
_label_str = "<unnamed>"
|
|
1818
|
+
print(
|
|
1819
|
+
f"install: warning: marker entry at {marker_path} "
|
|
1820
|
+
f"has non-string {_field} "
|
|
1821
|
+
f"(got {type(_val).__name__}); dropping entry for "
|
|
1822
|
+
f"pack {_label_str!r}",
|
|
1823
|
+
file=sys.stderr,
|
|
1824
|
+
)
|
|
1825
|
+
_skip_entry = True
|
|
1826
|
+
break
|
|
1827
|
+
if not _skip_entry:
|
|
1828
|
+
_route_val = e.get("install-route")
|
|
1829
|
+
if _route_val is not None and not isinstance(_route_val, str):
|
|
1830
|
+
_name_val = e.get("name", "<unnamed>")
|
|
1831
|
+
_name_str = _name_val if isinstance(_name_val, str) else "<unnamed>"
|
|
1832
|
+
print(
|
|
1833
|
+
f"install: warning: marker entry for {_name_str!r} at "
|
|
1834
|
+
f"{marker_path} has non-string install-route "
|
|
1835
|
+
f"(got {type(_route_val).__name__}); dropping field",
|
|
1836
|
+
file=sys.stderr,
|
|
1837
|
+
)
|
|
1838
|
+
e = dict(e)
|
|
1839
|
+
del e["install-route"]
|
|
1840
|
+
if _skip_entry:
|
|
1841
|
+
continue
|
|
1842
|
+
# Security Concern 1: coerce unresolved-markers and new-companions
|
|
1843
|
+
# to list[str]. Mirrors install-marker.py _read_entries:400-414.
|
|
1844
|
+
e = dict(e) # shallow copy so we don't mutate the tomllib-parsed dict
|
|
1845
|
+
for _field in ("unresolved-markers", "new-companions"):
|
|
1846
|
+
if _field not in e:
|
|
1847
|
+
continue
|
|
1848
|
+
_raw_val = e[_field]
|
|
1849
|
+
if not isinstance(_raw_val, list) or not all(
|
|
1850
|
+
isinstance(_item, str) for _item in _raw_val
|
|
1851
|
+
):
|
|
1852
|
+
_name_val = e.get("name", "?")
|
|
1853
|
+
_name_str = _name_val if isinstance(_name_val, str) else "?"
|
|
1854
|
+
print(
|
|
1855
|
+
f"install: warning: existing marker entry for "
|
|
1856
|
+
f"{_name_str} has malformed {_field} "
|
|
1857
|
+
f"({type(_raw_val).__name__}); dropping field",
|
|
1858
|
+
file=sys.stderr,
|
|
1859
|
+
)
|
|
1860
|
+
del e[_field]
|
|
1861
|
+
entries.append(e)
|
|
1862
|
+
|
|
1863
|
+
new_entry = {
|
|
1864
|
+
"name": pack_name,
|
|
1865
|
+
"version": pack_version,
|
|
1866
|
+
# Store as a `datetime` (not a strftime'd string) so the emit loop
|
|
1867
|
+
# has a single uniform type to handle for both new and re-read
|
|
1868
|
+
# entries, with the canonical strftime applied at emission time.
|
|
1869
|
+
"installed-at": datetime.now(timezone.utc),
|
|
1870
|
+
"unresolved-markers": unresolved_markers,
|
|
1871
|
+
"new-companions": new_companions,
|
|
1872
|
+
}
|
|
1873
|
+
entries.append(new_entry)
|
|
1874
|
+
|
|
1875
|
+
# Serialise. Single source of truth: this writer (no shared helper
|
|
1876
|
+
# because the install marker is a different shape from the other
|
|
1877
|
+
# CLI artifacts). Every pack-sourced basic-string position routes
|
|
1878
|
+
# through `_emit_basic_string` so adversarial pack metadata cannot
|
|
1879
|
+
# land phantom TOML structure here (see `config._emit_basic_string`).
|
|
1880
|
+
from agentbundle.config import _emit_basic_string
|
|
1881
|
+
|
|
1882
|
+
lines: list[str] = [
|
|
1883
|
+
f"marker-schema-version = {_emit_basic_string('0.1')}",
|
|
1884
|
+
"",
|
|
1885
|
+
]
|
|
1886
|
+
for entry in entries:
|
|
1887
|
+
lines.append("[[packs-installed]]")
|
|
1888
|
+
lines.append(f"name = {_emit_basic_string(entry['name'])}")
|
|
1889
|
+
lines.append(f"version = {_emit_basic_string(entry['version'])}")
|
|
1890
|
+
# `installed-at` is emitted bare as a TOML offset-datetime
|
|
1891
|
+
# literal. The dict's value is always a `datetime` (new entries
|
|
1892
|
+
# are stored that way above; re-read entries are filtered to
|
|
1893
|
+
# datetime-only at load time). `strftime` produces the canonical
|
|
1894
|
+
# `YYYY-MM-DDTHH:MM:SSZ` shape; no basic-string position, no
|
|
1895
|
+
# injection vector.
|
|
1896
|
+
ts_str = entry["installed-at"].strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1897
|
+
lines.append(f"installed-at = {ts_str}")
|
|
1898
|
+
# Re-emitted entries preserve their original install-route; newly
|
|
1899
|
+
# constructed entries (built in this function with no "install-route"
|
|
1900
|
+
# key) default to "cli" because this is the CLI install path.
|
|
1901
|
+
route = entry.get("install-route", "cli")
|
|
1902
|
+
lines.append(f"install-route = {_emit_basic_string(route)}")
|
|
1903
|
+
markers_repr = ", ".join(
|
|
1904
|
+
_emit_basic_string(m) for m in entry.get("unresolved-markers", [])
|
|
1905
|
+
)
|
|
1906
|
+
lines.append(f"unresolved-markers = [{markers_repr}]")
|
|
1907
|
+
comps_repr = ", ".join(
|
|
1908
|
+
_emit_basic_string(c) for c in entry.get("new-companions", [])
|
|
1909
|
+
)
|
|
1910
|
+
lines.append(f"new-companions = [{comps_repr}]")
|
|
1911
|
+
lines.append("")
|
|
1912
|
+
content = "\n".join(lines).rstrip() + "\n"
|
|
1913
|
+
|
|
1914
|
+
# Atomic-rename write per AC19a, routed through the per-scope
|
|
1915
|
+
# path-jail (safety.write_jailed) so user-scope marker writes
|
|
1916
|
+
# honour `allowed-prefixes.user` and a future contract change
|
|
1917
|
+
# cannot let the marker escape the jail without code review
|
|
1918
|
+
# noticing. At repo scope the marker is a top-level
|
|
1919
|
+
# `.adapt-install-marker.toml` (not under `.agentbundle/`); the
|
|
1920
|
+
# per-prefix check is skipped here because the file is CLI-owned
|
|
1921
|
+
# metadata, not pack-projected content, and the same root-level
|
|
1922
|
+
# placement was the pre-RFC-0012 contract.
|
|
1923
|
+
marker_prefixes = allowed_prefixes
|
|
1924
|
+
if scope == "repo" and marker_relpath == ".adapt-install-marker.toml":
|
|
1925
|
+
marker_prefixes = None
|
|
1926
|
+
safety.write_jailed(
|
|
1927
|
+
root,
|
|
1928
|
+
marker_relpath,
|
|
1929
|
+
content,
|
|
1930
|
+
scope=scope,
|
|
1931
|
+
allowed_prefixes=marker_prefixes,
|
|
1932
|
+
)
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
def _chain_adapt(repo_root: Path) -> int:
|
|
1936
|
+
"""Per AC19b: run `agentbundle.commands.adapt.run` in-process with
|
|
1937
|
+
`--values-from <repo>/.adapt-discovery.toml`.
|
|
1938
|
+
|
|
1939
|
+
Per AC19d:
|
|
1940
|
+
(i) missing `<repo>/.adapt-discovery.toml` → adapt step is
|
|
1941
|
+
skipped, emits one stderr line; install exits 0.
|
|
1942
|
+
(ii) malformed discovery → adapt returns non-zero; the install
|
|
1943
|
+
caller propagates non-zero. The marker file is still on disk
|
|
1944
|
+
because step 11 wrote it before this step.
|
|
1945
|
+
"""
|
|
1946
|
+
import argparse as _argparse
|
|
1947
|
+
|
|
1948
|
+
from agentbundle.commands import adapt as _adapt
|
|
1949
|
+
|
|
1950
|
+
discovery_path = repo_root / ".adapt-discovery.toml"
|
|
1951
|
+
if not discovery_path.exists():
|
|
1952
|
+
print(
|
|
1953
|
+
"adapt: no .adapt-discovery.toml at repo root; markers left unresolved",
|
|
1954
|
+
file=sys.stderr,
|
|
1955
|
+
)
|
|
1956
|
+
return 0
|
|
1957
|
+
|
|
1958
|
+
ns = _argparse.Namespace(
|
|
1959
|
+
root=str(repo_root),
|
|
1960
|
+
values_from=str(discovery_path),
|
|
1961
|
+
ci=False,
|
|
1962
|
+
)
|
|
1963
|
+
return _adapt.run(ns)
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
def _emit_recommends_warning(
|
|
1967
|
+
rec_name: str,
|
|
1968
|
+
*,
|
|
1969
|
+
recommending_scope: str,
|
|
1970
|
+
catalogue_dir: Path,
|
|
1971
|
+
repo_state,
|
|
1972
|
+
user_state,
|
|
1973
|
+
) -> None:
|
|
1974
|
+
"""Print the spec-shaped warning for a single `recommends` entry.
|
|
1975
|
+
|
|
1976
|
+
Three cases the spec text distinguishes (all to stderr):
|
|
1977
|
+
* Found at a compatible scope → `(found at <observed-scope> scope)`.
|
|
1978
|
+
* Not installed anywhere, installable at recommending scope →
|
|
1979
|
+
`(not installed)`.
|
|
1980
|
+
* Disjoint scopes (recommending scope ∉ recommended's allowed-scopes)
|
|
1981
|
+
→ ``which is <only>-only; install it in your active project`` /
|
|
1982
|
+
``which is <only>-only; install it at user scope``.
|
|
1983
|
+
|
|
1984
|
+
The dual-scope case (recommended permits both scopes) reduces to one
|
|
1985
|
+
of the first two — disjoint can only fire when the recommended
|
|
1986
|
+
pack's ``allowed-scopes`` is single-valued.
|
|
1987
|
+
"""
|
|
1988
|
+
import re
|
|
1989
|
+
import tomllib
|
|
1990
|
+
|
|
1991
|
+
# Pack names follow the catalogue's `^[a-z0-9][a-z0-9-]*$` shape
|
|
1992
|
+
# (CONVENTIONS.md). The contents of `recommends` are not currently
|
|
1993
|
+
# schema-validated, so a malicious pack could declare
|
|
1994
|
+
# ``recommends = ["../../../etc/passwd"]`` and probe the adopter's
|
|
1995
|
+
# filesystem via the lookup below. Refuse anything outside the
|
|
1996
|
+
# name-shape to keep the catalogue path-jail honest.
|
|
1997
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", rec_name):
|
|
1998
|
+
print(
|
|
1999
|
+
f"install: warning: ignoring malformed recommends entry "
|
|
2000
|
+
f"{rec_name!r} (not a legal pack name)",
|
|
2001
|
+
file=sys.stderr,
|
|
2002
|
+
)
|
|
2003
|
+
return
|
|
2004
|
+
|
|
2005
|
+
rec_repo_installed = rec_name in repo_state.packs if repo_state else False
|
|
2006
|
+
rec_user_installed = rec_name in user_state.packs if user_state else False
|
|
2007
|
+
|
|
2008
|
+
# Look up the recommended pack's allowed-scopes from the catalogue.
|
|
2009
|
+
# We only need this for the disjoint branch; cache the lookup result.
|
|
2010
|
+
rec_pack_toml = catalogue_dir / "packs" / rec_name / "pack.toml"
|
|
2011
|
+
if not rec_pack_toml.exists():
|
|
2012
|
+
rec_pack_toml = catalogue_dir / rec_name / "pack.toml"
|
|
2013
|
+
rec_allowed: list[str] = ["repo"] # legacy default
|
|
2014
|
+
if rec_pack_toml.exists():
|
|
2015
|
+
try:
|
|
2016
|
+
rec_data = tomllib.loads(rec_pack_toml.read_text(encoding="utf-8"))
|
|
2017
|
+
except Exception:
|
|
2018
|
+
rec_data = {}
|
|
2019
|
+
rec_install = rec_data.get("pack", {}).get("install")
|
|
2020
|
+
if isinstance(rec_install, dict):
|
|
2021
|
+
raw = rec_install.get("allowed-scopes")
|
|
2022
|
+
if isinstance(raw, list) and raw:
|
|
2023
|
+
rec_allowed = [s for s in raw if isinstance(s, str)]
|
|
2024
|
+
else:
|
|
2025
|
+
default = rec_install.get("default-scope")
|
|
2026
|
+
if isinstance(default, str):
|
|
2027
|
+
rec_allowed = [default]
|
|
2028
|
+
|
|
2029
|
+
# Case 1: installed at any compatible scope.
|
|
2030
|
+
if rec_repo_installed and "repo" in rec_allowed:
|
|
2031
|
+
print(
|
|
2032
|
+
f"note: recommends {rec_name!r} (found at repo scope)",
|
|
2033
|
+
file=sys.stderr,
|
|
2034
|
+
)
|
|
2035
|
+
return
|
|
2036
|
+
if rec_user_installed and "user" in rec_allowed:
|
|
2037
|
+
print(
|
|
2038
|
+
f"note: recommends {rec_name!r} (found at user scope)",
|
|
2039
|
+
file=sys.stderr,
|
|
2040
|
+
)
|
|
2041
|
+
return
|
|
2042
|
+
|
|
2043
|
+
# Case 3: disjoint allowed-scopes. Reachable only when the
|
|
2044
|
+
# recommended pack's allowed-scopes is single-valued and excludes
|
|
2045
|
+
# the recommending scope (a pack permitting both scopes can never
|
|
2046
|
+
# be disjoint from any recommender).
|
|
2047
|
+
if recommending_scope not in rec_allowed:
|
|
2048
|
+
if rec_allowed == ["repo"]:
|
|
2049
|
+
print(
|
|
2050
|
+
f"note: recommends {rec_name!r}, which is repo-only; "
|
|
2051
|
+
"install it in your active project",
|
|
2052
|
+
file=sys.stderr,
|
|
2053
|
+
)
|
|
2054
|
+
return
|
|
2055
|
+
if rec_allowed == ["user"]:
|
|
2056
|
+
print(
|
|
2057
|
+
f"note: recommends {rec_name!r}, which is user-only; "
|
|
2058
|
+
"install it at user scope",
|
|
2059
|
+
file=sys.stderr,
|
|
2060
|
+
)
|
|
2061
|
+
return
|
|
2062
|
+
|
|
2063
|
+
# Case 2: missing but installable at the recommending scope.
|
|
2064
|
+
print(
|
|
2065
|
+
f"note: recommends {rec_name!r} (not installed)",
|
|
2066
|
+
file=sys.stderr,
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
# ---------------------------------------------------------------------------
|
|
2071
|
+
# Helpers
|
|
2072
|
+
# ---------------------------------------------------------------------------
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def _render_for_user_scope(
|
|
2076
|
+
pack_dir: Path,
|
|
2077
|
+
*,
|
|
2078
|
+
adapter: str | None = None,
|
|
2079
|
+
allowed_adapters: list[str] | None = None,
|
|
2080
|
+
contract_version: str | None = None,
|
|
2081
|
+
state_adapter: str | None = None,
|
|
2082
|
+
command_name: str = "install",
|
|
2083
|
+
user_config: "UserConfig | None" = None,
|
|
2084
|
+
) -> dict[str, bytes]:
|
|
2085
|
+
"""Project a pack via the Claude Code / Kiro / Codex adapter
|
|
2086
|
+
(depending on RFC-0011 resolution), for user-scope install.
|
|
2087
|
+
|
|
2088
|
+
RFC-0004 § *State file per scope* and § *Adapter-level scope roots*
|
|
2089
|
+
imply that user-scope installs land per-adapter outputs (paths under
|
|
2090
|
+
``.claude/...``, ``.kiro/...``, or ``.agents/skills/...``) rather
|
|
2091
|
+
than the dist-tree shape ``render.render_pack`` produces. Calling
|
|
2092
|
+
the adapter's ``project`` function once into a tempdir gives us the
|
|
2093
|
+
per-primitive layout each IDE reads at ``~/``; we collect the
|
|
2094
|
+
result as a relpath→bytes mapping for the install walker.
|
|
2095
|
+
|
|
2096
|
+
The five kwargs flow into ``_resolve_target_adapter`` per
|
|
2097
|
+
RFC-0011's six-step (0–5) lookup with ``scope="user"`` (RFC-0012
|
|
2098
|
+
renamed the helper and added the explicit ``scope`` kwarg). They
|
|
2099
|
+
default to ``None`` / ``"install"`` for backward shape with
|
|
2100
|
+
legacy positional callers (tests), but every production call
|
|
2101
|
+
site threads explicit values.
|
|
2102
|
+
|
|
2103
|
+
Other adapters' projections (apm.yml, plugin manifests, etc.) are
|
|
2104
|
+
intentionally out of scope at user-scope install — they're
|
|
2105
|
+
dist-time build artifacts that the adopter's `~` should never
|
|
2106
|
+
carry.
|
|
2107
|
+
|
|
2108
|
+
Note: for v0.3 packs declaring ``user-scope-hooks = true``, the
|
|
2109
|
+
install handler applies a v0.3 post-projection rewrite via
|
|
2110
|
+
``_rewrite_user_scope_hook_paths`` to swap legacy hook-body
|
|
2111
|
+
targets (``tools/hooks/``) for the user-scope shape
|
|
2112
|
+
(``.claude/hooks/<pack>/`` or ``.kiro/hooks/<pack>/``) and drop
|
|
2113
|
+
the v0.2 wiring-target file (``.claude/settings.local.json``)
|
|
2114
|
+
from the projection map. The wiring TOMLs themselves are then
|
|
2115
|
+
consumed by ``_merge_user_scope_hook_wiring`` post-write.
|
|
2116
|
+
"""
|
|
2117
|
+
import tempfile
|
|
2118
|
+
|
|
2119
|
+
from agentbundle.build.adapters import (
|
|
2120
|
+
claude_code,
|
|
2121
|
+
codex,
|
|
2122
|
+
copilot,
|
|
2123
|
+
kiro,
|
|
2124
|
+
kiro_cli,
|
|
2125
|
+
kiro_ide,
|
|
2126
|
+
)
|
|
2127
|
+
from agentbundle.build.main import _read_bundled
|
|
2128
|
+
from agentbundle.render import _collect_tree
|
|
2129
|
+
|
|
2130
|
+
contract = tomllib.loads(_read_bundled("adapter.toml"))
|
|
2131
|
+
target_adapter = _resolve_target_adapter(
|
|
2132
|
+
pack_dir,
|
|
2133
|
+
scope="user",
|
|
2134
|
+
adapter=adapter,
|
|
2135
|
+
allowed_adapters=allowed_adapters,
|
|
2136
|
+
contract_version=contract_version,
|
|
2137
|
+
state_adapter=state_adapter,
|
|
2138
|
+
user_config=user_config,
|
|
2139
|
+
command_name=command_name,
|
|
2140
|
+
)
|
|
2141
|
+
with tempfile.TemporaryDirectory() as raw:
|
|
2142
|
+
out = Path(raw)
|
|
2143
|
+
if target_adapter == "kiro":
|
|
2144
|
+
kiro.project(pack_dir, contract, out)
|
|
2145
|
+
elif target_adapter == "kiro-ide":
|
|
2146
|
+
kiro_ide.project(pack_dir, contract, out)
|
|
2147
|
+
elif target_adapter == "kiro-cli":
|
|
2148
|
+
kiro_cli.project(pack_dir, contract, out)
|
|
2149
|
+
elif target_adapter == "codex":
|
|
2150
|
+
codex.project(pack_dir, contract, out)
|
|
2151
|
+
elif target_adapter == "claude-code":
|
|
2152
|
+
claude_code.project(pack_dir, contract, out)
|
|
2153
|
+
elif target_adapter == "copilot":
|
|
2154
|
+
# The copilot build adapter is scope-agnostic and emits
|
|
2155
|
+
# repo-relpaths (`.github/…`); the install handler rewrites
|
|
2156
|
+
# them to the user-scope home (`.copilot/…`) via
|
|
2157
|
+
# `_rewrite_copilot_user_scope_paths` before the path-jail
|
|
2158
|
+
# (RFC-0024 / copilot-full-parity).
|
|
2159
|
+
copilot.project(pack_dir, contract, out)
|
|
2160
|
+
else:
|
|
2161
|
+
# Defence-in-depth: every user-scope-capable adapter
|
|
2162
|
+
# should have an explicit branch above. A future contract
|
|
2163
|
+
# bump that ships a new adapter must extend this dispatch;
|
|
2164
|
+
# falling through to claude-code masked the gap.
|
|
2165
|
+
raise _AdapterResolutionRefused(
|
|
2166
|
+
f"{command_name}: no user-scope projection wired for "
|
|
2167
|
+
f"adapter {target_adapter!r}"
|
|
2168
|
+
)
|
|
2169
|
+
return _collect_tree(out)
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
def _render_for_repo_scope(
|
|
2173
|
+
pack_dir: Path,
|
|
2174
|
+
*,
|
|
2175
|
+
adapter: str | None = None,
|
|
2176
|
+
allowed_adapters: list[str] | None = None,
|
|
2177
|
+
contract_version: str | None = None,
|
|
2178
|
+
state_adapter: str | None = None,
|
|
2179
|
+
command_name: str = "install",
|
|
2180
|
+
user_config: "UserConfig | None" = None,
|
|
2181
|
+
) -> tuple[str, dict[str, bytes]]:
|
|
2182
|
+
"""Project a pack via the resolved adapter (RFC-0011 + RFC-0012
|
|
2183
|
+
six-step lookup at ``scope="repo"``), for repo-scope install at
|
|
2184
|
+
``--scope repo`` without ``--emit-install-routes``.
|
|
2185
|
+
|
|
2186
|
+
Mirrors :func:`_render_for_user_scope` but at the repo-scope root:
|
|
2187
|
+
the projection lands under ``<repo>/<adapter-prefix>/...`` instead
|
|
2188
|
+
of ``~/<adapter-prefix>/...``. Returns a ``(target_adapter,
|
|
2189
|
+
projection)`` tuple so the install handler can record
|
|
2190
|
+
``state.adapter`` and thread the matching ``allowed-prefixes.repo``
|
|
2191
|
+
into the path-jail.
|
|
2192
|
+
|
|
2193
|
+
RFC-0012 § *Prior art* names the build pipeline's
|
|
2194
|
+
``self-host.toml`` recipe as the in-tree mechanism that already
|
|
2195
|
+
produces per-IDE direct writes; this helper is the generalisation
|
|
2196
|
+
of that mechanism to the adopter-side install path.
|
|
2197
|
+
"""
|
|
2198
|
+
import tempfile
|
|
2199
|
+
|
|
2200
|
+
from agentbundle.build.adapters import claude_code, codex, copilot, kiro, kiro_cli, kiro_ide
|
|
2201
|
+
from agentbundle.build.main import _read_bundled
|
|
2202
|
+
from agentbundle.render import _collect_tree
|
|
2203
|
+
|
|
2204
|
+
contract = tomllib.loads(_read_bundled("adapter.toml"))
|
|
2205
|
+
target_adapter = _resolve_target_adapter(
|
|
2206
|
+
pack_dir,
|
|
2207
|
+
scope="repo",
|
|
2208
|
+
adapter=adapter,
|
|
2209
|
+
allowed_adapters=allowed_adapters,
|
|
2210
|
+
contract_version=contract_version,
|
|
2211
|
+
state_adapter=state_adapter,
|
|
2212
|
+
command_name=command_name,
|
|
2213
|
+
user_config=user_config,
|
|
2214
|
+
)
|
|
2215
|
+
with tempfile.TemporaryDirectory() as raw:
|
|
2216
|
+
out = Path(raw)
|
|
2217
|
+
if target_adapter == "kiro":
|
|
2218
|
+
kiro.project(pack_dir, contract, out)
|
|
2219
|
+
elif target_adapter == "kiro-ide":
|
|
2220
|
+
kiro_ide.project(pack_dir, contract, out)
|
|
2221
|
+
elif target_adapter == "kiro-cli":
|
|
2222
|
+
kiro_cli.project(pack_dir, contract, out)
|
|
2223
|
+
elif target_adapter == "codex":
|
|
2224
|
+
codex.project(pack_dir, contract, out)
|
|
2225
|
+
elif target_adapter == "claude-code":
|
|
2226
|
+
claude_code.project(pack_dir, contract, out)
|
|
2227
|
+
elif target_adapter == "copilot":
|
|
2228
|
+
copilot.project(pack_dir, contract, out)
|
|
2229
|
+
else:
|
|
2230
|
+
raise _AdapterResolutionRefused(
|
|
2231
|
+
f"{command_name}: no repo-scope projection wired for "
|
|
2232
|
+
f"adapter {target_adapter!r}"
|
|
2233
|
+
)
|
|
2234
|
+
return target_adapter, _collect_tree(out)
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def _refresh_merge_target_shas(
|
|
2238
|
+
*,
|
|
2239
|
+
pack_state,
|
|
2240
|
+
owned_rows: list[dict[str, str]],
|
|
2241
|
+
root: Path,
|
|
2242
|
+
) -> None:
|
|
2243
|
+
"""Refresh state.files SHA for every merge target the wiring touched.
|
|
2244
|
+
|
|
2245
|
+
The merge phase (user_merge_json / merge_into_agent_json) mutates
|
|
2246
|
+
the target file after the projection-write loop recorded its
|
|
2247
|
+
pre-merge SHA. Without this refresh, uninstall's Tier-1 check
|
|
2248
|
+
(recorded SHA == on-disk SHA) would misclassify the file as
|
|
2249
|
+
adopter-edited and refuse to remove it. Claude Code rows omit
|
|
2250
|
+
``target-file`` (the adapter-shared ``~/.claude/settings.json``
|
|
2251
|
+
isn't tracked in state.files); Kiro rows carry it explicitly.
|
|
2252
|
+
|
|
2253
|
+
Shared between install and upgrade so the fix is single-sourced.
|
|
2254
|
+
"""
|
|
2255
|
+
from agentbundle import safety
|
|
2256
|
+
|
|
2257
|
+
for row in owned_rows:
|
|
2258
|
+
target_file_rel = row.get("target-file")
|
|
2259
|
+
if not target_file_rel:
|
|
2260
|
+
continue
|
|
2261
|
+
target_path = root / target_file_rel.lstrip("/")
|
|
2262
|
+
if not target_path.exists():
|
|
2263
|
+
continue
|
|
2264
|
+
if target_file_rel in pack_state.files:
|
|
2265
|
+
pack_state.files[target_file_rel]["sha"] = (
|
|
2266
|
+
safety.sha256_file(target_path)
|
|
2267
|
+
)
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def _adapter_supports_user_scope_hook_wiring(adapter_name: str) -> bool:
|
|
2271
|
+
"""Return True iff the adapter declares a hook-wiring projection
|
|
2272
|
+
mode that works at user scope (RFC-0005 AC25).
|
|
2273
|
+
|
|
2274
|
+
Three shapes count:
|
|
2275
|
+
- Claude Code: ``mode.user = "user-merge-json"``.
|
|
2276
|
+
- Kiro: ``mode = "merge-into-agent-json"`` (single mode, no
|
|
2277
|
+
scope qualifier — the agent-file target is scope-conditional
|
|
2278
|
+
via `<scope-root>` resolution).
|
|
2279
|
+
- Copilot: ``mode = "copilot-hooks-json"`` in the **array-form**
|
|
2280
|
+
``[[adapter.copilot.projection]]`` table (RFC-0024 /
|
|
2281
|
+
copilot-full-parity). Copilot's hooks are a *file-based* model
|
|
2282
|
+
(one self-contained JSON per wiring file in a directory), not a
|
|
2283
|
+
merge into a shared settings/agent file — so they work at user
|
|
2284
|
+
scope via the build projection + the install handler's prefix
|
|
2285
|
+
rewrite (`_rewrite_copilot_user_scope_paths`), with no
|
|
2286
|
+
merge step. `_merge_user_scope_hook_wiring` returns no rows for
|
|
2287
|
+
copilot accordingly.
|
|
2288
|
+
|
|
2289
|
+
Anything else (``dropped``, ``degraded-info-log``, absent
|
|
2290
|
+
projection) is refused.
|
|
2291
|
+
"""
|
|
2292
|
+
import tomllib
|
|
2293
|
+
from agentbundle.build.main import _read_bundled
|
|
2294
|
+
|
|
2295
|
+
contract = tomllib.loads(_read_bundled("adapter.toml"))
|
|
2296
|
+
adapter_block = contract.get("adapter", {}).get(adapter_name, {})
|
|
2297
|
+
projections = adapter_block.get("projections", {}) if isinstance(adapter_block, dict) else {}
|
|
2298
|
+
hook_wiring = projections.get("hook-wiring") if isinstance(projections, dict) else None
|
|
2299
|
+
if isinstance(hook_wiring, dict):
|
|
2300
|
+
mode = hook_wiring.get("mode")
|
|
2301
|
+
if isinstance(mode, dict):
|
|
2302
|
+
# Claude-Code-shape scope-map: only `user-merge-json` is the
|
|
2303
|
+
# documented user-scope mode. `merge-into-agent-json` would
|
|
2304
|
+
# be a contract misconfiguration (it targets per-agent files,
|
|
2305
|
+
# not a settings file) — refuse it under the scope-map branch.
|
|
2306
|
+
return mode.get("user") == "user-merge-json"
|
|
2307
|
+
# Bare-string mode: only `merge-into-agent-json` (Kiro shape)
|
|
2308
|
+
# implies user-scope support. `merge-json` (the v0.2 repo-only
|
|
2309
|
+
# form) does not.
|
|
2310
|
+
return mode == "merge-into-agent-json"
|
|
2311
|
+
# Array-form projection table (copilot): a hook-wiring entry with the
|
|
2312
|
+
# file-based `copilot-hooks-json` mode is user-scope-capable.
|
|
2313
|
+
array_form = adapter_block.get("projection", []) if isinstance(adapter_block, dict) else []
|
|
2314
|
+
for entry in array_form:
|
|
2315
|
+
if (
|
|
2316
|
+
isinstance(entry, dict)
|
|
2317
|
+
and entry.get("primitive") == "hook-wiring"
|
|
2318
|
+
and entry.get("mode") == "copilot-hooks-json"
|
|
2319
|
+
):
|
|
2320
|
+
return True
|
|
2321
|
+
return False
|
|
2322
|
+
|
|
2323
|
+
|
|
2324
|
+
class _AdapterResolutionRefused(Exception):
|
|
2325
|
+
"""Raised by :func:`_resolve_target_adapter` for any of the pinned
|
|
2326
|
+
refusal paths (publisher-vs-installer drift, ``--adapter`` not in
|
|
2327
|
+
pack's set, ``--adapter`` not user-scope-capable at user scope,
|
|
2328
|
+
``--adapter`` not shipped at repo scope). Carries the exact
|
|
2329
|
+
stderr text — the install handler prints ``str(exc)`` and returns
|
|
2330
|
+
non-zero.
|
|
2331
|
+
"""
|
|
2332
|
+
|
|
2333
|
+
|
|
2334
|
+
def _user_scope_adapter_probes() -> dict[str, "Callable[[Path], bool]"]:
|
|
2335
|
+
"""Per-adapter CLI-home presence probe. Explicit table (not a
|
|
2336
|
+
single `Path.home() / f".{ide}"` interpolation) because codex is
|
|
2337
|
+
an OR-probe: either `~/.codex/` exists (Codex CLI installed) or
|
|
2338
|
+
`~/.agents/skills/` exists (the codex skills root, populated by
|
|
2339
|
+
a prior install). The function is module-private (leading
|
|
2340
|
+
underscore) — callers use the helpers below.
|
|
2341
|
+
"""
|
|
2342
|
+
return {
|
|
2343
|
+
"claude-code": lambda home: (home / ".claude").exists(),
|
|
2344
|
+
"kiro": lambda home: (home / ".kiro").exists(),
|
|
2345
|
+
"kiro-ide": lambda home: (home / ".kiro").exists(),
|
|
2346
|
+
"kiro-cli": lambda home: (home / ".kiro").exists(),
|
|
2347
|
+
"codex": lambda home: (
|
|
2348
|
+
(home / ".codex").exists()
|
|
2349
|
+
or (home / ".agents" / "skills").exists()
|
|
2350
|
+
),
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
def _resolve_target_adapter(
|
|
2355
|
+
pack_dir: Path,
|
|
2356
|
+
*,
|
|
2357
|
+
scope: str,
|
|
2358
|
+
adapter: str | None = None,
|
|
2359
|
+
allowed_adapters: list[str] | None = None,
|
|
2360
|
+
contract_version: str | None = None,
|
|
2361
|
+
state_adapter: str | None = None,
|
|
2362
|
+
command_name: str = "install",
|
|
2363
|
+
user_config: "UserConfig | None" = None,
|
|
2364
|
+
) -> str:
|
|
2365
|
+
"""Resolve the adapter that an install/upgrade targets at *scope*
|
|
2366
|
+
(RFC-0011 substrate; RFC-0012 widens to repo scope).
|
|
2367
|
+
|
|
2368
|
+
The six-step (0–5) lookup, with scope-branched points at 0, 1, 4,
|
|
2369
|
+
and 5:
|
|
2370
|
+
|
|
2371
|
+
0. **Publisher-vs-installer drift refusal** — if
|
|
2372
|
+
``allowed_adapters`` is declared, intersect with the bundled
|
|
2373
|
+
contract's shipped-adapter set; refuse on any miss with the
|
|
2374
|
+
pinned message. Runs first so neither ``--adapter`` (step 1)
|
|
2375
|
+
nor state-hint (step 2) can leak a no-longer-shipped value
|
|
2376
|
+
through. Refusal text is scope-uniform modulo the
|
|
2377
|
+
``<verb>`` prefix; the user-scope-capability subcheck is
|
|
2378
|
+
**skipped at repo scope** (Copilot is admissible at repo
|
|
2379
|
+
scope but not at user scope).
|
|
2380
|
+
|
|
2381
|
+
1. **``--adapter`` override** — validates against
|
|
2382
|
+
``allowed_adapters`` (when declared) or, at user scope,
|
|
2383
|
+
against the contract's user-scope-capable set; at repo
|
|
2384
|
+
scope, against the contract's shipped-adapter set (Copilot
|
|
2385
|
+
admissible).
|
|
2386
|
+
|
|
2387
|
+
2. **State-hint short-circuit (AC10b)** — return
|
|
2388
|
+
``state_adapter`` when admissible; the install was already
|
|
2389
|
+
pinned. Scope-uniform.
|
|
2390
|
+
|
|
2391
|
+
3. **Contract-version gate** — uses
|
|
2392
|
+
``contract_supports_hook_wiring(contract_version)``;
|
|
2393
|
+
scope-uniform.
|
|
2394
|
+
|
|
2395
|
+
4. **Per-scope branch**: at user scope, walk the per-adapter
|
|
2396
|
+
probe table and return the first match; at repo scope,
|
|
2397
|
+
**skip the probe** (RFC-0012 § *Alternatives* #4 — symmetric
|
|
2398
|
+
probing rejected) and return ``DEFAULT_ADAPTER``
|
|
2399
|
+
if in ``allowed_adapters``, else ``allowed_adapters[0]``.
|
|
2400
|
+
|
|
2401
|
+
5. **Legacy heuristic** — preserved for ``< 0.7`` packs that
|
|
2402
|
+
omit ``allowed-adapters``. Always returns ``DEFAULT_ADAPTER``
|
|
2403
|
+
so downstream catalogues that monkey-patch the constant
|
|
2404
|
+
rebrand uniformly across every resolver branch.
|
|
2405
|
+
|
|
2406
|
+
The function raises :class:`_AdapterResolutionRefused` for any of
|
|
2407
|
+
the pinned refusal paths; the caller prints the exception text and
|
|
2408
|
+
exits non-zero.
|
|
2409
|
+
|
|
2410
|
+
Known limitation: two packs claiming the same Kiro agent name
|
|
2411
|
+
(each ships ``.apm/agents/<name>.md``) will both write to the
|
|
2412
|
+
same projected ``.kiro/agents/<name>.json`` and the second install
|
|
2413
|
+
will silently overwrite the first's wiring. A follow-on RFC for
|
|
2414
|
+
shared-agent ownership will need to address this; this spec
|
|
2415
|
+
preserves the behaviour unchanged.
|
|
2416
|
+
"""
|
|
2417
|
+
from agentbundle.build.main import _read_bundled
|
|
2418
|
+
from agentbundle.scope import (
|
|
2419
|
+
DEFAULT_ADAPTER,
|
|
2420
|
+
configured_adapter,
|
|
2421
|
+
contract_supports_hook_wiring,
|
|
2422
|
+
shipped_adapters_from_contract,
|
|
2423
|
+
user_scope_capable_adapters_from_contract,
|
|
2424
|
+
)
|
|
2425
|
+
|
|
2426
|
+
pack_name = pack_dir.name
|
|
2427
|
+
shipped = shipped_adapters_from_contract()
|
|
2428
|
+
user_capable = user_scope_capable_adapters_from_contract()
|
|
2429
|
+
|
|
2430
|
+
# Step 0: publisher-vs-installer drift refusal — scope-uniform
|
|
2431
|
+
# except the user-scope-capability subcheck is skipped at repo
|
|
2432
|
+
# scope (Copilot is admissible there).
|
|
2433
|
+
if allowed_adapters is not None:
|
|
2434
|
+
for declared in allowed_adapters:
|
|
2435
|
+
if declared not in shipped:
|
|
2436
|
+
from agentbundle.version import CLI_VERSION as cli_version
|
|
2437
|
+
contract = tomllib.loads(_read_bundled("adapter.toml"))
|
|
2438
|
+
cv = contract.get("contract", {}).get("version", "?")
|
|
2439
|
+
raise _AdapterResolutionRefused(
|
|
2440
|
+
f"{command_name}: pack {pack_name!r} declares "
|
|
2441
|
+
f"allowed-adapter {declared!r} which is not admitted by "
|
|
2442
|
+
f"adapter contract v{cv} shipped with agentbundle "
|
|
2443
|
+
f"{cli_version}"
|
|
2444
|
+
)
|
|
2445
|
+
if scope == "user":
|
|
2446
|
+
# User-scope-capability subcheck — fires only at user
|
|
2447
|
+
# scope. RFC-0012: Copilot is admissible at repo scope
|
|
2448
|
+
# without declaring `[scope].user`, so this subcheck
|
|
2449
|
+
# must not fire there.
|
|
2450
|
+
for declared in allowed_adapters:
|
|
2451
|
+
if declared not in user_capable:
|
|
2452
|
+
contract = tomllib.loads(_read_bundled("adapter.toml"))
|
|
2453
|
+
cv = contract.get("contract", {}).get("version", "?")
|
|
2454
|
+
raise _AdapterResolutionRefused(
|
|
2455
|
+
f"{command_name}: pack {pack_name!r} declares "
|
|
2456
|
+
f"allowed-adapter {declared!r} which does not "
|
|
2457
|
+
f"declare a user-scope root in the v{cv} adapter "
|
|
2458
|
+
f"contract"
|
|
2459
|
+
)
|
|
2460
|
+
|
|
2461
|
+
# Step 1: --adapter override.
|
|
2462
|
+
if adapter is not None:
|
|
2463
|
+
if allowed_adapters is not None:
|
|
2464
|
+
if adapter not in allowed_adapters:
|
|
2465
|
+
raise _AdapterResolutionRefused(
|
|
2466
|
+
f"{command_name}: --adapter {adapter} not in pack's "
|
|
2467
|
+
f"allowed-adapters set"
|
|
2468
|
+
)
|
|
2469
|
+
else:
|
|
2470
|
+
if scope == "user":
|
|
2471
|
+
if adapter not in user_capable:
|
|
2472
|
+
contract = tomllib.loads(_read_bundled("adapter.toml"))
|
|
2473
|
+
cv = contract.get("contract", {}).get("version", "?")
|
|
2474
|
+
raise _AdapterResolutionRefused(
|
|
2475
|
+
f"{command_name}: --adapter {adapter} not admitted "
|
|
2476
|
+
f"as a user-scope-capable adapter under contract v{cv}"
|
|
2477
|
+
)
|
|
2478
|
+
else:
|
|
2479
|
+
# Repo scope: any shipped adapter is admissible.
|
|
2480
|
+
if adapter not in shipped:
|
|
2481
|
+
contract = tomllib.loads(_read_bundled("adapter.toml"))
|
|
2482
|
+
cv = contract.get("contract", {}).get("version", "?")
|
|
2483
|
+
raise _AdapterResolutionRefused(
|
|
2484
|
+
f"{command_name}: --adapter {adapter} not admitted "
|
|
2485
|
+
f"as a shipped adapter under contract v{cv}"
|
|
2486
|
+
)
|
|
2487
|
+
return adapter
|
|
2488
|
+
|
|
2489
|
+
# Step 2: state-hint short-circuit (AC10b) — scope-uniform.
|
|
2490
|
+
if state_adapter is not None:
|
|
2491
|
+
if allowed_adapters is not None:
|
|
2492
|
+
if state_adapter in allowed_adapters:
|
|
2493
|
+
return state_adapter
|
|
2494
|
+
else:
|
|
2495
|
+
admissible = user_capable if scope == "user" else shipped
|
|
2496
|
+
if state_adapter in admissible:
|
|
2497
|
+
return state_adapter
|
|
2498
|
+
# state_adapter is not admissible — fall through to step 3+
|
|
2499
|
+
# and the existing upgrade.py cross-adapter refusal will fire
|
|
2500
|
+
# if the new resolution differs.
|
|
2501
|
+
|
|
2502
|
+
# Step 2.5: user-config pre-flight (agentbundle-config-subcommand
|
|
2503
|
+
# spec AC12). Runs only when state_adapter is None — upgrades
|
|
2504
|
+
# preserve whatever adapter the existing install used; user-config
|
|
2505
|
+
# only affects fresh installs. When a user actively configured a
|
|
2506
|
+
# known adapter, either return it (when admissible at scope and
|
|
2507
|
+
# in pack allowed_adapters) or raise with AC13/AC14 messages. When
|
|
2508
|
+
# nothing is configured, this block is a no-op and Steps 3+ run
|
|
2509
|
+
# as today — preserving the probe-by-default behavior for users
|
|
2510
|
+
# who never ran `agentbundle config set`.
|
|
2511
|
+
candidate = (
|
|
2512
|
+
configured_adapter(user_config) if state_adapter is None else None
|
|
2513
|
+
)
|
|
2514
|
+
if candidate is not None:
|
|
2515
|
+
admissible_at_scope = user_capable if scope == "user" else shipped
|
|
2516
|
+
if candidate not in admissible_at_scope:
|
|
2517
|
+
raise _AdapterResolutionRefused(
|
|
2518
|
+
f"{command_name}: configured adapter {candidate!r} is "
|
|
2519
|
+
f"not supported at {scope} scope. Adapters supported "
|
|
2520
|
+
f"at {scope} scope: {sorted(admissible_at_scope)}. To "
|
|
2521
|
+
f"proceed: invoke the command at a different scope "
|
|
2522
|
+
f"(e.g. --scope repo) where {candidate!r} is "
|
|
2523
|
+
f"supported, or pass --adapter <name> for a per-install "
|
|
2524
|
+
f"override, or run `agentbundle config set adapter "
|
|
2525
|
+
f"<name>` to change the default, or `agentbundle "
|
|
2526
|
+
f"config unset adapter` to clear it."
|
|
2527
|
+
)
|
|
2528
|
+
if allowed_adapters is not None and candidate not in allowed_adapters:
|
|
2529
|
+
raise _AdapterResolutionRefused(
|
|
2530
|
+
f"{command_name}: pack {pack_name} is not supported "
|
|
2531
|
+
f"with your configured adapter {candidate!r}. The pack "
|
|
2532
|
+
f"supports: {sorted(allowed_adapters)}. To proceed: "
|
|
2533
|
+
f"pass --adapter <name> for a per-install override, or "
|
|
2534
|
+
f"run `agentbundle config set adapter <name>` to change "
|
|
2535
|
+
f"the default, or `agentbundle config unset adapter` to "
|
|
2536
|
+
f"clear it."
|
|
2537
|
+
)
|
|
2538
|
+
return candidate
|
|
2539
|
+
|
|
2540
|
+
# Step 3 + Step 4: contract-version gate + per-scope branch.
|
|
2541
|
+
if (
|
|
2542
|
+
allowed_adapters is not None
|
|
2543
|
+
and contract_supports_hook_wiring(contract_version)
|
|
2544
|
+
):
|
|
2545
|
+
if scope == "user":
|
|
2546
|
+
# Step 4 (user-scope): per-adapter probe table; first
|
|
2547
|
+
# match wins.
|
|
2548
|
+
probes = _user_scope_adapter_probes()
|
|
2549
|
+
home = Path.home()
|
|
2550
|
+
for declared in allowed_adapters:
|
|
2551
|
+
probe = probes.get(declared)
|
|
2552
|
+
if probe is not None and probe(home):
|
|
2553
|
+
return declared
|
|
2554
|
+
# Step 4 (repo-scope): no probe. RFC-0012 § *Alternatives* #4
|
|
2555
|
+
# rejects symmetric probing as load-bearing asymmetry —
|
|
2556
|
+
# probing `<repo>/.<ide>/` would silently override an explicit
|
|
2557
|
+
# `--adapter` (the probe runs only when `--adapter` is omitted,
|
|
2558
|
+
# but the same rule reads cleaner stated uniformly).
|
|
2559
|
+
if DEFAULT_ADAPTER in allowed_adapters:
|
|
2560
|
+
return DEFAULT_ADAPTER
|
|
2561
|
+
return allowed_adapters[0]
|
|
2562
|
+
|
|
2563
|
+
# Step 4b (repo-scope v0.7+ pack with no `allowed-adapters`):
|
|
2564
|
+
# AC9 step 5 — "legacy heuristic fires only for `< v0.7` packs
|
|
2565
|
+
# at repo scope" — means a v0.7+ pack with no `allowed-adapters`
|
|
2566
|
+
# at repo scope must NOT fall through to step 5; return the
|
|
2567
|
+
# configured default instead. Drawback #7 in RFC-0012 names the
|
|
2568
|
+
# repo-only-pack v0.2 → v0.7 bump as load-bearing precisely for
|
|
2569
|
+
# this branch. The version check is a literal `>= "0.7"` (string
|
|
2570
|
+
# comparison is correct for single-digit minor versions; once
|
|
2571
|
+
# major or two-digit minor bumps land the predicate moves into
|
|
2572
|
+
# a helper).
|
|
2573
|
+
if (
|
|
2574
|
+
scope == "repo"
|
|
2575
|
+
and isinstance(contract_version, str)
|
|
2576
|
+
and contract_version >= "0.7"
|
|
2577
|
+
):
|
|
2578
|
+
return DEFAULT_ADAPTER
|
|
2579
|
+
|
|
2580
|
+
# Step 5: legacy heuristic — preserved for `< v0.7` packs that
|
|
2581
|
+
# omit `allowed-adapters`. Always returns ``DEFAULT_ADAPTER`` so
|
|
2582
|
+
# downstream catalogues that monkey-patch the constant rebrand
|
|
2583
|
+
# uniformly across every resolver branch. The pre-fix agents-
|
|
2584
|
+
# presence ``"kiro"`` hint was a guess about pack-author intent;
|
|
2585
|
+
# an explicit downstream ``DEFAULT_ADAPTER`` is authoritative.
|
|
2586
|
+
return DEFAULT_ADAPTER
|
|
2587
|
+
|
|
2588
|
+
|
|
2589
|
+
|
|
2590
|
+
|
|
2591
|
+
def _rewrite_user_scope_hook_paths(
|
|
2592
|
+
projection: dict[str, bytes],
|
|
2593
|
+
pack_name: str,
|
|
2594
|
+
target_adapter: str,
|
|
2595
|
+
) -> dict[str, bytes]:
|
|
2596
|
+
"""Rewrite legacy hook-body paths in *projection* to v0.3 user-
|
|
2597
|
+
scope targets per RFC-0005 § hook-body at user scope, and drop the
|
|
2598
|
+
v0.2 wiring-target file (the v0.3 merge engine writes through the
|
|
2599
|
+
user-merge-json / merge-into-agent-json path instead).
|
|
2600
|
+
|
|
2601
|
+
Claude Code target: ``.claude/hooks/<pack>/<name>.{sh,py}``.
|
|
2602
|
+
Kiro target: ``.kiro/hooks/<pack>/<name>.{sh,py}``.
|
|
2603
|
+
|
|
2604
|
+
For Kiro user-scope installs, the build pipeline's Kiro adapter
|
|
2605
|
+
has already merged hook-wiring into the dist's
|
|
2606
|
+
``.kiro/agents/<name>.json``. The install-time merge runs again
|
|
2607
|
+
against the user's actual home — to keep ownership of the writes
|
|
2608
|
+
single-sourced (and avoid a fragile double-merge), we strip the
|
|
2609
|
+
``hooks`` key from any agent JSON in the user-scope projection
|
|
2610
|
+
here; install copies the body-only JSON, and
|
|
2611
|
+
``_merge_user_scope_hook_wiring`` re-adds the hook entries with
|
|
2612
|
+
the same id-shape, producing a single set of writes.
|
|
2613
|
+
|
|
2614
|
+
Copilot is an explicit no-op here: its hooks are file-based and the
|
|
2615
|
+
`.github/…`→`.copilot/…` rewrite is owned by
|
|
2616
|
+
``_rewrite_copilot_user_scope_paths`` (RFC-0024 / copilot-full-parity).
|
|
2617
|
+
Returning early keeps copilot's no-op intentional rather than relying on
|
|
2618
|
+
its `.github/hooks/` paths happening to miss the `tools/hooks/` branch
|
|
2619
|
+
below.
|
|
2620
|
+
"""
|
|
2621
|
+
import json
|
|
2622
|
+
|
|
2623
|
+
if target_adapter == "copilot":
|
|
2624
|
+
return projection
|
|
2625
|
+
|
|
2626
|
+
hook_subdir = ".claude/hooks" if target_adapter == "claude-code" else ".kiro/hooks"
|
|
2627
|
+
drop_keys = {".claude/settings.local.json"}
|
|
2628
|
+
hook_body_suffixes = (".sh", ".py")
|
|
2629
|
+
rewritten: dict[str, bytes] = {}
|
|
2630
|
+
for relpath, content in projection.items():
|
|
2631
|
+
if relpath in drop_keys:
|
|
2632
|
+
# The v0.2 wiring target — v0.3 merges directly into
|
|
2633
|
+
# `~/.claude/settings.json` via user_merge_json instead.
|
|
2634
|
+
continue
|
|
2635
|
+
if (
|
|
2636
|
+
relpath.startswith("tools/hooks/")
|
|
2637
|
+
and Path(relpath).suffix in hook_body_suffixes
|
|
2638
|
+
):
|
|
2639
|
+
basename = Path(relpath).name
|
|
2640
|
+
rewritten[f"{hook_subdir}/{pack_name}/{basename}"] = content
|
|
2641
|
+
elif (
|
|
2642
|
+
target_adapter == "kiro"
|
|
2643
|
+
and relpath.startswith(".kiro/agents/")
|
|
2644
|
+
and relpath.endswith(".json")
|
|
2645
|
+
):
|
|
2646
|
+
# Strip the `hooks` key — the install-time merge step
|
|
2647
|
+
# re-adds it. Single-writer discipline; double-merge
|
|
2648
|
+
# would be idempotent today but fragile under any
|
|
2649
|
+
# future timestamp/version field on entries.
|
|
2650
|
+
try:
|
|
2651
|
+
data = json.loads(content.decode("utf-8"))
|
|
2652
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
2653
|
+
rewritten[relpath] = content
|
|
2654
|
+
continue
|
|
2655
|
+
if isinstance(data, dict) and "hooks" in data:
|
|
2656
|
+
data.pop("hooks")
|
|
2657
|
+
rewritten[relpath] = (
|
|
2658
|
+
json.dumps(data, indent=2, sort_keys=False) + "\n"
|
|
2659
|
+
).encode("utf-8")
|
|
2660
|
+
else:
|
|
2661
|
+
rewritten[relpath] = content
|
|
2662
|
+
return rewritten
|
|
2663
|
+
|
|
2664
|
+
|
|
2665
|
+
def _rewrite_copilot_user_scope_paths(
|
|
2666
|
+
projection: dict[str, bytes],
|
|
2667
|
+
) -> dict[str, bytes]:
|
|
2668
|
+
"""Rewrite copilot's repo-relpath projection to the user-scope home.
|
|
2669
|
+
|
|
2670
|
+
The copilot build adapter is scope-agnostic and emits ``.github/…``
|
|
2671
|
+
relpaths at every scope (RFC-0024 / copilot-full-parity). At user scope
|
|
2672
|
+
Copilot discovers content from ``~/.copilot/…`` instead, so the install
|
|
2673
|
+
handler swaps the prefix for **all** copilot primitives — skill, agent,
|
|
2674
|
+
hook-wiring, hook-body — before the path-jail check.
|
|
2675
|
+
|
|
2676
|
+
Unlike claude-code (whose skills share ``.claude/`` at both scopes, so
|
|
2677
|
+
only its hooks diverge via ``_rewrite_user_scope_hook_paths``), copilot's
|
|
2678
|
+
whole prefix changes, so this rewrite is **not** hook-gated.
|
|
2679
|
+
"""
|
|
2680
|
+
prefix_map = {
|
|
2681
|
+
".github/instructions/": ".copilot/instructions/",
|
|
2682
|
+
".github/agents/": ".copilot/agents/",
|
|
2683
|
+
".github/hooks/": ".copilot/hooks/",
|
|
2684
|
+
}
|
|
2685
|
+
rewritten: dict[str, bytes] = {}
|
|
2686
|
+
for relpath, content in projection.items():
|
|
2687
|
+
for repo_prefix, user_prefix in prefix_map.items():
|
|
2688
|
+
if relpath.startswith(repo_prefix):
|
|
2689
|
+
relpath = user_prefix + relpath[len(repo_prefix) :]
|
|
2690
|
+
break
|
|
2691
|
+
rewritten[relpath] = content
|
|
2692
|
+
return rewritten
|
|
2693
|
+
|
|
2694
|
+
|
|
2695
|
+
def _merge_user_scope_hook_wiring(
|
|
2696
|
+
pack_dir: Path,
|
|
2697
|
+
pack_name: str,
|
|
2698
|
+
target_adapter: str,
|
|
2699
|
+
install_root: Path,
|
|
2700
|
+
force_merge: bool,
|
|
2701
|
+
) -> list[dict[str, str]]:
|
|
2702
|
+
"""Parse the pack's ``.apm/hook-wiring/*.toml`` and dispatch to the
|
|
2703
|
+
appropriate v0.3 merger.
|
|
2704
|
+
|
|
2705
|
+
Returns the list of ``{"event", "id", "target-file"?}`` rows the
|
|
2706
|
+
install handler stores on ``PackState.hook_wiring_owned``. The
|
|
2707
|
+
``target-file`` field is omitted for Claude Code rows (the
|
|
2708
|
+
adapter's user-scope default target — ``~/.claude/settings.json``
|
|
2709
|
+
— is the implicit target on read; RFC-0005 § State-file impact)
|
|
2710
|
+
and explicit for Kiro rows.
|
|
2711
|
+
"""
|
|
2712
|
+
import tomllib
|
|
2713
|
+
|
|
2714
|
+
wiring_dir = pack_dir / ".apm" / "hook-wiring"
|
|
2715
|
+
if not wiring_dir.exists():
|
|
2716
|
+
return []
|
|
2717
|
+
wiring_tomls: dict[str, dict] = {}
|
|
2718
|
+
for entry in sorted(wiring_dir.iterdir()):
|
|
2719
|
+
if entry.is_file() and entry.suffix == ".toml":
|
|
2720
|
+
wiring_tomls[entry.stem] = tomllib.loads(entry.read_text(encoding="utf-8"))
|
|
2721
|
+
if not wiring_tomls:
|
|
2722
|
+
return []
|
|
2723
|
+
|
|
2724
|
+
if target_adapter == "copilot":
|
|
2725
|
+
# Copilot's hooks are file-based (RFC-0024 / copilot-full-parity): each
|
|
2726
|
+
# wiring `.toml` is already projected to a self-contained
|
|
2727
|
+
# `~/.copilot/hooks/<name>.json` by the build adapter + the user-scope
|
|
2728
|
+
# prefix rewrite. There is no shared settings/agent file to merge into,
|
|
2729
|
+
# so there are no merge-owned rows to record here — the files are
|
|
2730
|
+
# tracked as ordinary projection writes (state.files), like skills.
|
|
2731
|
+
return []
|
|
2732
|
+
|
|
2733
|
+
if target_adapter == "claude-code":
|
|
2734
|
+
from agentbundle.build.projections.user_merge_json import project as _project
|
|
2735
|
+
|
|
2736
|
+
target = install_root / ".claude" / "settings.json"
|
|
2737
|
+
owned = _project(target, pack_name, wiring_tomls, force_merge=force_merge)
|
|
2738
|
+
return [{"event": event, "id": entry_id} for event, entry_id in owned]
|
|
2739
|
+
|
|
2740
|
+
# Kiro: group wiring by attach-to-agent; one merge call per agent.
|
|
2741
|
+
from agentbundle import safety
|
|
2742
|
+
from agentbundle.build.projections.merge_into_agent_json import project as _project
|
|
2743
|
+
|
|
2744
|
+
# Defence-in-depth on the merge target path: a malicious
|
|
2745
|
+
# ``attach-to-agent`` value (e.g. ``"../../../tmp/escape"``) would
|
|
2746
|
+
# otherwise resolve outside the user-scope jail. The Step-8 path-
|
|
2747
|
+
# jail probe walks the projection dict; the merge target is
|
|
2748
|
+
# constructed here, post-probe, so we re-jail manually.
|
|
2749
|
+
_AGENT_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
2750
|
+
|
|
2751
|
+
wiring_by_agent: dict[str, dict[str, dict]] = {}
|
|
2752
|
+
for basename, body in wiring_tomls.items():
|
|
2753
|
+
attach = body.get("attach-to-agent") if isinstance(body, dict) else None
|
|
2754
|
+
if not isinstance(attach, str):
|
|
2755
|
+
continue
|
|
2756
|
+
if not _AGENT_NAME_RE.fullmatch(attach):
|
|
2757
|
+
raise RuntimeError(
|
|
2758
|
+
f"install: pack {pack_name}'s hook-wiring {basename}.toml "
|
|
2759
|
+
f"declares attach-to-agent={attach!r} which violates the "
|
|
2760
|
+
f"agent-name grammar ^[a-z0-9][a-z0-9-]*$ — refusing"
|
|
2761
|
+
)
|
|
2762
|
+
wiring_by_agent.setdefault(attach, {})[basename] = body
|
|
2763
|
+
|
|
2764
|
+
rows: list[dict[str, str]] = []
|
|
2765
|
+
for attach_to_agent, partitioned in wiring_by_agent.items():
|
|
2766
|
+
target_file_rel = f".kiro/agents/{attach_to_agent}.json"
|
|
2767
|
+
target = install_root / target_file_rel
|
|
2768
|
+
try:
|
|
2769
|
+
safety.assert_under(install_root, target)
|
|
2770
|
+
except safety.PathJailError as exc:
|
|
2771
|
+
raise RuntimeError(f"install: merge target outside jail: {exc}") from exc
|
|
2772
|
+
owned = _project(target, pack_name, partitioned)
|
|
2773
|
+
for event, entry_id in owned:
|
|
2774
|
+
rows.append({"event": event, "id": entry_id, "target-file": target_file_rel})
|
|
2775
|
+
return rows
|
|
2776
|
+
|
|
2777
|
+
|
|
2778
|
+
def _resolved_allowed_scopes(pack_install: dict | None) -> list[str]:
|
|
2779
|
+
"""Mirror the rule the validate command applies for rails A/B/C."""
|
|
2780
|
+
if not isinstance(pack_install, dict):
|
|
2781
|
+
return ["repo"]
|
|
2782
|
+
raw = pack_install.get("allowed-scopes")
|
|
2783
|
+
if isinstance(raw, list) and raw:
|
|
2784
|
+
return [s for s in raw if isinstance(s, str)]
|
|
2785
|
+
default = pack_install.get("default-scope")
|
|
2786
|
+
if isinstance(default, str):
|
|
2787
|
+
return [default]
|
|
2788
|
+
return ["repo"]
|
|
2789
|
+
|
|
2790
|
+
|
|
2791
|
+
@functools.cache
|
|
2792
|
+
def _claude_code_allowed_prefixes_user() -> list[str]:
|
|
2793
|
+
"""Read the Claude Code adapter's `allowed-prefixes.user` from the
|
|
2794
|
+
bundled contract. The function lives here (not in scope.py) so
|
|
2795
|
+
callers depending on `agentbundle.scope` don't pay the cost of
|
|
2796
|
+
parsing the contract for the common repo-scope path. Cached so the
|
|
2797
|
+
five callers (install, uninstall, upgrade, init-state --migrate,
|
|
2798
|
+
adapt) parse the contract at most once per CLI invocation.
|
|
2799
|
+
|
|
2800
|
+
Retained as the Claude-Code-specific shortcut; new callers that
|
|
2801
|
+
need adapter-aware resolution should use
|
|
2802
|
+
``_adapter_allowed_prefixes_user(adapter_name)`` below.
|
|
2803
|
+
"""
|
|
2804
|
+
return _adapter_allowed_prefixes_user("claude-code")
|
|
2805
|
+
|
|
2806
|
+
|
|
2807
|
+
def _adapter_allowed_prefixes_user(adapter_name: str) -> list[str]:
|
|
2808
|
+
"""Read *adapter_name*'s `allowed-prefixes.user` from the contract.
|
|
2809
|
+
|
|
2810
|
+
RFC-0005's T1 added a `[adapter.kiro.scope]` table alongside Claude
|
|
2811
|
+
Code's existing one; user-scope installs of Kiro-targeted packs
|
|
2812
|
+
need Kiro's prefixes (`.kiro/`, `.agentbundle/`) not Claude Code's
|
|
2813
|
+
(`.claude/`, `.agentbundle/`). The fallback (legacy contract
|
|
2814
|
+
without a scope table for the requested adapter) is the
|
|
2815
|
+
conservative single-prefix list rooted at the adapter's documented
|
|
2816
|
+
directory.
|
|
2817
|
+
"""
|
|
2818
|
+
import tomllib
|
|
2819
|
+
from agentbundle.build.main import _read_bundled
|
|
2820
|
+
|
|
2821
|
+
contract = tomllib.loads(_read_bundled("adapter.toml"))
|
|
2822
|
+
try:
|
|
2823
|
+
return list(
|
|
2824
|
+
contract["adapter"][adapter_name]["scope"]["allowed-prefixes"]["user"]
|
|
2825
|
+
)
|
|
2826
|
+
except KeyError:
|
|
2827
|
+
# Defensive: contract didn't declare a [scope] block for this
|
|
2828
|
+
# adapter. Pick a sensible default rooted at the adapter's
|
|
2829
|
+
# documented user-scope directory.
|
|
2830
|
+
default_prefix = ".kiro/" if adapter_name == "kiro" else ".claude/"
|
|
2831
|
+
return [default_prefix]
|
|
2832
|
+
|
|
2833
|
+
|
|
2834
|
+
def _adapter_allowed_prefixes_repo(adapter_name: str) -> list[str]:
|
|
2835
|
+
"""Read *adapter_name*'s `allowed-prefixes.repo` from the contract.
|
|
2836
|
+
|
|
2837
|
+
RFC-0012 adds an `allowed-prefixes.repo` entry to every shipped
|
|
2838
|
+
adapter's scope table at contract v0.7. Mirrors the user-scope
|
|
2839
|
+
helper above; the fallback is the conservative single-prefix list
|
|
2840
|
+
rooted at the adapter's documented repo-scope directory.
|
|
2841
|
+
"""
|
|
2842
|
+
import tomllib
|
|
2843
|
+
from agentbundle.build.main import _read_bundled
|
|
2844
|
+
|
|
2845
|
+
contract = tomllib.loads(_read_bundled("adapter.toml"))
|
|
2846
|
+
try:
|
|
2847
|
+
return list(
|
|
2848
|
+
contract["adapter"][adapter_name]["scope"]["allowed-prefixes"]["repo"]
|
|
2849
|
+
)
|
|
2850
|
+
except KeyError:
|
|
2851
|
+
# Defensive: contract pre-dates v0.7 or the requested adapter
|
|
2852
|
+
# has no scope table.
|
|
2853
|
+
defaults = {
|
|
2854
|
+
"claude-code": [".claude/", ".agentbundle/"],
|
|
2855
|
+
"kiro": [".kiro/", ".agentbundle/"],
|
|
2856
|
+
"codex": [".agents/skills/", ".agentbundle/"],
|
|
2857
|
+
"copilot": [".github/instructions/"],
|
|
2858
|
+
}
|
|
2859
|
+
return defaults.get(adapter_name, [".agentbundle/"])
|
|
2860
|
+
|
|
2861
|
+
|
|
2862
|
+
def _classify_for_install(
|
|
2863
|
+
relpath: str,
|
|
2864
|
+
root: Path,
|
|
2865
|
+
incoming_content: bytes,
|
|
2866
|
+
state: "State",
|
|
2867
|
+
*,
|
|
2868
|
+
pack_name: str = "",
|
|
2869
|
+
) -> "Tier":
|
|
2870
|
+
"""Classify a projected relpath for the install command.
|
|
2871
|
+
|
|
2872
|
+
Unlike ``safety.classify``, this function treats every incoming projected
|
|
2873
|
+
path as adapter-contract space (never Tier-3). The distinction is only
|
|
2874
|
+
whether the on-disk copy is safe to overwrite:
|
|
2875
|
+
|
|
2876
|
+
- Not on disk OR content matches incoming bundle → Tier-1.
|
|
2877
|
+
- On disk with content that matches the *recorded* SHA (from a prior
|
|
2878
|
+
install of the same pack at the same version) → Tier-1.
|
|
2879
|
+
- On disk with content that differs from the bundle AND from the
|
|
2880
|
+
recorded SHA → Tier-2 (adopter has edited).
|
|
2881
|
+
"""
|
|
2882
|
+
from agentbundle import safety as _safety
|
|
2883
|
+
|
|
2884
|
+
on_disk = root / relpath
|
|
2885
|
+
if not on_disk.exists():
|
|
2886
|
+
return _safety.Tier.TIER_1
|
|
2887
|
+
|
|
2888
|
+
on_disk_sha = _safety.sha256_file(on_disk)
|
|
2889
|
+
incoming_sha = _safety.sha256_bytes(incoming_content)
|
|
2890
|
+
|
|
2891
|
+
if on_disk_sha == incoming_sha:
|
|
2892
|
+
return _safety.Tier.TIER_1
|
|
2893
|
+
|
|
2894
|
+
for other_pack_name, ps in state.packs.items():
|
|
2895
|
+
recorded = ps.file_sha(relpath)
|
|
2896
|
+
if recorded and on_disk_sha == recorded:
|
|
2897
|
+
if pack_name and other_pack_name and other_pack_name != pack_name:
|
|
2898
|
+
print(
|
|
2899
|
+
f"install: warning: {relpath!r} is also recorded under "
|
|
2900
|
+
f"pack {other_pack_name!r}; the two packs project the same path",
|
|
2901
|
+
file=sys.stderr,
|
|
2902
|
+
)
|
|
2903
|
+
return _safety.Tier.TIER_1
|
|
2904
|
+
|
|
2905
|
+
return _safety.Tier.TIER_2
|
|
2906
|
+
|
|
2907
|
+
|
|
2908
|
+
def _locate_pack(catalogue_dir: Path, pack_name: str) -> Path | None:
|
|
2909
|
+
"""Find the pack directory inside the resolved catalogue."""
|
|
2910
|
+
candidate_a = catalogue_dir / "packs" / pack_name
|
|
2911
|
+
if candidate_a.is_dir() and (candidate_a / "pack.toml").exists():
|
|
2912
|
+
return candidate_a
|
|
2913
|
+
candidate_b = catalogue_dir / pack_name
|
|
2914
|
+
if candidate_b.is_dir() and (candidate_b / "pack.toml").exists():
|
|
2915
|
+
return candidate_b
|
|
2916
|
+
return None
|
|
2917
|
+
|
|
2918
|
+
|
|
2919
|
+
def validate_dependencies_required(
|
|
2920
|
+
pack_toml: dict,
|
|
2921
|
+
*,
|
|
2922
|
+
repo_state: "State",
|
|
2923
|
+
user_state: "State",
|
|
2924
|
+
) -> None:
|
|
2925
|
+
"""Enforce [pack.dependencies.required] before any file write.
|
|
2926
|
+
|
|
2927
|
+
Reads the required entries from the installing pack's manifest and
|
|
2928
|
+
resolves each one against the *union* of repo_state.packs and
|
|
2929
|
+
user_state.packs (key by pack name; a pack at either scope satisfies
|
|
2930
|
+
the gate).
|
|
2931
|
+
|
|
2932
|
+
Version-range grammar: exactly ``^X.Y`` (caret-minor). An installed
|
|
2933
|
+
version ``A.B.C`` satisfies ``^X.Y`` when ``A == X AND (B > Y OR (B
|
|
2934
|
+
== Y AND C >= 0))``, i.e. ``>= X.Y.0 AND < (X+1).0.0``.
|
|
2935
|
+
|
|
2936
|
+
Raises:
|
|
2937
|
+
RuntimeError: on unsupported range grammar or missing/out-of-range dep.
|
|
2938
|
+
Caller is expected to print str(exc) to stderr and exit 1.
|
|
2939
|
+
"""
|
|
2940
|
+
import re
|
|
2941
|
+
|
|
2942
|
+
_CARET_RE = re.compile(r"^\^([0-9]+)\.([0-9]+)$")
|
|
2943
|
+
|
|
2944
|
+
pack_name = pack_toml.get("pack", {}).get("name", "<unknown>")
|
|
2945
|
+
deps = pack_toml.get("pack", {}).get("dependencies", {})
|
|
2946
|
+
if not isinstance(deps, dict):
|
|
2947
|
+
return
|
|
2948
|
+
required = deps.get("required")
|
|
2949
|
+
if not required:
|
|
2950
|
+
return
|
|
2951
|
+
|
|
2952
|
+
# Union of installed packs across both scopes (pack name → installed version string).
|
|
2953
|
+
installed: dict[str, str] = {}
|
|
2954
|
+
for name, ps in repo_state.packs.items():
|
|
2955
|
+
installed[name] = ps.installed_version
|
|
2956
|
+
for name, ps in user_state.packs.items():
|
|
2957
|
+
if name not in installed:
|
|
2958
|
+
installed[name] = ps.installed_version
|
|
2959
|
+
|
|
2960
|
+
for entry in required:
|
|
2961
|
+
if not isinstance(entry, dict):
|
|
2962
|
+
continue
|
|
2963
|
+
dep_name = entry.get("pack", "")
|
|
2964
|
+
dep_range = entry.get("version", "")
|
|
2965
|
+
|
|
2966
|
+
# Validate grammar first (even before checking if the dep is installed).
|
|
2967
|
+
m = _CARET_RE.match(dep_range)
|
|
2968
|
+
if m is None:
|
|
2969
|
+
raise RuntimeError(
|
|
2970
|
+
f"install: unsupported version range {dep_range!r} for required pack "
|
|
2971
|
+
f"{dep_name!r}; only ^X.Y is supported"
|
|
2972
|
+
)
|
|
2973
|
+
|
|
2974
|
+
req_major = int(m.group(1))
|
|
2975
|
+
req_minor = int(m.group(2))
|
|
2976
|
+
|
|
2977
|
+
dep_version = installed.get(dep_name)
|
|
2978
|
+
if dep_version is None:
|
|
2979
|
+
raise RuntimeError(
|
|
2980
|
+
f"install: pack {pack_name!r} requires {dep_name!r} "
|
|
2981
|
+
f"(version {dep_range}); install {dep_name} first"
|
|
2982
|
+
)
|
|
2983
|
+
|
|
2984
|
+
# Parse installed version X.Y.Z (allow fewer components).
|
|
2985
|
+
parts = dep_version.split(".")
|
|
2986
|
+
try:
|
|
2987
|
+
inst_major = int(parts[0]) if len(parts) > 0 else 0
|
|
2988
|
+
inst_minor = int(parts[1]) if len(parts) > 1 else 0
|
|
2989
|
+
inst_patch = int(parts[2]) if len(parts) > 2 else 0
|
|
2990
|
+
except (ValueError, IndexError):
|
|
2991
|
+
raise RuntimeError(
|
|
2992
|
+
f"install: pack {pack_name!r} requires {dep_name!r} "
|
|
2993
|
+
f"(version {dep_range}); install {dep_name} first"
|
|
2994
|
+
)
|
|
2995
|
+
|
|
2996
|
+
# Satisfy: major must match AND version >= X.Y.0 AND < (X+1).0.0.
|
|
2997
|
+
satisfies = (
|
|
2998
|
+
inst_major == req_major
|
|
2999
|
+
and (
|
|
3000
|
+
inst_minor > req_minor
|
|
3001
|
+
or (inst_minor == req_minor and inst_patch >= 0)
|
|
3002
|
+
)
|
|
3003
|
+
)
|
|
3004
|
+
if not satisfies:
|
|
3005
|
+
raise RuntimeError(
|
|
3006
|
+
f"install: pack {pack_name!r} requires {dep_name!r} "
|
|
3007
|
+
f"(version {dep_range}); install {dep_name} first"
|
|
3008
|
+
)
|
|
3009
|
+
|
|
3010
|
+
|
|
3011
|
+
def _collect_primitives(pack_dir: Path) -> list[str]:
|
|
3012
|
+
"""Enumerate which primitive types exist under ``.apm/``."""
|
|
3013
|
+
apm = pack_dir / ".apm"
|
|
3014
|
+
if not apm.exists():
|
|
3015
|
+
return []
|
|
3016
|
+
names = []
|
|
3017
|
+
for subdir_name, ptype in (
|
|
3018
|
+
("skills", "skill"),
|
|
3019
|
+
("agents", "agent"),
|
|
3020
|
+
("hooks", "hook-body"),
|
|
3021
|
+
("hook-wiring", "hook-wiring"),
|
|
3022
|
+
("commands", "command"),
|
|
3023
|
+
):
|
|
3024
|
+
if (apm / subdir_name).exists():
|
|
3025
|
+
names.append(ptype)
|
|
3026
|
+
return names
|