agentbundle 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. agentbundle-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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