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