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
agentbundle/config.py ADDED
@@ -0,0 +1,747 @@
1
+ """TOML loaders for the CLI's persistent on-disk artifacts.
2
+
3
+ Sources read here:
4
+ - `pack.toml` — a pack's manifest. Schema owned by
5
+ the sibling `distribution-adapters`
6
+ spec and validated by F-build's
7
+ `validate_pack_metadata` helper.
8
+ - `.agentbundle-state.toml` — install-time state. Schema documented
9
+ in the sibling spec § "state schema".
10
+ - `.adapt-discovery.toml` — adopter values for `<adapt:NAME>`
11
+ markers. CLI **reads only**; the
12
+ `adapt-to-project` LLM skill writes it.
13
+ - `--values-from <file.toml>` — explicit override values for
14
+ `agentbundle adapt`.
15
+
16
+ No source is written here — see `safety.write_jailed` for the only
17
+ sanctioned write surface.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import tomllib
24
+ from dataclasses import dataclass, field
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Any, Literal
28
+
29
+
30
+ STATE_SCHEMA_VERSION = "0.3"
31
+
32
+ # Read-time default for v0.3 rows lacking explicit ``target-file`` when the
33
+ # resolved adapter is ``claude-code`` — the adapter's user-scope settings
34
+ # file is the only place a claude-code hook-wiring row could land
35
+ # (RFC-0005 § State-file impact).
36
+ _CLAUDE_CODE_USER_SETTINGS_DEFAULT = "~/.claude/settings.json"
37
+
38
+
39
+ class ConfigError(ValueError):
40
+ """Raised when a TOML source fails to load or fails schema invariants."""
41
+
42
+
43
+ class StateFileLegacy(ConfigError):
44
+ """Raised when a write-capable invocation hits a v0.1 or v0.2 state file.
45
+
46
+ Migrations are append-only (v0.1 → v0.2 added a per-row scope column;
47
+ v0.2 → v0.3 added optional ``adapter`` / ``target-file`` /
48
+ ``hook-wiring-owned`` fields under RFC-0005's header-only-additive
49
+ rule), so the CLI never silently rewrites — every write-capable
50
+ handler surfaces this exception as a one-line refuse-and-explain
51
+ pointing the adopter at ``agentbundle init-state --migrate``.
52
+ Read-only paths still parse the legacy file: v0.1 implies repo scope
53
+ per RFC-0004 § *Backward compatibility*; v0.2 lets the v0.3
54
+ read-time defaults fire.
55
+
56
+ Carries the path on disk plus the offending version so the formatter
57
+ can name both in the stderr message — adopters running mixed CLI
58
+ versions across CI and local need to know which file is which.
59
+ """
60
+
61
+ def __init__(self, path: Path, version: str = "0.1") -> None:
62
+ self.path = path
63
+ self.version = version
64
+ super().__init__(
65
+ f"state file at {path} is schema-version {version}; "
66
+ f"run 'agentbundle init-state --migrate' first"
67
+ )
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # pack.toml
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def load_pack_toml(path: Path) -> dict[str, Any]:
76
+ """Load and lightly normalise a pack manifest.
77
+
78
+ Returns the raw parsed TOML dict. Schema validation against
79
+ `pack.schema.json` is performed by F-build's `validate_pack_metadata`;
80
+ we don't duplicate it here — keep one source of truth.
81
+
82
+ Raises:
83
+ ConfigError: if the file is missing, unreadable, or not valid TOML.
84
+ """
85
+ if not path.exists():
86
+ raise ConfigError(f"pack.toml not found at {path}")
87
+ try:
88
+ return tomllib.loads(path.read_text(encoding="utf-8"))
89
+ except tomllib.TOMLDecodeError as exc:
90
+ raise ConfigError(f"pack.toml at {path} is not valid TOML: {exc}") from exc
91
+
92
+
93
+ def pack_spec_version(pack_toml: dict[str, Any]) -> str | None:
94
+ """Return `[pack.adapter-contract] version` if declared, else None."""
95
+ table = pack_toml.get("pack", {}).get("adapter-contract", {})
96
+ if isinstance(table, dict):
97
+ v = table.get("version")
98
+ return v if isinstance(v, str) else None
99
+ return None
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # .agentbundle-state.toml
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ @dataclass
108
+ class PackState:
109
+ """One installed pack's slice of `.agentbundle-state.toml`."""
110
+
111
+ installed_version: str
112
+ source: str = "agent-ready-repo"
113
+ install_route: str = "cli"
114
+ # RFC-0004: every v0.2 entry carries an explicit scope. v0.1 state
115
+ # files are read as all-`"repo"` (the legacy implicit default);
116
+ # `init-state --migrate` writes the column out so the file is
117
+ # readable by both v0.1 and v0.2 consumers identically.
118
+ scope: str = "repo"
119
+ primitives: list[str] = field(default_factory=list)
120
+ files: dict[str, dict[str, str]] = field(default_factory=dict)
121
+ # Per-primitive overrides for mixed-version packs (T12). Optional;
122
+ # absent when the pack is at a single uniform version.
123
+ primitive_versions: dict[str, dict[str, str]] = field(default_factory=dict)
124
+ # RFC-0005 v0.3 additions — optional, read-time defaulted.
125
+ # ``adapter`` defaults to ``"claude-code"`` when absent on read
126
+ # (covers v0.2-vintage rows preserved across the header-only
127
+ # migration and v0.3-vintage claude-code rows omitting the field as
128
+ # a write-time space saving). ``target_file`` defaults to
129
+ # ``~/.claude/settings.json`` for claude-code rows; **required**
130
+ # (no default) for kiro rows. ``hook_wiring_owned`` is the per-pack
131
+ # array-of-tables that uninstall walks to remove the right entries
132
+ # from the right files.
133
+ adapter: str = "claude-code"
134
+ target_file: str | None = None
135
+ hook_wiring_owned: list[dict[str, str]] = field(default_factory=list)
136
+
137
+ def file_sha(self, relpath: str) -> str | None:
138
+ entry = self.files.get(relpath)
139
+ return entry.get("sha") if isinstance(entry, dict) else None
140
+
141
+
142
+ @dataclass
143
+ class State:
144
+ """Parsed `.agentbundle-state.toml` — all installed packs."""
145
+
146
+ schema_version: str = STATE_SCHEMA_VERSION
147
+ packs: dict[str, PackState] = field(default_factory=dict)
148
+
149
+ def projected_paths(self) -> set[str]:
150
+ out: set[str] = set()
151
+ for ps in self.packs.values():
152
+ out.update(ps.files.keys())
153
+ return out
154
+
155
+
156
+ def load_state(path: Path, *, for_write: bool = False) -> State:
157
+ """Load `.agentbundle-state.toml`. Returns empty State if file is absent.
158
+
159
+ Absent is **not** an error — fresh repos legitimately have no state file
160
+ before the first install / init-state. Callers distinguish "absent" from
161
+ "present but empty" via `path.exists()` if they need to.
162
+
163
+ RFC-0004 read-vs-write split:
164
+ - Read paths (``for_write=False``, default): a v0.1 file is loaded
165
+ with every ``[pack.<name>]`` entry getting an implicit
166
+ ``scope = "repo"``; the returned ``State.schema_version`` preserves
167
+ ``"0.1"`` so the caller can detect legacy state without re-reading
168
+ the file. No migration is forced at read.
169
+ - Write paths (``for_write=True``): a v0.1 file raises
170
+ ``StateFileLegacy(path)``. The CLI's top-level handler formats this
171
+ as ``state file at <path> is schema-version 0.1; run 'agentbundle
172
+ init-state --migrate' first``. Migration is destructive — adopters
173
+ running mixed CLI versions across CI and local must opt into it
174
+ explicitly via ``init-state --migrate``.
175
+ """
176
+ if not path.exists():
177
+ return State()
178
+ try:
179
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
180
+ except tomllib.TOMLDecodeError as exc:
181
+ raise ConfigError(
182
+ f".agentbundle-state.toml at {path} is not valid TOML: {exc}"
183
+ ) from exc
184
+
185
+ schema_version = raw.get("schema-version", STATE_SCHEMA_VERSION)
186
+ if not isinstance(schema_version, str):
187
+ raise ConfigError(f"schema-version must be a string, got {type(schema_version)!r}")
188
+
189
+ # Refuse-and-explain on writes to a legacy state file. We check this
190
+ # *before* parsing pack entries so callers can rely on the exception
191
+ # type alone — no half-parsed State leaks out. Both v0.1 and v0.2
192
+ # are legacy from v0.3's perspective (RFC-0005 § State-file impact).
193
+ if for_write and schema_version in ("0.1", "0.2"):
194
+ raise StateFileLegacy(path, version=schema_version)
195
+
196
+ state = State(schema_version=schema_version)
197
+ pack_table = raw.get("pack", {})
198
+ if not isinstance(pack_table, dict):
199
+ raise ConfigError("[pack] must be a table")
200
+ for name, body in pack_table.items():
201
+ if not isinstance(body, dict):
202
+ raise ConfigError(f"[pack.{name}] must be a table")
203
+ files = body.get("files", {}) or {}
204
+ if not isinstance(files, dict):
205
+ raise ConfigError(f"[pack.{name}.files] must be a table")
206
+
207
+ # Primitive-version sub-tables look like `[pack.<name>.skill.<X>]`,
208
+ # one nested table per primitive type. We collect them lazily; if a
209
+ # body key is one of the five primitive type names, it's a
210
+ # mixed-version override map rather than a top-level field.
211
+ PRIMITIVE_KEYS = ("skill", "agent", "hook-body", "hook-wiring", "command")
212
+ primitive_versions: dict[str, dict[str, str]] = {}
213
+ for ptype in PRIMITIVE_KEYS:
214
+ sub = body.get(ptype)
215
+ if isinstance(sub, dict):
216
+ primitive_versions[ptype] = {
217
+ pname: pbody.get("version", "")
218
+ for pname, pbody in sub.items()
219
+ if isinstance(pbody, dict)
220
+ }
221
+
222
+ # RFC-0004 scope column. v0.2 carries it explicitly; v0.1 files
223
+ # imply repo scope for every pack (read-time compatibility). A
224
+ # v0.2 file with an unknown scope value falls back to "repo" so
225
+ # readers never trip on a typo — schema validation catches that
226
+ # earlier in the write path.
227
+ raw_scope = body.get("scope") if schema_version != "0.1" else None
228
+ scope = raw_scope if isinstance(raw_scope, str) and raw_scope in ("repo", "user") else "repo"
229
+
230
+ # RFC-0005 v0.3 read-time defaults. ``adapter`` absent → claude-code
231
+ # (covers v0.2-vintage rows preserved across the header-only
232
+ # migration and v0.3-vintage claude-code rows omitting the field).
233
+ # ``target-file`` absent on claude-code → ``~/.claude/settings.json``;
234
+ # absent on kiro is a contract violation (no implicit default).
235
+ raw_adapter = body.get("adapter") if schema_version not in ("0.1",) else None
236
+ adapter = raw_adapter if isinstance(raw_adapter, str) else "claude-code"
237
+
238
+ raw_target = body.get("target-file") if schema_version not in ("0.1",) else None
239
+ if isinstance(raw_target, str):
240
+ target_file: str | None = raw_target
241
+ elif adapter == "claude-code":
242
+ target_file = _CLAUDE_CODE_USER_SETTINGS_DEFAULT
243
+ else:
244
+ # Kiro rows require ``target-file`` per RFC-0005 § State-file
245
+ # impact ("no implicit default — Kiro rows always carry the
246
+ # pack-owned `.kiro/agents/<agent>.json` path explicitly").
247
+ # The read path stays operationally tolerant (returns None);
248
+ # consumers that need the field surface their own error when
249
+ # they go to use it. This keeps ``init-state --migrate``
250
+ # able to operate on a malformed v0.3 file without trapping
251
+ # the adopter in a refuse-and-explain dead end.
252
+ target_file = None
253
+
254
+ # RFC-0005: optional `[[pack.<name>.hook-wiring-owned]]` array-of-tables.
255
+ # Each entry carries at least `event` and `id`, and optionally
256
+ # `target-file` (used by upgrade reconciliation when a Kiro pack's
257
+ # `attach-to-agent` value changes between versions — T8c).
258
+ raw_owned = body.get("hook-wiring-owned", []) or []
259
+ hook_wiring_owned: list[dict[str, str]] = []
260
+ if isinstance(raw_owned, list):
261
+ for i, entry in enumerate(raw_owned):
262
+ if not isinstance(entry, dict):
263
+ raise ConfigError(
264
+ f"[pack.{name}.hook-wiring-owned] entry {i} must be a table"
265
+ )
266
+ hook_wiring_owned.append({
267
+ k: str(v) for k, v in entry.items() if isinstance(v, str)
268
+ })
269
+
270
+ ps = PackState(
271
+ installed_version=body.get("installed-version", ""),
272
+ source=body.get("source", "agent-ready-repo"),
273
+ install_route=body.get("install-route", "cli"),
274
+ scope=scope,
275
+ primitives=list(body.get("primitives", []) or []),
276
+ files={k: dict(v) for k, v in files.items() if isinstance(v, dict)},
277
+ primitive_versions=primitive_versions,
278
+ adapter=adapter,
279
+ target_file=target_file,
280
+ hook_wiring_owned=hook_wiring_owned,
281
+ )
282
+ state.packs[name] = ps
283
+ return state
284
+
285
+
286
+ def dump_state(state: State) -> str:
287
+ """Serialise a State to TOML.
288
+
289
+ Stdlib `tomllib` is read-only; we emit a deterministic textual form by
290
+ hand. Order: schema-version, then packs sorted by name, then per-pack
291
+ fields in fixed order, then files sorted by path. Determinism matters
292
+ because the state file participates in diffing and merging.
293
+ """
294
+ # Every basic-string interpolation routes through `_emit_basic_string`
295
+ # (helper emits the surrounding quotes) so pack-sourced values like
296
+ # `installed-version` can never break out into phantom TOML structure.
297
+ lines: list[str] = [
298
+ f"schema-version = {_emit_basic_string(state.schema_version)}",
299
+ "",
300
+ ]
301
+ for name in sorted(state.packs):
302
+ ps = state.packs[name]
303
+ lines.append(f"[pack.{_toml_key(name)}]")
304
+ lines.append(f"installed-version = {_emit_basic_string(ps.installed_version)}")
305
+ lines.append(f"source = {_emit_basic_string(ps.source)}")
306
+ lines.append(f"install-route = {_emit_basic_string(ps.install_route)}")
307
+ # RFC-0004: emit `scope` only when the state file's schema is v0.2+.
308
+ # A v0.1 file round-trips unchanged (no scope column) because the
309
+ # read-only-as-repo-scope contract works at *read* time; the only
310
+ # write path through this branch is `init-state --migrate`, which
311
+ # bumps schema_version before calling dump_state.
312
+ if state.schema_version != "0.1":
313
+ lines.append(f"scope = {_emit_basic_string(ps.scope)}")
314
+ # RFC-0005 v0.3 fields. ``adapter`` defaults to "claude-code"
315
+ # on read; only emit when non-default to keep the common case
316
+ # byte-compatible with v0.2. ``target-file`` is always emitted
317
+ # when set (even if it equals the claude-code default) so
318
+ # round-trip is byte-stable for explicit-default rows that
319
+ # T8b/T8c install/upgrade writers may produce.
320
+ if state.schema_version == "0.3":
321
+ if ps.adapter and ps.adapter != "claude-code":
322
+ lines.append(f"adapter = {_emit_basic_string(ps.adapter)}")
323
+ if ps.target_file is not None:
324
+ lines.append(f"target-file = {_emit_basic_string(ps.target_file)}")
325
+ primitives_repr = ", ".join(_emit_basic_string(p) for p in ps.primitives)
326
+ lines.append(f"primitives = [{primitives_repr}]")
327
+ lines.append("")
328
+ lines.append(f"[pack.{_toml_key(name)}.files]")
329
+ for relpath in sorted(ps.files):
330
+ entry = ps.files[relpath]
331
+ inline = ", ".join(
332
+ f"{k} = {_emit_basic_string(v)}" for k, v in sorted(entry.items())
333
+ )
334
+ lines.append(f"{_emit_basic_string(relpath)} = {{ {inline} }}")
335
+ lines.append("")
336
+ # Mixed-version primitive overrides (T12).
337
+ for ptype, primitives in sorted(ps.primitive_versions.items()):
338
+ for pname, version in sorted(primitives.items()):
339
+ lines.append(f"[pack.{_toml_key(name)}.{ptype}.{_toml_key(pname)}]")
340
+ lines.append(f"version = {_emit_basic_string(version)}")
341
+ lines.append("")
342
+ # RFC-0005 v0.3: `[[pack.<name>.hook-wiring-owned]]` rows. Order
343
+ # is the in-memory order — install appends; uninstall walks the
344
+ # stored list. T8a does not introduce sort discipline; T8b/T9
345
+ # will lock that down if needed.
346
+ if state.schema_version == "0.3" and ps.hook_wiring_owned:
347
+ for entry in ps.hook_wiring_owned:
348
+ lines.append(f"[[pack.{_toml_key(name)}.hook-wiring-owned]]")
349
+ for key in sorted(entry):
350
+ lines.append(f"{key} = {_emit_basic_string(entry[key])}")
351
+ lines.append("")
352
+ return "\n".join(lines).rstrip() + "\n"
353
+
354
+
355
+ def _toml_key(name: str) -> str:
356
+ """Quote a TOML key if it contains characters that require quoting.
357
+
358
+ A quoted key follows TOML 1.0 basic-string escaping (§ Keys), so
359
+ delegate the quoting path to :func:`_emit_basic_string` rather than
360
+ inlining ``f'"{name}"'`` — otherwise a key containing ``"`` or a
361
+ backslash would land malformed TOML, the same injection shape the
362
+ value-side emitters guard against.
363
+ """
364
+ if name and all(c.isalnum() or c in "-_" for c in name):
365
+ return name
366
+ return _emit_basic_string(name)
367
+
368
+
369
+ # Control characters that TOML 1.0 § Strings forbids unescaped inside a
370
+ # basic-string. Everything in U+0000..U+001F except `\t` (which has a
371
+ # short escape), plus U+007F. The `\uXXXX` long-form covers them all.
372
+ _TOML_SHORT_ESCAPES = {
373
+ "\b": "\\b",
374
+ "\t": "\\t",
375
+ "\n": "\\n",
376
+ "\f": "\\f",
377
+ "\r": "\\r",
378
+ '"': '\\"',
379
+ "\\": "\\\\",
380
+ }
381
+
382
+
383
+ def _emit_basic_string(value: str) -> str:
384
+ """Serialise *value* as a TOML 1.0 basic-string literal (incl. quotes).
385
+
386
+ Every CLI write-path that interpolates a pack-sourced string into
387
+ TOML output routes through here. The grammar matches what
388
+ ``tomllib`` will accept: short escapes for ``\\b \\t \\n \\f \\r ``
389
+ ``\\" \\\\``, ``\\uXXXX`` for any other control char (U+0000..U+001F
390
+ and U+007F), and verbatim emission for everything else (including
391
+ multi-byte UTF-8).
392
+
393
+ Returns the *quoted* form ``"...escaped..."`` so callers write
394
+ ``key = {_emit_basic_string(v)}`` without re-adding quotes.
395
+
396
+ Raises ``ConfigError`` (not ``TypeError``) if *value* is not a
397
+ string. Callers ship a typed contract; this guard means an
398
+ accidental non-string field on a future ``State``/``AdaptDiscovery``
399
+ extension surfaces as a domain-shaped refusal rather than a
400
+ ``for-char-in-non-iterable`` traceback.
401
+ """
402
+ if not isinstance(value, str):
403
+ raise ConfigError(
404
+ f"basic-string position expects str, got {type(value).__name__}"
405
+ )
406
+ chunks: list[str] = ['"']
407
+ for ch in value:
408
+ short = _TOML_SHORT_ESCAPES.get(ch)
409
+ if short is not None:
410
+ chunks.append(short)
411
+ elif ord(ch) < 0x20 or ord(ch) == 0x7F:
412
+ chunks.append(f"\\u{ord(ch):04X}")
413
+ else:
414
+ chunks.append(ch)
415
+ chunks.append('"')
416
+ return "".join(chunks)
417
+
418
+
419
+ # ---------------------------------------------------------------------------
420
+ # .adapt-discovery.toml — typed schema (v0.1)
421
+ #
422
+ # Spec rail: the CLI may **read** this file but must never write it.
423
+ # The `adapt-to-project` LLM skill owns the write side.
424
+ # ---------------------------------------------------------------------------
425
+
426
+ _KNOWN_DISCOVERY_SCHEMA_VERSIONS = {"0.1"}
427
+ _KNOWN_FINDING_KINDS = {"companion-merge", "restructure", "consolidate"}
428
+
429
+
430
+ @dataclass(frozen=True)
431
+ class Finding:
432
+ """One structural finding in `.adapt-discovery.toml`.
433
+
434
+ `accepted` is True when the finding lives under ``[[findings.accepted]]``
435
+ and False when it lives under ``[[findings.declined]]``.
436
+ `recorded_at` holds `accepted-at` or `declined-at` (whichever is present);
437
+ None when the timestamp was omitted.
438
+ """
439
+
440
+ finding_id: str
441
+ kind: str # one of: "companion-merge" | "restructure" | "consolidate"
442
+ source_path: str
443
+ destination_path: str
444
+ action: str | None
445
+ recorded_at: datetime | None
446
+ accepted: bool
447
+
448
+
449
+ @dataclass
450
+ class AdaptDiscovery:
451
+ """Parsed `.adapt-discovery.toml` in typed form.
452
+
453
+ `markers` is always a dict; it is empty ``{}`` for user-scope files
454
+ (which must not carry a ``[markers]`` table per RFC-0004).
455
+ """
456
+
457
+ schema_version: str
458
+ markers: dict[str, str] = field(default_factory=dict)
459
+ findings_accepted: list[Finding] = field(default_factory=list)
460
+ findings_declined: list[Finding] = field(default_factory=list)
461
+
462
+
463
+ def finding_id_for(
464
+ pack: str,
465
+ kind: str,
466
+ source_paths: list[str],
467
+ dest_paths: list[str],
468
+ ) -> str:
469
+ """Return the canonical finding-id for the given inputs.
470
+
471
+ Visible form : ``<pack>/<kind>:<8-hex>``
472
+ Hashed input : ``<pack>:<kind>:<sorted-source-paths>:<sorted-dest-paths>``
473
+ (fields joined by ``:``, paths within a field joined by
474
+ ``:`` after sorting — mirrors the spec's hash grammar).
475
+ Hash algorithm: SHA-1; first 8 hex chars form the visible tail.
476
+
477
+ Per spec AC2: ``/`` separates pack from kind (pack names never contain
478
+ ``/``); ``:`` separates the hash-input fields because path values may
479
+ contain ``/``.
480
+ """
481
+ raw = f"{pack}:{kind}:{':'.join(sorted(source_paths))}:{':'.join(sorted(dest_paths))}"
482
+ digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
483
+ return f"{pack}/{kind}:{digest}"
484
+
485
+
486
+ def load_adapt_discovery_typed(
487
+ path: Path,
488
+ *,
489
+ scope: Literal["repo", "user"] = "repo",
490
+ ) -> AdaptDiscovery:
491
+ """Read `.adapt-discovery.toml` and return a typed ``AdaptDiscovery``.
492
+
493
+ Raises ``ConfigError`` on any of:
494
+ - File not valid TOML.
495
+ - Top-level ``[accepted]`` table (legacy CLI shape, AC8).
496
+ - Top-level ``[adapt]`` table (legacy self-host shape, AC9).
497
+ - Unknown ``discovery-schema-version`` (AC16).
498
+ - ``scope="user"`` and file contains a ``[markers]`` table (AC2/RFC-0004).
499
+ - A ``[[findings.*]]`` entry with an unknown ``kind``.
500
+
501
+ Returns an ``AdaptDiscovery`` with ``markers={}`` when the file lacks a
502
+ ``[markers]`` table (valid for both scopes).
503
+
504
+ Missing file returns ``AdaptDiscovery(schema_version="0.1")`` (no
505
+ markers, no findings) rather than raising — absent is not an error.
506
+ """
507
+ if not path.exists():
508
+ return AdaptDiscovery(schema_version="0.1")
509
+
510
+ try:
511
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
512
+ except tomllib.TOMLDecodeError as exc:
513
+ raise ConfigError(
514
+ f".adapt-discovery.toml at {path} is not valid TOML: {exc}"
515
+ ) from exc
516
+
517
+ # AC8: legacy [accepted] top-level table (old CLI shape).
518
+ if "accepted" in raw:
519
+ raise ConfigError(
520
+ "legacy [accepted] table; migrate to [markers] per "
521
+ "docs/specs/adapt-to-project/spec.md"
522
+ )
523
+
524
+ # AC9: legacy [adapt] top-level table (old self-host shape).
525
+ if "adapt" in raw:
526
+ raise ConfigError(
527
+ "legacy [adapt] table; migrate to [markers] per "
528
+ "docs/specs/adapt-to-project/spec.md"
529
+ )
530
+
531
+ # AC16: unknown schema version.
532
+ schema_version = raw.get("discovery-schema-version")
533
+ if schema_version not in _KNOWN_DISCOVERY_SCHEMA_VERSIONS:
534
+ known = ", ".join(sorted(_KNOWN_DISCOVERY_SCHEMA_VERSIONS))
535
+ raise ConfigError(
536
+ f"unknown discovery-schema-version {schema_version!r}; "
537
+ f"known: {known}"
538
+ )
539
+
540
+ # AC2 / RFC-0004: user-scope files must not carry [markers].
541
+ if scope == "user" and "markers" in raw:
542
+ raise ConfigError(
543
+ "user-scope .adapt-discovery.toml may not contain a [markers] table; "
544
+ "markers are repo-only per RFC-0004"
545
+ )
546
+
547
+ markers: dict[str, str] = {}
548
+ raw_markers = raw.get("markers", {})
549
+ if isinstance(raw_markers, dict):
550
+ import re as _re
551
+
552
+ marker_key_re = _re.compile(r"^[a-z][a-z0-9-]*$")
553
+ for k, v in raw_markers.items():
554
+ # Spec § Canonical .adapt-discovery.toml schemas (v0.1):
555
+ # "a repo-scope file with [markers] that contains keys
556
+ # violating the lowercase-hyphen grammar is refused".
557
+ if not marker_key_re.fullmatch(str(k)):
558
+ raise ConfigError(
559
+ f"marker key {k!r} violates lowercase-hyphen grammar "
560
+ f"^[a-z][a-z0-9-]*$ per docs/specs/adapt-to-project/spec.md"
561
+ )
562
+ if not isinstance(v, str):
563
+ raise ConfigError(
564
+ f"markers[{k!r}] must be a string, got {type(v).__name__}"
565
+ )
566
+ markers[k] = v
567
+
568
+ findings_raw = raw.get("findings", {})
569
+ findings_accepted = _parse_findings(findings_raw.get("accepted", []), accepted=True)
570
+ findings_declined = _parse_findings(findings_raw.get("declined", []), accepted=False)
571
+
572
+ return AdaptDiscovery(
573
+ schema_version=schema_version,
574
+ markers=markers,
575
+ findings_accepted=findings_accepted,
576
+ findings_declined=findings_declined,
577
+ )
578
+
579
+
580
+ def _parse_findings(entries: list[Any], *, accepted: bool) -> list[Finding]:
581
+ out: list[Finding] = []
582
+ for i, entry in enumerate(entries):
583
+ if not isinstance(entry, dict):
584
+ raise ConfigError(f"findings entry {i} must be a table")
585
+
586
+ kind = entry.get("kind", "")
587
+ if kind not in _KNOWN_FINDING_KINDS:
588
+ raise ConfigError(
589
+ f"unknown finding kind {kind!r}; "
590
+ f"known: {', '.join(sorted(_KNOWN_FINDING_KINDS))}"
591
+ )
592
+
593
+ # Timestamps: accepted-at or declined-at depending on bucket.
594
+ ts_key = "accepted-at" if accepted else "declined-at"
595
+ ts_raw = entry.get(ts_key)
596
+ recorded_at: datetime | None = None
597
+ if isinstance(ts_raw, datetime):
598
+ recorded_at = ts_raw if ts_raw.tzinfo is not None else ts_raw.replace(tzinfo=timezone.utc)
599
+
600
+ out.append(
601
+ Finding(
602
+ finding_id=str(entry.get("finding-id", "")),
603
+ kind=kind,
604
+ source_path=str(entry.get("source-path", "")),
605
+ destination_path=str(entry.get("destination-path", "")),
606
+ action=entry.get("action") if isinstance(entry.get("action"), str) else None,
607
+ recorded_at=recorded_at,
608
+ accepted=accepted,
609
+ )
610
+ )
611
+ return out
612
+
613
+
614
+ def adapt_discovery_to_toml(d: AdaptDiscovery) -> str:
615
+ """Serialise an ``AdaptDiscovery`` to a TOML string.
616
+
617
+ Deterministic key order: schema-version, markers (keys sorted),
618
+ findings.accepted (sorted by finding-id), findings.declined (sorted
619
+ by finding-id). Timestamps are omitted when ``recorded_at`` is None.
620
+
621
+ This helper is used by the round-trip test (T1) and will be used by
622
+ T13's idempotency story.
623
+ """
624
+ # Every basic-string interpolation routes through `_emit_basic_string`
625
+ # for consistency with `dump_state` and `_append_install_marker`. The
626
+ # CLI is read-only on `.adapt-discovery.toml` today (the skill owns
627
+ # the write side), but keeping the discipline here means a future
628
+ # caller can't reintroduce the injection class — and the round-trip
629
+ # test in test_config covers this helper, so the escape behaviour is
630
+ # pinned wherever it ships.
631
+ lines: list[str] = [
632
+ f"discovery-schema-version = {_emit_basic_string(d.schema_version)}",
633
+ "",
634
+ ]
635
+
636
+ if d.markers:
637
+ lines.append("[markers]")
638
+ for k in sorted(d.markers):
639
+ # Marker keys are loader-constrained by
640
+ # `load_adapt_discovery_typed` to `^[a-z][a-z0-9-]*$`, but
641
+ # the dataclass has no constructor validator. Route through
642
+ # `_toml_key` so a directly-built `AdaptDiscovery` with a
643
+ # malformed key still emits well-formed TOML — no phantom
644
+ # structure can land. The loader-side grammar still applies
645
+ # on re-read; the asymmetry is intentional (the emitter's
646
+ # job is structural safety, the loader's is grammar
647
+ # enforcement).
648
+ lines.append(f"{_toml_key(k)} = {_emit_basic_string(d.markers[k])}")
649
+ lines.append("")
650
+
651
+ for finding in sorted(d.findings_accepted, key=lambda f: f.finding_id):
652
+ lines.append("[[findings.accepted]]")
653
+ lines.append(f"finding-id = {_emit_basic_string(finding.finding_id)}")
654
+ lines.append(f"kind = {_emit_basic_string(finding.kind)}")
655
+ lines.append(f"source-path = {_emit_basic_string(finding.source_path)}")
656
+ lines.append(f"destination-path = {_emit_basic_string(finding.destination_path)}")
657
+ if finding.action is not None:
658
+ lines.append(f"action = {_emit_basic_string(finding.action)}")
659
+ if finding.recorded_at is not None:
660
+ ts = finding.recorded_at.strftime("%Y-%m-%dT%H:%M:%SZ")
661
+ lines.append(f"accepted-at = {ts}")
662
+ lines.append("")
663
+
664
+ for finding in sorted(d.findings_declined, key=lambda f: f.finding_id):
665
+ lines.append("[[findings.declined]]")
666
+ lines.append(f"finding-id = {_emit_basic_string(finding.finding_id)}")
667
+ lines.append(f"kind = {_emit_basic_string(finding.kind)}")
668
+ lines.append(f"source-path = {_emit_basic_string(finding.source_path)}")
669
+ lines.append(f"destination-path = {_emit_basic_string(finding.destination_path)}")
670
+ if finding.action is not None:
671
+ lines.append(f"action = {_emit_basic_string(finding.action)}")
672
+ if finding.recorded_at is not None:
673
+ ts = finding.recorded_at.strftime("%Y-%m-%dT%H:%M:%SZ")
674
+ lines.append(f"declined-at = {ts}")
675
+ lines.append("")
676
+
677
+ return "\n".join(lines).rstrip() + "\n"
678
+
679
+
680
+ # ---------------------------------------------------------------------------
681
+ # --values-from <file.toml>
682
+ # ---------------------------------------------------------------------------
683
+
684
+
685
+ _VALUES_DISCOVERY_RESERVED = frozenset(
686
+ {"discovery-schema-version", "findings", "marker-schema-version"}
687
+ )
688
+
689
+
690
+ def load_values_from(path: Path) -> dict[str, str]:
691
+ """Load `--values-from` TOML; return a flat dict of marker → value.
692
+
693
+ Accepts (in order tried):
694
+
695
+ 1. A ``[markers]`` table — canonical ``.adapt-discovery.toml`` shape
696
+ when the skill hands a discovery file directly to the CLI.
697
+ 2. A ``[values]`` table — original ``--values-from`` shape kept
698
+ for hand-authored override files.
699
+ 3. A flat top-level table — keys at the root, skipping the
700
+ reserved discovery keys (``discovery-schema-version``,
701
+ ``findings``, ``marker-schema-version``) so a canonical
702
+ user-scope discovery file (no ``[markers]``, no ``[values]``)
703
+ passes through cleanly as an empty mapping.
704
+
705
+ Presence of *both* ``[markers]`` and ``[values]`` is ambiguous and
706
+ refused — per AC15.
707
+ """
708
+ if not path.exists():
709
+ raise ConfigError(f"--values-from path not found: {path}")
710
+ try:
711
+ text = path.read_text(encoding="utf-8")
712
+ except (OSError, UnicodeDecodeError) as exc:
713
+ raise ConfigError(
714
+ f"--values-from at {path} is not a readable text file: {exc}"
715
+ ) from exc
716
+ try:
717
+ raw = tomllib.loads(text)
718
+ except tomllib.TOMLDecodeError as exc:
719
+ raise ConfigError(
720
+ f"--values-from at {path} is not valid TOML: {exc}"
721
+ ) from exc
722
+
723
+ has_markers = isinstance(raw.get("markers"), dict)
724
+ has_values = isinstance(raw.get("values"), dict)
725
+ if has_markers and has_values:
726
+ raise ConfigError(
727
+ "ambiguous --values-from file: both [markers] and [values] "
728
+ "tables present; use one"
729
+ )
730
+
731
+ if has_markers:
732
+ values = raw["markers"]
733
+ elif has_values:
734
+ values = raw["values"]
735
+ else:
736
+ values = {
737
+ k: v for k, v in raw.items()
738
+ if k not in _VALUES_DISCOVERY_RESERVED
739
+ }
740
+ if not isinstance(values, dict):
741
+ raise ConfigError("expected a [values] table of string entries")
742
+ out: dict[str, str] = {}
743
+ for k, v in values.items():
744
+ if not isinstance(v, str):
745
+ raise ConfigError(f"value for {k!r} must be a string, got {type(v).__name__}")
746
+ out[str(k)] = v
747
+ return out