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,125 @@
|
|
|
1
|
+
"""``agentbundle config`` — get/set/unset/path on the user-scope config file.
|
|
2
|
+
|
|
3
|
+
See `docs/specs/agentbundle-config-subcommand/spec.md` AC1, AC2, AC5–AC10.
|
|
4
|
+
|
|
5
|
+
The handler is intentionally small: an action-dispatch dict, a small
|
|
6
|
+
known-keys registry, and four `_do_*` workers. Future settings keys
|
|
7
|
+
should be a one-line addition to `_KNOWN_KEYS` plus their write-time
|
|
8
|
+
validator — no abstraction beyond that.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from agentbundle.scope import DEFAULT_ADAPTER
|
|
17
|
+
from agentbundle.user_config import (
|
|
18
|
+
_KNOWN_KEYS,
|
|
19
|
+
UserConfig,
|
|
20
|
+
_user_config_path,
|
|
21
|
+
read_user_config,
|
|
22
|
+
unset_setting,
|
|
23
|
+
write_setting,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _do_path(args: argparse.Namespace) -> int:
|
|
28
|
+
print(str(_user_config_path()))
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _effective_value(key: str, cfg: UserConfig) -> tuple[str, str]:
|
|
33
|
+
"""Return `(value, provenance)` for key under the current config.
|
|
34
|
+
|
|
35
|
+
Provenance is one of `file` (set in the on-disk config) or
|
|
36
|
+
`builtin` (no on-disk value; resolver falls back to the constant).
|
|
37
|
+
"""
|
|
38
|
+
if key == "adapter":
|
|
39
|
+
if cfg.adapter is not None:
|
|
40
|
+
return cfg.adapter, "file"
|
|
41
|
+
return DEFAULT_ADAPTER, "builtin"
|
|
42
|
+
# Defense in depth — _KNOWN_KEYS gates entry to this branch.
|
|
43
|
+
raise ValueError(f"agentbundle: unknown setting {key!r}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _do_get(args: argparse.Namespace) -> int:
|
|
47
|
+
key = args.key
|
|
48
|
+
if key is not None and key not in _KNOWN_KEYS:
|
|
49
|
+
print(
|
|
50
|
+
f"agentbundle: unknown setting {key!r}. Known settings: "
|
|
51
|
+
f"{list(_KNOWN_KEYS)}.",
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
54
|
+
return 1
|
|
55
|
+
cfg = read_user_config(_user_config_path())
|
|
56
|
+
keys = [key] if key is not None else list(_KNOWN_KEYS)
|
|
57
|
+
for k in keys:
|
|
58
|
+
value, provenance = _effective_value(k, cfg)
|
|
59
|
+
print(f"{k}\t{value}\t({provenance})")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _do_set(args: argparse.Namespace) -> int:
|
|
64
|
+
key = args.key
|
|
65
|
+
value = args.value
|
|
66
|
+
if key is None or value is None:
|
|
67
|
+
print(
|
|
68
|
+
"agentbundle: `config set` requires both a key and a value. "
|
|
69
|
+
"Usage: agentbundle config set <key> <value>",
|
|
70
|
+
file=sys.stderr,
|
|
71
|
+
)
|
|
72
|
+
return 1
|
|
73
|
+
try:
|
|
74
|
+
write_setting(_user_config_path(), key, value)
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
print(str(exc), file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _do_unset(args: argparse.Namespace) -> int:
|
|
82
|
+
key = args.key
|
|
83
|
+
if key is None:
|
|
84
|
+
print(
|
|
85
|
+
"agentbundle: `config unset` requires a key. Usage: "
|
|
86
|
+
"agentbundle config unset <key>",
|
|
87
|
+
file=sys.stderr,
|
|
88
|
+
)
|
|
89
|
+
return 1
|
|
90
|
+
if key not in _KNOWN_KEYS:
|
|
91
|
+
print(
|
|
92
|
+
f"agentbundle: unknown setting {key!r}. Known settings: "
|
|
93
|
+
f"{list(_KNOWN_KEYS)}.",
|
|
94
|
+
file=sys.stderr,
|
|
95
|
+
)
|
|
96
|
+
return 1
|
|
97
|
+
try:
|
|
98
|
+
unset_setting(_user_config_path(), key)
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
print(str(exc), file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_ACTIONS = {
|
|
106
|
+
"get": _do_get,
|
|
107
|
+
"set": _do_set,
|
|
108
|
+
"unset": _do_unset,
|
|
109
|
+
"path": _do_path,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run(args: argparse.Namespace) -> int:
|
|
114
|
+
action = args.config_action
|
|
115
|
+
handler = _ACTIONS.get(action)
|
|
116
|
+
if handler is None:
|
|
117
|
+
# argparse's `choices=` should catch this before dispatch; guard
|
|
118
|
+
# anyway so a hand-built Namespace gets a clean refusal.
|
|
119
|
+
print(
|
|
120
|
+
f"agentbundle: unknown config action {action!r}. Known: "
|
|
121
|
+
f"{list(_ACTIONS)}.",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
)
|
|
124
|
+
return 1
|
|
125
|
+
return handler(args)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""T9: `diff` subcommand.
|
|
2
|
+
|
|
3
|
+
Compare the on-disk projection (under `args.root`) against a fresh in-memory
|
|
4
|
+
render of the same pack. If they match: exit 0. If anything drifted (modified
|
|
5
|
+
or missing): exit 1 with a one-line list of drifted paths.
|
|
6
|
+
|
|
7
|
+
Algorithm:
|
|
8
|
+
1. Render `args.pack_path` in-memory via `render.render_pack`.
|
|
9
|
+
2. For each `(relpath, expected_bytes)` in the render dict:
|
|
10
|
+
- Compute on-disk SHA at `args.root / relpath`.
|
|
11
|
+
- If absent on disk or differs from `sha256_bytes(expected_bytes)`,
|
|
12
|
+
mark as drifted.
|
|
13
|
+
3. If drifted set is empty, exit 0.
|
|
14
|
+
4. If drifted, print one line per drifted relpath to stdout, exit 1.
|
|
15
|
+
5. Exit non-zero on missing pack.toml or render failure.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from agentbundle import render, safety
|
|
25
|
+
from agentbundle.commands._common import check_spec_version_gate
|
|
26
|
+
from agentbundle.config import ConfigError, load_pack_toml
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(args: argparse.Namespace) -> int:
|
|
30
|
+
"""Entry point called by the CLI dispatcher. Returns exit code."""
|
|
31
|
+
pack_path = Path(args.pack_path).resolve()
|
|
32
|
+
root = Path(args.root).resolve()
|
|
33
|
+
cli_scope: str | None = getattr(args, "scope", None)
|
|
34
|
+
|
|
35
|
+
# ── Multi-scope disambiguator (RFC-0004) ──────────────────────────────────
|
|
36
|
+
# diff is read-only but still subject to the --scope rule: pick which
|
|
37
|
+
# scope's projection to compare against. If the pack is at both
|
|
38
|
+
# scopes, --scope is required.
|
|
39
|
+
from agentbundle import scope as scope_mod
|
|
40
|
+
from agentbundle.config import load_state
|
|
41
|
+
|
|
42
|
+
# Best-effort pack name lookup from pack.toml for the multi-scope check.
|
|
43
|
+
try:
|
|
44
|
+
pack_toml_data = load_pack_toml(pack_path / "pack.toml")
|
|
45
|
+
pack_name = pack_toml_data.get("pack", {}).get("name", "")
|
|
46
|
+
except ConfigError:
|
|
47
|
+
pack_name = ""
|
|
48
|
+
|
|
49
|
+
installed_at_repo = False
|
|
50
|
+
installed_at_user = False
|
|
51
|
+
user_root_resolved: Path | None = None
|
|
52
|
+
repo_state_for_check = None
|
|
53
|
+
user_state_for_check = None
|
|
54
|
+
if pack_name:
|
|
55
|
+
repo_state_for_check = load_state(root / ".agentbundle-state.toml")
|
|
56
|
+
installed_at_repo = pack_name in repo_state_for_check.packs
|
|
57
|
+
try:
|
|
58
|
+
user_root_resolved = scope_mod.resolve_user_root()
|
|
59
|
+
user_state_for_check = load_state(
|
|
60
|
+
user_root_resolved / ".agentbundle" / "state.toml"
|
|
61
|
+
)
|
|
62
|
+
installed_at_user = pack_name in user_state_for_check.packs
|
|
63
|
+
except scope_mod.UserScopeUnresolvable:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
if installed_at_repo and installed_at_user and cli_scope is None:
|
|
67
|
+
print(
|
|
68
|
+
f"diff: {pack_name} installed at multiple scopes; "
|
|
69
|
+
"pass --scope {repo, user}",
|
|
70
|
+
file=sys.stderr,
|
|
71
|
+
)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
if cli_scope == "user" or (
|
|
75
|
+
cli_scope is None and installed_at_user and not installed_at_repo
|
|
76
|
+
):
|
|
77
|
+
if user_root_resolved is None:
|
|
78
|
+
print(
|
|
79
|
+
"diff: cannot resolve user scope: $HOME unset or invalid",
|
|
80
|
+
file=sys.stderr,
|
|
81
|
+
)
|
|
82
|
+
return 1
|
|
83
|
+
root = user_root_resolved
|
|
84
|
+
|
|
85
|
+
# Resolve effective scope and the matching pack_state (if any) so
|
|
86
|
+
# the renderer below can mirror the shape install used.
|
|
87
|
+
effective_scope = "repo"
|
|
88
|
+
pack_state = None
|
|
89
|
+
if cli_scope == "user" or (
|
|
90
|
+
cli_scope is None and installed_at_user and not installed_at_repo
|
|
91
|
+
):
|
|
92
|
+
effective_scope = "user"
|
|
93
|
+
if user_state_for_check is not None and pack_name:
|
|
94
|
+
pack_state = user_state_for_check.packs.get(pack_name)
|
|
95
|
+
elif repo_state_for_check is not None and pack_name:
|
|
96
|
+
pack_state = repo_state_for_check.packs.get(pack_name)
|
|
97
|
+
|
|
98
|
+
if not (pack_path / "pack.toml").exists():
|
|
99
|
+
print(
|
|
100
|
+
f"error: no pack.toml found at {pack_path}",
|
|
101
|
+
file=sys.stderr,
|
|
102
|
+
)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
gate = check_spec_version_gate(load_pack_toml(pack_path / "pack.toml"))
|
|
107
|
+
except ConfigError as exc:
|
|
108
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
if gate is not None:
|
|
111
|
+
return gate
|
|
112
|
+
|
|
113
|
+
# Pick the projection shape to compare against. RFC-0012 changed
|
|
114
|
+
# install's default at repo scope from the dist-tree producer
|
|
115
|
+
# (`render_pack`) to a per-IDE projection (`.claude/...`,
|
|
116
|
+
# `.kiro/...`). Comparing on-disk per-IDE files against a dist-tree
|
|
117
|
+
# render flags every per-IDE path as missing — exactly what makes
|
|
118
|
+
# `agentbundle diff` useless after a v0.7+ install.
|
|
119
|
+
#
|
|
120
|
+
# Detection rule: if the pack is recorded in state, the install
|
|
121
|
+
# itself tells us which shape landed:
|
|
122
|
+
# - state.files contains `apm/<pack>/...`, `claude-plugins/<pack>/...`,
|
|
123
|
+
# or `marketplace.json` → install used `--emit-install-routes`
|
|
124
|
+
# (legacy dist-tree); keep the dist-tree render.
|
|
125
|
+
# - otherwise → install used the RFC-0012 per-IDE path; render
|
|
126
|
+
# via `_render_for_repo_scope` / `_render_for_user_scope` with
|
|
127
|
+
# the recorded `state.adapter` as the hint.
|
|
128
|
+
# When state has no row (a maintainer running diff against a fresh
|
|
129
|
+
# render directory, the test_diff_cmd.py shape), fall back to the
|
|
130
|
+
# dist-tree render — that's the catalogue-publishing surface.
|
|
131
|
+
_use_dist_tree = pack_state is None or any(
|
|
132
|
+
rp.startswith(("apm/", "claude-plugins/")) or rp == "marketplace.json"
|
|
133
|
+
for rp in pack_state.files
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
if _use_dist_tree:
|
|
137
|
+
rendered: dict[str, bytes] = render.render_pack(pack_path)
|
|
138
|
+
else:
|
|
139
|
+
import tomllib as _tomllib
|
|
140
|
+
|
|
141
|
+
_pack_toml = _tomllib.loads(
|
|
142
|
+
(pack_path / "pack.toml").read_text(encoding="utf-8")
|
|
143
|
+
)
|
|
144
|
+
_install_table = _pack_toml.get("pack", {}).get("install")
|
|
145
|
+
_allowed_adapters = None
|
|
146
|
+
if isinstance(_install_table, dict):
|
|
147
|
+
_raw_aa = _install_table.get("allowed-adapters")
|
|
148
|
+
if isinstance(_raw_aa, list):
|
|
149
|
+
_allowed_adapters = [s for s in _raw_aa if isinstance(s, str)]
|
|
150
|
+
_contract_version = (
|
|
151
|
+
_pack_toml.get("pack", {}).get("adapter-contract", {}).get("version")
|
|
152
|
+
if isinstance(_pack_toml.get("pack", {}).get("adapter-contract"), dict)
|
|
153
|
+
else None
|
|
154
|
+
)
|
|
155
|
+
if effective_scope == "user":
|
|
156
|
+
from agentbundle.commands.install import (
|
|
157
|
+
_AdapterResolutionRefused,
|
|
158
|
+
_render_for_user_scope,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
rendered = _render_for_user_scope(
|
|
163
|
+
pack_path,
|
|
164
|
+
adapter=None,
|
|
165
|
+
allowed_adapters=_allowed_adapters,
|
|
166
|
+
contract_version=_contract_version,
|
|
167
|
+
state_adapter=pack_state.adapter,
|
|
168
|
+
command_name="diff",
|
|
169
|
+
)
|
|
170
|
+
except _AdapterResolutionRefused as exc:
|
|
171
|
+
print(str(exc), file=sys.stderr)
|
|
172
|
+
return 1
|
|
173
|
+
else:
|
|
174
|
+
from agentbundle.commands.install import (
|
|
175
|
+
_AdapterResolutionRefused,
|
|
176
|
+
_render_for_repo_scope,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
_adapter, rendered = _render_for_repo_scope(
|
|
181
|
+
pack_path,
|
|
182
|
+
adapter=None,
|
|
183
|
+
allowed_adapters=_allowed_adapters,
|
|
184
|
+
contract_version=_contract_version,
|
|
185
|
+
state_adapter=pack_state.adapter,
|
|
186
|
+
command_name="diff",
|
|
187
|
+
)
|
|
188
|
+
except _AdapterResolutionRefused as exc:
|
|
189
|
+
print(str(exc), file=sys.stderr)
|
|
190
|
+
return 1
|
|
191
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
192
|
+
print(f"error: render failed for pack at '{pack_path}': {exc}", file=sys.stderr)
|
|
193
|
+
return 1
|
|
194
|
+
|
|
195
|
+
drifted: list[str] = []
|
|
196
|
+
for relpath, expected_bytes in sorted(rendered.items()):
|
|
197
|
+
on_disk = root / relpath
|
|
198
|
+
if not on_disk.exists():
|
|
199
|
+
drifted.append(relpath)
|
|
200
|
+
continue
|
|
201
|
+
expected_sha = safety.sha256_bytes(expected_bytes)
|
|
202
|
+
actual_sha = safety.sha256_file(on_disk)
|
|
203
|
+
if expected_sha != actual_sha:
|
|
204
|
+
drifted.append(relpath)
|
|
205
|
+
|
|
206
|
+
if not drifted:
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
for path in drifted:
|
|
210
|
+
print(path)
|
|
211
|
+
return 1
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""T10: `init-state` subcommand.
|
|
2
|
+
|
|
3
|
+
Given a working tree that already contains a pack's projection (e.g. from a
|
|
4
|
+
manual install or a `make build-self`), hash the on-disk projected files and
|
|
5
|
+
write `.agentbundle-state.toml` with their SHA-256s. This is the recovery
|
|
6
|
+
path: produce a state file for a tree that doesn't have one.
|
|
7
|
+
|
|
8
|
+
Algorithm (without ``--migrate``):
|
|
9
|
+
1. Locate `<packs_dir>/<pack>/`; render in-memory via `render.render_pack`
|
|
10
|
+
to learn the projection's relpath set.
|
|
11
|
+
2. For each projected relpath:
|
|
12
|
+
- Compute on-disk SHA at `<root>/<relpath>`.
|
|
13
|
+
- If absent on disk, skip with a warning to stderr.
|
|
14
|
+
- Otherwise add to `PackState.files[relpath] = {sha, from-pack-version}`.
|
|
15
|
+
3. Load existing `.agentbundle-state.toml` (may be absent).
|
|
16
|
+
Replace only `[pack.<args.pack>]`; leave other packs untouched.
|
|
17
|
+
4. Write via `safety.write_jailed(args.root, ".agentbundle-state.toml", ...)`.
|
|
18
|
+
5. Print summary to stdout.
|
|
19
|
+
|
|
20
|
+
With ``--migrate`` (RFC-0004 T13): the subcommand becomes a migration
|
|
21
|
+
verb instead — it reads the on-disk state file, augments every
|
|
22
|
+
``[pack.<name>]`` entry with ``scope = "repo"``, sets ``schema-version =
|
|
23
|
+
"0.2"``, and writes atomically. Idempotent against already-v0.2 files.
|
|
24
|
+
The flag accepts no ``--pack`` (the migration is whole-file), and the
|
|
25
|
+
``--scope`` selector picks which scope's state file to operate on
|
|
26
|
+
(``repo`` → ``<root>/.agentbundle-state.toml`` — the default;
|
|
27
|
+
``user`` → ``~/.agentbundle/state.toml`` via
|
|
28
|
+
``safety.user_state_path()``).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from agentbundle import config, render, safety
|
|
38
|
+
from agentbundle.commands._common import check_spec_version_gate
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run(args: argparse.Namespace) -> int:
|
|
42
|
+
"""Entry point called by the CLI dispatcher. Returns exit code."""
|
|
43
|
+
# ── --migrate branch (RFC-0004 T13) ──────────────────────────────────
|
|
44
|
+
# Migration is a different verb shape: no pack name, no rendering,
|
|
45
|
+
# just rewrite the state file in place. Run before the regular init-
|
|
46
|
+
# state path so the absent-file vs v0.1-file branches don't trip the
|
|
47
|
+
# `--pack` requirement.
|
|
48
|
+
if getattr(args, "migrate", False):
|
|
49
|
+
return _run_migrate(args)
|
|
50
|
+
|
|
51
|
+
root = Path(args.root).resolve()
|
|
52
|
+
packs_dir = Path(getattr(args, "packs_dir", "packs"))
|
|
53
|
+
if not packs_dir.is_absolute():
|
|
54
|
+
packs_dir = root / packs_dir
|
|
55
|
+
pack_name: str | None = getattr(args, "pack", None)
|
|
56
|
+
if not pack_name:
|
|
57
|
+
# argparse can't enforce the "required unless --migrate" shape
|
|
58
|
+
# without an arg-group hack; we enforce it here so the handler's
|
|
59
|
+
# entry contract stays simple and the error message names the
|
|
60
|
+
# right verb.
|
|
61
|
+
print(
|
|
62
|
+
"init-state: --pack is required (omit it only with --migrate)",
|
|
63
|
+
file=sys.stderr,
|
|
64
|
+
)
|
|
65
|
+
return 1
|
|
66
|
+
pack_path = packs_dir / pack_name
|
|
67
|
+
|
|
68
|
+
if not pack_path.is_dir():
|
|
69
|
+
print(
|
|
70
|
+
f"error: pack directory not found: {pack_path}",
|
|
71
|
+
file=sys.stderr,
|
|
72
|
+
)
|
|
73
|
+
return 1
|
|
74
|
+
|
|
75
|
+
# Read the pack version from pack.toml; refuse if absent (state file with
|
|
76
|
+
# empty version cascades into useless install/uninstall comparisons).
|
|
77
|
+
pack_toml_path = pack_path / "pack.toml"
|
|
78
|
+
try:
|
|
79
|
+
pack_meta = config.load_pack_toml(pack_toml_path)
|
|
80
|
+
except config.ConfigError as exc:
|
|
81
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
gate = check_spec_version_gate(pack_meta)
|
|
85
|
+
if gate is not None:
|
|
86
|
+
return gate
|
|
87
|
+
|
|
88
|
+
pack_version = pack_meta.get("pack", {}).get("version")
|
|
89
|
+
if not pack_version:
|
|
90
|
+
print(
|
|
91
|
+
f"error: pack {pack_name!r} has no [pack] version; "
|
|
92
|
+
f"refusing to init-state without a known version anchor",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
return 1
|
|
96
|
+
|
|
97
|
+
# Render in-memory to get the relpath set.
|
|
98
|
+
try:
|
|
99
|
+
rendered: dict[str, bytes] = render.render_pack(pack_path)
|
|
100
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
101
|
+
print(f"error: render failed for pack '{pack_name}': {exc}", file=sys.stderr)
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
# Hash on-disk files; skip absent ones with a warning.
|
|
105
|
+
files: dict[str, dict[str, str]] = {}
|
|
106
|
+
skipped = 0
|
|
107
|
+
for relpath in sorted(rendered):
|
|
108
|
+
on_disk = root / relpath
|
|
109
|
+
if not on_disk.exists():
|
|
110
|
+
print(
|
|
111
|
+
f"warning: projected file absent on disk, skipping: {relpath}",
|
|
112
|
+
file=sys.stderr,
|
|
113
|
+
)
|
|
114
|
+
skipped += 1
|
|
115
|
+
continue
|
|
116
|
+
sha = safety.sha256_file(on_disk)
|
|
117
|
+
files[relpath] = {"sha": sha, "from-pack-version": pack_version}
|
|
118
|
+
|
|
119
|
+
# Load existing state (may be absent) and replace only this pack's table.
|
|
120
|
+
# init-state is a *write* — refuse-and-explain on a v0.1 file so the
|
|
121
|
+
# adopter opts into migration explicitly.
|
|
122
|
+
state_path = root / ".agentbundle-state.toml"
|
|
123
|
+
try:
|
|
124
|
+
existing_state = config.load_state(state_path, for_write=True)
|
|
125
|
+
except config.StateFileLegacy as exc:
|
|
126
|
+
print(f"init-state: {exc}", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
new_pack_state = config.PackState(
|
|
130
|
+
installed_version=pack_version,
|
|
131
|
+
files=files,
|
|
132
|
+
)
|
|
133
|
+
existing_state.packs[pack_name] = new_pack_state
|
|
134
|
+
|
|
135
|
+
serialised = config.dump_state(existing_state)
|
|
136
|
+
try:
|
|
137
|
+
safety.write_jailed(root, ".agentbundle-state.toml", serialised)
|
|
138
|
+
except safety.PathJailError as exc:
|
|
139
|
+
print(f"init-state: {exc}", file=sys.stderr)
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
hashed_count = len(files)
|
|
143
|
+
print(
|
|
144
|
+
f"init-state: {hashed_count} file(s) hashed"
|
|
145
|
+
+ (f", {skipped} skipped (absent)" if skipped else "")
|
|
146
|
+
+ f" → {state_path}"
|
|
147
|
+
)
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_migrate(args: argparse.Namespace) -> int:
|
|
152
|
+
"""`init-state --migrate`: bump a state file to the current schema-version.
|
|
153
|
+
|
|
154
|
+
Two migration shapes the CLI supports:
|
|
155
|
+
|
|
156
|
+
- **v0.1 → v0.3 (legacy full re-serialize).** Adds ``scope = "repo"``
|
|
157
|
+
to every pack entry, sets ``schema-version = "0.3"``, and writes
|
|
158
|
+
atomically. The v0.1 → v0.2 invariant (per-row scope backfill)
|
|
159
|
+
lands here in a single step because RFC-0005's v0.2 → v0.3
|
|
160
|
+
migration is header-only (no per-row changes), so the two compose
|
|
161
|
+
cleanly.
|
|
162
|
+
- **v0.2 → v0.3 (header-only-additive, RFC-0005 § State-file
|
|
163
|
+
impact).** Rewrites only the ``schema-version = "0.2"`` line to
|
|
164
|
+
``"0.3"``. Every body byte is preserved; existing rows omit the
|
|
165
|
+
new ``adapter`` / ``target-file`` fields and let v0.3 read-time
|
|
166
|
+
defaults supply them. Cheapest possible additive migration.
|
|
167
|
+
|
|
168
|
+
Already-v0.3 files are a no-op exit-zero — idempotent.
|
|
169
|
+
"""
|
|
170
|
+
scope = getattr(args, "scope", None) or "repo"
|
|
171
|
+
user_prefixes: list[str] | None = None
|
|
172
|
+
if scope == "user":
|
|
173
|
+
try:
|
|
174
|
+
state_path = safety.user_state_path()
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
print(f"init-state: cannot create user state directory: {exc}", file=sys.stderr)
|
|
177
|
+
return 1
|
|
178
|
+
# Write through the user root + relative path so the path-jail
|
|
179
|
+
# rail fires on the migration write — same shape every other
|
|
180
|
+
# user-scope write uses.
|
|
181
|
+
write_root = state_path.parent.parent # = ~
|
|
182
|
+
relpath = state_path.relative_to(write_root).as_posix()
|
|
183
|
+
from agentbundle.commands.install import _claude_code_allowed_prefixes_user
|
|
184
|
+
|
|
185
|
+
user_prefixes = _claude_code_allowed_prefixes_user()
|
|
186
|
+
else:
|
|
187
|
+
root = Path(args.root).resolve()
|
|
188
|
+
state_path = root / ".agentbundle-state.toml"
|
|
189
|
+
write_root = root
|
|
190
|
+
relpath = ".agentbundle-state.toml"
|
|
191
|
+
|
|
192
|
+
if not state_path.exists():
|
|
193
|
+
# Migration of an absent file is meaningless — surface so the
|
|
194
|
+
# adopter doesn't think a no-op succeeded silently.
|
|
195
|
+
print(
|
|
196
|
+
f"init-state --migrate: no state file at {state_path}; nothing to migrate",
|
|
197
|
+
file=sys.stderr,
|
|
198
|
+
)
|
|
199
|
+
return 1
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
original_text = state_path.read_text(encoding="utf-8")
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
print(f"init-state --migrate: cannot read {state_path}: {exc}", file=sys.stderr)
|
|
205
|
+
return 1
|
|
206
|
+
|
|
207
|
+
source_version = _peek_schema_version(original_text)
|
|
208
|
+
|
|
209
|
+
if source_version in ("0.2", "0.3"):
|
|
210
|
+
# Header-only-additive (RFC-0005). Rewrite only the version
|
|
211
|
+
# line. The already-v0.3 case is a true byte-no-op (identity
|
|
212
|
+
# rewrite) so adopters running ``--migrate`` against a current
|
|
213
|
+
# file get a round-trip-stable result — full re-serialize would
|
|
214
|
+
# silently strip explicit fields T8b/T8c writers may produce.
|
|
215
|
+
serialised = _rewrite_schema_version_line(
|
|
216
|
+
original_text, config.STATE_SCHEMA_VERSION
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
# v0.1 or unrecognised — full re-serialize. load_state with
|
|
220
|
+
# for_write=False so the legacy refusal does not fire (we *are*
|
|
221
|
+
# the migration).
|
|
222
|
+
try:
|
|
223
|
+
state = config.load_state(state_path, for_write=False)
|
|
224
|
+
except config.ConfigError as exc:
|
|
225
|
+
print(f"init-state --migrate: {exc}", file=sys.stderr)
|
|
226
|
+
return 1
|
|
227
|
+
state.schema_version = config.STATE_SCHEMA_VERSION
|
|
228
|
+
for ps in state.packs.values():
|
|
229
|
+
if not ps.scope:
|
|
230
|
+
ps.scope = "repo"
|
|
231
|
+
serialised = config.dump_state(state)
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
safety.write_jailed(
|
|
235
|
+
write_root, relpath, serialised,
|
|
236
|
+
scope=scope,
|
|
237
|
+
allowed_prefixes=user_prefixes,
|
|
238
|
+
)
|
|
239
|
+
except safety.PathJailError as exc:
|
|
240
|
+
print(f"init-state --migrate: {exc}", file=sys.stderr)
|
|
241
|
+
return 1
|
|
242
|
+
|
|
243
|
+
print(
|
|
244
|
+
f"init-state --migrate: {state_path} → schema-version "
|
|
245
|
+
f"{config.STATE_SCHEMA_VERSION}"
|
|
246
|
+
)
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
import re as _re
|
|
251
|
+
|
|
252
|
+
# Match the top-level ``schema-version = "X.Y"`` line. The pattern
|
|
253
|
+
# anchors to *file* start (``\A``) with optional leading blank lines —
|
|
254
|
+
# TOML convention puts top-level keys at the head, and ``dump_state``
|
|
255
|
+
# emits ``schema-version`` as the first line. Restricting the regex to
|
|
256
|
+
# the file head prevents a stale pack-table value or a comment further
|
|
257
|
+
# down from being rewritten by accident.
|
|
258
|
+
_SCHEMA_VERSION_LINE_RE = _re.compile(
|
|
259
|
+
r'\A(\s*)(schema-version\s*=\s*")([^"]*)(".*)',
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _peek_schema_version(text: str) -> str | None:
|
|
264
|
+
"""Return the schema-version string from a state-file body, or None."""
|
|
265
|
+
m = _SCHEMA_VERSION_LINE_RE.match(text)
|
|
266
|
+
return m.group(3) if m else None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _rewrite_schema_version_line(text: str, new_version: str) -> str:
|
|
270
|
+
"""Rewrite the ``schema-version = "X.Y"`` header in *text* to *new_version*.
|
|
271
|
+
|
|
272
|
+
Touches only the first match (anchored to file start) — every other
|
|
273
|
+
byte is preserved. Trailing newline is preserved. This is the
|
|
274
|
+
v0.2 → v0.3 (and v0.3 → v0.3 no-op) migration's whole job per
|
|
275
|
+
RFC-0005 § State-file impact.
|
|
276
|
+
"""
|
|
277
|
+
def _sub(m: _re.Match[str]) -> str:
|
|
278
|
+
return f"{m.group(1)}{m.group(2)}{new_version}{m.group(4)}"
|
|
279
|
+
return _SCHEMA_VERSION_LINE_RE.sub(_sub, text, count=1)
|