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.
- workstate_bootstrap/__init__.py +6 -0
- workstate_bootstrap/__main__.py +5 -0
- workstate_bootstrap/cli.py +610 -0
- workstate_bootstrap/install.py +2057 -0
- workstate_bootstrap/mcp_sync.py +293 -0
- workstate_bootstrap/subcommands.py +1358 -0
- workstate_bootstrap-0.5.2.dist-info/METADATA +179 -0
- workstate_bootstrap-0.5.2.dist-info/RECORD +10 -0
- workstate_bootstrap-0.5.2.dist-info/WHEEL +4 -0
- workstate_bootstrap-0.5.2.dist-info/entry_points.txt +2 -0
|
@@ -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)
|