agentbundle 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentbundle/__init__.py +14 -0
- agentbundle/__main__.py +5 -0
- agentbundle/_data/adapter.schema.json +270 -0
- agentbundle/_data/adapter.toml +584 -0
- agentbundle/_data/install-marker.py +1099 -0
- agentbundle/_data/pack.schema.json +152 -0
- agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
- agentbundle/_data/plugin-manifest.schema.json +18 -0
- agentbundle/build/__init__.py +206 -0
- agentbundle/build/__main__.py +8 -0
- agentbundle/build/adapter_root_bins.py +336 -0
- agentbundle/build/adapters/__init__.py +46 -0
- agentbundle/build/adapters/claude_code.py +142 -0
- agentbundle/build/adapters/codex.py +227 -0
- agentbundle/build/adapters/copilot.py +149 -0
- agentbundle/build/adapters/kiro.py +608 -0
- agentbundle/build/adapters/kiro_cli.py +53 -0
- agentbundle/build/adapters/kiro_ide.py +275 -0
- agentbundle/build/contract.py +20 -0
- agentbundle/build/lint_packs.py +555 -0
- agentbundle/build/main.py +596 -0
- agentbundle/build/phase_order.py +40 -0
- agentbundle/build/projections/__init__.py +13 -0
- agentbundle/build/projections/codex_agent_toml.py +232 -0
- agentbundle/build/projections/copilot_agent_md.py +206 -0
- agentbundle/build/projections/copilot_hooks_json.py +142 -0
- agentbundle/build/projections/direct_directory.py +41 -0
- agentbundle/build/projections/hook_id.py +27 -0
- agentbundle/build/projections/kiro_ide_hook.py +256 -0
- agentbundle/build/projections/merge_into_agent_json.py +264 -0
- agentbundle/build/projections/merge_json.py +58 -0
- agentbundle/build/projections/user_merge_json.py +324 -0
- agentbundle/build/scope_rails.py +728 -0
- agentbundle/build/self_host.py +1486 -0
- agentbundle/build/shared_libs.py +309 -0
- agentbundle/build/target_resolver.py +85 -0
- agentbundle/build/tests/__init__.py +0 -0
- agentbundle/build/tests/test_adapter_claude_code.py +275 -0
- agentbundle/build/tests/test_adapter_codex.py +699 -0
- agentbundle/build/tests/test_adapter_copilot.py +91 -0
- agentbundle/build/tests/test_adapter_kiro.py +449 -0
- agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
- agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
- agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
- agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
- agentbundle/build/tests/test_build_ships_seeds.py +78 -0
- agentbundle/build/tests/test_contract.py +582 -0
- agentbundle/build/tests/test_contract_scope.py +224 -0
- agentbundle/build/tests/test_contract_v07.py +191 -0
- agentbundle/build/tests/test_contract_v08.py +230 -0
- agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
- agentbundle/build/tests/test_end_to_end_build.py +227 -0
- agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
- agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
- agentbundle/build/tests/test_lint_packs.py +703 -0
- agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
- agentbundle/build/tests/test_pack_schema.py +265 -0
- agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
- agentbundle/build/tests/test_pack_schema_install.py +305 -0
- agentbundle/build/tests/test_pipeline.py +272 -0
- agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
- agentbundle/build/tests/test_projections_merge_json.py +148 -0
- agentbundle/build/tests/test_scope_rails.py +398 -0
- agentbundle/build/tests/test_security.py +97 -0
- agentbundle/build/tests/test_self_host_check.py +2100 -0
- agentbundle/build/tests/test_shared_libs_projection.py +415 -0
- agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
- agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
- agentbundle/build/tests/test_validate.py +250 -0
- agentbundle/build/validate.py +141 -0
- agentbundle/catalogue.py +164 -0
- agentbundle/cli.py +486 -0
- agentbundle/commands/__init__.py +5 -0
- agentbundle/commands/_common.py +174 -0
- agentbundle/commands/_drop_warning.py +329 -0
- agentbundle/commands/adapt.py +343 -0
- agentbundle/commands/config.py +125 -0
- agentbundle/commands/diff.py +211 -0
- agentbundle/commands/init_state.py +279 -0
- agentbundle/commands/install.py +3026 -0
- agentbundle/commands/list_packs.py +170 -0
- agentbundle/commands/list_targets.py +23 -0
- agentbundle/commands/reconcile.py +161 -0
- agentbundle/commands/render.py +165 -0
- agentbundle/commands/scaffold.py +69 -0
- agentbundle/commands/uninstall.py +294 -0
- agentbundle/commands/upgrade.py +699 -0
- agentbundle/commands/validate.py +688 -0
- agentbundle/config.py +747 -0
- agentbundle/render.py +123 -0
- agentbundle/safety.py +633 -0
- agentbundle/scope.py +319 -0
- agentbundle/user_config.py +284 -0
- agentbundle/version.py +49 -0
- agentbundle-0.2.0.dist-info/METADATA +37 -0
- agentbundle-0.2.0.dist-info/RECORD +99 -0
- agentbundle-0.2.0.dist-info/WHEEL +5 -0
- agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
- agentbundle-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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}
|