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,324 @@
|
|
|
1
|
+
"""``user-merge-json`` projection mode — Claude Code user scope.
|
|
2
|
+
|
|
3
|
+
Merges a pack's ``.apm/hook-wiring/*.toml`` content into the
|
|
4
|
+
hand-edited shared ``~/.claude/settings.json`` under the ``hooks`` key,
|
|
5
|
+
using **array-append-with-id** (not key-replace). The merger respects
|
|
6
|
+
three boundaries documented in RFC-0005 § Merge semantics:
|
|
7
|
+
|
|
8
|
+
1. Adopter-authored keys at the top level (``theme``, ``model``,
|
|
9
|
+
``env``, ...) are never read or rewritten.
|
|
10
|
+
2. Adopter-authored entries under ``hooks.<event>`` (entries without
|
|
11
|
+
an id matching any installed pack's owned ids) are never reordered,
|
|
12
|
+
never rewritten, and only inspected for textual collision against
|
|
13
|
+
incoming pack commands.
|
|
14
|
+
3. Empty ``hooks.<event>`` arrays are removed after ``unproject``, so
|
|
15
|
+
the file stays tidy across upgrade churn.
|
|
16
|
+
|
|
17
|
+
The module exposes two callables: ``project`` (install / reinstall)
|
|
18
|
+
and ``unproject`` (uninstall). Both write atomically via tmp + rename.
|
|
19
|
+
|
|
20
|
+
This module is stdlib-only — ``json`` + ``pathlib`` per the spec's
|
|
21
|
+
*Never do — No new top-level dependency* boundary.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import tempfile
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from agentbundle.build.projections.hook_id import synthesize_id
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UserMergeRefusal(Exception):
|
|
36
|
+
"""Raised when ``project`` / ``unproject`` refuses to write.
|
|
37
|
+
|
|
38
|
+
The exception's string is the refuse-and-explain text RFC-0005
|
|
39
|
+
specifies. CLI callers (T8b's install / uninstall handlers) catch
|
|
40
|
+
and print to stderr without paraphrasing.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# RFC-0005 § Merge semantics: "textual equality after whitespace
|
|
45
|
+
# normalisation". Collapse runs of whitespace to a single space and
|
|
46
|
+
# strip leading/trailing whitespace before comparing commands.
|
|
47
|
+
_WS_RE = re.compile(r"\s+")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _normalize_command(value: object) -> str:
|
|
51
|
+
if not isinstance(value, str):
|
|
52
|
+
return ""
|
|
53
|
+
return _WS_RE.sub(" ", value).strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def project(
|
|
57
|
+
target_path: Path,
|
|
58
|
+
pack_name: str,
|
|
59
|
+
wiring_tomls: dict[str, dict],
|
|
60
|
+
force_merge: bool = False,
|
|
61
|
+
) -> list[tuple[str, str]]:
|
|
62
|
+
"""Merge *wiring_tomls* into the JSON file at *target_path*.
|
|
63
|
+
|
|
64
|
+
Arguments:
|
|
65
|
+
target_path: ``~/.claude/settings.json`` (or test-redirected
|
|
66
|
+
equivalent). Created with ``{}`` if absent.
|
|
67
|
+
pack_name: the pack's ``[pack].name``. Substituted into id tags
|
|
68
|
+
and into refusal text.
|
|
69
|
+
wiring_tomls: map of wiring TOML basename (no ``.toml``) → parsed
|
|
70
|
+
TOML body (typically ``{"hooks": {"<Event>": [entries]}}``).
|
|
71
|
+
Iteration order is the call's order.
|
|
72
|
+
force_merge: when True, an adopter-authored entry whose
|
|
73
|
+
``command`` collides with an incoming pack command is replaced
|
|
74
|
+
rather than refused (RFC-0005 § User-already-set-this-key).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of ``(event, id)`` tuples reflecting every owned entry the
|
|
78
|
+
call wrote. T8b records these in the state file's
|
|
79
|
+
``hook-wiring-owned`` table so ``unproject`` can be precise.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
UserMergeRefusal: on unparseable settings, wrong-shape ``hooks``
|
|
83
|
+
or ``hooks.<event>``, adopter collision without ``force_merge``.
|
|
84
|
+
"""
|
|
85
|
+
data = _load_settings(target_path)
|
|
86
|
+
_shape_check_hooks(target_path, data)
|
|
87
|
+
|
|
88
|
+
owned: list[tuple[str, str]] = []
|
|
89
|
+
for basename, body in wiring_tomls.items():
|
|
90
|
+
entry_id = synthesize_id(pack_name, basename)
|
|
91
|
+
hooks_in_wiring = body.get("hooks", {}) if isinstance(body, dict) else {}
|
|
92
|
+
if not isinstance(hooks_in_wiring, dict):
|
|
93
|
+
continue
|
|
94
|
+
for event, incoming_entries in hooks_in_wiring.items():
|
|
95
|
+
if not isinstance(incoming_entries, list):
|
|
96
|
+
continue
|
|
97
|
+
data.setdefault("hooks", {})
|
|
98
|
+
data["hooks"].setdefault(event, [])
|
|
99
|
+
event_array = data["hooks"][event]
|
|
100
|
+
_shape_check_event_array(target_path, event, event_array)
|
|
101
|
+
for incoming in incoming_entries:
|
|
102
|
+
if not isinstance(incoming, dict):
|
|
103
|
+
continue
|
|
104
|
+
tagged = dict(incoming)
|
|
105
|
+
tagged["id"] = entry_id
|
|
106
|
+
_merge_one_entry(
|
|
107
|
+
target_path=target_path,
|
|
108
|
+
pack_name=pack_name,
|
|
109
|
+
basename=basename,
|
|
110
|
+
event=event,
|
|
111
|
+
event_array=event_array,
|
|
112
|
+
tagged_entry=tagged,
|
|
113
|
+
force_merge=force_merge,
|
|
114
|
+
)
|
|
115
|
+
owned.append((event, entry_id))
|
|
116
|
+
|
|
117
|
+
_atomic_write(target_path, data)
|
|
118
|
+
# Deduplicate (event, id) tuples — a single wiring TOML may contribute
|
|
119
|
+
# multiple entries under one event, but the state-side ownership
|
|
120
|
+
# record only needs (event, id) once per logical pair.
|
|
121
|
+
seen: set[tuple[str, str]] = set()
|
|
122
|
+
result: list[tuple[str, str]] = []
|
|
123
|
+
for item in owned:
|
|
124
|
+
if item not in seen:
|
|
125
|
+
seen.add(item)
|
|
126
|
+
result.append(item)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def unproject(target_path: Path, owned: list[tuple[str, str]]) -> None:
|
|
131
|
+
"""Remove every ``(event, id)`` pair in *owned* from *target_path*.
|
|
132
|
+
|
|
133
|
+
Empty ``hooks.<event>`` arrays are removed (not left as ``[]``).
|
|
134
|
+
If the target file is absent, ``unproject`` is a no-op — there's
|
|
135
|
+
nothing to remove. Unparseable JSON refuses with the same shape
|
|
136
|
+
``project`` does.
|
|
137
|
+
|
|
138
|
+
Absent-file no-op note: a state row pointing at a now-absent
|
|
139
|
+
target is an orphan-in-state condition that T9's
|
|
140
|
+
``reconcile --scope user`` reporter will surface. Refusing here
|
|
141
|
+
would block uninstall of unrelated packs whose own target files
|
|
142
|
+
happen to be absent — too aggressive for the uninstall path.
|
|
143
|
+
"""
|
|
144
|
+
if not target_path.exists():
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
data = _load_settings(target_path)
|
|
148
|
+
_shape_check_hooks(target_path, data)
|
|
149
|
+
|
|
150
|
+
hooks = data.get("hooks")
|
|
151
|
+
if not isinstance(hooks, dict):
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
owned_by_event: dict[str, set[str]] = {}
|
|
155
|
+
for event, entry_id in owned:
|
|
156
|
+
owned_by_event.setdefault(event, set()).add(entry_id)
|
|
157
|
+
|
|
158
|
+
for event, ids_to_remove in owned_by_event.items():
|
|
159
|
+
if event not in hooks:
|
|
160
|
+
continue
|
|
161
|
+
event_array = hooks[event]
|
|
162
|
+
if not isinstance(event_array, list):
|
|
163
|
+
continue
|
|
164
|
+
hooks[event] = [
|
|
165
|
+
e for e in event_array
|
|
166
|
+
if not (isinstance(e, dict) and e.get("id") in ids_to_remove)
|
|
167
|
+
]
|
|
168
|
+
if not hooks[event]:
|
|
169
|
+
del hooks[event]
|
|
170
|
+
|
|
171
|
+
if not hooks:
|
|
172
|
+
# Empty hooks dict is kept (it might still hold un-owned events
|
|
173
|
+
# other than the ones we just cleared); only purely empty
|
|
174
|
+
# arrays get pruned. If hooks is itself now empty after the
|
|
175
|
+
# loop above, leave it as an empty object — other packs may
|
|
176
|
+
# still target it on a future install.
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
_atomic_write(target_path, data)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Internals
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _load_settings(target_path: Path) -> dict:
|
|
188
|
+
"""Read the settings file. Returns ``{}`` for an absent file;
|
|
189
|
+
raises ``UserMergeRefusal`` with the RFC-0005 unparseable text
|
|
190
|
+
when the file exists but is not valid JSON."""
|
|
191
|
+
if not target_path.exists():
|
|
192
|
+
return {}
|
|
193
|
+
try:
|
|
194
|
+
text = target_path.read_text(encoding="utf-8")
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
raise UserMergeRefusal(
|
|
197
|
+
f"cannot parse {target_path}: {exc}; fix or back up the file and retry"
|
|
198
|
+
) from exc
|
|
199
|
+
if not text.strip():
|
|
200
|
+
return {}
|
|
201
|
+
try:
|
|
202
|
+
data = json.loads(text)
|
|
203
|
+
except json.JSONDecodeError as exc:
|
|
204
|
+
raise UserMergeRefusal(
|
|
205
|
+
f"cannot parse {target_path}: {exc}; fix or back up the file and retry"
|
|
206
|
+
) from exc
|
|
207
|
+
if not isinstance(data, dict):
|
|
208
|
+
# Root is structurally a different file shape than v0.2 ever
|
|
209
|
+
# produced; route through the `cannot parse` text so the
|
|
210
|
+
# refusal aligns with the unparseable case rather than
|
|
211
|
+
# introducing a third dialect of `<key-path> has unexpected
|
|
212
|
+
# shape` (the latter only applies under the `hooks` key path
|
|
213
|
+
# tree, not at the JSON root).
|
|
214
|
+
raise UserMergeRefusal(
|
|
215
|
+
f"cannot parse {target_path}: top-level value is "
|
|
216
|
+
f"{type(data).__name__}, expected object; fix or back up "
|
|
217
|
+
f"the file and retry"
|
|
218
|
+
)
|
|
219
|
+
return data
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _shape_check_hooks(target_path: Path, data: dict) -> None:
|
|
223
|
+
if "hooks" in data and not isinstance(data["hooks"], dict):
|
|
224
|
+
raise UserMergeRefusal(
|
|
225
|
+
f"{target_path}: hooks has unexpected shape {type(data['hooks']).__name__}; "
|
|
226
|
+
f"expected object"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _shape_check_event_array(target_path: Path, event: str, value: object) -> None:
|
|
231
|
+
if not isinstance(value, list):
|
|
232
|
+
raise UserMergeRefusal(
|
|
233
|
+
f"{target_path}: hooks.{event} has unexpected shape {type(value).__name__}; "
|
|
234
|
+
f"expected array"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _merge_one_entry(
|
|
239
|
+
*,
|
|
240
|
+
target_path: Path,
|
|
241
|
+
pack_name: str,
|
|
242
|
+
basename: str,
|
|
243
|
+
event: str,
|
|
244
|
+
event_array: list,
|
|
245
|
+
tagged_entry: dict,
|
|
246
|
+
force_merge: bool,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Append, replace-in-place, or refuse for a single tagged entry.
|
|
249
|
+
|
|
250
|
+
Mutates ``event_array`` in place. Three cases per RFC-0005:
|
|
251
|
+
1. An existing entry with the same ``id`` → replace in place
|
|
252
|
+
(idempotency / reinstall).
|
|
253
|
+
2. An existing entry without ``id`` whose ``command`` matches
|
|
254
|
+
(after whitespace normalisation) → adopter collision. Refuse
|
|
255
|
+
unless ``force_merge`` (then replace in place).
|
|
256
|
+
3. Otherwise → append.
|
|
257
|
+
"""
|
|
258
|
+
incoming_id = tagged_entry["id"]
|
|
259
|
+
incoming_cmd = _normalize_command(tagged_entry.get("command"))
|
|
260
|
+
|
|
261
|
+
for index, existing in enumerate(event_array):
|
|
262
|
+
if not isinstance(existing, dict):
|
|
263
|
+
continue
|
|
264
|
+
if existing.get("id") == incoming_id:
|
|
265
|
+
event_array[index] = tagged_entry
|
|
266
|
+
return
|
|
267
|
+
if existing.get("id") is None:
|
|
268
|
+
existing_cmd = _normalize_command(existing.get("command"))
|
|
269
|
+
if existing_cmd and existing_cmd == incoming_cmd:
|
|
270
|
+
if force_merge:
|
|
271
|
+
event_array[index] = tagged_entry
|
|
272
|
+
return
|
|
273
|
+
raise UserMergeRefusal(
|
|
274
|
+
f"pack {pack_name}'s hook {basename} at event {event} "
|
|
275
|
+
f"appears to be already wired in {target_path}; "
|
|
276
|
+
f"remove the manual entry or pass --force-merge to take "
|
|
277
|
+
f"ownership"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
event_array.append(tagged_entry)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _atomic_write(target_path: Path, data: dict) -> None:
|
|
284
|
+
"""Write *data* to *target_path* via a temp file + rename.
|
|
285
|
+
|
|
286
|
+
The rename is the atomic step on POSIX — readers either see the
|
|
287
|
+
old file or the fully-written new file, never a partial. Uses
|
|
288
|
+
``tempfile.NamedTemporaryFile`` in the target's parent so the
|
|
289
|
+
rename stays on the same filesystem (cross-filesystem rename is
|
|
290
|
+
a copy, not atomic). The serialiser writes pretty JSON with
|
|
291
|
+
2-space indent so the file diffs cleanly under version control —
|
|
292
|
+
adopters who track ``~/.claude/settings.json`` in a dotfiles repo
|
|
293
|
+
are the load-bearing audience here.
|
|
294
|
+
"""
|
|
295
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
serialised = json.dumps(data, indent=2, sort_keys=False) + "\n"
|
|
297
|
+
with tempfile.NamedTemporaryFile(
|
|
298
|
+
mode="w",
|
|
299
|
+
encoding="utf-8",
|
|
300
|
+
dir=str(target_path.parent),
|
|
301
|
+
prefix=target_path.name + ".",
|
|
302
|
+
suffix=".tmp",
|
|
303
|
+
delete=False,
|
|
304
|
+
) as tmp:
|
|
305
|
+
tmp.write(serialised)
|
|
306
|
+
tmp.flush()
|
|
307
|
+
os.fsync(tmp.fileno())
|
|
308
|
+
tmp_path = Path(tmp.name)
|
|
309
|
+
tmp_path.replace(target_path)
|
|
310
|
+
# fsync the parent directory so the rename's directory entry hits
|
|
311
|
+
# disk. Without this, a power loss between `replace()` and the
|
|
312
|
+
# directory entry being flushed can leave the target absent
|
|
313
|
+
# despite the rename appearing to succeed — the same byte-stability
|
|
314
|
+
# concern AC9 / AC13 pin via their "file unchanged on refusal"
|
|
315
|
+
# contracts. Tolerate OSError on platforms where dir-fsync is a
|
|
316
|
+
# no-op (some macOS configurations).
|
|
317
|
+
try:
|
|
318
|
+
dir_fd = os.open(str(target_path.parent), os.O_RDONLY)
|
|
319
|
+
try:
|
|
320
|
+
os.fsync(dir_fd)
|
|
321
|
+
finally:
|
|
322
|
+
os.close(dir_fd)
|
|
323
|
+
except OSError:
|
|
324
|
+
pass
|