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
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
|