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,336 @@
|
|
|
1
|
+
"""T6: adapter-root-bins/ build-pipeline primitive class.
|
|
2
|
+
|
|
3
|
+
Source rule: ``packs/<pack>/.apm/adapter-root-bins/*.py``.
|
|
4
|
+
Target rule (self-host, repo scope): project each
|
|
5
|
+
``adapter-root-bins/*.py`` file byte-identically to
|
|
6
|
+
``<working_tree>/.agentbundle/bin/<basename>.py`` with POSIX mode
|
|
7
|
+
``0o755`` (Windows inherits the parent DACL — no explicit chmod).
|
|
8
|
+
At user-scope install time the install command projects the same
|
|
9
|
+
files to ``$HOME/.agentbundle/bin/<basename>.py``; that surface is
|
|
10
|
+
the install command's responsibility, not this module's.
|
|
11
|
+
|
|
12
|
+
This module owns both halves of the build-pipeline contract for the
|
|
13
|
+
new primitive class:
|
|
14
|
+
|
|
15
|
+
- ``apply_projection(working_tree, packs_dir)`` — write the files.
|
|
16
|
+
Called by ``make build-self``.
|
|
17
|
+
- ``check_drift(working_tree, packs_dir)`` — read-only gate. Returns
|
|
18
|
+
a list of drift descriptions (empty list == clean). Three outcomes
|
|
19
|
+
per RFC-0013 § 4d / spec AC22-AC23:
|
|
20
|
+
* **modified** — projected file exists but bytes diverge from source
|
|
21
|
+
* **missing** — source exists but projected file absent
|
|
22
|
+
* **orphaned** — projected file present but source has been removed
|
|
23
|
+
|
|
24
|
+
Inter-pack basename collision is a hard error at ``collect_sources``
|
|
25
|
+
time. v1 ships exactly one source (``sso-broker.py`` in
|
|
26
|
+
``credential-brokers``); the rail guards against a future collision.
|
|
27
|
+
|
|
28
|
+
Path-jail compliance: the target (``.agentbundle/``) is fenced by the
|
|
29
|
+
v0.7 contract's ``allowed-prefixes.repo`` for the three user-scope
|
|
30
|
+
adapters (``claude-code``, ``kiro``, ``codex``). The projection writes
|
|
31
|
+
under that prefix and never anywhere else; no PATH manipulation, no
|
|
32
|
+
shell-config edits.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import shutil
|
|
39
|
+
import stat
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from . import shared_libs
|
|
44
|
+
|
|
45
|
+
# Pin the source path so a downstream consumer that wants to enumerate
|
|
46
|
+
# sources doesn't hardcode the literal repeatedly.
|
|
47
|
+
SOURCE_SUBDIR = ".apm/adapter-root-bins"
|
|
48
|
+
|
|
49
|
+
# Target subtree under the per-scope artifact root. Mirrors the
|
|
50
|
+
# `~/.agentbundle/bin/` path at user scope (install-time surface).
|
|
51
|
+
TARGET_SUBDIR = Path(".agentbundle") / "bin"
|
|
52
|
+
|
|
53
|
+
# POSIX mode bits applied after copy. AC22 pins 0o755; Windows
|
|
54
|
+
# inherits the DACL from %USERPROFILE% (no explicit chmod call).
|
|
55
|
+
EXECUTABLE_MODE = 0o755
|
|
56
|
+
|
|
57
|
+
# AC22b: shim-companion projection. When a pack ships both
|
|
58
|
+
# adapter-root-bins/ and shared-libs/credentials_shim.py, the shim is
|
|
59
|
+
# projected as a sibling under `bin/` so that per-platform Tier-2
|
|
60
|
+
# backend modules under adapter-root-bins/ (e.g. _sso_keychain_macos.py)
|
|
61
|
+
# can resolve `from .credentials_shim import Tier2HardFailError`.
|
|
62
|
+
SHIM_COMPANION_BASENAME = "credentials_shim.py"
|
|
63
|
+
|
|
64
|
+
# AC22b content-grep trigger. Any *.py under adapter-root-bins/ whose
|
|
65
|
+
# bytes contain this literal substring is considered shim-dependent;
|
|
66
|
+
# the pack must then ship .apm/shared-libs/credentials_shim.py or the
|
|
67
|
+
# build hard-errors. Literal-substring match has a documented
|
|
68
|
+
# false-positive surface (a docstring quoting the line); accepted for
|
|
69
|
+
# v1 because the failure mode is benign (the shim is projected
|
|
70
|
+
# unnecessarily — no functional or security regression). AST-walk is
|
|
71
|
+
# the documented tightening path.
|
|
72
|
+
SHIM_IMPORT_GREP = b"from .credentials_shim import"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class AdapterRootBinProjection:
|
|
77
|
+
"""One concrete projection: copy ``source`` to ``target``."""
|
|
78
|
+
|
|
79
|
+
source: Path
|
|
80
|
+
target: Path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def collect_sources(packs_dir: Path) -> dict[str, Path]:
|
|
84
|
+
"""Return ``{basename → source_path}`` for every
|
|
85
|
+
``.apm/adapter-root-bins/*.py`` file across every pack.
|
|
86
|
+
|
|
87
|
+
Raises ``ValueError`` on inter-pack basename collision — two
|
|
88
|
+
packs shipping the same basename produces non-deterministic
|
|
89
|
+
projection order and silent overwrites; refuse hard at
|
|
90
|
+
enumeration time.
|
|
91
|
+
"""
|
|
92
|
+
sources: dict[str, Path] = {}
|
|
93
|
+
for pack in sorted(packs_dir.iterdir()):
|
|
94
|
+
if not pack.is_dir() or not (pack / "pack.toml").exists():
|
|
95
|
+
continue
|
|
96
|
+
bins = pack / SOURCE_SUBDIR
|
|
97
|
+
if not bins.is_dir():
|
|
98
|
+
continue
|
|
99
|
+
for src in sorted(bins.glob("*.py")):
|
|
100
|
+
if src.name in sources:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"adapter-root-bins collision: '{src.name}' shipped by both "
|
|
103
|
+
f"{sources[src.name]} and {src}"
|
|
104
|
+
)
|
|
105
|
+
sources[src.name] = src
|
|
106
|
+
return sources
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _packs_with_adapter_root_bins(packs_dir: Path) -> list[Path]:
|
|
110
|
+
"""Return every pack directory whose ``.apm/adapter-root-bins/``
|
|
111
|
+
contains at least one ``*.py`` source. Sorted for determinism.
|
|
112
|
+
|
|
113
|
+
Used by the AC22b shim-companion enumeration and by the
|
|
114
|
+
content-grep hard-error rail — both predicate on "the pack ships
|
|
115
|
+
adapter-root-bins/", not on what's inside it.
|
|
116
|
+
"""
|
|
117
|
+
out: list[Path] = []
|
|
118
|
+
for pack in sorted(packs_dir.iterdir()):
|
|
119
|
+
if not pack.is_dir() or not (pack / "pack.toml").exists():
|
|
120
|
+
continue
|
|
121
|
+
bins = pack / SOURCE_SUBDIR
|
|
122
|
+
if not bins.is_dir():
|
|
123
|
+
continue
|
|
124
|
+
if any(bins.glob("*.py")):
|
|
125
|
+
out.append(pack)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _assert_shim_companion_present(packs_dir: Path) -> None:
|
|
130
|
+
"""AC22b hard-error rail (content-based, generalises past _sso_*).
|
|
131
|
+
|
|
132
|
+
For each pack that ships any ``.apm/adapter-root-bins/*.py``,
|
|
133
|
+
content-grep its sources for the literal substring
|
|
134
|
+
``from .credentials_shim import``; if any match AND the pack does
|
|
135
|
+
not ship ``.apm/shared-libs/credentials_shim.py``, raise
|
|
136
|
+
``ValueError`` with the broker-agnostic pinned message. Generalises
|
|
137
|
+
so a future ``_oauth_macos.py`` or any other adapter-root-bins
|
|
138
|
+
module with the same dependency is auto-covered.
|
|
139
|
+
"""
|
|
140
|
+
for pack in _packs_with_adapter_root_bins(packs_dir):
|
|
141
|
+
shim_source = pack / shared_libs.SOURCE_SUBDIR / SHIM_COMPANION_BASENAME
|
|
142
|
+
if shim_source.is_file():
|
|
143
|
+
continue # pack ships the companion — no need to grep.
|
|
144
|
+
bins_dir = pack / SOURCE_SUBDIR
|
|
145
|
+
offenders: list[str] = []
|
|
146
|
+
for src in sorted(bins_dir.glob("*.py")):
|
|
147
|
+
try:
|
|
148
|
+
body = src.read_bytes()
|
|
149
|
+
except OSError:
|
|
150
|
+
continue
|
|
151
|
+
if SHIM_IMPORT_GREP in body:
|
|
152
|
+
offenders.append(src.name)
|
|
153
|
+
if offenders:
|
|
154
|
+
offender_list = ", ".join(offenders)
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"adapter-root-bins/{{{offender_list}}} imports "
|
|
157
|
+
f".credentials_shim but .apm/shared-libs/credentials_shim.py "
|
|
158
|
+
f"is missing in pack {pack.name!r} — the importing module's "
|
|
159
|
+
f"Tier-2 dispatch would degrade silently on macOS/Windows"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def collect_companion_shim(packs_dir: Path) -> dict[str, Path]:
|
|
164
|
+
"""AC22b companion projection enumeration.
|
|
165
|
+
|
|
166
|
+
Returns ``{basename → source_path}`` for the shim companion when
|
|
167
|
+
at least one pack ships BOTH ``.apm/adapter-root-bins/`` AND
|
|
168
|
+
``.apm/shared-libs/credentials_shim.py``. Cross-pack basename
|
|
169
|
+
collision on the shim is detected by ``shared_libs.collect_sources``
|
|
170
|
+
(single source of truth — one error shape, one ownership boundary).
|
|
171
|
+
The companion's target is always
|
|
172
|
+
``<working_tree>/.agentbundle/bin/credentials_shim.py``; callers
|
|
173
|
+
compose ``working_tree`` themselves.
|
|
174
|
+
"""
|
|
175
|
+
shim_sources = shared_libs.collect_sources(packs_dir)
|
|
176
|
+
shim_source = shim_sources.get(SHIM_COMPANION_BASENAME)
|
|
177
|
+
if shim_source is None:
|
|
178
|
+
return {}
|
|
179
|
+
for pack in _packs_with_adapter_root_bins(packs_dir):
|
|
180
|
+
pack_shim = pack / shared_libs.SOURCE_SUBDIR / SHIM_COMPANION_BASENAME
|
|
181
|
+
if pack_shim.is_file():
|
|
182
|
+
# At least one pack ships both adapter-root-bins/ and
|
|
183
|
+
# shared-libs/credentials_shim.py. Project the canonical
|
|
184
|
+
# shim source as the companion. Opt-in by ship-both: packs
|
|
185
|
+
# that ship adapter-root-bins/ alone do not get the shim
|
|
186
|
+
# — the AC22b hard-error rail catches the case where they
|
|
187
|
+
# *need* it but don't ship it.
|
|
188
|
+
return {SHIM_COMPANION_BASENAME: shim_source}
|
|
189
|
+
return {}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def compute_projections(
|
|
193
|
+
working_tree: Path, packs_dir: Path
|
|
194
|
+
) -> list[AdapterRootBinProjection]:
|
|
195
|
+
"""Return the full list of ``(source → target)`` pairs.
|
|
196
|
+
|
|
197
|
+
Deterministic order — drift gates depend on it. Includes the AC22b
|
|
198
|
+
shim companion when applicable (opt-in by ship-both).
|
|
199
|
+
"""
|
|
200
|
+
sources = collect_sources(packs_dir)
|
|
201
|
+
target_dir = working_tree / TARGET_SUBDIR
|
|
202
|
+
projections: list[AdapterRootBinProjection] = [
|
|
203
|
+
AdapterRootBinProjection(source=sources[name], target=target_dir / name)
|
|
204
|
+
for name in sorted(sources)
|
|
205
|
+
]
|
|
206
|
+
companion = collect_companion_shim(packs_dir)
|
|
207
|
+
for basename in sorted(companion):
|
|
208
|
+
projections.append(
|
|
209
|
+
AdapterRootBinProjection(
|
|
210
|
+
source=companion[basename],
|
|
211
|
+
target=target_dir / basename,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return projections
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _is_companion_projection(proj: AdapterRootBinProjection) -> bool:
|
|
218
|
+
"""True iff ``proj`` is the AC22b shim-companion (source rooted in
|
|
219
|
+
``shared-libs/``), not a primary adapter-root-bins target.
|
|
220
|
+
|
|
221
|
+
Drives the ``[adapter-root-bins:shim-companion]`` diagnostic
|
|
222
|
+
prefix in ``check_drift`` so the source-side reference reads
|
|
223
|
+
coherently next to its diagnostic class. Derives the comparison
|
|
224
|
+
leaf-name from ``shared_libs.SOURCE_SUBDIR`` so a future rename of
|
|
225
|
+
that constant propagates here automatically.
|
|
226
|
+
"""
|
|
227
|
+
shared_libs_leaf = Path(shared_libs.SOURCE_SUBDIR).name
|
|
228
|
+
return proj.source.parent.name == shared_libs_leaf
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def apply_projection(working_tree: Path, packs_dir: Path) -> None:
|
|
232
|
+
"""Write every projection target and remove orphans.
|
|
233
|
+
|
|
234
|
+
Called by ``make build-self``. Idempotent — running twice produces
|
|
235
|
+
the same on-disk state. POSIX mode bits set to ``0o755`` after
|
|
236
|
+
copy. Windows inherits the parent DACL (no explicit chmod).
|
|
237
|
+
|
|
238
|
+
Three drift outcomes resolved here:
|
|
239
|
+
* **missing** → file written from source
|
|
240
|
+
* **modified** → file overwritten from source
|
|
241
|
+
* **orphaned** → file removed (source basename no longer
|
|
242
|
+
shipped by any pack)
|
|
243
|
+
|
|
244
|
+
AC22b: also projects the shim companion when a pack ships both
|
|
245
|
+
``.apm/adapter-root-bins/`` and ``.apm/shared-libs/credentials_shim.py``.
|
|
246
|
+
AC22b hard-error rail fires before any writes if a pack imports
|
|
247
|
+
the shim but doesn't ship the source.
|
|
248
|
+
"""
|
|
249
|
+
_assert_shim_companion_present(packs_dir)
|
|
250
|
+
projections = compute_projections(working_tree, packs_dir)
|
|
251
|
+
expected_targets = {p.target for p in projections}
|
|
252
|
+
for proj in projections:
|
|
253
|
+
proj.target.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
shutil.copy2(proj.source, proj.target)
|
|
255
|
+
if os.name == "posix":
|
|
256
|
+
os.chmod(proj.target, EXECUTABLE_MODE)
|
|
257
|
+
# Orphan removal: any *.py file under <working_tree>/.agentbundle/bin/
|
|
258
|
+
# not claimed by an expected target.
|
|
259
|
+
target_dir = working_tree / TARGET_SUBDIR
|
|
260
|
+
if target_dir.is_dir():
|
|
261
|
+
for existing in sorted(target_dir.glob("*.py")):
|
|
262
|
+
if existing not in expected_targets:
|
|
263
|
+
try:
|
|
264
|
+
existing.unlink()
|
|
265
|
+
except FileNotFoundError: # pragma: no cover — race-only
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def check_drift(working_tree: Path, packs_dir: Path) -> list[str]:
|
|
270
|
+
"""Return drift descriptions for ``make build-check``.
|
|
271
|
+
|
|
272
|
+
Three outcomes per RFC-0013 § 4d / spec AC22-AC23:
|
|
273
|
+
* **modified** — projected bytes diverge from source
|
|
274
|
+
* **missing** — source exists but projected file absent
|
|
275
|
+
* **orphaned** — projected file present, no source claiming it
|
|
276
|
+
|
|
277
|
+
Each description ends with the regeneration command.
|
|
278
|
+
"""
|
|
279
|
+
drifts: list[str] = []
|
|
280
|
+
try:
|
|
281
|
+
sources = collect_sources(packs_dir)
|
|
282
|
+
except ValueError as exc:
|
|
283
|
+
drifts.append(f"[adapter-root-bins] {exc}; run: make build-self")
|
|
284
|
+
return drifts
|
|
285
|
+
try:
|
|
286
|
+
_assert_shim_companion_present(packs_dir)
|
|
287
|
+
except ValueError as exc:
|
|
288
|
+
drifts.append(f"[adapter-root-bins:shim-companion] {exc}; run: make build-self")
|
|
289
|
+
return drifts
|
|
290
|
+
|
|
291
|
+
target_dir = working_tree / TARGET_SUBDIR
|
|
292
|
+
expected_targets: set[Path] = set()
|
|
293
|
+
|
|
294
|
+
for proj in compute_projections(working_tree, packs_dir):
|
|
295
|
+
expected_targets.add(proj.target)
|
|
296
|
+
prefix = (
|
|
297
|
+
"[adapter-root-bins:shim-companion]"
|
|
298
|
+
if _is_companion_projection(proj)
|
|
299
|
+
else "[adapter-root-bins]"
|
|
300
|
+
)
|
|
301
|
+
try:
|
|
302
|
+
source_bytes = proj.source.read_bytes()
|
|
303
|
+
except OSError as exc: # pragma: no cover — defensive
|
|
304
|
+
drifts.append(f"{prefix} source unreadable: {exc}")
|
|
305
|
+
continue
|
|
306
|
+
if not proj.target.exists():
|
|
307
|
+
drifts.append(
|
|
308
|
+
f"{prefix} missing: "
|
|
309
|
+
f"{proj.target.relative_to(working_tree).as_posix()} "
|
|
310
|
+
f"(source: "
|
|
311
|
+
f"{proj.source.relative_to(packs_dir.parent).as_posix()}); "
|
|
312
|
+
f"run: make build-self FORCE=1"
|
|
313
|
+
)
|
|
314
|
+
continue
|
|
315
|
+
if proj.target.read_bytes() != source_bytes:
|
|
316
|
+
drifts.append(
|
|
317
|
+
f"{prefix} modified: "
|
|
318
|
+
f"{proj.target.relative_to(working_tree).as_posix()} "
|
|
319
|
+
f"diverges from "
|
|
320
|
+
f"{proj.source.relative_to(packs_dir.parent).as_posix()}; "
|
|
321
|
+
f"run: make build-self FORCE=1"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Orphan check.
|
|
325
|
+
if target_dir.is_dir():
|
|
326
|
+
for existing in sorted(target_dir.glob("*.py")):
|
|
327
|
+
if existing not in expected_targets:
|
|
328
|
+
drifts.append(
|
|
329
|
+
f"[adapter-root-bins] orphaned: "
|
|
330
|
+
f"{existing.relative_to(working_tree).as_posix()} "
|
|
331
|
+
f"present but no pack ships "
|
|
332
|
+
f"adapter-root-bins/{existing.name}; "
|
|
333
|
+
f"run: make build-self FORCE=1"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return drifts
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Adapter registry — keyed by contract adapter name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Callable, Dict, Mapping
|
|
9
|
+
|
|
10
|
+
from agentbundle.build.adapters import claude_code, codex, copilot, kiro, kiro_cli, kiro_ide
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _kiro_alias_project(pack_path: Path, contract: dict, output_root: Path) -> None:
|
|
14
|
+
"""Deprecated alias: `kiro` → `kiro-ide`. Emits a build-time warning."""
|
|
15
|
+
warnings.warn(
|
|
16
|
+
"kiro: deprecated alias for kiro-ide; update allowed-adapters in pack.toml",
|
|
17
|
+
DeprecationWarning,
|
|
18
|
+
stacklevel=2,
|
|
19
|
+
)
|
|
20
|
+
kiro_ide.project(pack_path, contract, output_root)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Original callable registry (hyphenated contract names) — preserved for
|
|
24
|
+
# F-build's recipe runner which keys recipes by contract names.
|
|
25
|
+
ADAPTERS: Dict[str, Callable] = {
|
|
26
|
+
"claude-code": claude_code.project,
|
|
27
|
+
"kiro-ide": kiro_ide.project,
|
|
28
|
+
"kiro-cli": kiro_cli.project,
|
|
29
|
+
"kiro": _kiro_alias_project, # deprecated alias → kiro-ide (RFC-0022 D1)
|
|
30
|
+
"copilot": copilot.project,
|
|
31
|
+
"codex": codex.project,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Module-keyed registry — the surface RFC-0003 F-cli AC requires.
|
|
35
|
+
# Keys are the Python module names (`claude_code`, etc.) which is what the
|
|
36
|
+
# CLI's `list-targets` and the AC's test reference. Values are the adapter
|
|
37
|
+
# modules themselves so callers can introspect any future per-adapter
|
|
38
|
+
# attribute the sibling spec pins onto `AdapterModule`.
|
|
39
|
+
registry: Mapping[str, ModuleType] = {
|
|
40
|
+
"claude_code": claude_code,
|
|
41
|
+
"kiro_ide": kiro_ide,
|
|
42
|
+
"kiro_cli": kiro_cli,
|
|
43
|
+
"kiro": kiro, # legacy module; use kiro_ide for new code
|
|
44
|
+
"copilot": copilot,
|
|
45
|
+
"codex": codex,
|
|
46
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Claude Code adapter — projects every primitive per the contract.
|
|
2
|
+
|
|
3
|
+
Projection modes used (read from contract["adapter"]["claude-code"]):
|
|
4
|
+
- skill → direct-directory → .claude/skills/<name>/
|
|
5
|
+
- agent → direct-file → .claude/agents/<name>.md
|
|
6
|
+
- hook-body → direct-file → tools/hooks/<name>.{sh,py}
|
|
7
|
+
- hook-wiring → merge-json → .claude/settings.local.json (hooks key)
|
|
8
|
+
- command → direct-file → .claude/commands/<name>.md
|
|
9
|
+
|
|
10
|
+
The merge-json projection is idempotent because we re-serialise with
|
|
11
|
+
`sort_keys=True` and re-read the existing file's `hooks` key before
|
|
12
|
+
deep-merging the incoming TOML payload.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import shutil
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Phase order from RFC-0005 § Build-pipeline ordering invariant.
|
|
23
|
+
# Uniform across all reference adapters even though Claude Code's
|
|
24
|
+
# wiring lands in a settings file (not in agents) — the uniformity
|
|
25
|
+
# keeps the phases predictable, which the spec calls for.
|
|
26
|
+
from agentbundle.build.phase_order import PHASE_ORDER as _PHASE_ORDER
|
|
27
|
+
from agentbundle.build.projections.direct_directory import sweep_orphans
|
|
28
|
+
from agentbundle.build.projections.merge_json import project_merge_json
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _iter_primitives(contract: dict) -> Iterator[str]:
|
|
32
|
+
"""Yield Claude Code's projected primitive names in phase order."""
|
|
33
|
+
adapter_block = contract["adapter"]["claude-code"]
|
|
34
|
+
array_form = {entry["primitive"]: entry for entry in adapter_block.get("projection", [])}
|
|
35
|
+
for primitive_name in _PHASE_ORDER:
|
|
36
|
+
if primitive_name in array_form and array_form[primitive_name].get("mode") != "dropped":
|
|
37
|
+
yield primitive_name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def project(pack_path: Path, contract: dict, output_root: Path) -> None:
|
|
41
|
+
"""Single-pack convenience wrapper. Delegates to `project_packs`."""
|
|
42
|
+
project_packs([pack_path], contract, output_root)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def project_packs(pack_paths: list[Path], contract: dict, output_root: Path) -> None:
|
|
46
|
+
"""Project every pack in `pack_paths` in order, then run the
|
|
47
|
+
shared orphan-sweep post-pass on the `skill` target directory.
|
|
48
|
+
|
|
49
|
+
Same-name collision rule: pack source order as supplied here; the
|
|
50
|
+
last pack's `<name>` overwrites earlier packs' (`_project_direct_directory`
|
|
51
|
+
`rmtree`s the destination before `copytree`). The orphan sweep
|
|
52
|
+
observes the union of source skill names across the call's pack
|
|
53
|
+
list (not per-pack) so a pack shipping a subset can co-exist with
|
|
54
|
+
another that ships the union complement.
|
|
55
|
+
"""
|
|
56
|
+
for pack_path in pack_paths:
|
|
57
|
+
_project_single(pack_path, contract, output_root)
|
|
58
|
+
_sweep_skill_orphans(pack_paths, contract, output_root)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Mirror of kiro.py:_skill_direct_directory_target — keep in sync.
|
|
62
|
+
# A shared helper is barred by the spec's `Never do` boundary (no
|
|
63
|
+
# expansion of projections/direct_directory.py beyond `sweep_orphans`).
|
|
64
|
+
def _skill_direct_directory_target(contract: dict, output_root: Path) -> Path | None:
|
|
65
|
+
adapter_block = contract["adapter"]["claude-code"]
|
|
66
|
+
for entry in adapter_block.get("projection", []):
|
|
67
|
+
if entry.get("primitive") == "skill" and entry.get("mode") == "direct-directory":
|
|
68
|
+
return output_root / entry["target-path"].rstrip("/")
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sweep_skill_orphans(pack_paths: list[Path], contract: dict, output_root: Path) -> None:
|
|
73
|
+
target_dir = _skill_direct_directory_target(contract, output_root)
|
|
74
|
+
if target_dir is None:
|
|
75
|
+
return
|
|
76
|
+
skill_source_path = contract["primitive"]["skill"]["source-path"].rstrip("/")
|
|
77
|
+
expected_names: set[str] = set()
|
|
78
|
+
for pack_path in pack_paths:
|
|
79
|
+
source_dir = pack_path / skill_source_path
|
|
80
|
+
if not source_dir.exists():
|
|
81
|
+
continue
|
|
82
|
+
for entry in source_dir.iterdir():
|
|
83
|
+
if entry.is_dir():
|
|
84
|
+
expected_names.add(entry.name)
|
|
85
|
+
sweep_orphans(target_dir, expected_names)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _project_single(pack_path: Path, contract: dict, output_root: Path) -> None:
|
|
89
|
+
adapter_block = contract["adapter"]["claude-code"]
|
|
90
|
+
rules_by_primitive = {entry["primitive"]: entry for entry in adapter_block.get("projection", [])}
|
|
91
|
+
|
|
92
|
+
for primitive_name in _iter_primitives(contract):
|
|
93
|
+
rule = rules_by_primitive[primitive_name]
|
|
94
|
+
mode = rule["mode"]
|
|
95
|
+
primitive = contract["primitive"][primitive_name]
|
|
96
|
+
source_dir = pack_path / primitive["source-path"].rstrip("/")
|
|
97
|
+
if not source_dir.exists():
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if mode == "direct-directory":
|
|
101
|
+
_project_direct_directory(source_dir, output_root / rule["target-path"].rstrip("/"))
|
|
102
|
+
elif mode == "direct-file":
|
|
103
|
+
_project_direct_file(source_dir, output_root, rule["target-path"])
|
|
104
|
+
elif mode == "merge-json":
|
|
105
|
+
project_merge_json(source_dir, output_root, rule)
|
|
106
|
+
else:
|
|
107
|
+
raise ValueError(f"claude-code: unhandled mode {mode!r} for {primitive_name}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _project_direct_directory(source_dir: Path, target_dir: Path) -> None:
|
|
111
|
+
for entry in sorted(source_dir.iterdir()):
|
|
112
|
+
# Defense-in-depth — `lint-packs` rejects packs that ship
|
|
113
|
+
# symlinks, but a direct `project_packs` caller bypasses
|
|
114
|
+
# that gate. A symlink at the skill-root level would be
|
|
115
|
+
# dereferenced by `copytree`.
|
|
116
|
+
if entry.is_symlink():
|
|
117
|
+
continue
|
|
118
|
+
if entry.is_dir():
|
|
119
|
+
destination = target_dir / entry.name
|
|
120
|
+
# Spec § Never do — `shutil.rmtree` is barred against
|
|
121
|
+
# any entry whose `is_symlink()` is true. If a previous
|
|
122
|
+
# run left a symlink at the destination path, unlink it
|
|
123
|
+
# (removes the link, not the target).
|
|
124
|
+
if destination.is_symlink():
|
|
125
|
+
destination.unlink()
|
|
126
|
+
elif destination.exists():
|
|
127
|
+
shutil.rmtree(destination)
|
|
128
|
+
# symlinks=True preserves symlinks rather than dereferencing
|
|
129
|
+
# them — a malicious pack with a symlink to /etc/passwd
|
|
130
|
+
# cannot exfiltrate the target into the projection.
|
|
131
|
+
shutil.copytree(entry, destination, symlinks=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _project_direct_file(source_dir: Path, output_root: Path, target_prefix: str) -> None:
|
|
135
|
+
target_dir = output_root / target_prefix.rstrip("/")
|
|
136
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
for entry in sorted(source_dir.iterdir()):
|
|
138
|
+
if entry.is_file():
|
|
139
|
+
destination = target_dir / entry.name
|
|
140
|
+
shutil.copy2(entry, destination, follow_symlinks=False)
|
|
141
|
+
|
|
142
|
+
|