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,699 @@
1
+ """``agentbundle upgrade`` — whole-pack or per-primitive upgrade.
2
+
3
+ Two shapes:
4
+
5
+ 1. **Whole-pack upgrade** (no ``--skill`` / ``--agent`` / ``--hook`` /
6
+ ``--seed`` / ``--command`` flag):
7
+
8
+ - Resolve the catalogue URI to the new pack version directory.
9
+ - Run the spec-version gate.
10
+ - Render the new projection in memory.
11
+ - Walk every (relpath, content) pair; apply the Tier-1/2/3 contract via
12
+ ``safety.classify`` + ``safety.write_jailed``/``safety.write_companion``.
13
+ - Update ``PackState.installed_version`` to ``args.to_version``.
14
+ - If the current state has any ``primitive_versions`` for this pack, emit
15
+ a warning to stderr *before* proceeding (mixed-version surface).
16
+
17
+ 2. **Per-primitive upgrade** (exactly one of the five primitive flags set):
18
+
19
+ - Identify the named primitive's file set from the rendered projection
20
+ using a path-segment heuristic (see ``_filter_for_primitive``).
21
+ - Validate that the primitive exists (non-empty filter result → exists).
22
+ - Apply Tier-1/2/3 contract for the filtered file set only.
23
+ - Record ``PackState.primitive_versions[<ptype>][<name>] = args.to_version``.
24
+ - Leave ``PackState.installed_version`` unchanged.
25
+
26
+ Writes go through ``safety.write_jailed`` — path-jail is non-optional.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import sys
32
+ from pathlib import Path
33
+ from typing import TYPE_CHECKING
34
+
35
+ if TYPE_CHECKING:
36
+ import argparse
37
+
38
+ from agentbundle.config import State
39
+ from agentbundle.user_config import UserConfig
40
+
41
+
42
+ # Mapping from CLI flag attribute name → (primitive-type key, source-dir segment).
43
+ # The source-dir segment is the subdirectory name under ``.apm/`` that holds
44
+ # the primitive (used by ``_filter_for_primitive`` to scope path matches).
45
+ _PRIMITIVE_FLAG_MAP: dict[str, tuple[str, str]] = {
46
+ "skill": ("skill", "skills"),
47
+ "agent": ("agent", "agents"),
48
+ "hook": ("hook-body", "hooks"),
49
+ "seed": ("seed", "seeds"),
50
+ "command": ("command", "commands"),
51
+ }
52
+
53
+
54
+ def run(args: "argparse.Namespace") -> int:
55
+ """Entry point for ``agentbundle upgrade``.
56
+
57
+ Args:
58
+ args.pack — pack name (required).
59
+ args.catalogue — catalogue URI (local path or git+https://...).
60
+ args.to_version — target version string (required, from ``--to``).
61
+ args.skill — primitive name for a skill-only upgrade (optional).
62
+ args.agent — primitive name for an agent-only upgrade (optional).
63
+ args.hook — primitive name for a hook-only upgrade (optional).
64
+ args.seed — primitive name for a seed-only upgrade (optional).
65
+ args.command — primitive name for a command-only upgrade (optional).
66
+ args.root — repo root (default ``'.'``).
67
+
68
+ Returns 0 on success, non-zero on any failure.
69
+ """
70
+ from agentbundle.catalogue import CatalogueError, resolve_catalogue
71
+ from agentbundle.commands._common import check_spec_version_gate
72
+ from agentbundle.config import (
73
+ ConfigError,
74
+ dump_state,
75
+ load_pack_toml,
76
+ load_state,
77
+ )
78
+ from agentbundle.render import render_pack
79
+ from agentbundle import safety
80
+
81
+ pack_name: str = args.pack
82
+ catalogue_uri: str = args.catalogue
83
+ to_version: str = args.to_version
84
+ cli_scope: str | None = getattr(args, "scope", None)
85
+ # User-config attached by `cli.py:main()` via args._user_config.
86
+ # The pre-flight in `_resolve_target_adapter` no-ops when
87
+ # `state_adapter` is set (upgrades preserve their existing-install
88
+ # adapter), so on a normal upgrade this is read but unused. We
89
+ # still thread it so the AC15(c) AST check is satisfied and so the
90
+ # state-pin-mismatch fall-through path stays well-defined.
91
+ user_config: "UserConfig | None" = getattr(args, "_user_config", None)
92
+ root = Path(args.root).resolve()
93
+
94
+ # ── Multi-scope disambiguator (RFC-0004) ──────────────────────────────────
95
+ # If the pack is at both scopes, --scope is required; at one scope, infer.
96
+ from agentbundle import scope as scope_mod
97
+
98
+ repo_state_path = root / ".agentbundle-state.toml"
99
+ repo_state_for_check = load_state(repo_state_path)
100
+ installed_at_repo = pack_name in repo_state_for_check.packs
101
+ user_state_path = None
102
+ installed_at_user = False
103
+ try:
104
+ user_root_resolved = scope_mod.resolve_user_root()
105
+ user_state_path = user_root_resolved / ".agentbundle" / "state.toml"
106
+ user_state_for_check = load_state(user_state_path)
107
+ installed_at_user = pack_name in user_state_for_check.packs
108
+ except scope_mod.UserScopeUnresolvable:
109
+ pass
110
+
111
+ if installed_at_repo and installed_at_user and cli_scope is None:
112
+ print(
113
+ f"upgrade: {pack_name} installed at multiple scopes; "
114
+ "pass --scope {repo, user}",
115
+ file=sys.stderr,
116
+ )
117
+ return 1
118
+
119
+ # Effective scope is "user" only when the CLI explicitly asked or
120
+ # the pack is installed at user only. At user scope, `root` is the
121
+ # user's home and the state file is `<root>/.agentbundle/state.toml`.
122
+ effective_scope = "repo"
123
+ allowed_prefixes: list[str] | None = None
124
+ if cli_scope == "user" or (cli_scope is None and installed_at_user and not installed_at_repo):
125
+ if user_state_path is None:
126
+ print(
127
+ "upgrade: cannot resolve user scope: $HOME unset or invalid",
128
+ file=sys.stderr,
129
+ )
130
+ return 1
131
+ root = user_state_path.parent.parent
132
+ effective_scope = "user"
133
+ from agentbundle.commands.install import _adapter_allowed_prefixes_user
134
+
135
+ # Use the recorded adapter from state so Kiro-installed packs
136
+ # get .kiro/ prefixes, not Claude Code's .claude/ prefixes.
137
+ recorded_adapter = (
138
+ user_state_for_check.packs[pack_name].adapter
139
+ if pack_name in user_state_for_check.packs
140
+ else "claude-code"
141
+ ) or "claude-code"
142
+ allowed_prefixes = _adapter_allowed_prefixes_user(recorded_adapter)
143
+
144
+ # ── Detect per-primitive flag ─────────────────────────────────────────────
145
+ prim_flag: str | None = None
146
+ prim_name: str | None = None
147
+ for flag_attr, (ptype, _src_dir) in _PRIMITIVE_FLAG_MAP.items():
148
+ val = getattr(args, flag_attr, None)
149
+ if val:
150
+ prim_flag = flag_attr
151
+ prim_name = val
152
+ break
153
+
154
+ is_per_primitive = prim_flag is not None
155
+
156
+ # ── Resolve catalogue ─────────────────────────────────────────────────────
157
+ try:
158
+ catalogue_dir = resolve_catalogue(catalogue_uri)
159
+ except CatalogueError as exc:
160
+ print(f"upgrade: {exc}", file=sys.stderr)
161
+ return 1
162
+
163
+ # ── Locate pack dir ───────────────────────────────────────────────────────
164
+ pack_dir = _locate_pack(catalogue_dir, pack_name)
165
+ if pack_dir is None:
166
+ print(
167
+ f"upgrade: pack {pack_name!r} not found in catalogue at {catalogue_dir}; "
168
+ "expected packs/<pack>/ or <catalogue>/<pack>/",
169
+ file=sys.stderr,
170
+ )
171
+ return 1
172
+
173
+ # ── Spec-version gate ─────────────────────────────────────────────────────
174
+ try:
175
+ pack_toml = load_pack_toml(pack_dir / "pack.toml")
176
+ except ConfigError as exc:
177
+ print(f"upgrade: {exc}", file=sys.stderr)
178
+ return 1
179
+
180
+ gate = check_spec_version_gate(pack_toml)
181
+ if gate is not None:
182
+ return gate
183
+
184
+ # ── Load current state ────────────────────────────────────────────────────
185
+ # upgrade is a write — refuse-and-explain on a v0.1 file (RFC-0004).
186
+ # At user scope, the state file lives at `<root>/.agentbundle/state.toml`,
187
+ # not the repo-style `<root>/.agentbundle-state.toml`.
188
+ if effective_scope == "user":
189
+ state_path = user_state_path # already resolved above
190
+ else:
191
+ state_path = root / ".agentbundle-state.toml"
192
+ try:
193
+ state = load_state(state_path, for_write=True)
194
+ except ConfigError as exc:
195
+ print(f"upgrade: {exc}", file=sys.stderr)
196
+ return 1
197
+
198
+ if pack_name not in state.packs:
199
+ print(f"upgrade: pack {pack_name!r} not installed", file=sys.stderr)
200
+ return 1
201
+
202
+ pack_state = state.packs[pack_name]
203
+
204
+ # ── Mixed-version warning (whole-pack only) ────────────────────────────────
205
+ if not is_per_primitive and pack_state.primitive_versions:
206
+ mixed_parts: list[str] = []
207
+ for ptype, pv_map in sorted(pack_state.primitive_versions.items()):
208
+ for pname, ver in sorted(pv_map.items()):
209
+ mixed_parts.append(f"{ptype}/{pname}@{ver}")
210
+ print(
211
+ f"warning: pack {pack_name!r} has mixed-version primitives: "
212
+ f"{mixed_parts}; proceeding with whole-pack upgrade",
213
+ file=sys.stderr,
214
+ )
215
+
216
+ # ── Render new projection in memory ──────────────────────────────────────
217
+ # At user scope, render via the Claude Code adapter directly (paths
218
+ # under `.claude/...`) — the dist-tree shape `render_pack` produces
219
+ # (`apm/...`, `claude-plugins/...`) would fail the user-scope
220
+ # `allowed-prefixes` jail and wouldn't match the user-scope-installed
221
+ # state's `.claude/...` paths. Mirrors `install._render_for_user_scope`.
222
+ # RFC-0011: thread the pack's allowed-adapters, contract version,
223
+ # and recorded state.adapter through the resolver so v0.6+ packs
224
+ # use the six-step (0–5) lookup (and existing adopters get the
225
+ # state-hint short-circuit AC10b on upgrade, avoiding the
226
+ # cross-adapter refusal when they've populated a second CLI home).
227
+ _pack_install_table = pack_toml.get("pack", {}).get("install")
228
+ _pack_allowed_adapters = None
229
+ if isinstance(_pack_install_table, dict):
230
+ _raw_aa = _pack_install_table.get("allowed-adapters")
231
+ if isinstance(_raw_aa, list):
232
+ _pack_allowed_adapters = [s for s in _raw_aa if isinstance(s, str)]
233
+ _pack_contract_version = (
234
+ pack_toml.get("pack", {}).get("adapter-contract", {}).get("version")
235
+ if isinstance(pack_toml.get("pack", {}).get("adapter-contract"), dict)
236
+ else None
237
+ )
238
+ try:
239
+ if effective_scope == "user":
240
+ from agentbundle.commands.install import (
241
+ _AdapterResolutionRefused,
242
+ _render_for_user_scope,
243
+ _resolve_target_adapter,
244
+ _rewrite_user_scope_hook_paths,
245
+ )
246
+
247
+ try:
248
+ projection = _render_for_user_scope(
249
+ pack_dir,
250
+ adapter=None,
251
+ allowed_adapters=_pack_allowed_adapters,
252
+ contract_version=_pack_contract_version,
253
+ state_adapter=pack_state.adapter,
254
+ command_name="upgrade",
255
+ user_config=user_config,
256
+ )
257
+ except _AdapterResolutionRefused as exc:
258
+ print(str(exc), file=sys.stderr)
259
+ return 1
260
+ # Mirror install: rewrite v0.2 hook-body paths to the v0.3
261
+ # user-scope shape (`.claude/hooks/<pack>/` or
262
+ # `.kiro/hooks/<pack>/`) and drop the v0.2 settings.local.json
263
+ # target. Without this, the path-jail probe refuses
264
+ # `tools/hooks/<name>.sh` at user scope.
265
+ try:
266
+ _new_target_adapter = _resolve_target_adapter(
267
+ pack_dir,
268
+ scope="user",
269
+ adapter=None,
270
+ allowed_adapters=_pack_allowed_adapters,
271
+ contract_version=_pack_contract_version,
272
+ state_adapter=pack_state.adapter,
273
+ command_name="upgrade",
274
+ user_config=user_config,
275
+ )
276
+ except _AdapterResolutionRefused as exc:
277
+ print(str(exc), file=sys.stderr)
278
+ return 1
279
+ projection = _rewrite_user_scope_hook_paths(
280
+ projection,
281
+ pack_name=pack_name,
282
+ target_adapter=_new_target_adapter,
283
+ )
284
+ else:
285
+ # Repo-scope render. RFC-0012 lifted the install default at
286
+ # this scope from the dist-tree producer to a per-IDE
287
+ # projection (`.claude/...`, `.kiro/...`, ...). Upgrade
288
+ # must mirror that shape, else the rendered keys won't
289
+ # overlap the install-time state.files keys and a whole-
290
+ # pack upgrade silently accretes a parallel dist-tree
291
+ # subtree into state.files (and onto disk via
292
+ # safety.write_jailed below).
293
+ #
294
+ # Backward compat for the `--emit-install-routes` install
295
+ # path (RFC-0012 § *CLI surface*): if existing state.files
296
+ # already carries dist-tree-shaped paths, this was a
297
+ # catalogue-publishing install — keep rendering the legacy
298
+ # shape so we don't accrete a parallel per-IDE subtree on
299
+ # top.
300
+ _was_dist_tree_install = any(
301
+ rp.startswith(("apm/", "claude-plugins/"))
302
+ or rp == "marketplace.json"
303
+ for rp in pack_state.files
304
+ )
305
+ if _was_dist_tree_install:
306
+ projection = render_pack(pack_dir)
307
+ else:
308
+ from agentbundle.commands.install import (
309
+ _AdapterResolutionRefused,
310
+ _adapter_allowed_prefixes_repo,
311
+ _render_for_repo_scope,
312
+ )
313
+
314
+ try:
315
+ _resolved_adapter, projection = _render_for_repo_scope(
316
+ pack_dir,
317
+ adapter=None,
318
+ allowed_adapters=_pack_allowed_adapters,
319
+ contract_version=_pack_contract_version,
320
+ state_adapter=pack_state.adapter,
321
+ command_name="upgrade",
322
+ user_config=user_config,
323
+ )
324
+ except _AdapterResolutionRefused as exc:
325
+ print(str(exc), file=sys.stderr)
326
+ return 1
327
+ allowed_prefixes = _adapter_allowed_prefixes_repo(
328
+ _resolved_adapter
329
+ )
330
+ except (FileNotFoundError, ValueError) as exc:
331
+ print(f"upgrade: render failed for pack {pack_name!r}: {exc}", file=sys.stderr)
332
+ return 1
333
+
334
+ # ── Per-primitive: validate and filter ────────────────────────────────────
335
+ if is_per_primitive:
336
+ ptype, src_dir = _PRIMITIVE_FLAG_MAP[prim_flag]
337
+ filtered = _filter_for_primitive(projection, prim_name, src_dir)
338
+ # --hook is atomic over hook-body + matching hook-wiring of the
339
+ # same name (per spec AC #10 — wiring co-moves with body so a
340
+ # per-hook upgrade can never land a torn pair).
341
+ if prim_flag == "hook":
342
+ filtered.update(
343
+ _filter_for_primitive(projection, prim_name, "hook-wiring")
344
+ )
345
+ if not filtered:
346
+ print(
347
+ f"primitive {prim_name!r} not in pack {pack_name}",
348
+ file=sys.stderr,
349
+ )
350
+ return 1
351
+ work_projection = filtered
352
+ else:
353
+ work_projection = projection
354
+
355
+ # ── Walk projection; apply Tier contract ──────────────────────────────────
356
+ for relpath, content in sorted(work_projection.items()):
357
+ tier = safety.classify(relpath, root, state)
358
+
359
+ if tier is safety.Tier.TIER_3:
360
+ # Path is in the new pack but not yet in state (first upgrade to a
361
+ # newly-added file). Treat as Tier-1 — the upgrade contract is the
362
+ # same as install for new paths.
363
+ tier = safety.Tier.TIER_1
364
+
365
+ if tier is safety.Tier.TIER_2:
366
+ try:
367
+ safety.write_companion(root, relpath, content)
368
+ except safety.PathJailError as exc:
369
+ print(f"upgrade: {exc}", file=sys.stderr)
370
+ return 1
371
+ pack_state.files[relpath] = {
372
+ "sha": safety.sha256_bytes(content),
373
+ "from-pack-version": to_version,
374
+ }
375
+ else:
376
+ try:
377
+ safety.write_jailed(
378
+ root, relpath, content,
379
+ scope=effective_scope,
380
+ allowed_prefixes=allowed_prefixes,
381
+ )
382
+ except safety.PathJailError as exc:
383
+ print(f"upgrade: {exc}", file=sys.stderr)
384
+ return 1
385
+ pack_state.files[relpath] = {
386
+ "sha": safety.sha256_bytes(content),
387
+ "from-pack-version": to_version,
388
+ }
389
+
390
+ # ── Hook-wiring reconciliation (RFC-0005 T8c, user-scope only) ───────────
391
+ # Compute the symmetric difference between old state's
392
+ # ``hook_wiring_owned`` and the new pack's wiring TOMLs. The
393
+ # ``attach-to-agent`` rename case (Kiro) lands here: rows whose
394
+ # target-file changes between versions get dropped from the OLD
395
+ # target file and added to the NEW one. In-place upgrades
396
+ # (identical wiring) are a no-op; adds and removes shift state
397
+ # rows accordingly.
398
+ if effective_scope == "user" and not is_per_primitive:
399
+ from agentbundle.commands.install import (
400
+ _AdapterResolutionRefused,
401
+ _merge_user_scope_hook_wiring,
402
+ _refresh_merge_target_shas,
403
+ _resolve_target_adapter,
404
+ )
405
+
406
+ try:
407
+ new_target_adapter = _resolve_target_adapter(
408
+ pack_dir,
409
+ scope="user",
410
+ adapter=None,
411
+ allowed_adapters=_pack_allowed_adapters,
412
+ contract_version=_pack_contract_version,
413
+ state_adapter=pack_state.adapter,
414
+ command_name="upgrade",
415
+ user_config=user_config,
416
+ )
417
+ except _AdapterResolutionRefused as exc:
418
+ print(str(exc), file=sys.stderr)
419
+ return 1
420
+ old_adapter_recorded = pack_state.adapter or "claude-code"
421
+
422
+ # Concern #3: cross-adapter upgrades are out of scope. AC19b
423
+ # covers attach-to-agent renames *within Kiro*, not Kiro→CC
424
+ # or CC→Kiro. Refuse with a refuse-and-explain shape; the
425
+ # operator uninstalls + reinstalls instead.
426
+ if old_adapter_recorded != new_target_adapter:
427
+ print(
428
+ f"upgrade: pack adapter changed from "
429
+ f"{old_adapter_recorded!r} → {new_target_adapter!r} "
430
+ f"between versions; run uninstall + install instead "
431
+ f"(cross-adapter upgrade is not supported)",
432
+ file=sys.stderr,
433
+ )
434
+ return 1
435
+
436
+ try:
437
+ new_owned = _compute_new_wiring_rows(pack_dir, pack_name, new_target_adapter)
438
+ old_owned = list(pack_state.hook_wiring_owned)
439
+ # Step A: unproject rows in old that aren't in new.
440
+ _unproject_removed_rows(
441
+ root=root,
442
+ old_owned=old_owned,
443
+ new_owned=new_owned,
444
+ old_adapter=old_adapter_recorded,
445
+ )
446
+ # Step B: project the new pack's wiring against new targets.
447
+ # The merger is idempotent for unchanged rows (replace-in-
448
+ # place by id); for added rows it appends.
449
+ new_rows = _merge_user_scope_hook_wiring(
450
+ pack_dir=pack_dir,
451
+ pack_name=pack_name,
452
+ target_adapter=new_target_adapter,
453
+ install_root=root,
454
+ force_merge=False,
455
+ )
456
+ pack_state.hook_wiring_owned = new_rows
457
+ pack_state.adapter = (
458
+ new_target_adapter if new_target_adapter == "kiro" else "claude-code"
459
+ )
460
+ # Blocker #1: refresh state.files SHA for the agent JSON the
461
+ # merge phase rewrote. Without this, post-upgrade uninstall
462
+ # would misclassify it as Tier-2 and refuse to remove it.
463
+ _refresh_merge_target_shas(
464
+ pack_state=pack_state,
465
+ owned_rows=new_rows,
466
+ root=root,
467
+ )
468
+ except Exception as exc:
469
+ print(f"upgrade: hook-wiring reconciliation failed: {exc}", file=sys.stderr)
470
+ return 1
471
+
472
+ # ── Update state ──────────────────────────────────────────────────────────
473
+ if is_per_primitive:
474
+ # Record per-primitive version override; leave installed_version alone.
475
+ ptype, _src_dir = _PRIMITIVE_FLAG_MAP[prim_flag]
476
+ if ptype not in pack_state.primitive_versions:
477
+ pack_state.primitive_versions[ptype] = {}
478
+ pack_state.primitive_versions[ptype][prim_name] = to_version
479
+ else:
480
+ pack_state.installed_version = to_version
481
+
482
+ state_toml_content = dump_state(state)
483
+ state_relpath = state_path.relative_to(root).as_posix()
484
+ # Mirror install.py:858-861 — the repo-scope state file lives at
485
+ # `<root>/.agentbundle-state.toml`, a top-level path that won't
486
+ # match the per-IDE `allowed-prefixes.repo` list. Skip the prefix
487
+ # check for that one file so the state write isn't blocked.
488
+ state_prefixes = allowed_prefixes
489
+ if effective_scope == "repo" and state_relpath == ".agentbundle-state.toml":
490
+ state_prefixes = None
491
+ try:
492
+ safety.write_jailed(
493
+ root, state_relpath, state_toml_content,
494
+ scope=effective_scope,
495
+ allowed_prefixes=state_prefixes,
496
+ )
497
+ except safety.PathJailError as exc:
498
+ print(f"upgrade: {exc}", file=sys.stderr)
499
+ return 1
500
+
501
+ if is_per_primitive:
502
+ ptype, _src_dir = _PRIMITIVE_FLAG_MAP[prim_flag]
503
+ print(
504
+ f"upgraded: {pack_name} {ptype}/{prim_name} @ "
505
+ f"{effective_scope} -> {to_version}"
506
+ )
507
+ else:
508
+ print(f"upgraded: {pack_name} @ {effective_scope} -> {to_version}")
509
+ return 0
510
+
511
+
512
+ # ---------------------------------------------------------------------------
513
+ # Helpers
514
+ # ---------------------------------------------------------------------------
515
+
516
+
517
+ def _compute_new_wiring_rows(
518
+ pack_dir: Path,
519
+ pack_name: str,
520
+ target_adapter: str,
521
+ ) -> list[dict[str, str]]:
522
+ """Parse the new pack's `.apm/hook-wiring/*.toml` files and
523
+ compute the ``hook-wiring-owned`` rows the upgraded state would
524
+ carry — without writing anything yet. The actual writes come from
525
+ `_unproject_removed_rows` (removes rows present in old, absent
526
+ from new) followed by an idempotent re-call to
527
+ `_merge_user_scope_hook_wiring` (lays down the new row set).
528
+
529
+ The id synthesis matches T5/T6's: `<pack-name>:<basename>` per
530
+ wiring TOML. Claude Code rows omit `target-file` (defaulted to
531
+ `~/.claude/settings.json`); Kiro rows carry it explicitly with
532
+ `.kiro/agents/<attach-to-agent>.json`.
533
+ """
534
+ import re
535
+ import tomllib
536
+ from agentbundle.build.projections.hook_id import synthesize_id
537
+
538
+ # Same grammar `install._merge_user_scope_hook_wiring` enforces.
539
+ # Validating here ensures a malformed `attach-to-agent` cannot
540
+ # corrupt the symmetric-diff computation (e.g. a path-traversal
541
+ # payload producing a phantom "removal" that we'd then unproject
542
+ # against the old target file).
543
+ _AGENT_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
544
+
545
+ wiring_dir = pack_dir / ".apm" / "hook-wiring"
546
+ if not wiring_dir.exists():
547
+ return []
548
+ rows: list[dict[str, str]] = []
549
+ for entry in sorted(wiring_dir.iterdir()):
550
+ if not (entry.is_file() and entry.suffix == ".toml"):
551
+ continue
552
+ # Don't silently swallow TOMLDecodeError — the merger raises
553
+ # on the same file, so the asymmetry would let step A unproject
554
+ # entries the merger will then refuse to re-project. Propagate.
555
+ try:
556
+ body = tomllib.loads(entry.read_text(encoding="utf-8"))
557
+ except tomllib.TOMLDecodeError as exc:
558
+ raise RuntimeError(
559
+ f"upgrade: pack {pack_name}'s hook-wiring {entry.stem}.toml "
560
+ f"failed to parse: {exc}"
561
+ ) from exc
562
+ entry_id = synthesize_id(pack_name, entry.stem)
563
+ hooks_in_wiring = body.get("hooks", {}) if isinstance(body, dict) else {}
564
+ if not isinstance(hooks_in_wiring, dict):
565
+ continue
566
+ attach = body.get("attach-to-agent") if isinstance(body, dict) else None
567
+ # Grammar guard for Kiro: refuse anything that would corrupt
568
+ # `target_file_rel` (path-traversal, special chars, …).
569
+ if target_adapter == "kiro" and isinstance(attach, str):
570
+ if not _AGENT_NAME_RE.fullmatch(attach):
571
+ raise RuntimeError(
572
+ f"upgrade: pack {pack_name}'s hook-wiring {entry.stem}.toml "
573
+ f"declares attach-to-agent={attach!r} which violates the "
574
+ f"agent-name grammar ^[a-z0-9][a-z0-9-]*$ — refusing"
575
+ )
576
+ for event, incoming in hooks_in_wiring.items():
577
+ if not isinstance(incoming, list):
578
+ continue
579
+ row: dict[str, str] = {"event": event, "id": entry_id}
580
+ if target_adapter == "kiro" and isinstance(attach, str):
581
+ row["target-file"] = f".kiro/agents/{attach}.json"
582
+ rows.append(row)
583
+ return rows
584
+
585
+
586
+ def _unproject_removed_rows(
587
+ *,
588
+ root: Path,
589
+ old_owned: list[dict[str, str]],
590
+ new_owned: list[dict[str, str]],
591
+ old_adapter: str,
592
+ ) -> None:
593
+ """Unproject rows present in *old_owned* but absent from *new_owned*.
594
+
595
+ A row's "presence" is the (event, id, target-file) triple — so an
596
+ ``attach-to-agent`` rename (Kiro: same id, same event, different
597
+ target-file) counts as a removal at the OLD target-file. The
598
+ install-time merge step (caller's Step B) will subsequently
599
+ project the same row at the NEW target-file.
600
+
601
+ Walks the OLD adapter for dispatch (Claude Code vs Kiro). Claude
602
+ Code rows default ``target-file`` to ``.claude/settings.json`` per
603
+ RFC-0005 § State-file impact.
604
+ """
605
+ def _key(row: dict[str, str]) -> tuple[str, str, str]:
606
+ return (row.get("event", ""), row.get("id", ""), row.get("target-file", ""))
607
+
608
+ new_keys = {_key(r) for r in new_owned}
609
+ removed = [r for r in old_owned if _key(r) not in new_keys]
610
+
611
+ removed_by_target: dict[str, list[tuple[str, str]]] = {}
612
+ for r in removed:
613
+ target = r.get("target-file") or (
614
+ ".claude/settings.json" if old_adapter == "claude-code" else ""
615
+ )
616
+ if not target:
617
+ continue
618
+ removed_by_target.setdefault(target, []).append((r["event"], r["id"]))
619
+
620
+ for target_rel, pairs in removed_by_target.items():
621
+ target_path = root / target_rel.lstrip("/")
622
+ if old_adapter == "kiro":
623
+ from agentbundle.build.projections.merge_into_agent_json import unproject
624
+ else:
625
+ from agentbundle.build.projections.user_merge_json import unproject
626
+ unproject(target_path, pairs)
627
+
628
+
629
+ def _locate_pack(catalogue_dir: Path, pack_name: str) -> Path | None:
630
+ """Find the pack directory inside the resolved catalogue.
631
+
632
+ Tries two layouts:
633
+ 1. ``<catalogue_dir>/packs/<pack_name>/`` — standard catalogue layout.
634
+ 2. ``<catalogue_dir>/<pack_name>/`` — catalogue is a pack root.
635
+ """
636
+ candidate_a = catalogue_dir / "packs" / pack_name
637
+ if candidate_a.is_dir() and (candidate_a / "pack.toml").exists():
638
+ return candidate_a
639
+ candidate_b = catalogue_dir / pack_name
640
+ if candidate_b.is_dir() and (candidate_b / "pack.toml").exists():
641
+ return candidate_b
642
+ return None
643
+
644
+
645
+ def _filter_for_primitive(
646
+ projection: dict[str, bytes],
647
+ prim_name: str,
648
+ src_dir: str,
649
+ ) -> dict[str, bytes]:
650
+ """Return the subset of ``projection`` that belongs to the named primitive.
651
+
652
+ Heuristic (v1):
653
+ A projected relpath is considered part of primitive ``prim_name`` of
654
+ source-dir type ``src_dir`` when the relpath contains a path segment
655
+ that starts with ``/<src_dir>/<prim_name>/`` (directory primitive) or
656
+ ``/<src_dir>/<prim_name>.`` (single-file primitive).
657
+
658
+ For example, skill ``work-loop`` under source dir ``skills`` matches:
659
+ - ``apm/core/.apm/skills/work-loop/SKILL.md``
660
+ - ``claude-plugins/core/.claude/skills/work-loop/SKILL.md``
661
+
662
+ Hook ``pre-commit`` under source dir ``hooks`` matches:
663
+ - ``apm/core/.apm/hooks/pre-commit.sh``
664
+ - ``apm/core/.apm/hooks/pre-commit.py``
665
+ - ``claude-plugins/core/tools/hooks/pre-commit.sh``
666
+
667
+ This heuristic intersects naturally with the adapter projection because
668
+ every adapter mirrors the source-dir tree structure from the pack root.
669
+ The heuristic is documented here rather than in a more general schema so
670
+ that a future pack.toml ``source-path`` field can replace it without
671
+ touching test or command logic — just update this function.
672
+
673
+ Disambiguation: if a pack contains both `<src_dir>/<name>/...` (dir
674
+ primitive) and `<src_dir>/<name>.<ext>` (single-file primitive), the
675
+ primitive name is ambiguous and the function raises `ValueError`.
676
+ F-build's `validate_pack_uniqueness` already rejects this shape at
677
+ build time; this check is defence-in-depth at the upgrade boundary.
678
+
679
+ Name terminators (`/` for dir, `.` for file) prevent prefix bleed:
680
+ `skills/work-loop/` is never matched by `skills/work/`.
681
+ """
682
+ dir_segment = f"/{src_dir}/{prim_name}/"
683
+ file_segment = f"/{src_dir}/{prim_name}."
684
+
685
+ via_dir: dict[str, bytes] = {}
686
+ via_file: dict[str, bytes] = {}
687
+ for relpath, content in projection.items():
688
+ norm = relpath if relpath.startswith("/") else "/" + relpath
689
+ if dir_segment in norm:
690
+ via_dir[relpath] = content
691
+ elif file_segment in norm:
692
+ via_file[relpath] = content
693
+
694
+ if via_dir and via_file:
695
+ raise ValueError(
696
+ f"primitive {prim_name!r} is ambiguous in source dir {src_dir!r}: "
697
+ f"matches both a directory and a single-file form"
698
+ )
699
+ return {**via_dir, **via_file}