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,294 @@
1
+ """``agentbundle uninstall`` — remove a pack's Tier-1 files from the adopter tree.
2
+
3
+ Algorithm:
4
+ 1. Load ``.agentbundle-state.toml`` from ``args.root``.
5
+ Exit non-zero with stderr if the pack is not in state.
6
+ 2. For each file recorded under ``[pack.<name>.files]``:
7
+ - Compute the on-disk SHA.
8
+ - If it matches the state-recorded SHA (Tier-1) → ``os.remove``.
9
+ - If it differs (or file is absent) → Tier-2 → warn on stderr and keep.
10
+ 3. Best-effort: remove empty parent directories left behind by removals.
11
+ 4. Save the updated state file with ``[pack.<name>]`` table dropped.
12
+ 5. Print summary to stdout: N removed, M kept.
13
+ 6. Exit 0.
14
+
15
+ Tier-3 files (paths not recorded in the pack's state table) are never touched.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ import argparse
27
+
28
+
29
+ def run(args: "argparse.Namespace") -> int:
30
+ """Entry point for ``agentbundle uninstall``.
31
+
32
+ Args:
33
+ args.pack — name of the pack to uninstall (required).
34
+ args.root — repo root directory (default '.').
35
+
36
+ Returns 0 on success, non-zero on error.
37
+ """
38
+ from agentbundle.config import ConfigError, dump_state, load_state
39
+ from agentbundle import safety
40
+
41
+ pack_name: str = args.pack
42
+ cli_scope: str | None = getattr(args, "scope", None)
43
+ root = Path(args.root).resolve()
44
+ state_path = root / ".agentbundle-state.toml"
45
+
46
+ # ── Step 1: Multi-scope disambiguator (RFC-0004) ──────────────────────────
47
+ # If the pack is at both repo and user scopes, --scope is required.
48
+ # If it's at exactly one scope, infer. The check needs *read* access
49
+ # to both state files (read-only — v0.1 refusal fires at the *write*
50
+ # below). Importing scope_mod lazily so --version stays fast.
51
+ from agentbundle import scope as scope_mod
52
+
53
+ repo_state_for_check = load_state(state_path)
54
+ installed_at_repo = pack_name in repo_state_for_check.packs
55
+ user_state_path = None
56
+ installed_at_user = False
57
+ try:
58
+ user_root = scope_mod.resolve_user_root()
59
+ user_state_path = user_root / ".agentbundle" / "state.toml"
60
+ user_state_for_check = load_state(user_state_path)
61
+ installed_at_user = pack_name in user_state_for_check.packs
62
+ except scope_mod.UserScopeUnresolvable:
63
+ # No accessible user root — treat user scope as empty for the
64
+ # disambiguator. The repo write below is unaffected.
65
+ pass
66
+
67
+ if installed_at_repo and installed_at_user and cli_scope is None:
68
+ print(
69
+ f"uninstall: {pack_name} installed at multiple scopes; "
70
+ "pass --scope {repo, user}",
71
+ file=sys.stderr,
72
+ )
73
+ return 1
74
+
75
+ # Resolve the effective scope: explicit CLI flag, else the inferred
76
+ # single-scope value, else repo (the historical default).
77
+ if cli_scope is not None:
78
+ effective_scope = cli_scope
79
+ elif installed_at_user and not installed_at_repo:
80
+ effective_scope = "user"
81
+ else:
82
+ effective_scope = "repo"
83
+
84
+ # Route to the correct state file based on effective scope.
85
+ # At user scope, `root` is the user's home (where projected files
86
+ # live) and the state file lives at `<root>/.agentbundle/state.toml`.
87
+ # Keep the two values separate so the path-jailed write picks the
88
+ # right *relpath relative to root*, not a bare dotfile in $HOME.
89
+ user_prefixes: list[str] | None = None
90
+ if effective_scope == "user":
91
+ if user_state_path is None:
92
+ print(
93
+ "uninstall: cannot resolve user scope: $HOME unset or invalid",
94
+ file=sys.stderr,
95
+ )
96
+ return 1
97
+ state_path = user_state_path
98
+ root = user_state_path.parent.parent # = ~ (the user-scope projection root)
99
+ # Pull the adapter contract's allowed-prefixes so the user-scope
100
+ # path-jail fires on the per-file removal walk below. The
101
+ # adapter is recorded on the pack's state row (v0.3); fall back
102
+ # to claude-code when absent (v0.2-vintage rows preserved across
103
+ # the header-only migration, per RFC-0005 § State-file impact).
104
+ from agentbundle.commands.install import _adapter_allowed_prefixes_user
105
+
106
+ recorded_adapter = (
107
+ user_state_for_check.packs[pack_name].adapter
108
+ if pack_name in user_state_for_check.packs
109
+ else "claude-code"
110
+ )
111
+ user_prefixes = _adapter_allowed_prefixes_user(recorded_adapter or "claude-code")
112
+
113
+ # ── Step 1b: Load state for write (refuse v0.1 here, after disambig) ─────
114
+ try:
115
+ state = load_state(state_path, for_write=True)
116
+ except ConfigError as exc:
117
+ print(f"uninstall: {exc}", file=sys.stderr)
118
+ return 1
119
+
120
+ if pack_name not in state.packs:
121
+ print(f"uninstall: pack {pack_name!r} not installed", file=sys.stderr)
122
+ return 1
123
+
124
+ pack_state = state.packs[pack_name]
125
+
126
+ # ── Step 2: Walk the pack's recorded files ────────────────────────────────
127
+ # State-file relpaths are *untrusted input* — a malicious state file
128
+ # could record `relpath = "../.ssh/authorized_keys"` and the
129
+ # `os.remove` below would happily delete a path outside the jail.
130
+ # At user scope that blast radius reaches the user's whole home
131
+ # directory; at repo scope it's still wrong even though smaller.
132
+ # `safety.assert_under` + (at user scope) the prefix-list check
133
+ # refuses any entry that escapes the per-scope jail before any
134
+ # filesystem operation runs.
135
+ removed: list[str] = []
136
+ kept: list[str] = []
137
+
138
+ for relpath, entry in sorted(pack_state.files.items()):
139
+ on_disk = root / relpath
140
+ try:
141
+ safety.assert_under(root, on_disk)
142
+ except safety.PathJailError:
143
+ print(
144
+ f"uninstall: warning: state entry {relpath!r} resolves outside "
145
+ f"jail root; refusing to touch",
146
+ file=sys.stderr,
147
+ )
148
+ continue
149
+ if effective_scope == "user":
150
+ target_relpath = on_disk.resolve().relative_to(root.resolve()).as_posix()
151
+ if not any(target_relpath.startswith(p) for p in (user_prefixes or [])):
152
+ print(
153
+ f"uninstall: warning: state entry {relpath!r} lies outside "
154
+ f"allowed-prefixes for scope 'user'; refusing to touch",
155
+ file=sys.stderr,
156
+ )
157
+ continue
158
+ recorded_sha = entry.get("sha") if isinstance(entry, dict) else None
159
+
160
+ # If the file is absent on disk, treat as already gone (Tier-2 edge
161
+ # case: the adopter deleted it manually). Do not error — just skip.
162
+ if not on_disk.exists():
163
+ # Not present → nothing to remove; also not an adopter edit we
164
+ # need to preserve. Skip silently.
165
+ continue
166
+
167
+ # Compute on-disk SHA and compare against the recorded value.
168
+ on_disk_sha = safety.sha256_file(on_disk)
169
+ if recorded_sha and on_disk_sha == recorded_sha:
170
+ # Tier-1: the bundle owns this file — safe to remove.
171
+ try:
172
+ os.remove(on_disk)
173
+ except OSError as exc:
174
+ print(
175
+ f"uninstall: could not remove {relpath}: {exc}",
176
+ file=sys.stderr,
177
+ )
178
+ return 1
179
+ removed.append(relpath)
180
+ else:
181
+ # Tier-2: adopter-edited (or no recorded SHA) — preserve with warning.
182
+ print(
183
+ f"uninstall: keeping adopter-edited file: {relpath}",
184
+ file=sys.stderr,
185
+ )
186
+ kept.append(relpath)
187
+
188
+ # ── Step 2b: RFC-0005 T8b — unproject hook-wiring-owned entries ─────────
189
+ # When the pack has hook_wiring_owned rows (v0.3 user-scope hooks),
190
+ # dispatch to the right merge engine's `unproject` to remove those
191
+ # entries from the merge target file. Empty `hooks.<event>` arrays
192
+ # are pruned; the target file itself stays in place (Kiro: agent
193
+ # primitive's direct-file uninstall handles it; Claude Code: the
194
+ # settings file is adopter-shared and must not be deleted).
195
+ if pack_state.hook_wiring_owned:
196
+ adapter = pack_state.adapter or "claude-code"
197
+ owned_by_target: dict[str, list[tuple[str, str]]] = {}
198
+ for entry in pack_state.hook_wiring_owned:
199
+ event = entry.get("event")
200
+ entry_id = entry.get("id")
201
+ if not (isinstance(event, str) and isinstance(entry_id, str)):
202
+ continue
203
+ target_file_rel = entry.get("target-file")
204
+ if not target_file_rel:
205
+ # Claude Code rows default to `~/.claude/settings.json`
206
+ # per RFC-0005 § State-file impact (resolve via the
207
+ # user-scope target on the adapter contract).
208
+ target_file_rel = ".claude/settings.json"
209
+ owned_by_target.setdefault(target_file_rel, []).append((event, entry_id))
210
+
211
+ for target_file_rel, owned in owned_by_target.items():
212
+ target_path = root / target_file_rel.lstrip("/")
213
+ if adapter == "kiro":
214
+ from agentbundle.build.projections.merge_into_agent_json import (
215
+ unproject as _unproject,
216
+ )
217
+ else:
218
+ from agentbundle.build.projections.user_merge_json import (
219
+ unproject as _unproject,
220
+ )
221
+ try:
222
+ _unproject(target_path, owned)
223
+ except Exception as exc:
224
+ print(f"uninstall: hook-wiring unproject failed: {exc}", file=sys.stderr)
225
+ return 1
226
+
227
+ # ── Step 3: Best-effort cleanup of empty parent directories ──────────────
228
+ _prune_empty_parents(root, removed)
229
+
230
+ # ── Step 4: Remove the pack's table from state and persist ────────────────
231
+ del state.packs[pack_name]
232
+ serialised = dump_state(state)
233
+ # Write the state file at the right per-scope relpath. At repo scope
234
+ # the relpath is `.agentbundle-state.toml`; at user scope it's
235
+ # `.agentbundle/state.toml` (the namespaced dot-directory). Compute
236
+ # from `state_path.relative_to(root)` so the layout is single-sourced.
237
+ state_relpath = state_path.relative_to(root).as_posix()
238
+ try:
239
+ safety.write_jailed(
240
+ root,
241
+ state_relpath,
242
+ serialised,
243
+ scope=effective_scope,
244
+ allowed_prefixes=user_prefixes,
245
+ )
246
+ except safety.PathJailError as exc:
247
+ print(f"uninstall: {exc}", file=sys.stderr)
248
+ return 1
249
+
250
+ # ── Step 5: Summary ───────────────────────────────────────────────────────
251
+ print(f"uninstall: {len(removed)} removed, {len(kept)} kept")
252
+ return 0
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # Helpers
257
+ # ---------------------------------------------------------------------------
258
+
259
+
260
+ def _prune_empty_parents(root: Path, removed_relpaths: list[str]) -> None:
261
+ """Remove empty directories left behind after file deletions.
262
+
263
+ Works bottom-up: for each removed file, walk its parents upward until
264
+ reaching ``root`` or a non-empty directory. Ignores all errors — this is
265
+ best-effort housekeeping.
266
+
267
+ `removed_relpaths` is filtered to jail-clean entries by the caller
268
+ (see the path-jail check in `run`), so the recursive parent walk
269
+ here is guaranteed to stay under `root`. We still defensively
270
+ `assert_under` each candidate before `rmdir` to catch any future
271
+ regression in the caller — leaving the rmdir unguarded would
272
+ silently re-introduce the same path-traversal gap.
273
+ """
274
+ from agentbundle.safety import PathJailError, assert_under
275
+
276
+ # Collect unique parent directories in deepest-first order.
277
+ dirs_to_check: set[Path] = set()
278
+ for relpath in removed_relpaths:
279
+ parent = (root / relpath).parent
280
+ while parent != root and parent != parent.parent:
281
+ try:
282
+ assert_under(root, parent)
283
+ except PathJailError:
284
+ break
285
+ dirs_to_check.add(parent)
286
+ parent = parent.parent
287
+
288
+ # Sort deepest first (longest path first) so we remove children before
289
+ # parents — avoids trying to remove a directory that still has children.
290
+ for d in sorted(dirs_to_check, key=lambda p: len(p.parts), reverse=True):
291
+ try:
292
+ d.rmdir() # Only succeeds if the directory is empty.
293
+ except OSError:
294
+ pass # Not empty or other error — skip silently.