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,309 @@
|
|
|
1
|
+
"""T4: shared-libs/ build-pipeline primitive class.
|
|
2
|
+
|
|
3
|
+
Source rule: ``packs/<pack>/.apm/shared-libs/*.py``.
|
|
4
|
+
Target rule: for every skill in any pack whose ``SKILL.md`` declares
|
|
5
|
+
``metadata.auth: creds``, project each ``shared-libs/*.py`` byte-
|
|
6
|
+
identical into that skill's ``scripts/`` directory; create
|
|
7
|
+
``scripts/`` if absent.
|
|
8
|
+
|
|
9
|
+
This module owns both halves of the projection contract:
|
|
10
|
+
|
|
11
|
+
- ``apply_projection(packs_dir)`` — write the files. Called by
|
|
12
|
+
``make build-self``.
|
|
13
|
+
- ``check_drift(packs_dir)`` — read-only gate. Returns a list of
|
|
14
|
+
drift descriptions (empty list == clean). Each description
|
|
15
|
+
classifies one of the three outcomes RFC-0013 § 4c pins:
|
|
16
|
+
* **modified** — projected file exists but bytes diverge from source
|
|
17
|
+
* **missing** — consumer declares ``auth: creds`` but projected file absent
|
|
18
|
+
* **orphaned** — projected file present but consumer no longer
|
|
19
|
+
declares ``auth: creds`` (or the source has been removed)
|
|
20
|
+
|
|
21
|
+
Inter-pack collision: two packs both shipping ``shared-libs/<file>``
|
|
22
|
+
under the same basename is a hard error (``ValueError``) at
|
|
23
|
+
projection time. The v1 catalogue ships one source pack
|
|
24
|
+
(``credential-brokers``); the rail exists to refuse a future
|
|
25
|
+
second-pack collision before it can silently overwrite.
|
|
26
|
+
|
|
27
|
+
The detection of ``metadata.auth: creds`` is a regex scan against
|
|
28
|
+
``SKILL.md`` text, not a full YAML parse. The strict lint
|
|
29
|
+
(``tools/lint-agent-artifacts.py``) does the YAML round-trip;
|
|
30
|
+
this module only needs to know *which* skills are consumers, and
|
|
31
|
+
the regex is stdlib-only — the build pipeline carries no PyYAML
|
|
32
|
+
dependency.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
import shutil
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
# Pin the source path so a downstream consumer that wants to
|
|
43
|
+
# enumerate shim sources doesn't hardcode the literal repeatedly.
|
|
44
|
+
SOURCE_SUBDIR = ".apm/shared-libs"
|
|
45
|
+
|
|
46
|
+
# Static allow-list of basenames the build pipeline recognises as
|
|
47
|
+
# shim files. Used by the orphan-rail when ``collect_sources`` returns
|
|
48
|
+
# empty (a future PR removes the shared-libs source) — without this,
|
|
49
|
+
# the orphan check would key on an empty source set and silently miss
|
|
50
|
+
# stale projected copies under consumer skills.
|
|
51
|
+
KNOWN_SHIM_BASENAMES: frozenset[str] = frozenset({
|
|
52
|
+
"credentials_shim.py",
|
|
53
|
+
"_keychain_macos.py",
|
|
54
|
+
"_credman_windows.py",
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
# Regex match against `auth: creds` as an indented mapping value under
|
|
58
|
+
# `metadata:`. The shape we look for, anchored to start-of-line:
|
|
59
|
+
# metadata:
|
|
60
|
+
# ...
|
|
61
|
+
# auth: creds
|
|
62
|
+
# We don't try to honour YAML's full grammar — quoted form
|
|
63
|
+
# (`auth: "creds"`) and inline form (`metadata: { auth: creds }`) are
|
|
64
|
+
# refused by the lint, so they cannot reach the build pipeline.
|
|
65
|
+
# An unquoted scalar token is what every in-tree credentialed skill
|
|
66
|
+
# carries today.
|
|
67
|
+
_AUTH_CREDS_RE = re.compile(
|
|
68
|
+
r"^[ \t]+auth:[ \t]+creds[ \t]*(?:#.*)?$",
|
|
69
|
+
re.MULTILINE,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class SharedLibProjection:
|
|
75
|
+
"""One concrete projection: copy `source` to `target`."""
|
|
76
|
+
|
|
77
|
+
source: Path
|
|
78
|
+
target: Path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def collect_sources(packs_dir: Path) -> dict[str, Path]:
|
|
82
|
+
"""Return ``{basename → source_path}`` for every ``.apm/shared-libs/*.py``.
|
|
83
|
+
|
|
84
|
+
Raises ``ValueError`` on inter-pack basename collision — two packs
|
|
85
|
+
shipping the same basename produces non-deterministic projection
|
|
86
|
+
order and silent overwrites; refuse hard at enumeration time.
|
|
87
|
+
"""
|
|
88
|
+
sources: dict[str, Path] = {}
|
|
89
|
+
for pack in sorted(packs_dir.iterdir()):
|
|
90
|
+
if not pack.is_dir() or not (pack / "pack.toml").exists():
|
|
91
|
+
continue
|
|
92
|
+
shared = pack / SOURCE_SUBDIR
|
|
93
|
+
if not shared.is_dir():
|
|
94
|
+
continue
|
|
95
|
+
for src in sorted(shared.glob("*.py")):
|
|
96
|
+
if src.name in sources:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"shared-libs collision: '{src.name}' shipped by both "
|
|
99
|
+
f"{sources[src.name]} and {src}"
|
|
100
|
+
)
|
|
101
|
+
sources[src.name] = src
|
|
102
|
+
return sources
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _skill_declares_auth_creds(skill_md: Path) -> bool:
|
|
106
|
+
"""Return True if ``SKILL.md``'s frontmatter declares ``auth: creds``.
|
|
107
|
+
|
|
108
|
+
Scoped to the frontmatter — text between the first pair of ``---``
|
|
109
|
+
delimiters. A body-only mention (e.g. inside a code fence) is not
|
|
110
|
+
a declaration.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
text = skill_md.read_text(encoding="utf-8")
|
|
114
|
+
except (OSError, UnicodeDecodeError):
|
|
115
|
+
return False
|
|
116
|
+
lines = text.splitlines()
|
|
117
|
+
if not lines or lines[0].strip() != "---":
|
|
118
|
+
return False
|
|
119
|
+
end = None
|
|
120
|
+
for i in range(1, len(lines)):
|
|
121
|
+
if lines[i].strip() == "---":
|
|
122
|
+
end = i
|
|
123
|
+
break
|
|
124
|
+
if end is None:
|
|
125
|
+
return False
|
|
126
|
+
fm = "\n".join(lines[1:end])
|
|
127
|
+
return _AUTH_CREDS_RE.search(fm) is not None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def find_creds_consumers(packs_dir: Path) -> list[Path]:
|
|
131
|
+
"""Return every skill-source directory whose ``SKILL.md`` declares
|
|
132
|
+
``auth: creds``. Sorted for deterministic projection order.
|
|
133
|
+
"""
|
|
134
|
+
consumers: list[Path] = []
|
|
135
|
+
for pack in sorted(packs_dir.iterdir()):
|
|
136
|
+
if not pack.is_dir() or not (pack / "pack.toml").exists():
|
|
137
|
+
continue
|
|
138
|
+
skills_dir = pack / ".apm" / "skills"
|
|
139
|
+
if not skills_dir.is_dir():
|
|
140
|
+
continue
|
|
141
|
+
for skill_dir in sorted(skills_dir.iterdir()):
|
|
142
|
+
if not skill_dir.is_dir():
|
|
143
|
+
continue
|
|
144
|
+
skill_md = skill_dir / "SKILL.md"
|
|
145
|
+
if not skill_md.is_file():
|
|
146
|
+
continue
|
|
147
|
+
if _skill_declares_auth_creds(skill_md):
|
|
148
|
+
consumers.append(skill_dir)
|
|
149
|
+
return consumers
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def compute_projections(packs_dir: Path) -> list[SharedLibProjection]:
|
|
153
|
+
"""Return the full list of ``(source → target)`` pairs.
|
|
154
|
+
|
|
155
|
+
Order: outer loop over consumers (sorted), inner loop over source
|
|
156
|
+
basenames (sorted). Deterministic — drift gates depend on it.
|
|
157
|
+
"""
|
|
158
|
+
sources = collect_sources(packs_dir)
|
|
159
|
+
if not sources:
|
|
160
|
+
return []
|
|
161
|
+
consumers = find_creds_consumers(packs_dir)
|
|
162
|
+
out: list[SharedLibProjection] = []
|
|
163
|
+
for skill_dir in consumers:
|
|
164
|
+
scripts = skill_dir / "scripts"
|
|
165
|
+
for basename in sorted(sources):
|
|
166
|
+
out.append(
|
|
167
|
+
SharedLibProjection(
|
|
168
|
+
source=sources[basename],
|
|
169
|
+
target=scripts / basename,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def apply_projection(packs_dir: Path) -> None:
|
|
176
|
+
"""Write every projection target and remove orphans. Creates
|
|
177
|
+
``scripts/`` if absent.
|
|
178
|
+
|
|
179
|
+
Called by ``make build-self``. Idempotent — running twice produces
|
|
180
|
+
the same on-disk state.
|
|
181
|
+
|
|
182
|
+
Three drift outcomes RFC-0013 § 4c pins are all resolved here:
|
|
183
|
+
* **missing** → file written from source
|
|
184
|
+
* **modified** → file overwritten from source
|
|
185
|
+
* **orphaned** → file removed (consumer no longer declares
|
|
186
|
+
``auth: creds``, OR the source basename is no longer shipped)
|
|
187
|
+
"""
|
|
188
|
+
projections = compute_projections(packs_dir)
|
|
189
|
+
expected_targets = {p.target for p in projections}
|
|
190
|
+
# Write current set first so an orphan removed below cannot be
|
|
191
|
+
# mistaken for a missing write that needs re-running.
|
|
192
|
+
for proj in projections:
|
|
193
|
+
proj.target.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
shutil.copy2(proj.source, proj.target)
|
|
195
|
+
# Orphan removal uses the static basename allow-list so the rail
|
|
196
|
+
# still fires when ``collect_sources`` returns empty (the source
|
|
197
|
+
# pack has been dropped). Without the static list, the orphan
|
|
198
|
+
# set would silently be empty and stale projected copies would
|
|
199
|
+
# survive.
|
|
200
|
+
for existing in _enumerate_existing_projections(packs_dir, set(KNOWN_SHIM_BASENAMES)):
|
|
201
|
+
if existing not in expected_targets:
|
|
202
|
+
try:
|
|
203
|
+
existing.unlink()
|
|
204
|
+
except FileNotFoundError: # pragma: no cover — race-only
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _enumerate_existing_projections(
|
|
209
|
+
packs_dir: Path, source_basenames: set[str]
|
|
210
|
+
) -> list[Path]:
|
|
211
|
+
"""Return every existing projected file in any skill's ``scripts/``
|
|
212
|
+
whose basename matches a shared-libs source.
|
|
213
|
+
|
|
214
|
+
Used to detect orphans: a projected file that exists on disk but
|
|
215
|
+
is no longer claimed by any (source × creds-consumer) pairing.
|
|
216
|
+
"""
|
|
217
|
+
found: list[Path] = []
|
|
218
|
+
for pack in sorted(packs_dir.iterdir()):
|
|
219
|
+
if not pack.is_dir() or not (pack / "pack.toml").exists():
|
|
220
|
+
continue
|
|
221
|
+
skills_dir = pack / ".apm" / "skills"
|
|
222
|
+
if not skills_dir.is_dir():
|
|
223
|
+
continue
|
|
224
|
+
for skill_dir in sorted(skills_dir.iterdir()):
|
|
225
|
+
if not skill_dir.is_dir():
|
|
226
|
+
continue
|
|
227
|
+
scripts = skill_dir / "scripts"
|
|
228
|
+
if not scripts.is_dir():
|
|
229
|
+
continue
|
|
230
|
+
for entry in sorted(scripts.iterdir()):
|
|
231
|
+
if entry.is_file() and entry.name in source_basenames:
|
|
232
|
+
found.append(entry)
|
|
233
|
+
return found
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def check_drift(packs_dir: Path) -> list[str]:
|
|
237
|
+
"""Return drift descriptions for ``make build-check``.
|
|
238
|
+
|
|
239
|
+
Three outcomes per RFC-0013 § 4c:
|
|
240
|
+
* **modified** — projected bytes diverge from source
|
|
241
|
+
* **missing** — consumer declares ``auth: creds`` but file absent
|
|
242
|
+
* **orphaned** — projected file present, no claiming pairing
|
|
243
|
+
|
|
244
|
+
Each description ends with the regeneration command so the
|
|
245
|
+
operator can resolve drift in one invocation.
|
|
246
|
+
"""
|
|
247
|
+
drifts: list[str] = []
|
|
248
|
+
try:
|
|
249
|
+
sources = collect_sources(packs_dir)
|
|
250
|
+
except ValueError as exc:
|
|
251
|
+
# Collision blocks the projection entirely — report and stop.
|
|
252
|
+
drifts.append(f"[shared-libs] {exc}; run: make build-self")
|
|
253
|
+
return drifts
|
|
254
|
+
if not sources:
|
|
255
|
+
# No source pack carries shared-libs/. The orphan rail still
|
|
256
|
+
# fires: stale projected copies under any consumer skill must
|
|
257
|
+
# surface, otherwise dropping the source pack silently leaves
|
|
258
|
+
# vendored copies behind. Use the static basename allow-list
|
|
259
|
+
# so the rail keys on a known set rather than the (empty)
|
|
260
|
+
# source set.
|
|
261
|
+
for existing in _enumerate_existing_projections(
|
|
262
|
+
packs_dir, set(KNOWN_SHIM_BASENAMES)
|
|
263
|
+
):
|
|
264
|
+
drifts.append(
|
|
265
|
+
f"[shared-libs] orphaned: "
|
|
266
|
+
f"{existing.relative_to(packs_dir.parent).as_posix()} "
|
|
267
|
+
f"present but no source pack ships shared-libs/ "
|
|
268
|
+
f"(remove the file); "
|
|
269
|
+
f"run: make build-self FORCE=1"
|
|
270
|
+
)
|
|
271
|
+
return drifts
|
|
272
|
+
|
|
273
|
+
expected_targets: set[Path] = set()
|
|
274
|
+
for proj in compute_projections(packs_dir):
|
|
275
|
+
expected_targets.add(proj.target)
|
|
276
|
+
source_bytes = proj.source.read_bytes()
|
|
277
|
+
if not proj.target.exists():
|
|
278
|
+
drifts.append(
|
|
279
|
+
f"[shared-libs] missing: "
|
|
280
|
+
f"{proj.target.relative_to(packs_dir.parent).as_posix()} "
|
|
281
|
+
f"(consumer declares 'auth: creds'; source: "
|
|
282
|
+
f"{proj.source.relative_to(packs_dir.parent).as_posix()}); "
|
|
283
|
+
f"run: make build-self FORCE=1"
|
|
284
|
+
)
|
|
285
|
+
continue
|
|
286
|
+
actual_bytes = proj.target.read_bytes()
|
|
287
|
+
if actual_bytes != source_bytes:
|
|
288
|
+
drifts.append(
|
|
289
|
+
f"[shared-libs] modified: "
|
|
290
|
+
f"{proj.target.relative_to(packs_dir.parent).as_posix()} "
|
|
291
|
+
f"diverges from "
|
|
292
|
+
f"{proj.source.relative_to(packs_dir.parent).as_posix()}; "
|
|
293
|
+
f"run: make build-self FORCE=1"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Orphan check: any projected file whose basename matches a known
|
|
297
|
+
# source but is NOT in expected_targets is orphaned.
|
|
298
|
+
for existing in _enumerate_existing_projections(packs_dir, set(sources)):
|
|
299
|
+
if existing not in expected_targets:
|
|
300
|
+
drifts.append(
|
|
301
|
+
f"[shared-libs] orphaned: "
|
|
302
|
+
f"{existing.relative_to(packs_dir.parent).as_posix()} "
|
|
303
|
+
f"present but no consumer skill claims it "
|
|
304
|
+
f"(remove the file or restore 'metadata.auth: creds' "
|
|
305
|
+
f"in the surrounding SKILL.md); "
|
|
306
|
+
f"run: make build-self FORCE=1"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return drifts
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Scope-conditional `target` resolver for v0.3 projection declarations.
|
|
2
|
+
|
|
3
|
+
The v0.3 adapter contract (RFC-0005) introduced a string-or-scope-map shape
|
|
4
|
+
for `target` on `[adapter.<x>.projections.<primitive>]` entries:
|
|
5
|
+
|
|
6
|
+
# bare-string (v0.1 legacy shorthand)
|
|
7
|
+
target = "tools/hooks/<name>.{sh,py}"
|
|
8
|
+
|
|
9
|
+
# scope-map (v0.3 fork)
|
|
10
|
+
target.repo = "tools/hooks/<name>.{sh,py}"
|
|
11
|
+
target.user = ".claude/hooks/<name>.{sh,py}"
|
|
12
|
+
|
|
13
|
+
Some templates also carry the `<attach-to-agent>` placeholder for
|
|
14
|
+
`merge-into-agent-json` consumers (Kiro hook-wiring), resolved per wiring
|
|
15
|
+
entry from the pack-side TOML's `attach-to-agent` field.
|
|
16
|
+
|
|
17
|
+
This module is a pure-function utility — no I/O, no filesystem access.
|
|
18
|
+
Scope-root resolution (`.` vs `~`) and `<name>` / `<pack>` placeholder
|
|
19
|
+
substitution are the pipeline consumers' (T5/T6) concern; the resolver
|
|
20
|
+
returns a target-template string they can further process.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_ATTACH_TO_AGENT_PLACEHOLDER = "<attach-to-agent>"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_target(
|
|
30
|
+
projection: dict,
|
|
31
|
+
scope: str,
|
|
32
|
+
attach_to_agent: str | None = None,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Resolve a projection entry's `target` for a given scope.
|
|
35
|
+
|
|
36
|
+
Arguments:
|
|
37
|
+
projection: a single entry from `adapter.<x>.projections.<primitive>`.
|
|
38
|
+
Must contain a `target` key (bare string or `{repo, user}` table).
|
|
39
|
+
scope: `"repo"` or `"user"`.
|
|
40
|
+
attach_to_agent: optional pack-side agent name; substituted for any
|
|
41
|
+
`<attach-to-agent>` placeholder in the resolved template. If the
|
|
42
|
+
template contains the placeholder but no name is given, the call
|
|
43
|
+
refuses — passing an unsubstituted template downstream is a bug.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The resolved target template as a string. `<name>` and `<pack>`
|
|
47
|
+
placeholders survive verbatim — they're the pipeline's responsibility,
|
|
48
|
+
not this resolver's.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: when `target` is absent, when the requested `scope` is
|
|
52
|
+
absent from a scope-map declaration, or when the resolved template
|
|
53
|
+
contains the `<attach-to-agent>` placeholder but `attach_to_agent`
|
|
54
|
+
is None.
|
|
55
|
+
"""
|
|
56
|
+
if "target" not in projection:
|
|
57
|
+
raise ValueError("projection missing 'target' field")
|
|
58
|
+
|
|
59
|
+
target = projection["target"]
|
|
60
|
+
if isinstance(target, str):
|
|
61
|
+
resolved = target
|
|
62
|
+
elif isinstance(target, dict):
|
|
63
|
+
if scope not in target:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"projection target has no entry for scope {scope!r}; "
|
|
66
|
+
f"declared scopes: {sorted(target.keys())}"
|
|
67
|
+
)
|
|
68
|
+
resolved = target[scope]
|
|
69
|
+
if not isinstance(resolved, str):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"projection target.{scope} is not a string: {type(resolved).__name__}"
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"projection target must be string or scope-map; got {type(target).__name__}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if _ATTACH_TO_AGENT_PLACEHOLDER in resolved:
|
|
79
|
+
if attach_to_agent is None:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"target template requires attach-to-agent; got None. Template: {resolved!r}"
|
|
82
|
+
)
|
|
83
|
+
resolved = resolved.replace(_ATTACH_TO_AGENT_PLACEHOLDER, attach_to_agent)
|
|
84
|
+
|
|
85
|
+
return resolved
|
|
File without changes
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Tests for the Claude Code adapter (T2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from agentbundle.build.adapters.claude_code import project, project_packs
|
|
11
|
+
from agentbundle.build.contract import load as load_contract
|
|
12
|
+
|
|
13
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
14
|
+
CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _seed_pack(root: Path) -> Path:
|
|
18
|
+
pack = root / "pack"
|
|
19
|
+
(pack / ".apm" / "skills" / "foo").mkdir(parents=True)
|
|
20
|
+
(pack / ".apm" / "skills" / "foo" / "SKILL.md").write_text(
|
|
21
|
+
"---\ndescription: foo skill\n---\n# foo\n",
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
)
|
|
24
|
+
(pack / ".apm" / "skills" / "foo" / "extra.txt").write_text("nested\n", encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
(pack / ".apm" / "agents").mkdir(parents=True)
|
|
27
|
+
(pack / ".apm" / "agents" / "bar.md").write_text(
|
|
28
|
+
"---\nname: bar\n---\nagent body\n",
|
|
29
|
+
encoding="utf-8",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
(pack / ".apm" / "hooks").mkdir(parents=True)
|
|
33
|
+
(pack / ".apm" / "hooks" / "baz.sh").write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
|
|
34
|
+
(pack / ".apm" / "hooks" / "baz.py").write_text("print('hi')\n", encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
(pack / ".apm" / "hook-wiring").mkdir(parents=True)
|
|
37
|
+
(pack / ".apm" / "hook-wiring" / "baz.toml").write_text(
|
|
38
|
+
'[hooks]\nbaz = "tools/hooks/baz.sh"\n',
|
|
39
|
+
encoding="utf-8",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
(pack / ".apm" / "commands").mkdir(parents=True)
|
|
43
|
+
(pack / ".apm" / "commands" / "qux.md").write_text("# qux\n", encoding="utf-8")
|
|
44
|
+
return pack
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ClaudeCodeAdapterTests(unittest.TestCase):
|
|
48
|
+
@classmethod
|
|
49
|
+
def setUpClass(cls) -> None:
|
|
50
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
51
|
+
|
|
52
|
+
def test_skill_projects_to_claude_skills_directory(self) -> None:
|
|
53
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
54
|
+
tmp_path = Path(tmp)
|
|
55
|
+
pack = _seed_pack(tmp_path)
|
|
56
|
+
out = tmp_path / "out"
|
|
57
|
+
project(pack, self.contract, out)
|
|
58
|
+
self.assertTrue((out / ".claude" / "skills" / "foo" / "SKILL.md").exists())
|
|
59
|
+
self.assertTrue((out / ".claude" / "skills" / "foo" / "extra.txt").exists())
|
|
60
|
+
|
|
61
|
+
def test_agent_projects_to_claude_agents_file(self) -> None:
|
|
62
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
63
|
+
tmp_path = Path(tmp)
|
|
64
|
+
pack = _seed_pack(tmp_path)
|
|
65
|
+
out = tmp_path / "out"
|
|
66
|
+
project(pack, self.contract, out)
|
|
67
|
+
agent_path = out / ".claude" / "agents" / "bar.md"
|
|
68
|
+
self.assertTrue(agent_path.exists())
|
|
69
|
+
self.assertIn("name: bar", agent_path.read_text(encoding="utf-8"))
|
|
70
|
+
|
|
71
|
+
def test_agent_model_alias_preserved_verbatim(self) -> None:
|
|
72
|
+
"""Claude Code agents stay markdown-with-frontmatter; `model:
|
|
73
|
+
opus` is Claude Code's native alias and the projection must
|
|
74
|
+
not translate it (unlike the kiro JSON projection, which
|
|
75
|
+
rewrites aliases via the contract's values map). Comma-string
|
|
76
|
+
`tools` is also preserved verbatim because Claude Code parses
|
|
77
|
+
the markdown frontmatter itself."""
|
|
78
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
79
|
+
tmp_path = Path(tmp)
|
|
80
|
+
pack = tmp_path / "pack"
|
|
81
|
+
(pack / ".apm" / "agents").mkdir(parents=True)
|
|
82
|
+
(pack / ".apm" / "agents" / "reviewer.md").write_text(
|
|
83
|
+
"---\nname: reviewer\nmodel: opus\ntools: Read, Grep, Glob, Bash\n---\nbody\n",
|
|
84
|
+
encoding="utf-8",
|
|
85
|
+
)
|
|
86
|
+
out = tmp_path / "out"
|
|
87
|
+
project(pack, self.contract, out)
|
|
88
|
+
text = (out / ".claude" / "agents" / "reviewer.md").read_text(encoding="utf-8")
|
|
89
|
+
self.assertIn("model: opus", text)
|
|
90
|
+
self.assertIn("tools: Read, Grep, Glob, Bash", text)
|
|
91
|
+
|
|
92
|
+
def test_hook_body_sh_preserved(self) -> None:
|
|
93
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
94
|
+
tmp_path = Path(tmp)
|
|
95
|
+
pack = _seed_pack(tmp_path)
|
|
96
|
+
out = tmp_path / "out"
|
|
97
|
+
project(pack, self.contract, out)
|
|
98
|
+
self.assertTrue((out / "tools" / "hooks" / "baz.sh").exists())
|
|
99
|
+
|
|
100
|
+
def test_hook_body_py_preserved(self) -> None:
|
|
101
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
102
|
+
tmp_path = Path(tmp)
|
|
103
|
+
pack = _seed_pack(tmp_path)
|
|
104
|
+
out = tmp_path / "out"
|
|
105
|
+
project(pack, self.contract, out)
|
|
106
|
+
self.assertTrue((out / "tools" / "hooks" / "baz.py").exists())
|
|
107
|
+
|
|
108
|
+
def test_hook_wiring_merges_under_hooks_key(self) -> None:
|
|
109
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
110
|
+
tmp_path = Path(tmp)
|
|
111
|
+
pack = _seed_pack(tmp_path)
|
|
112
|
+
out = tmp_path / "out"
|
|
113
|
+
settings_path = out / ".claude" / "settings.local.json"
|
|
114
|
+
settings_path.parent.mkdir(parents=True)
|
|
115
|
+
settings_path.write_text(
|
|
116
|
+
json.dumps({"otherKey": {"preserved": True}}),
|
|
117
|
+
encoding="utf-8",
|
|
118
|
+
)
|
|
119
|
+
project(pack, self.contract, out)
|
|
120
|
+
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
121
|
+
self.assertEqual(data["hooks"], {"baz": "tools/hooks/baz.sh"})
|
|
122
|
+
self.assertEqual(data["otherKey"], {"preserved": True})
|
|
123
|
+
|
|
124
|
+
def test_command_projects_to_claude_commands(self) -> None:
|
|
125
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
126
|
+
tmp_path = Path(tmp)
|
|
127
|
+
pack = _seed_pack(tmp_path)
|
|
128
|
+
out = tmp_path / "out"
|
|
129
|
+
project(pack, self.contract, out)
|
|
130
|
+
self.assertTrue((out / ".claude" / "commands" / "qux.md").exists())
|
|
131
|
+
|
|
132
|
+
def test_idempotent_direct_file_and_merge_json(self) -> None:
|
|
133
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
134
|
+
tmp_path = Path(tmp)
|
|
135
|
+
pack = _seed_pack(tmp_path)
|
|
136
|
+
out = tmp_path / "out"
|
|
137
|
+
project(pack, self.contract, out)
|
|
138
|
+
first_agent = (out / ".claude" / "agents" / "bar.md").read_bytes()
|
|
139
|
+
first_settings = (out / ".claude" / "settings.local.json").read_bytes()
|
|
140
|
+
project(pack, self.contract, out)
|
|
141
|
+
second_agent = (out / ".claude" / "agents" / "bar.md").read_bytes()
|
|
142
|
+
second_settings = (out / ".claude" / "settings.local.json").read_bytes()
|
|
143
|
+
self.assertEqual(first_agent, second_agent)
|
|
144
|
+
self.assertEqual(first_settings, second_settings)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _seed_minimal_pack(root: Path, name: str, skill_name: str, body: str) -> Path:
|
|
148
|
+
"""Pack with a single skill at .apm/skills/<skill_name>/SKILL.md."""
|
|
149
|
+
pack = root / name
|
|
150
|
+
skill_dir = pack / ".apm" / "skills" / skill_name
|
|
151
|
+
skill_dir.mkdir(parents=True)
|
|
152
|
+
(skill_dir / "SKILL.md").write_text(body, encoding="utf-8")
|
|
153
|
+
return pack
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ProjectPacksTests(unittest.TestCase):
|
|
157
|
+
@classmethod
|
|
158
|
+
def setUpClass(cls) -> None:
|
|
159
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
160
|
+
|
|
161
|
+
def test_project_packs_iterates_in_order(self) -> None:
|
|
162
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
163
|
+
tmp_path = Path(tmp)
|
|
164
|
+
pack_a = _seed_minimal_pack(tmp_path, "pack-a", "skill-a", "# a\n")
|
|
165
|
+
pack_b = _seed_minimal_pack(tmp_path, "pack-b", "skill-b", "# b\n")
|
|
166
|
+
out = tmp_path / "out"
|
|
167
|
+
|
|
168
|
+
project_packs([pack_a, pack_b], self.contract, out)
|
|
169
|
+
|
|
170
|
+
self.assertTrue((out / ".claude" / "skills" / "skill-a" / "SKILL.md").is_file())
|
|
171
|
+
self.assertTrue((out / ".claude" / "skills" / "skill-b" / "SKILL.md").is_file())
|
|
172
|
+
|
|
173
|
+
def test_single_pack_project_delegates_to_project_packs(self) -> None:
|
|
174
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
175
|
+
tmp_path = Path(tmp)
|
|
176
|
+
pack = _seed_minimal_pack(tmp_path, "pack", "skill-x", "# x\n")
|
|
177
|
+
out_a = tmp_path / "out-a"
|
|
178
|
+
out_b = tmp_path / "out-b"
|
|
179
|
+
|
|
180
|
+
project(pack, self.contract, out_a)
|
|
181
|
+
project_packs([pack], self.contract, out_b)
|
|
182
|
+
|
|
183
|
+
self.assertEqual(
|
|
184
|
+
(out_a / ".claude" / "skills" / "skill-x" / "SKILL.md").read_bytes(),
|
|
185
|
+
(out_b / ".claude" / "skills" / "skill-x" / "SKILL.md").read_bytes(),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def test_same_name_last_wins(self) -> None:
|
|
189
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
190
|
+
tmp_path = Path(tmp)
|
|
191
|
+
pack_a = _seed_minimal_pack(
|
|
192
|
+
tmp_path, "pack-a", "same-name", "# pack-a\nPACK_A_SENTINEL\n",
|
|
193
|
+
)
|
|
194
|
+
pack_b = _seed_minimal_pack(
|
|
195
|
+
tmp_path, "pack-b", "same-name", "# pack-b\nPACK_B_SENTINEL\n",
|
|
196
|
+
)
|
|
197
|
+
out = tmp_path / "out"
|
|
198
|
+
|
|
199
|
+
project_packs([pack_a, pack_b], self.contract, out)
|
|
200
|
+
body = (out / ".claude" / "skills" / "same-name" / "SKILL.md").read_text(
|
|
201
|
+
encoding="utf-8",
|
|
202
|
+
)
|
|
203
|
+
self.assertIn("PACK_B_SENTINEL", body)
|
|
204
|
+
self.assertNotIn("PACK_A_SENTINEL", body)
|
|
205
|
+
|
|
206
|
+
def test_same_name_last_wins_reversed(self) -> None:
|
|
207
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
208
|
+
tmp_path = Path(tmp)
|
|
209
|
+
pack_a = _seed_minimal_pack(
|
|
210
|
+
tmp_path, "pack-a", "same-name", "# pack-a\nPACK_A_SENTINEL\n",
|
|
211
|
+
)
|
|
212
|
+
pack_b = _seed_minimal_pack(
|
|
213
|
+
tmp_path, "pack-b", "same-name", "# pack-b\nPACK_B_SENTINEL\n",
|
|
214
|
+
)
|
|
215
|
+
out = tmp_path / "out"
|
|
216
|
+
|
|
217
|
+
project_packs([pack_b, pack_a], self.contract, out)
|
|
218
|
+
body = (out / ".claude" / "skills" / "same-name" / "SKILL.md").read_text(
|
|
219
|
+
encoding="utf-8",
|
|
220
|
+
)
|
|
221
|
+
self.assertIn("PACK_A_SENTINEL", body)
|
|
222
|
+
self.assertNotIn("PACK_B_SENTINEL", body)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _seed_named_skills_pack(root: Path, pack_name: str, skill_names: list[str]) -> Path:
|
|
226
|
+
pack = root / pack_name
|
|
227
|
+
for skill_name in skill_names:
|
|
228
|
+
skill_dir = pack / ".apm" / "skills" / skill_name
|
|
229
|
+
skill_dir.mkdir(parents=True)
|
|
230
|
+
(skill_dir / "SKILL.md").write_text(
|
|
231
|
+
f"# {skill_name}\nfrom {pack_name}\n",
|
|
232
|
+
encoding="utf-8",
|
|
233
|
+
)
|
|
234
|
+
return pack
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TestClaudeCodeOrphanSweep(unittest.TestCase):
|
|
238
|
+
@classmethod
|
|
239
|
+
def setUpClass(cls) -> None:
|
|
240
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
241
|
+
|
|
242
|
+
def test_two_stage_shrink(self) -> None:
|
|
243
|
+
# AC18: project {a, b, c} then {a, c} into the same output.
|
|
244
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
245
|
+
tmp_path = Path(tmp)
|
|
246
|
+
three = _seed_named_skills_pack(tmp_path, "three-skill", ["a", "b", "c"])
|
|
247
|
+
shrink = _seed_named_skills_pack(tmp_path, "two-skill-shrink", ["a", "c"])
|
|
248
|
+
out = tmp_path / "out"
|
|
249
|
+
|
|
250
|
+
project_packs([three], self.contract, out)
|
|
251
|
+
self.assertTrue((out / ".claude" / "skills" / "b").is_dir())
|
|
252
|
+
|
|
253
|
+
project_packs([shrink], self.contract, out)
|
|
254
|
+
children = {p.name for p in (out / ".claude" / "skills").iterdir()}
|
|
255
|
+
self.assertEqual(children, {"a", "c"})
|
|
256
|
+
|
|
257
|
+
def test_two_pack_union(self) -> None:
|
|
258
|
+
# AC20 — claude-code case.
|
|
259
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
260
|
+
tmp_path = Path(tmp)
|
|
261
|
+
pack_a = _seed_named_skills_pack(tmp_path, "pack-a", ["a", "b"])
|
|
262
|
+
pack_b = _seed_named_skills_pack(tmp_path, "pack-b", ["b", "c"])
|
|
263
|
+
out = tmp_path / "out"
|
|
264
|
+
|
|
265
|
+
project_packs([pack_a, pack_b], self.contract, out)
|
|
266
|
+
children = {p.name for p in (out / ".claude" / "skills").iterdir()}
|
|
267
|
+
self.assertEqual(children, {"a", "b", "c"})
|
|
268
|
+
|
|
269
|
+
project_packs([pack_a], self.contract, out)
|
|
270
|
+
children = {p.name for p in (out / ".claude" / "skills").iterdir()}
|
|
271
|
+
self.assertEqual(children, {"a", "b"})
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
unittest.main()
|