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,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)