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,256 @@
|
|
|
1
|
+
"""``kiro-ide-hook`` projection — direct-file copy + ``then.command``
|
|
2
|
+
placeholder expansion (RFC-0005, v0.4).
|
|
3
|
+
|
|
4
|
+
The projection mode is ``direct-file`` byte-for-byte for askAgent-shaped
|
|
5
|
+
hooks (no scan surface) and parse-modify-emit for runCommand-shaped
|
|
6
|
+
hooks that contain ``${hook-body:<name>}`` placeholders in
|
|
7
|
+
``then.command``. RFC-0005 § Substitution rules pins the placeholder
|
|
8
|
+
mechanics:
|
|
9
|
+
|
|
10
|
+
1. Scan surface — ``then.command`` only. Every other field in the
|
|
11
|
+
``.kiro.hook`` JSON (``then.prompt``, ``when.patterns``, ``name``,
|
|
12
|
+
``description``, …) passes through verbatim.
|
|
13
|
+
2. Verbatim substitution — no shell quoting; pack authors quote
|
|
14
|
+
placeholders themselves.
|
|
15
|
+
3. Multiple placeholders allowed; single-pass resolution. Resolved
|
|
16
|
+
text is NOT re-scanned.
|
|
17
|
+
4. Placeholder grammar — strict regex ``\\$\\{hook-body:[a-zA-Z0-9_-]+\\}``.
|
|
18
|
+
Validated upstream at ``validate`` time (T-C2's
|
|
19
|
+
``check_kiro_ide_hook`` rail in ``scope_rails.py``); a malformed
|
|
20
|
+
placeholder reaching the projector is a defense-in-depth refusal.
|
|
21
|
+
5. Unresolvable references refuse — same defense-in-depth.
|
|
22
|
+
|
|
23
|
+
The output path's ``<pack>`` placeholder resolves to the source
|
|
24
|
+
pack's directory name; the ``<name>`` placeholder resolves to the
|
|
25
|
+
``.kiro.hook`` file's bare name (extension stripped). Both are
|
|
26
|
+
substituted into the contract-declared ``target.repo`` template.
|
|
27
|
+
|
|
28
|
+
This module is stdlib-only — ``json`` + ``re`` + ``shutil`` +
|
|
29
|
+
``pathlib``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import shutil
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class KiroIdeHookRefusal(Exception):
|
|
41
|
+
"""Raised when projection refuses to write.
|
|
42
|
+
|
|
43
|
+
All reachable paths are defense-in-depth (the validate rail
|
|
44
|
+
refuses these cases upstream); the exception only fires when
|
|
45
|
+
a caller skipped validate or supplied a malformed pack
|
|
46
|
+
directly to the projector.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Strict placeholder grammar — same regex as the validate rail.
|
|
51
|
+
# Kept inline rather than imported so this module's contract is
|
|
52
|
+
# self-evident from its source; the rail's regex carries the same
|
|
53
|
+
# grammar.
|
|
54
|
+
_HOOK_BODY_PLACEHOLDER_RE = re.compile(r"\$\{hook-body:([a-zA-Z0-9_-]+)\}")
|
|
55
|
+
|
|
56
|
+
# Loose `${...}` matcher; an offender that matches this but not the
|
|
57
|
+
# strict regex above is a malformed placeholder.
|
|
58
|
+
_ANY_PLACEHOLDER_RE = re.compile(r"\$\{[^}]*\}")
|
|
59
|
+
|
|
60
|
+
# `.kiro.hook` is a compound extension; pathlib treats ``.hook`` as
|
|
61
|
+
# the suffix and ``.kiro`` as the prior segment. Endswith-check on
|
|
62
|
+
# the literal extension is more readable than juggling ``suffixes``.
|
|
63
|
+
_KIRO_HOOK_EXTENSION = ".kiro.hook"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def project(
|
|
67
|
+
pack_path: Path,
|
|
68
|
+
output_root: Path,
|
|
69
|
+
target_template: str,
|
|
70
|
+
hook_body_target_dir: str,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Project every ``.apm/kiro-ide-hooks/<name>.kiro.hook`` under
|
|
73
|
+
*pack_path* into *output_root* per the *target_template* shape.
|
|
74
|
+
|
|
75
|
+
Arguments:
|
|
76
|
+
pack_path: pack source root.
|
|
77
|
+
output_root: where to write projected files (the per-pack base
|
|
78
|
+
the caller passes in — typically ``<dist>/<pack>/`` or the
|
|
79
|
+
repo root for ``make build --self``).
|
|
80
|
+
target_template: contract-declared
|
|
81
|
+
``[adapter.kiro.projections.kiro-ide-hook].target.repo``,
|
|
82
|
+
e.g. ``".kiro/hooks/<pack>/<name>.kiro.hook"``. The
|
|
83
|
+
``<pack>`` and ``<name>`` placeholders resolve at projection
|
|
84
|
+
time. The pre-bump v0.3 contract carries no such field
|
|
85
|
+
(the v0.4 declaration is probe-gated, T-CONTRACT); callers
|
|
86
|
+
targeting v0.3 contracts simply don't invoke this projector.
|
|
87
|
+
hook_body_target_dir: the projected hook-body directory for
|
|
88
|
+
same-pack ``${hook-body:<name>}`` references — e.g.
|
|
89
|
+
``"tools/hooks"`` at repo scope (the Kiro adapter's legacy
|
|
90
|
+
``[[adapter.kiro.projection]] primitive = "hook-body"``
|
|
91
|
+
target). Resolved placeholders emit
|
|
92
|
+
``./{hook_body_target_dir}/<actual-filename>``.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
KiroIdeHookRefusal: on JSON parse failure, on a malformed
|
|
96
|
+
placeholder reaching projection, or on an unresolvable
|
|
97
|
+
placeholder. All three are validate-rail-covered cases;
|
|
98
|
+
the projector refuses defensively rather than emitting a
|
|
99
|
+
silently-wrong artifact.
|
|
100
|
+
"""
|
|
101
|
+
source_dir = pack_path / ".apm" / "kiro-ide-hooks"
|
|
102
|
+
if not source_dir.exists():
|
|
103
|
+
return
|
|
104
|
+
pack_name = pack_path.name
|
|
105
|
+
hook_body_files = _collect_hook_body_files(pack_path)
|
|
106
|
+
|
|
107
|
+
for entry in sorted(source_dir.iterdir()):
|
|
108
|
+
if not entry.name.endswith(_KIRO_HOOK_EXTENSION):
|
|
109
|
+
continue
|
|
110
|
+
if not entry.is_file() or entry.is_symlink():
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
bare_name = entry.name[: -len(_KIRO_HOOK_EXTENSION)]
|
|
114
|
+
if not bare_name:
|
|
115
|
+
# A file named exactly `.kiro.hook` would land as a dotfile
|
|
116
|
+
# at `.kiro/hooks/<pack>/.kiro.hook` and collide with any
|
|
117
|
+
# other dotfile in the directory. Defense-in-depth refusal —
|
|
118
|
+
# the validate rail catches this upstream too.
|
|
119
|
+
raise KiroIdeHookRefusal(
|
|
120
|
+
f"pack {pack_name}'s kiro-ide-hook entry has an "
|
|
121
|
+
f"empty bare name; expected <name>.kiro.hook with "
|
|
122
|
+
f"<name> non-empty"
|
|
123
|
+
)
|
|
124
|
+
resolved_target = (
|
|
125
|
+
target_template
|
|
126
|
+
.replace("<pack>", pack_name)
|
|
127
|
+
.replace("<name>", bare_name)
|
|
128
|
+
)
|
|
129
|
+
target_path = output_root / resolved_target.lstrip("/")
|
|
130
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
|
|
132
|
+
raw_bytes = entry.read_bytes()
|
|
133
|
+
|
|
134
|
+
# askAgent byte-copy shortcut. RFC's placeholder grammar uses
|
|
135
|
+
# `${` as the unambiguous prefix; if the raw file carries no
|
|
136
|
+
# such substring AND the parsed JSON's then.type is askAgent,
|
|
137
|
+
# the file has no expansion work to do and a byte copy
|
|
138
|
+
# preserves the source's key order, whitespace, and trailing
|
|
139
|
+
# newline.
|
|
140
|
+
if b"${" not in raw_bytes:
|
|
141
|
+
try:
|
|
142
|
+
parsed = json.loads(raw_bytes.decode("utf-8"))
|
|
143
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
144
|
+
raise KiroIdeHookRefusal(
|
|
145
|
+
f"pack {pack_name}'s kiro-ide-hook {entry.name} "
|
|
146
|
+
f"failed to parse: {exc}"
|
|
147
|
+
)
|
|
148
|
+
if (
|
|
149
|
+
isinstance(parsed, dict)
|
|
150
|
+
and isinstance(parsed.get("then"), dict)
|
|
151
|
+
and parsed["then"].get("type") == "askAgent"
|
|
152
|
+
):
|
|
153
|
+
shutil.copy2(entry, target_path, follow_symlinks=False)
|
|
154
|
+
continue
|
|
155
|
+
# Non-askAgent without placeholders — also byte-copy. No
|
|
156
|
+
# scan surface; nothing to rewrite.
|
|
157
|
+
shutil.copy2(entry, target_path, follow_symlinks=False)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Otherwise: parse, expand, re-emit. The parse step also
|
|
161
|
+
# catches malformed JSON the validate rail would already have
|
|
162
|
+
# refused.
|
|
163
|
+
try:
|
|
164
|
+
body = json.loads(raw_bytes.decode("utf-8"))
|
|
165
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
166
|
+
raise KiroIdeHookRefusal(
|
|
167
|
+
f"pack {pack_name}'s kiro-ide-hook {entry.name} "
|
|
168
|
+
f"failed to parse: {exc}"
|
|
169
|
+
)
|
|
170
|
+
if not isinstance(body, dict):
|
|
171
|
+
raise KiroIdeHookRefusal(
|
|
172
|
+
f"pack {pack_name}'s kiro-ide-hook {entry.name} "
|
|
173
|
+
f"is not a JSON object"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
then = body.get("then")
|
|
177
|
+
command = then.get("command") if isinstance(then, dict) else None
|
|
178
|
+
if isinstance(command, str):
|
|
179
|
+
new_command = _expand_placeholders(
|
|
180
|
+
command,
|
|
181
|
+
pack_name=pack_name,
|
|
182
|
+
file_name=entry.name,
|
|
183
|
+
hook_body_files=hook_body_files,
|
|
184
|
+
hook_body_target_dir=hook_body_target_dir.rstrip("/"),
|
|
185
|
+
)
|
|
186
|
+
then["command"] = new_command
|
|
187
|
+
|
|
188
|
+
# Emit with stable formatting — `indent=2` matches the
|
|
189
|
+
# fixtures' shape and the RFC's example, `sort_keys=False`
|
|
190
|
+
# preserves source ordering best-effort, trailing newline for
|
|
191
|
+
# POSIX-friendliness.
|
|
192
|
+
target_path.write_text(
|
|
193
|
+
json.dumps(body, indent=2, sort_keys=False) + "\n",
|
|
194
|
+
encoding="utf-8",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _collect_hook_body_files(pack_path: Path) -> dict[str, str]:
|
|
199
|
+
"""Return basename → filename for every hook-body the pack ships.
|
|
200
|
+
|
|
201
|
+
e.g. ``{"lint": "lint.py", "format": "format.sh"}``. Used by
|
|
202
|
+
placeholder resolution to emit the actual extension. Symlinks
|
|
203
|
+
silently skipped (Rail B is the gate for symlinked hook-bodies
|
|
204
|
+
at user scope; at repo scope the safer default is to ignore).
|
|
205
|
+
"""
|
|
206
|
+
out: dict[str, str] = {}
|
|
207
|
+
hook_body_dir = pack_path / ".apm" / "hooks"
|
|
208
|
+
if not hook_body_dir.exists():
|
|
209
|
+
return out
|
|
210
|
+
for entry in sorted(hook_body_dir.iterdir()):
|
|
211
|
+
if entry.is_symlink():
|
|
212
|
+
continue
|
|
213
|
+
if entry.is_file():
|
|
214
|
+
out[entry.stem] = entry.name
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _expand_placeholders(
|
|
219
|
+
command: str,
|
|
220
|
+
*,
|
|
221
|
+
pack_name: str,
|
|
222
|
+
file_name: str,
|
|
223
|
+
hook_body_files: dict[str, str],
|
|
224
|
+
hook_body_target_dir: str,
|
|
225
|
+
) -> str:
|
|
226
|
+
"""Single-pass placeholder expansion against ``then.command``.
|
|
227
|
+
|
|
228
|
+
Refuses (defense-in-depth) on malformed or unresolvable
|
|
229
|
+
placeholders even though the validate rail covered these
|
|
230
|
+
upstream. Resolved text is NOT re-scanned (RFC § Substitution
|
|
231
|
+
rules clause 3) — single pass via ``re.sub``.
|
|
232
|
+
"""
|
|
233
|
+
# Defense-in-depth check 1 — malformed placeholder.
|
|
234
|
+
for match in _ANY_PLACEHOLDER_RE.finditer(command):
|
|
235
|
+
literal = match.group(0)
|
|
236
|
+
if not _HOOK_BODY_PLACEHOLDER_RE.fullmatch(literal):
|
|
237
|
+
raise KiroIdeHookRefusal(
|
|
238
|
+
f"pack {pack_name}'s kiro-ide-hook {file_name} "
|
|
239
|
+
f"contains malformed placeholder '{literal}'; "
|
|
240
|
+
f"expected ${{hook-body:<name>}} with name "
|
|
241
|
+
f"matching [a-zA-Z0-9_-]+"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _resolve(match: re.Match[str]) -> str:
|
|
245
|
+
name = match.group(1)
|
|
246
|
+
filename = hook_body_files.get(name)
|
|
247
|
+
if filename is None:
|
|
248
|
+
raise KiroIdeHookRefusal(
|
|
249
|
+
f"pack {pack_name}'s kiro-ide-hook {file_name} "
|
|
250
|
+
f"references unknown hook-body "
|
|
251
|
+
f"'${{hook-body:{name}}}'; no such hook-body "
|
|
252
|
+
f"in pack"
|
|
253
|
+
)
|
|
254
|
+
return f"./{hook_body_target_dir}/{filename}"
|
|
255
|
+
|
|
256
|
+
return _HOOK_BODY_PLACEHOLDER_RE.sub(_resolve, command)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""``merge-into-agent-json`` projection mode — Kiro at both scopes.
|
|
2
|
+
|
|
3
|
+
Merges a pack's ``.apm/hook-wiring/*.toml`` content into a **pack-owned**
|
|
4
|
+
agent JSON at ``<scope-root>/.kiro/agents/<attach-to-agent>.json`` under
|
|
5
|
+
the ``hooks`` key. The mode reuses ``user-merge-json``'s
|
|
6
|
+
array-append-with-id discipline, with structural differences from the
|
|
7
|
+
Claude-Code-user-scope shape:
|
|
8
|
+
|
|
9
|
+
1. **Target is pack-owned, not adopter-shared.** Adopter hand-edits to
|
|
10
|
+
the agent JSON are squatting on a managed surface — the next upgrade
|
|
11
|
+
replaces the file via the agent primitive's ``direct-file``
|
|
12
|
+
projection. No collision detection, no ``--force-merge`` flag.
|
|
13
|
+
2. **Agent file must exist before merge runs.** RFC-0005 establishes
|
|
14
|
+
the build-pipeline ordering invariant — agents project before any
|
|
15
|
+
wiring merges run. The absent-file case is a refuse-with-internal-
|
|
16
|
+
error path, exercised only via test instrumentation.
|
|
17
|
+
3. **Per-agent target, scope-conditional.** Each wiring TOML targets
|
|
18
|
+
a single agent named by its ``attach-to-agent`` field. The caller
|
|
19
|
+
(T8b) is responsible for resolving the target file path; this
|
|
20
|
+
module operates on the resolved path.
|
|
21
|
+
|
|
22
|
+
This module is stdlib-only — ``json`` + ``pathlib`` + ``tempfile``.
|
|
23
|
+
|
|
24
|
+
T8b will own the install/uninstall CLI threading; T7 enforces the
|
|
25
|
+
pipeline ordering in the iterator. T6 (this module) ships the merge
|
|
26
|
+
engine plus the per-adapter event-vocabulary rail used by
|
|
27
|
+
``commands/validate.py``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import tempfile
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from agentbundle.build.projections.hook_id import synthesize_id
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AgentJsonRefusal(Exception):
|
|
41
|
+
"""Raised when ``project`` / ``unproject`` refuses to write.
|
|
42
|
+
|
|
43
|
+
The exception's string is the refuse-and-explain text RFC-0005
|
|
44
|
+
specifies. CLI callers (T8b) catch and print to stderr without
|
|
45
|
+
paraphrasing.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def project(
|
|
50
|
+
target_path: Path,
|
|
51
|
+
pack_name: str,
|
|
52
|
+
wiring_tomls: dict[str, dict],
|
|
53
|
+
) -> list[tuple[str, str]]:
|
|
54
|
+
"""Merge *wiring_tomls* into the agent JSON at *target_path*.
|
|
55
|
+
|
|
56
|
+
Arguments:
|
|
57
|
+
target_path: resolved path to the agent JSON. **Must exist** —
|
|
58
|
+
the build pipeline (T7) projects agents before wiring runs.
|
|
59
|
+
Absent → refuse with the ``internal:`` text.
|
|
60
|
+
pack_name: the pack's ``[pack].name``. Substituted into id tags.
|
|
61
|
+
wiring_tomls: map of wiring TOML basename (no ``.toml``) → parsed
|
|
62
|
+
TOML body (typically ``{"attach-to-agent": "<name>", "hooks":
|
|
63
|
+
{"<Event>": [entries]}}``). The ``attach-to-agent`` field is
|
|
64
|
+
not consumed here — the caller has already resolved
|
|
65
|
+
*target_path* using it.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
List of ``(event, id)`` tuples reflecting every owned entry
|
|
69
|
+
written. T8b records these in the state file's
|
|
70
|
+
``hook-wiring-owned`` table so ``unproject`` can be precise.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
AgentJsonRefusal: missing agent file (pipeline-ordering
|
|
74
|
+
violation), unparseable JSON, wrong-shape ``hooks`` or
|
|
75
|
+
``hooks.<event>``.
|
|
76
|
+
"""
|
|
77
|
+
if not target_path.exists():
|
|
78
|
+
# The text below extends RFC-0005's bare `internal: <agent-file>
|
|
79
|
+
# missing` shape with diagnostic context. This is intentional —
|
|
80
|
+
# the message is a CLI-internal-error string, not an
|
|
81
|
+
# adopter-facing contract, so we trade brevity for the breadcrumb
|
|
82
|
+
# "agent must project before wiring" that points at the
|
|
83
|
+
# pipeline-ordering invariant. Tests assert the substrings
|
|
84
|
+
# `internal:` / `missing` / `agent must project before wiring`
|
|
85
|
+
# to keep T8b's CLI handler portable across text refinements.
|
|
86
|
+
raise AgentJsonRefusal(
|
|
87
|
+
f"internal: {target_path} missing at hook-wiring merge time; "
|
|
88
|
+
f"agent must project before wiring"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
data = _load_agent_json(target_path)
|
|
92
|
+
_shape_check_hooks(target_path, data)
|
|
93
|
+
|
|
94
|
+
owned: list[tuple[str, str]] = []
|
|
95
|
+
for basename, body in wiring_tomls.items():
|
|
96
|
+
entry_id = synthesize_id(pack_name, basename)
|
|
97
|
+
hooks_in_wiring = body.get("hooks", {}) if isinstance(body, dict) else {}
|
|
98
|
+
if not isinstance(hooks_in_wiring, dict):
|
|
99
|
+
continue
|
|
100
|
+
for event, incoming_entries in hooks_in_wiring.items():
|
|
101
|
+
if not isinstance(incoming_entries, list):
|
|
102
|
+
continue
|
|
103
|
+
data.setdefault("hooks", {})
|
|
104
|
+
data["hooks"].setdefault(event, [])
|
|
105
|
+
event_array = data["hooks"][event]
|
|
106
|
+
_shape_check_event_array(target_path, event, event_array)
|
|
107
|
+
for incoming in incoming_entries:
|
|
108
|
+
if not isinstance(incoming, dict):
|
|
109
|
+
continue
|
|
110
|
+
tagged = dict(incoming)
|
|
111
|
+
tagged["id"] = entry_id
|
|
112
|
+
_merge_one_entry(event_array, tagged)
|
|
113
|
+
owned.append((event, entry_id))
|
|
114
|
+
|
|
115
|
+
_atomic_write(target_path, data)
|
|
116
|
+
# Deduplicate (event, id) tuples — see T5's note in user_merge_json.
|
|
117
|
+
seen: set[tuple[str, str]] = set()
|
|
118
|
+
result: list[tuple[str, str]] = []
|
|
119
|
+
for item in owned:
|
|
120
|
+
if item not in seen:
|
|
121
|
+
seen.add(item)
|
|
122
|
+
result.append(item)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def unproject(target_path: Path, owned: list[tuple[str, str]]) -> None:
|
|
127
|
+
"""Remove every ``(event, id)`` pair in *owned* from *target_path*.
|
|
128
|
+
|
|
129
|
+
Empty ``hooks.<event>`` arrays are removed. The agent file itself
|
|
130
|
+
is **never** removed by this function — that's the agent
|
|
131
|
+
primitive's ``direct-file`` uninstall's responsibility (RFC-0005
|
|
132
|
+
§ Conflict, idempotency, uninstall).
|
|
133
|
+
|
|
134
|
+
If the target file is absent, ``unproject`` is a no-op — same
|
|
135
|
+
rationale as T5's ``user_merge_json.unproject``: a state row
|
|
136
|
+
pointing at a now-absent file is an orphan-in-state condition that
|
|
137
|
+
T9's reconcile surfaces. Refusing here would block uninstall of
|
|
138
|
+
unrelated packs whose own target files happen to be absent.
|
|
139
|
+
"""
|
|
140
|
+
if not target_path.exists():
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
data = _load_agent_json(target_path)
|
|
144
|
+
_shape_check_hooks(target_path, data)
|
|
145
|
+
|
|
146
|
+
hooks = data.get("hooks")
|
|
147
|
+
if not isinstance(hooks, dict):
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
owned_by_event: dict[str, set[str]] = {}
|
|
151
|
+
for event, entry_id in owned:
|
|
152
|
+
owned_by_event.setdefault(event, set()).add(entry_id)
|
|
153
|
+
|
|
154
|
+
for event, ids_to_remove in owned_by_event.items():
|
|
155
|
+
if event not in hooks:
|
|
156
|
+
continue
|
|
157
|
+
event_array = hooks[event]
|
|
158
|
+
if not isinstance(event_array, list):
|
|
159
|
+
continue
|
|
160
|
+
hooks[event] = [
|
|
161
|
+
e for e in event_array
|
|
162
|
+
if not (isinstance(e, dict) and e.get("id") in ids_to_remove)
|
|
163
|
+
]
|
|
164
|
+
if not hooks[event]:
|
|
165
|
+
del hooks[event]
|
|
166
|
+
|
|
167
|
+
_atomic_write(target_path, data)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Internals
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _load_agent_json(target_path: Path) -> dict:
|
|
176
|
+
"""Read the agent JSON. Raises ``AgentJsonRefusal`` on
|
|
177
|
+
unparseable content (matches T5's `user_merge_json` shape)."""
|
|
178
|
+
try:
|
|
179
|
+
text = target_path.read_text(encoding="utf-8")
|
|
180
|
+
except OSError as exc:
|
|
181
|
+
raise AgentJsonRefusal(
|
|
182
|
+
f"cannot parse {target_path}: {exc}; fix or back up the file and retry"
|
|
183
|
+
) from exc
|
|
184
|
+
if not text.strip():
|
|
185
|
+
# An empty agent JSON file is a Kiro-side artifact (an agent
|
|
186
|
+
# body with no fields yet) — treat as `{}` for merge.
|
|
187
|
+
return {}
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(text)
|
|
190
|
+
except json.JSONDecodeError as exc:
|
|
191
|
+
raise AgentJsonRefusal(
|
|
192
|
+
f"cannot parse {target_path}: {exc}; fix or back up the file and retry"
|
|
193
|
+
) from exc
|
|
194
|
+
if not isinstance(data, dict):
|
|
195
|
+
raise AgentJsonRefusal(
|
|
196
|
+
f"cannot parse {target_path}: top-level value is "
|
|
197
|
+
f"{type(data).__name__}, expected object; fix or back up "
|
|
198
|
+
f"the file and retry"
|
|
199
|
+
)
|
|
200
|
+
return data
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _shape_check_hooks(target_path: Path, data: dict) -> None:
|
|
204
|
+
if "hooks" in data and not isinstance(data["hooks"], dict):
|
|
205
|
+
raise AgentJsonRefusal(
|
|
206
|
+
f"{target_path}: hooks has unexpected shape {type(data['hooks']).__name__}; "
|
|
207
|
+
f"expected object"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _shape_check_event_array(target_path: Path, event: str, value: object) -> None:
|
|
212
|
+
if not isinstance(value, list):
|
|
213
|
+
raise AgentJsonRefusal(
|
|
214
|
+
f"{target_path}: hooks.{event} has unexpected shape {type(value).__name__}; "
|
|
215
|
+
f"expected array"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _merge_one_entry(event_array: list, tagged_entry: dict) -> None:
|
|
220
|
+
"""Append or replace-in-place by id.
|
|
221
|
+
|
|
222
|
+
No adopter-collision branch: the agent JSON is pack-owned (RFC-0005
|
|
223
|
+
§ What this section does NOT add — no ``--force-merge`` for Kiro).
|
|
224
|
+
Adopter hand-edits to the agent file are squatting on a managed
|
|
225
|
+
surface; the next upgrade replaces the file via the agent
|
|
226
|
+
primitive's ``direct-file`` projection.
|
|
227
|
+
"""
|
|
228
|
+
incoming_id = tagged_entry["id"]
|
|
229
|
+
for index, existing in enumerate(event_array):
|
|
230
|
+
if isinstance(existing, dict) and existing.get("id") == incoming_id:
|
|
231
|
+
event_array[index] = tagged_entry
|
|
232
|
+
return
|
|
233
|
+
event_array.append(tagged_entry)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _atomic_write(target_path: Path, data: dict) -> None:
|
|
237
|
+
"""Write *data* to *target_path* via temp + rename + dir-fsync.
|
|
238
|
+
|
|
239
|
+
Same shape as T5's ``user_merge_json._atomic_write`` — see there
|
|
240
|
+
for the directory-fsync rationale.
|
|
241
|
+
"""
|
|
242
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
serialised = json.dumps(data, indent=2, sort_keys=False) + "\n"
|
|
244
|
+
with tempfile.NamedTemporaryFile(
|
|
245
|
+
mode="w",
|
|
246
|
+
encoding="utf-8",
|
|
247
|
+
dir=str(target_path.parent),
|
|
248
|
+
prefix=target_path.name + ".",
|
|
249
|
+
suffix=".tmp",
|
|
250
|
+
delete=False,
|
|
251
|
+
) as tmp:
|
|
252
|
+
tmp.write(serialised)
|
|
253
|
+
tmp.flush()
|
|
254
|
+
os.fsync(tmp.fileno())
|
|
255
|
+
tmp_path = Path(tmp.name)
|
|
256
|
+
tmp_path.replace(target_path)
|
|
257
|
+
try:
|
|
258
|
+
dir_fd = os.open(str(target_path.parent), os.O_RDONLY)
|
|
259
|
+
try:
|
|
260
|
+
os.fsync(dir_fd)
|
|
261
|
+
finally:
|
|
262
|
+
os.close(dir_fd)
|
|
263
|
+
except OSError:
|
|
264
|
+
pass
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shared `merge-json` projection — claude-code's settings.local.json
|
|
2
|
+
and codex's hooks.json share this implementation.
|
|
3
|
+
|
|
4
|
+
Both adapters' hook-wiring lands in a JSON file with the same
|
|
5
|
+
``{ "<managed-key>": { "<event>": [...handlers...] } }`` shape; the
|
|
6
|
+
build-pipeline dispatcher in each adapter calls this function for any
|
|
7
|
+
projection rule with ``mode == "merge-json"``.
|
|
8
|
+
|
|
9
|
+
Originally private to ``adapters/claude_code.py`` as
|
|
10
|
+
``_project_merge_json``; lifted to this sibling module by
|
|
11
|
+
docs/specs/dropped-primitives-coverage (T2) so codex.py can reuse the
|
|
12
|
+
exact same code path without re-implementing.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import tomllib
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def project_merge_json(source_dir: Path, output_root: Path, rule: dict) -> None:
|
|
24
|
+
"""Merge TOML hook-wiring source files into a JSON target file.
|
|
25
|
+
|
|
26
|
+
Reads every ``*.toml`` under ``source_dir`` (sorted), pulls the
|
|
27
|
+
payload at ``rule["managed-key"]`` (default ``"hooks"``), and
|
|
28
|
+
merges into ``output_root / rule["target-path"]``'s managed key.
|
|
29
|
+
Existing non-managed keys in the JSON target are preserved.
|
|
30
|
+
|
|
31
|
+
Output is serialised with ``indent=2, sort_keys=True`` and a
|
|
32
|
+
trailing newline — idempotent across re-runs.
|
|
33
|
+
"""
|
|
34
|
+
target_path = output_root / rule["target-path"].lstrip("/")
|
|
35
|
+
managed_key = rule.get("managed-key", "hooks")
|
|
36
|
+
|
|
37
|
+
incoming: dict[str, Any] = {}
|
|
38
|
+
for entry in sorted(source_dir.iterdir()):
|
|
39
|
+
if entry.is_file() and entry.suffix == ".toml":
|
|
40
|
+
payload = tomllib.loads(entry.read_text(encoding="utf-8"))
|
|
41
|
+
for key, value in payload.get(managed_key, {}).items():
|
|
42
|
+
incoming[key] = value
|
|
43
|
+
if not incoming:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
existing: dict[str, Any] = {}
|
|
47
|
+
if target_path.exists():
|
|
48
|
+
existing = json.loads(target_path.read_text(encoding="utf-8"))
|
|
49
|
+
|
|
50
|
+
merged = dict(existing.get(managed_key, {}))
|
|
51
|
+
merged.update(incoming)
|
|
52
|
+
existing[managed_key] = merged
|
|
53
|
+
|
|
54
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
target_path.write_text(
|
|
56
|
+
json.dumps(existing, indent=2, sort_keys=True) + "\n",
|
|
57
|
+
encoding="utf-8",
|
|
58
|
+
)
|