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,329 @@
|
|
|
1
|
+
"""Shared drop-warning helpers for the install and validate commands.
|
|
2
|
+
|
|
3
|
+
Owns the per-file hook-wiring enumerator and the unified formatter that
|
|
4
|
+
produces both the install-time ``warning:`` line and the validate-time
|
|
5
|
+
``info:`` line.
|
|
6
|
+
|
|
7
|
+
docs/specs/incompatible-hook-event-drop AC6 / AC6b / AC6c / AC7.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import tomllib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Pinned ordering for <reason-summary> in the formatter.
|
|
19
|
+
# Any future category is appended after these three in stable-sorted order.
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
_REASON_ORDER: tuple[str, ...] = (
|
|
22
|
+
"event not in adapter vocabulary",
|
|
23
|
+
"kiro requires 'attach-to-agent'",
|
|
24
|
+
"hook-wiring TOML failed to parse",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _adapter_agent_event_vocabulary(
|
|
29
|
+
contract: dict,
|
|
30
|
+
adapter: str,
|
|
31
|
+
) -> list[str] | None:
|
|
32
|
+
"""Return the ``agent-event-vocabulary`` list for *adapter*'s
|
|
33
|
+
hook-wiring projections, or ``None`` if the adapter doesn't declare
|
|
34
|
+
one.
|
|
35
|
+
|
|
36
|
+
Reads ``[adapter.<name>.projections.hook-wiring].agent-event-vocabulary``
|
|
37
|
+
from the contract dict.
|
|
38
|
+
"""
|
|
39
|
+
projections = (
|
|
40
|
+
contract.get("adapter", {})
|
|
41
|
+
.get(adapter, {})
|
|
42
|
+
.get("projections", {})
|
|
43
|
+
.get("hook-wiring", {})
|
|
44
|
+
)
|
|
45
|
+
vocab = projections.get("agent-event-vocabulary")
|
|
46
|
+
if isinstance(vocab, list):
|
|
47
|
+
return vocab
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_primitive_type_dropped(
|
|
52
|
+
contract: dict,
|
|
53
|
+
adapter: str,
|
|
54
|
+
primitive: str,
|
|
55
|
+
) -> bool:
|
|
56
|
+
"""Return ``True`` when the adapter projects *primitive* with
|
|
57
|
+
``mode = "dropped"`` at the type level.
|
|
58
|
+
|
|
59
|
+
Walks ``[[adapter.<name>.projection]]`` entries — the legacy array
|
|
60
|
+
that carries the coarse-grained per-type mode (used by the
|
|
61
|
+
``_enumerate_dropped_primitives`` rail in install.py). Returns
|
|
62
|
+
``False`` for any adapter that has no entry for the primitive or
|
|
63
|
+
whose entry uses a non-dropped mode.
|
|
64
|
+
"""
|
|
65
|
+
adapter_entries = (
|
|
66
|
+
contract.get("adapter", {}).get(adapter, {}).get("projection", [])
|
|
67
|
+
)
|
|
68
|
+
for entry in adapter_entries:
|
|
69
|
+
if entry.get("primitive") == primitive and entry.get("mode") == "dropped":
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def enumerate_event_dropped_wirings(
|
|
75
|
+
pack_dir: Path,
|
|
76
|
+
adapter: str,
|
|
77
|
+
contract: dict,
|
|
78
|
+
) -> list[tuple[str, str]]:
|
|
79
|
+
"""Return per-file hook-wiring drops as ``(relpath, reason_category)`` pairs.
|
|
80
|
+
|
|
81
|
+
AC6 / AC6b / AC6c of spec incompatible-hook-event-drop.
|
|
82
|
+
|
|
83
|
+
Walk semantics:
|
|
84
|
+
Step 1 (type-level gate): if hook-wiring is ``mode = "dropped"``
|
|
85
|
+
for the adapter at the type level, return ``[]`` early — the
|
|
86
|
+
coarse-grained rail already covers it; no double-warning.
|
|
87
|
+
Step 2: walk ``<pack_dir>/.apm/hook-wiring/*.toml`` (sorted by
|
|
88
|
+
basename):
|
|
89
|
+
2a (vocab check): if the adapter declares ``agent-event-vocabulary``
|
|
90
|
+
and any ``[[hooks.<EventName>]]`` event-name isn't in the vocab,
|
|
91
|
+
append one drop entry per file (break after first — one entry per
|
|
92
|
+
file per reason category, AC6 dedup).
|
|
93
|
+
2b (attach-to-agent, kiro-only): if ``attach-to-agent`` is omitted
|
|
94
|
+
or empty (truthy check), append ``(relpath,
|
|
95
|
+
"kiro requires 'attach-to-agent'")``.
|
|
96
|
+
Parse-fail (AC6c): on ``tomllib.TOMLDecodeError`` or ``OSError``,
|
|
97
|
+
append ``(relpath, "hook-wiring TOML failed to parse")``.
|
|
98
|
+
"""
|
|
99
|
+
# Step 1: gate on non-dropped type.
|
|
100
|
+
if _is_primitive_type_dropped(contract, adapter, "hook-wiring"):
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
drops: list[tuple[str, str]] = []
|
|
104
|
+
hook_wiring_dir = pack_dir / ".apm" / "hook-wiring"
|
|
105
|
+
if not hook_wiring_dir.exists():
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
vocab = _adapter_agent_event_vocabulary(contract, adapter)
|
|
109
|
+
|
|
110
|
+
for toml_path in sorted(hook_wiring_dir.glob("*.toml")):
|
|
111
|
+
relpath = f"hook-wiring/{toml_path.name}"
|
|
112
|
+
# Split read + parse so OSError (unreadable file: permission,
|
|
113
|
+
# truncation, race after glob) doesn't masquerade as a parse
|
|
114
|
+
# failure in the warning. Unreadable files are skipped silently
|
|
115
|
+
# — they'll surface elsewhere (project_pack's read attempt) and
|
|
116
|
+
# the warning rail is for compatibility issues, not I/O.
|
|
117
|
+
try:
|
|
118
|
+
text = toml_path.read_text(encoding="utf-8")
|
|
119
|
+
except OSError:
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
data = tomllib.loads(text)
|
|
123
|
+
except tomllib.TOMLDecodeError:
|
|
124
|
+
# AC6c: install-time emits a parse-fail drop entry;
|
|
125
|
+
# validate-time refuses earlier (separate code path).
|
|
126
|
+
drops.append((relpath, "hook-wiring TOML failed to parse"))
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# Step 2a: vocab check.
|
|
130
|
+
if vocab is not None:
|
|
131
|
+
events = data.get("hooks", {})
|
|
132
|
+
if isinstance(events, dict):
|
|
133
|
+
for event_name in sorted(events.keys()):
|
|
134
|
+
if event_name not in vocab:
|
|
135
|
+
drops.append((relpath, "event not in adapter vocabulary"))
|
|
136
|
+
break # one entry per file per reason category (AC6 dedup)
|
|
137
|
+
|
|
138
|
+
# Step 2b: attach-to-agent check (kiro-only, presence-only).
|
|
139
|
+
# AC4b carve-out: non-empty unknown-agent references remain
|
|
140
|
+
# validate-time refusals; omitted-or-empty both flow here as
|
|
141
|
+
# install-side drops. The validate side refuses on attach = ""
|
|
142
|
+
# per the test pin, but install-side enumerator treats
|
|
143
|
+
# omitted-or-empty as "effectively missing" for warning purposes.
|
|
144
|
+
if adapter == "kiro":
|
|
145
|
+
attach = data.get("attach-to-agent")
|
|
146
|
+
if not isinstance(attach, str) or not attach:
|
|
147
|
+
drops.append((relpath, "kiro requires 'attach-to-agent'"))
|
|
148
|
+
|
|
149
|
+
return drops
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _join_serial_comma(items: list[str]) -> str:
|
|
153
|
+
"""Project's list-formatting convention: serial comma + 'and'.
|
|
154
|
+
|
|
155
|
+
Examples:
|
|
156
|
+
- ``[]`` → ``""``
|
|
157
|
+
- ``["a"]`` → ``"a"``
|
|
158
|
+
- ``["a", "b"]`` → ``"a and b"``
|
|
159
|
+
- ``["a", "b", "c"]`` → ``"a, b, and c"``
|
|
160
|
+
"""
|
|
161
|
+
if not items:
|
|
162
|
+
return ""
|
|
163
|
+
if len(items) == 1:
|
|
164
|
+
return items[0]
|
|
165
|
+
if len(items) == 2:
|
|
166
|
+
return f"{items[0]} and {items[1]}"
|
|
167
|
+
return ", ".join(items[:-1]) + ", and " + items[-1]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _build_reason_summary(reason_categories: list[str]) -> str:
|
|
171
|
+
"""Build the ``<reason-summary>`` string: deduplicated reason
|
|
172
|
+
categories in pinned order, joined with `` + ``.
|
|
173
|
+
|
|
174
|
+
Pinned order: vocabulary first, then attach-to-agent, then
|
|
175
|
+
parse-fail. Any future category appears after in stable-sorted
|
|
176
|
+
order.
|
|
177
|
+
"""
|
|
178
|
+
seen: set[str] = set()
|
|
179
|
+
ordered: list[str] = []
|
|
180
|
+
for cat in _REASON_ORDER:
|
|
181
|
+
if cat in reason_categories and cat not in seen:
|
|
182
|
+
ordered.append(cat)
|
|
183
|
+
seen.add(cat)
|
|
184
|
+
# Defensive: any future category not in _REASON_ORDER.
|
|
185
|
+
for cat in sorted(set(reason_categories)):
|
|
186
|
+
if cat not in seen:
|
|
187
|
+
ordered.append(cat)
|
|
188
|
+
return " + ".join(ordered)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _join_serial_comma_files(items: list[str]) -> str:
|
|
192
|
+
"""File-list variant: always uses serial comma (Oxford comma) before
|
|
193
|
+
``and``, including for two-item lists.
|
|
194
|
+
|
|
195
|
+
AC2 pins the two-file form as ``"a, and b"`` (comma before "and")
|
|
196
|
+
— differs from the compatible-list formatter which uses ``"a and b"``
|
|
197
|
+
for two items. Isolating the file-list join prevents the two
|
|
198
|
+
contracts from drifting if either is changed independently.
|
|
199
|
+
|
|
200
|
+
Examples:
|
|
201
|
+
- ``[]`` → ``""``
|
|
202
|
+
- ``["a"]`` → ``"a"``
|
|
203
|
+
- ``["a", "b"]`` → ``"a, and b"``
|
|
204
|
+
- ``["a", "b", "c"]`` → ``"a, b, and c"``
|
|
205
|
+
"""
|
|
206
|
+
if not items:
|
|
207
|
+
return ""
|
|
208
|
+
if len(items) == 1:
|
|
209
|
+
return items[0]
|
|
210
|
+
return ", ".join(items[:-1]) + ", and " + items[-1]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _build_file_list(file_relpath_pairs: list[tuple[str, str]]) -> str:
|
|
214
|
+
"""Build the ``<file-list>`` string: deduplicated file paths,
|
|
215
|
+
lexicographically sorted, joined with serial-comma-plus-``and``.
|
|
216
|
+
"""
|
|
217
|
+
files = sorted(set(f for f, _ in file_relpath_pairs))
|
|
218
|
+
return _join_serial_comma_files(files)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _pluralize_primitive_name(name: str) -> str:
|
|
222
|
+
"""Plural form of a primitive-type name."""
|
|
223
|
+
if name == "hook-body":
|
|
224
|
+
return "hook-bodies"
|
|
225
|
+
return name + "s"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def format_drop_message(
|
|
229
|
+
*,
|
|
230
|
+
pack_name: str,
|
|
231
|
+
adapter: str,
|
|
232
|
+
dropped_counts: dict[str, int],
|
|
233
|
+
compatible_types: list[str],
|
|
234
|
+
event_drops: list[tuple[str, str]] | None = None,
|
|
235
|
+
mode: Literal["install_warning", "validate_info"] = "install_warning",
|
|
236
|
+
) -> str:
|
|
237
|
+
"""Build the drop warning / info message.
|
|
238
|
+
|
|
239
|
+
``install_warning`` mode composes the three-clause grammar per
|
|
240
|
+
spec AC7 + AC10's "Pinned wording":
|
|
241
|
+
- Primitive-type clause (when ``dropped_counts`` non-empty).
|
|
242
|
+
- Event-level clause (when ``event_drops`` non-empty), prefixed
|
|
243
|
+
``Additionally, `` when primitive clause also present.
|
|
244
|
+
- Closing clause (when either prior fired).
|
|
245
|
+
|
|
246
|
+
``validate_info`` mode (AC2):
|
|
247
|
+
- Ignores ``dropped_counts`` and ``compatible_types``.
|
|
248
|
+
- Raises ``ValueError`` if ``dropped_counts`` is non-empty.
|
|
249
|
+
- Raises ``ValueError`` if ``event_drops`` is empty.
|
|
250
|
+
- Output: ``info: pack <name>: the following hook-wiring file(s)
|
|
251
|
+
will not project to <adapter> (<reason-summary>): <file-list>.``
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
ValueError: in ``install_warning`` mode when both
|
|
255
|
+
``dropped_counts`` and ``event_drops`` are empty.
|
|
256
|
+
ValueError: in ``validate_info`` mode when ``event_drops`` is
|
|
257
|
+
empty or when ``dropped_counts`` is non-empty.
|
|
258
|
+
"""
|
|
259
|
+
effective_event_drops: list[tuple[str, str]] = event_drops or []
|
|
260
|
+
|
|
261
|
+
if mode == "validate_info":
|
|
262
|
+
if dropped_counts:
|
|
263
|
+
raise ValueError(
|
|
264
|
+
"format_drop_message: validate_info mode does not accept "
|
|
265
|
+
"dropped_counts; validate-side rail is event-only"
|
|
266
|
+
)
|
|
267
|
+
if not effective_event_drops:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
"format_drop_message: validate_info mode requires non-empty event_drops"
|
|
270
|
+
)
|
|
271
|
+
reason_cats = [reason for _, reason in effective_event_drops]
|
|
272
|
+
reason_summary = _build_reason_summary(reason_cats)
|
|
273
|
+
file_list = _build_file_list(effective_event_drops)
|
|
274
|
+
return (
|
|
275
|
+
f"info: pack {pack_name}: the following hook-wiring file(s) "
|
|
276
|
+
f"will not project to {adapter} ({reason_summary}): {file_list}."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# install_warning mode
|
|
280
|
+
# Determine non-zero dropped counts.
|
|
281
|
+
nonzero_dropped = {
|
|
282
|
+
ptype: count for ptype, count in dropped_counts.items() if count > 0
|
|
283
|
+
}
|
|
284
|
+
has_prim = bool(nonzero_dropped)
|
|
285
|
+
has_event = bool(effective_event_drops)
|
|
286
|
+
|
|
287
|
+
if not has_prim and not has_event:
|
|
288
|
+
raise ValueError(
|
|
289
|
+
"format_drop_message: install_warning mode has nothing to format; "
|
|
290
|
+
"both dropped_counts and event_drops are empty"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
clauses: list[str] = []
|
|
294
|
+
|
|
295
|
+
# Primitive-type clause.
|
|
296
|
+
if has_prim:
|
|
297
|
+
count_parts: list[str] = []
|
|
298
|
+
for ptype, count in sorted(nonzero_dropped.items()):
|
|
299
|
+
if count == 1:
|
|
300
|
+
count_parts.append(f"1 {ptype}")
|
|
301
|
+
else:
|
|
302
|
+
count_parts.append(f"{count} {_pluralize_primitive_name(ptype)}")
|
|
303
|
+
count_list = _join_serial_comma(count_parts)
|
|
304
|
+
clauses.append(
|
|
305
|
+
f"pack {pack_name} ships {count_list} that {adapter} "
|
|
306
|
+
f"projects as 'dropped'; these primitives will not be installed."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Event-level clause.
|
|
310
|
+
if has_event:
|
|
311
|
+
reason_cats = [reason for _, reason in effective_event_drops]
|
|
312
|
+
reason_summary = _build_reason_summary(reason_cats)
|
|
313
|
+
file_list = _build_file_list(effective_event_drops)
|
|
314
|
+
event_clause = (
|
|
315
|
+
f"the following hook-wiring file(s) will be skipped "
|
|
316
|
+
f"({reason_summary}): {file_list}."
|
|
317
|
+
)
|
|
318
|
+
if has_prim:
|
|
319
|
+
event_clause = "Additionally, " + event_clause
|
|
320
|
+
clauses.append(event_clause)
|
|
321
|
+
|
|
322
|
+
# Closing clause.
|
|
323
|
+
compatible_parts = [
|
|
324
|
+
_pluralize_primitive_name(ptype) for ptype in sorted(compatible_types)
|
|
325
|
+
]
|
|
326
|
+
compatible_list = _join_serial_comma(compatible_parts)
|
|
327
|
+
clauses.append(f"The compatible primitives ({compatible_list}) will proceed.")
|
|
328
|
+
|
|
329
|
+
return "warning: " + " ".join(clauses)
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""``agentbundle adapt`` — marker resolution and pending-companion report.
|
|
2
|
+
|
|
3
|
+
RFC-0004 turned this into a **dual-state-file** walk:
|
|
4
|
+
|
|
5
|
+
- Read both ``<repo>/.agentbundle-state.toml`` and
|
|
6
|
+
``~/.agentbundle/state.toml``. Either may be absent (a fresh repo,
|
|
7
|
+
or no user-scope installs yet).
|
|
8
|
+
- Read marker values from both ``<repo>/.adapt-discovery.toml`` and
|
|
9
|
+
``~/.agentbundle/.adapt-discovery.toml`` (user-scope discovery
|
|
10
|
+
lives inside the namespaced dot-directory, not as a bare dotfile).
|
|
11
|
+
``--values-from`` still wins as an explicit override.
|
|
12
|
+
- Walk for ``.upstream.<ext>`` companions per scope; write per-scope
|
|
13
|
+
``.adapt-pending.md`` reports at the same per-scope locations.
|
|
14
|
+
- ``adapt --ci`` exits non-zero when *either* scope's pending file
|
|
15
|
+
would be non-empty (or any companion is on disk).
|
|
16
|
+
|
|
17
|
+
Findings are routed by the scope of the state file that recorded them —
|
|
18
|
+
a squatter under ``~/.claude/`` is a user-scope finding, a
|
|
19
|
+
``.upstream.<ext>`` companion in ``<repo>/`` is a repo-scope finding.
|
|
20
|
+
|
|
21
|
+
Spec rail: ``.adapt-discovery.toml`` is **never written** here. The
|
|
22
|
+
``adapt-to-project`` LLM skill owns the write side.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import TYPE_CHECKING
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
import argparse
|
|
35
|
+
|
|
36
|
+
# Marker regex (AC14): canonical lowercase-hyphen identifiers. The CLI
|
|
37
|
+
# narrows from the prior UPPER_SNAKE-only regex to the canonical form
|
|
38
|
+
# that the adapt-to-project skill writes. UPPER_SNAKE markers still
|
|
39
|
+
# appearing in adopter trees are left in place with a one-shot warning
|
|
40
|
+
# (``_LEGACY_UPPER_RE``); they're not substituted.
|
|
41
|
+
_MARKER_RE = re.compile(r"<adapt:([a-z][a-z0-9-]*)>")
|
|
42
|
+
_LEGACY_UPPER_RE = re.compile(r"<adapt:([A-Z_][A-Z0-9_]*)>")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class _Scope:
|
|
47
|
+
"""Per-scope artifact paths the adapt verb operates on."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
root: Path
|
|
51
|
+
state_path: Path
|
|
52
|
+
discovery_path: Path
|
|
53
|
+
pending_path: Path
|
|
54
|
+
# User-scope writes must pass the adapter's `allowed-prefixes.user`
|
|
55
|
+
# list through to `safety.write_jailed` so the path-jail rail
|
|
56
|
+
# fires. Repo-scope leaves this as None (the repo-jail is the
|
|
57
|
+
# repo root; no additional prefix gate).
|
|
58
|
+
allowed_prefixes: list[str] | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _find_upstream_companions(root: Path, projected_paths: set[str] | None = None) -> list[Path]:
|
|
62
|
+
"""Return ``.upstream.<ext>`` companions of paths recorded in state.
|
|
63
|
+
|
|
64
|
+
When *projected_paths* is provided, only companions that sit next to
|
|
65
|
+
a path in the install's projection count — this prevents a stray
|
|
66
|
+
``vendor/upstream.tar.gz`` or documentation artifact from making
|
|
67
|
+
``adapt --ci`` exit non-zero. When *projected_paths* is ``None``
|
|
68
|
+
(e.g. no state file present), fall back to the tree walk so the
|
|
69
|
+
command still does something useful.
|
|
70
|
+
"""
|
|
71
|
+
from agentbundle.safety import companion_path
|
|
72
|
+
|
|
73
|
+
companions: list[Path] = []
|
|
74
|
+
if projected_paths is not None:
|
|
75
|
+
for relpath in sorted(projected_paths):
|
|
76
|
+
comp = root / companion_path(Path(relpath))
|
|
77
|
+
if comp.is_file():
|
|
78
|
+
companions.append(comp)
|
|
79
|
+
return companions
|
|
80
|
+
|
|
81
|
+
for p in sorted(root.rglob("*")):
|
|
82
|
+
if not p.is_file():
|
|
83
|
+
continue
|
|
84
|
+
name = p.name
|
|
85
|
+
parts = name.split(".")
|
|
86
|
+
if len(parts) >= 2 and "upstream" in parts:
|
|
87
|
+
idx = parts.index("upstream")
|
|
88
|
+
if idx > 0:
|
|
89
|
+
companions.append(p)
|
|
90
|
+
return companions
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _diff_summary(original: Path, companion: Path) -> str:
|
|
94
|
+
"""Return a one-line diff summary: line-count delta and first divergent line."""
|
|
95
|
+
try:
|
|
96
|
+
orig_lines = original.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
97
|
+
comp_lines = companion.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
98
|
+
except Exception:
|
|
99
|
+
return "binary or unreadable"
|
|
100
|
+
|
|
101
|
+
delta = len(comp_lines) - len(orig_lines)
|
|
102
|
+
sign = "+" if delta >= 0 else ""
|
|
103
|
+
first_diff_line: str | None = None
|
|
104
|
+
for i, (ol, cl) in enumerate(zip(orig_lines, comp_lines)):
|
|
105
|
+
if ol != cl:
|
|
106
|
+
first_diff_line = f"line {i + 1}: original={ol[:60]!r} upstream={cl[:60]!r}"
|
|
107
|
+
break
|
|
108
|
+
if first_diff_line is None and len(orig_lines) != len(comp_lines):
|
|
109
|
+
first_diff_line = f"line {min(len(orig_lines), len(comp_lines)) + 1}: (line count differs)"
|
|
110
|
+
|
|
111
|
+
parts = [f"lines {sign}{delta}"]
|
|
112
|
+
if first_diff_line:
|
|
113
|
+
parts.append(first_diff_line)
|
|
114
|
+
return "; ".join(parts)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _apply_markers(text: str, values: dict[str, str], *, src_label: str) -> str:
|
|
118
|
+
"""Replace ``<adapt:NAME>`` in *text* using *values*.
|
|
119
|
+
|
|
120
|
+
Unknown markers are left in place; a warning is printed to stderr.
|
|
121
|
+
Legacy UPPER_SNAKE markers (per AC14) are left in place with a single
|
|
122
|
+
warning per file.
|
|
123
|
+
"""
|
|
124
|
+
if _LEGACY_UPPER_RE.search(text):
|
|
125
|
+
print(
|
|
126
|
+
f"adapt: warning: legacy UPPER_SNAKE marker(s) in {src_label}; "
|
|
127
|
+
f"left in place (canonical form is <adapt:[a-z][a-z0-9-]*>)",
|
|
128
|
+
file=sys.stderr,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _replace(m: re.Match) -> str:
|
|
132
|
+
name = m.group(1)
|
|
133
|
+
if name in values:
|
|
134
|
+
return values[name]
|
|
135
|
+
print(
|
|
136
|
+
f"adapt: warning: no value for marker <adapt:{name}> in {src_label}; leaving in place",
|
|
137
|
+
file=sys.stderr,
|
|
138
|
+
)
|
|
139
|
+
return m.group(0)
|
|
140
|
+
|
|
141
|
+
return _MARKER_RE.sub(_replace, text)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _resolve_scopes(args: "argparse.Namespace") -> list[_Scope]:
|
|
145
|
+
"""Return the per-scope artifact descriptions adapt walks.
|
|
146
|
+
|
|
147
|
+
The repo scope is always present (rooted at ``args.root``). The
|
|
148
|
+
user scope is only added if ``~`` can be resolved — when
|
|
149
|
+
``$HOME=/`` or otherwise unresolvable, the user scope is silently
|
|
150
|
+
skipped (a repo-only fixture should not refuse on a malformed
|
|
151
|
+
user-scope environment).
|
|
152
|
+
"""
|
|
153
|
+
from agentbundle import scope as scope_mod
|
|
154
|
+
|
|
155
|
+
repo_root = Path(args.root).resolve()
|
|
156
|
+
scopes: list[_Scope] = [
|
|
157
|
+
_Scope(
|
|
158
|
+
name="repo",
|
|
159
|
+
root=repo_root,
|
|
160
|
+
state_path=repo_root / ".agentbundle-state.toml",
|
|
161
|
+
discovery_path=repo_root / ".adapt-discovery.toml",
|
|
162
|
+
pending_path=repo_root / ".adapt-pending.md",
|
|
163
|
+
),
|
|
164
|
+
]
|
|
165
|
+
try:
|
|
166
|
+
user_root = scope_mod.resolve_user_root()
|
|
167
|
+
except scope_mod.UserScopeUnresolvable:
|
|
168
|
+
return scopes
|
|
169
|
+
# User-scope dot-directory: `<user_root>/.agentbundle/`. We don't
|
|
170
|
+
# *create* it here; we only operate on it if it already exists
|
|
171
|
+
# (i.e. some prior user-scope install set it up).
|
|
172
|
+
user_dir = user_root / ".agentbundle"
|
|
173
|
+
if user_dir.is_dir():
|
|
174
|
+
from agentbundle.commands.install import _claude_code_allowed_prefixes_user
|
|
175
|
+
|
|
176
|
+
scopes.append(
|
|
177
|
+
_Scope(
|
|
178
|
+
name="user",
|
|
179
|
+
root=user_root,
|
|
180
|
+
state_path=user_dir / "state.toml",
|
|
181
|
+
discovery_path=user_dir / ".adapt-discovery.toml",
|
|
182
|
+
pending_path=user_dir / ".adapt-pending.md",
|
|
183
|
+
allowed_prefixes=_claude_code_allowed_prefixes_user(),
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
return scopes
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run(args: "argparse.Namespace") -> int:
|
|
190
|
+
"""Entry point for ``agentbundle adapt``.
|
|
191
|
+
|
|
192
|
+
Returns 0 on success; 1 on ``--ci`` with pending companions or
|
|
193
|
+
path-jail refusal at write time.
|
|
194
|
+
"""
|
|
195
|
+
from agentbundle.config import ConfigError, load_adapt_discovery_typed, load_state, load_values_from
|
|
196
|
+
from agentbundle import safety
|
|
197
|
+
|
|
198
|
+
scopes = _resolve_scopes(args)
|
|
199
|
+
|
|
200
|
+
# ── --ci mode ─────────────────────────────────────────────────────────────
|
|
201
|
+
if args.ci:
|
|
202
|
+
any_pending = False
|
|
203
|
+
for s in scopes:
|
|
204
|
+
try:
|
|
205
|
+
state = load_state(s.state_path) if s.state_path.exists() else None
|
|
206
|
+
except ConfigError as exc:
|
|
207
|
+
print(f"adapt: {exc}", file=sys.stderr)
|
|
208
|
+
return 1
|
|
209
|
+
projected = state.projected_paths() if state else None
|
|
210
|
+
companions = _find_upstream_companions(s.root, projected)
|
|
211
|
+
if companions:
|
|
212
|
+
if not any_pending:
|
|
213
|
+
print(
|
|
214
|
+
"adapt --ci: pending .upstream.* companions found:",
|
|
215
|
+
file=sys.stderr,
|
|
216
|
+
)
|
|
217
|
+
any_pending = True
|
|
218
|
+
for cp in companions:
|
|
219
|
+
try:
|
|
220
|
+
rel = cp.relative_to(s.root)
|
|
221
|
+
except ValueError:
|
|
222
|
+
rel = cp
|
|
223
|
+
print(f" [{s.name}] {rel}", file=sys.stderr)
|
|
224
|
+
return 1 if any_pending else 0
|
|
225
|
+
|
|
226
|
+
# ── Default mode ──────────────────────────────────────────────────────────
|
|
227
|
+
# Build marker values from the **repo-scope** discovery file's
|
|
228
|
+
# [markers] table. Markers are repo-only per RFC-0004 — the user-
|
|
229
|
+
# scope discovery file is still read (to surface legacy-shape errors
|
|
230
|
+
# symmetrically and to honour the dual-scope walk contract) but
|
|
231
|
+
# carries no [markers] table by rail. --values-from (when supplied)
|
|
232
|
+
# wins as the explicit override.
|
|
233
|
+
values: dict[str, str] = {}
|
|
234
|
+
for s in scopes:
|
|
235
|
+
try:
|
|
236
|
+
discovery = load_adapt_discovery_typed(s.discovery_path, scope=s.name) # type: ignore[arg-type]
|
|
237
|
+
except ConfigError as exc:
|
|
238
|
+
print(f"adapt: {exc}", file=sys.stderr)
|
|
239
|
+
return 1
|
|
240
|
+
if s.name == "repo":
|
|
241
|
+
for k, v in discovery.markers.items():
|
|
242
|
+
values[k] = v
|
|
243
|
+
|
|
244
|
+
if getattr(args, "values_from", None):
|
|
245
|
+
try:
|
|
246
|
+
explicit = load_values_from(Path(args.values_from))
|
|
247
|
+
except ConfigError as exc:
|
|
248
|
+
print(f"adapt: {exc}", file=sys.stderr)
|
|
249
|
+
return 1
|
|
250
|
+
values.update(explicit)
|
|
251
|
+
|
|
252
|
+
# ── Per-scope walk: substitute markers + emit pending report ─────────────
|
|
253
|
+
for s in scopes:
|
|
254
|
+
try:
|
|
255
|
+
state = load_state(s.state_path) if s.state_path.exists() else None
|
|
256
|
+
except ConfigError as exc:
|
|
257
|
+
print(f"adapt: {exc}", file=sys.stderr)
|
|
258
|
+
return 1
|
|
259
|
+
projected = state.projected_paths() if state else set()
|
|
260
|
+
|
|
261
|
+
# Substitute markers only when --values-from was given (preserve
|
|
262
|
+
# the read-only-without-values-from contract).
|
|
263
|
+
if getattr(args, "values_from", None) and values and projected:
|
|
264
|
+
for relpath in sorted(projected):
|
|
265
|
+
target = s.root / relpath
|
|
266
|
+
if not target.exists() or not target.is_file():
|
|
267
|
+
continue
|
|
268
|
+
try:
|
|
269
|
+
text = target.read_bytes().decode("utf-8")
|
|
270
|
+
except (UnicodeDecodeError, ValueError):
|
|
271
|
+
print(f"adapt: skipping binary file: [{s.name}] {relpath}", file=sys.stderr)
|
|
272
|
+
continue
|
|
273
|
+
substituted = _apply_markers(text, values, src_label=f"[{s.name}] {relpath}")
|
|
274
|
+
if substituted != text:
|
|
275
|
+
try:
|
|
276
|
+
safety.write_jailed(
|
|
277
|
+
s.root, relpath, substituted,
|
|
278
|
+
scope=s.name,
|
|
279
|
+
allowed_prefixes=s.allowed_prefixes,
|
|
280
|
+
)
|
|
281
|
+
except safety.PathJailError as exc:
|
|
282
|
+
print(f"adapt: {exc}", file=sys.stderr)
|
|
283
|
+
return 1
|
|
284
|
+
|
|
285
|
+
# Build the per-scope pending report.
|
|
286
|
+
companions = _find_upstream_companions(s.root, projected)
|
|
287
|
+
report_lines: list[str] = [
|
|
288
|
+
f"# Adapt Pending Report ({s.name} scope)",
|
|
289
|
+
"",
|
|
290
|
+
"Companions awaiting human merge:",
|
|
291
|
+
"",
|
|
292
|
+
]
|
|
293
|
+
if companions:
|
|
294
|
+
for cp in sorted(companions):
|
|
295
|
+
try:
|
|
296
|
+
rel_companion = cp.relative_to(s.root)
|
|
297
|
+
except ValueError:
|
|
298
|
+
rel_companion = cp
|
|
299
|
+
original = _original_from_companion(cp)
|
|
300
|
+
if original.exists():
|
|
301
|
+
summary = _diff_summary(original, cp)
|
|
302
|
+
else:
|
|
303
|
+
summary = "original file not found"
|
|
304
|
+
report_lines.append(f"- `{rel_companion}`: {summary}")
|
|
305
|
+
else:
|
|
306
|
+
report_lines.append("_No pending companions._")
|
|
307
|
+
report_lines.append("")
|
|
308
|
+
report_content = "\n".join(report_lines)
|
|
309
|
+
|
|
310
|
+
# Write the pending report through the per-scope path-jail. At
|
|
311
|
+
# user scope this routes through `allowed-prefixes.user` —
|
|
312
|
+
# `.adapt-pending.md` lives at `.agentbundle/.adapt-pending.md`
|
|
313
|
+
# under the user root, which the `.agentbundle/` prefix admits.
|
|
314
|
+
report_relpath = s.pending_path.relative_to(s.root).as_posix()
|
|
315
|
+
try:
|
|
316
|
+
safety.write_jailed(
|
|
317
|
+
s.root, report_relpath, report_content,
|
|
318
|
+
scope=s.name,
|
|
319
|
+
allowed_prefixes=s.allowed_prefixes,
|
|
320
|
+
)
|
|
321
|
+
except safety.PathJailError as exc:
|
|
322
|
+
print(f"adapt: {exc}", file=sys.stderr)
|
|
323
|
+
return 1
|
|
324
|
+
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _original_from_companion(companion: Path) -> Path:
|
|
329
|
+
"""Derive the original file path from a companion path.
|
|
330
|
+
|
|
331
|
+
Inverse of ``safety.companion_path``:
|
|
332
|
+
- ``AGENTS.upstream.md`` → ``AGENTS.md``
|
|
333
|
+
- ``Makefile.upstream`` → ``Makefile``
|
|
334
|
+
- ``foo.upstream.md`` → ``foo.md``
|
|
335
|
+
"""
|
|
336
|
+
name = companion.name
|
|
337
|
+
parts = name.split(".")
|
|
338
|
+
if "upstream" in parts:
|
|
339
|
+
idx = parts.index("upstream")
|
|
340
|
+
new_parts = parts[:idx] + parts[idx + 1:]
|
|
341
|
+
new_name = ".".join(new_parts)
|
|
342
|
+
return companion.parent / new_name
|
|
343
|
+
return companion
|