workstate-bootstrap 0.5.2__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.
@@ -0,0 +1,293 @@
1
+ """Config-only MCP-server reconciliation for ``workstate-bootstrap``.
2
+
3
+ Public entry point ``sync_mcp_configs(target, mcp_servers, *, surfaces,
4
+ check_only)`` rewrites (or, in check mode, only inspects) the three
5
+ client config surfaces and the bootstrap ledger's ``mcp_servers``
6
+ provenance block — without fetching the remote, regenerating skill
7
+ surfaces, or running ``init-state``. ``install`` and ``mcp-sync`` share
8
+ the same render seam in ``install.py`` so byte output is identical.
9
+
10
+ This module is parameter-only by design (no implicit file discovery
11
+ past what is passed in) so non-CLI callers — Make targets, the
12
+ ``bootstrap doctor`` drift check, future release helpers — drive the
13
+ same code path as the CLI subcommand. implementation note covers the basic check
14
+ + apply paths plus the surfaces filter; ``--prune-removed-managed``
15
+ lands in a follow-up slice.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from collections.abc import Mapping, Sequence
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, Literal
25
+
26
+ from workstate_bootstrap.install import (
27
+ BOOTSTRAP_MANIFEST_NAME,
28
+ _render_codex_config,
29
+ _render_mcp_json,
30
+ _render_vscode_mcp_json,
31
+ _write_codex_config,
32
+ _write_mcp_json,
33
+ _write_vscode_mcp_json,
34
+ )
35
+
36
+
37
+ SurfaceName = Literal["claude", "vscode", "codex"]
38
+ SurfaceAction = Literal["created", "merged", "unchanged", "would_write"]
39
+
40
+
41
+ SUPPORTED_SURFACES: frozenset[str] = frozenset({"claude", "vscode", "codex"})
42
+ """Stable set of surface names this sync API knows how to render."""
43
+
44
+ DEFAULT_SURFACES: tuple[str, ...] = ("claude", "vscode", "codex")
45
+ """Surfaces touched when the caller does not pass an explicit subset."""
46
+
47
+
48
+ _SURFACE_PATHS: dict[str, str] = {
49
+ "claude": ".mcp.json",
50
+ "vscode": ".vscode/mcp.json",
51
+ "codex": ".codex/config.toml",
52
+ }
53
+
54
+
55
+ _RENDERERS: dict[str, Any] = {
56
+ "claude": _render_mcp_json,
57
+ "vscode": _render_vscode_mcp_json,
58
+ "codex": _render_codex_config,
59
+ }
60
+
61
+
62
+ _WRITERS: dict[str, Any] = {
63
+ "claude": _write_mcp_json,
64
+ "vscode": _write_vscode_mcp_json,
65
+ "codex": _write_codex_config,
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class SurfaceReport:
71
+ """Per-surface outcome of a sync pass.
72
+
73
+ ``drift`` is True when the rendered bytes differ from the on-disk
74
+ file (or the file is absent). ``action`` is the operator-facing
75
+ label for what happened: ``created`` (file did not exist and apply
76
+ wrote it), ``merged`` (file existed and apply rewrote it),
77
+ ``unchanged`` (no drift), or ``would_write`` (check mode saw drift
78
+ but did not touch disk).
79
+ """
80
+
81
+ name: str
82
+ path: str
83
+ drift: bool
84
+ action: SurfaceAction
85
+ preserved_third_party: tuple[str, ...] = ()
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class SyncReport:
90
+ """Aggregate result of a ``sync_mcp_configs`` call.
91
+
92
+ ``exit_code`` matches the CLI contract (``0`` clean reconcile,
93
+ ``1`` drift detected with ``check_only=True``, ``>=2`` reserved for
94
+ resolution failures the CLI raises before reaching this code path).
95
+ ``ledger_mcp_servers`` reflects the names persisted to the ledger
96
+ after this pass — the empty list when the ledger was not rewritten
97
+ (e.g. ``check_only=True``).
98
+ """
99
+
100
+ surfaces: tuple[SurfaceReport, ...]
101
+ preserved_third_party: tuple[str, ...] = ()
102
+ pruned_managed: tuple[str, ...] = ()
103
+ ledger_mcp_servers: tuple[str, ...] = ()
104
+ exit_code: int = 0
105
+
106
+
107
+ def sync_mcp_configs(
108
+ target: Path,
109
+ mcp_servers: Mapping[str, Mapping[str, Any]],
110
+ *,
111
+ surfaces: Sequence[str] = DEFAULT_SURFACES,
112
+ check_only: bool = False,
113
+ prune_removed_managed: bool = False,
114
+ ) -> SyncReport:
115
+ """Reconcile the three client config surfaces against ``mcp_servers``.
116
+
117
+ ``check_only=True`` returns drift information without touching disk
118
+ (the render seam guarantees no surface file is created or modified).
119
+ ``check_only=False`` writes only the surfaces that drift and rewrites
120
+ the ledger's ``mcp_servers`` block to ``sorted(mcp_servers.keys())``
121
+ so the next run sees the new baseline.
122
+
123
+ ``prune_removed_managed=True`` reads the ledger's ``mcp_servers``
124
+ provenance block — the authoritative record of names this tool
125
+ previously managed — computes
126
+ ``prune_set = previously_managed - resolved_map.keys()``, and drops
127
+ those keys from the rendered surfaces. Third-party launchers (names
128
+ NOT in the ledger) are never pruned. On legacy targets where the
129
+ ledger lacks the block (or has ``[]``), the first run is a prune
130
+ no-op; the block is seeded from the resolved map at write time so
131
+ the next run has provenance.
132
+
133
+ Raises:
134
+ ValueError: ``surfaces`` contains a name not in
135
+ :data:`SUPPORTED_SURFACES`.
136
+ """
137
+ target = Path(target)
138
+ requested = tuple(surfaces)
139
+ unknown = [name for name in requested if name not in SUPPORTED_SURFACES]
140
+ if unknown:
141
+ raise ValueError(
142
+ f"surfaces={requested!r} contains unknown name(s) {unknown!r}; "
143
+ f"expected a subset of {sorted(SUPPORTED_SURFACES)!r}."
144
+ )
145
+
146
+ prune_names: tuple[str, ...] = ()
147
+ if prune_removed_managed:
148
+ previously_managed = _read_ledger_mcp_servers(target)
149
+ resolved = set(mcp_servers)
150
+ prune_names = tuple(sorted(set(previously_managed) - resolved))
151
+
152
+ surface_reports: list[SurfaceReport] = []
153
+ any_drift = False
154
+ for name in requested:
155
+ report = _evaluate_surface(
156
+ target,
157
+ name,
158
+ mcp_servers,
159
+ check_only=check_only,
160
+ prune_names=prune_names,
161
+ )
162
+ surface_reports.append(report)
163
+ if report.drift:
164
+ any_drift = True
165
+
166
+ ledger_names: tuple[str, ...] = ()
167
+ if not check_only:
168
+ ledger_names = _rewrite_ledger_mcp_servers(target, sorted(mcp_servers))
169
+
170
+ preserved = tuple(
171
+ sorted({name for s in surface_reports for name in s.preserved_third_party})
172
+ )
173
+
174
+ exit_code = 1 if (check_only and any_drift) else 0
175
+ return SyncReport(
176
+ surfaces=tuple(surface_reports),
177
+ preserved_third_party=preserved,
178
+ pruned_managed=prune_names,
179
+ ledger_mcp_servers=ledger_names,
180
+ exit_code=exit_code,
181
+ )
182
+
183
+
184
+ def _evaluate_surface(
185
+ target: Path,
186
+ name: str,
187
+ mcp_servers: Mapping[str, Mapping[str, Any]],
188
+ *,
189
+ check_only: bool,
190
+ prune_names: tuple[str, ...] = (),
191
+ ) -> SurfaceReport:
192
+ surface_path = _SURFACE_PATHS[name]
193
+ on_disk_path = target / surface_path
194
+ rendered = _RENDERERS[name](target, mcp_servers, prune_names=prune_names)
195
+ existed = on_disk_path.exists()
196
+ on_disk = on_disk_path.read_bytes() if existed else b""
197
+ drift = rendered != on_disk
198
+
199
+ if not drift:
200
+ action: SurfaceAction = "unchanged"
201
+ elif check_only:
202
+ action = "would_write"
203
+ else:
204
+ _WRITERS[name](target, mcp_servers, prune_names=prune_names)
205
+ action = "merged" if existed else "created"
206
+
207
+ preserved = _preserved_third_party_names(name, on_disk, mcp_servers, prune_names)
208
+
209
+ return SurfaceReport(
210
+ name=name,
211
+ path=surface_path,
212
+ drift=drift,
213
+ action=action,
214
+ preserved_third_party=preserved,
215
+ )
216
+
217
+
218
+ def _preserved_third_party_names(
219
+ name: str,
220
+ on_disk: bytes,
221
+ mcp_servers: Mapping[str, Mapping[str, Any]],
222
+ prune_names: tuple[str, ...] = (),
223
+ ) -> tuple[str, ...]:
224
+ """Return server names present on disk that are NOT in the managed map.
225
+
226
+ These are launchers the consumer added themselves; they survive the
227
+ render path because the writers only merge managed names. Reporting
228
+ the set lets the operator see at a glance that their custom entries
229
+ are not being silently rewritten.
230
+ """
231
+ if not on_disk:
232
+ return ()
233
+ managed = set(mcp_servers)
234
+ if name == "claude":
235
+ try:
236
+ doc = json.loads(on_disk.decode("utf-8"))
237
+ except (UnicodeDecodeError, json.JSONDecodeError):
238
+ return ()
239
+ servers = doc.get("mcpServers", {})
240
+ elif name == "vscode":
241
+ try:
242
+ doc = json.loads(on_disk.decode("utf-8"))
243
+ except (UnicodeDecodeError, json.JSONDecodeError):
244
+ return ()
245
+ servers = doc.get("servers", {})
246
+ else:
247
+ # Codex TOML: no managed/third-party split is required for slice
248
+ # 1d's report shape — preservation is structural in the writer
249
+ # (managed tables are replaced; everything else stays). Treat
250
+ # third-party as empty here; the byte-parity test in
251
+ # test_render_seam.py already pins the preservation property.
252
+ return ()
253
+ if not isinstance(servers, dict):
254
+ return ()
255
+ return tuple(sorted(set(servers) - managed - set(prune_names)))
256
+
257
+
258
+ def _read_ledger_mcp_servers(target: Path) -> tuple[str, ...]:
259
+ """Return the ledger's previously-managed names, or ``()`` when the
260
+ ledger is missing or the block is absent / empty.
261
+
262
+ The empty result drives the legacy-fallback path in
263
+ ``sync_mcp_configs(prune_removed_managed=True)``: the first run is a
264
+ prune no-op for that target; the rewrite step seeds the block from
265
+ the resolved map so the next run has provenance.
266
+ """
267
+ ledger_path = target / BOOTSTRAP_MANIFEST_NAME
268
+ if not ledger_path.exists():
269
+ return ()
270
+ try:
271
+ payload = json.loads(ledger_path.read_text())
272
+ except json.JSONDecodeError:
273
+ return ()
274
+ block = payload.get("mcp_servers")
275
+ if not isinstance(block, list):
276
+ return ()
277
+ return tuple(name for name in block if isinstance(name, str))
278
+
279
+
280
+ def _rewrite_ledger_mcp_servers(target: Path, names: list[str]) -> tuple[str, ...]:
281
+ """Rewrite the ledger's ``mcp_servers`` block to ``names``.
282
+
283
+ No-op when the ledger does not exist — ``sync_mcp_configs`` is a
284
+ config-only refresh path and does not synthesize ledgers for
285
+ targets that were never bootstrapped.
286
+ """
287
+ ledger_path = target / BOOTSTRAP_MANIFEST_NAME
288
+ if not ledger_path.exists():
289
+ return tuple(names)
290
+ payload = json.loads(ledger_path.read_text())
291
+ payload["mcp_servers"] = list(names)
292
+ ledger_path.write_text(json.dumps(payload, indent=2) + "\n")
293
+ return tuple(names)