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