agentbundle 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. agentbundle-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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