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,2057 @@
|
|
|
1
|
+
"""Minimal install flow for the workstate-bootstrap CLI.
|
|
2
|
+
|
|
3
|
+
This slice implements four responsibilities:
|
|
4
|
+
|
|
5
|
+
1. Clone (or fast-forward) ``<remote_url>`` at ``<remote_ref>`` into
|
|
6
|
+
``<target>/.agentic/remote/``.
|
|
7
|
+
2. Symlink the six known shared overlay surfaces from the clone into the
|
|
8
|
+
consumer repo, preserving any pre-existing real local directory at the
|
|
9
|
+
same path (overlay precedence: local wins per surface).
|
|
10
|
+
3. When ``mcp_servers`` is provided, configure the three consumer-tool
|
|
11
|
+
surfaces — ``.mcp.json`` (Claude Code), ``.vscode/mcp.json`` (VS Code),
|
|
12
|
+
and ``.codex/config.toml`` (Codex CLI) — by deep-merging or
|
|
13
|
+
tomlkit-replacing only the managed entries while preserving everything
|
|
14
|
+
else the user had configured.
|
|
15
|
+
4. When ``<target>`` is a git repo, point ``core.hooksPath`` at the
|
|
16
|
+
materialized ``scripts/hooks/git`` directory so git resolves shared
|
|
17
|
+
hooks by name (``post-checkout``, ``pre-commit``, ``pre-push`` …).
|
|
18
|
+
The parent ``scripts/hooks/`` symlink ships Python helpers and other
|
|
19
|
+
non-git-hook files; setting ``core.hooksPath`` there makes git
|
|
20
|
+
silently resolve nothing; the bootstrap-managed git hook directory is the
|
|
21
|
+
only valid hooksPath target.
|
|
22
|
+
5. Write ``<target>/.workstate-bootstrap.json`` describing the resolved remote,
|
|
23
|
+
the materialized surfaces, and the configs that were touched. Older
|
|
24
|
+
installs wrote ``.workstate-overlay.json``; the legacy file is migrated
|
|
25
|
+
in-place on first run when present.
|
|
26
|
+
|
|
27
|
+
The ``doctor`` / ``repair`` / ``update`` / ``status`` subcommands are
|
|
28
|
+
implemented in adjacent modules and are deliberately out of scope here.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
from datetime import UTC, datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from collections.abc import Iterable
|
|
41
|
+
from typing import Any, Mapping
|
|
42
|
+
|
|
43
|
+
import tomlkit
|
|
44
|
+
import yaml
|
|
45
|
+
|
|
46
|
+
BOOTSTRAP_MANIFEST_NAME = ".workstate-bootstrap.json"
|
|
47
|
+
LEGACY_OVERLAY_MANIFEST_NAME = ".workstate-overlay.json"
|
|
48
|
+
# Deprecated alias kept for downstream code importing the old name. Points
|
|
49
|
+
# at the canonical (new) filename, NOT the legacy file. Reading legacy
|
|
50
|
+
# installs goes through _migrate_legacy_manifest below.
|
|
51
|
+
OVERLAY_MANIFEST_NAME = BOOTSTRAP_MANIFEST_NAME
|
|
52
|
+
SCHEMA_VERSION = 2
|
|
53
|
+
CLONE_SUBDIR = (".agentic", "remote")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_install_manifest(
|
|
57
|
+
*,
|
|
58
|
+
remote_url: str,
|
|
59
|
+
remote_ref: str,
|
|
60
|
+
remote_sha: str,
|
|
61
|
+
profile: str,
|
|
62
|
+
surfaces: list[dict[str, str]],
|
|
63
|
+
configs: list[dict[str, str]],
|
|
64
|
+
mcp_servers: Mapping[str, Mapping[str, Any]] | None,
|
|
65
|
+
plugin_overrides_path: str | None,
|
|
66
|
+
) -> dict[str, object]:
|
|
67
|
+
"""Build the dict that will be written to ``.workstate-bootstrap.json``.
|
|
68
|
+
|
|
69
|
+
``mcp_servers`` is the mapping that ``install()`` actually used to
|
|
70
|
+
write the three config surfaces. Persisting the sorted key list as
|
|
71
|
+
``manifest["mcp_servers"]`` gives ``sync_mcp_configs(prune_removed_managed=True)``
|
|
72
|
+
an authoritative previously-managed provenance: any name in this
|
|
73
|
+
list that disappears from the new managed set is a removal that
|
|
74
|
+
sync may prune from the surface files; everything else is treated
|
|
75
|
+
as third-party and left untouched.
|
|
76
|
+
"""
|
|
77
|
+
manifest = {
|
|
78
|
+
"schema_version": SCHEMA_VERSION,
|
|
79
|
+
"remote_url": remote_url,
|
|
80
|
+
"remote_ref": remote_ref,
|
|
81
|
+
"remote_sha": remote_sha,
|
|
82
|
+
"profile": profile,
|
|
83
|
+
"surfaces": surfaces,
|
|
84
|
+
"configs": configs,
|
|
85
|
+
"mcp_servers": sorted(mcp_servers) if mcp_servers else [],
|
|
86
|
+
}
|
|
87
|
+
if plugin_overrides_path is not None:
|
|
88
|
+
manifest["plugin_overrides_path"] = plugin_overrides_path
|
|
89
|
+
return manifest
|
|
90
|
+
|
|
91
|
+
# In the workstate, the shared workstate-system surfaces live
|
|
92
|
+
# under packages/workstate-system/ rather than at the clone root. We probe this
|
|
93
|
+
# subdirectory first when resolving a surface in the clone, and fall back to
|
|
94
|
+
# the clone root for legacy/hoisted overlay layouts (and for the
|
|
95
|
+
# fake_remote_with_surfaces fixture used elsewhere in the test suite).
|
|
96
|
+
WORKSTATE_SYSTEM_SUBDIR = "packages/workstate-system"
|
|
97
|
+
|
|
98
|
+
# Shared overlay surfaces materialized as symlinks into ``.agentic/remote``.
|
|
99
|
+
# Per-agent surfaces (.claude/skills, .claude/commands, .github/prompts,
|
|
100
|
+
# .codex/skills) are no longer canonical in the overlay clone; they are
|
|
101
|
+
# generated artifacts produced by generate_agent_workflows.py during install.
|
|
102
|
+
# Only the truly shared surfaces remain symlinked here.
|
|
103
|
+
SHARED_SURFACES: tuple[str, ...] = (
|
|
104
|
+
".github/hooks",
|
|
105
|
+
"scripts/hooks",
|
|
106
|
+
"docs/agentic/contracts",
|
|
107
|
+
# Hoist canonical rule docs (development-workflow.md,
|
|
108
|
+
# branch-review-guide.md, planning-artifact-home.md) to consumers.
|
|
109
|
+
# Prior to this entry rule docs sat repo-local even though hooks and
|
|
110
|
+
# skills cite them by path; only contracts propagated.
|
|
111
|
+
"docs/agentic/rules",
|
|
112
|
+
# Plan-targets surface and the optional git plan-cat shell wrapper.
|
|
113
|
+
# Hoisted as directory symlinks so consumers
|
|
114
|
+
# inherit Makefile.d/plans.mk and scripts/workstate/git-plan-cat.sh
|
|
115
|
+
# (and any sibling files added in later slices) without re-running
|
|
116
|
+
# bootstrap on every file addition. The Python logic the wrappers
|
|
117
|
+
# invoke lives in the workstate_handoff_mcp package, fetched on demand
|
|
118
|
+
# by uvx — no Python module is hoisted via overlay.
|
|
119
|
+
"Makefile.d",
|
|
120
|
+
"scripts/workstate",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# WS-REBRAND-01 Phase A: children of a SHARED_SURFACE that must be *absent*
|
|
124
|
+
# from the consumer tree. A whole-directory symlink exposes every child, so a
|
|
125
|
+
# surface that has any excluded child is materialized as a real directory with
|
|
126
|
+
# an individual symlink per non-excluded child instead — the named children
|
|
127
|
+
# simply never appear. The evals harness (config + runner + Make fragment) is
|
|
128
|
+
# private operational tooling excluded from the public consumer surface; its
|
|
129
|
+
# config (config/evals) was never shipped, so the runner and Make fragment
|
|
130
|
+
# that rode the whole-directory symlinks are carved out here to match.
|
|
131
|
+
SURFACE_CHILD_EXCLUSIONS: dict[str, frozenset[str]] = {
|
|
132
|
+
"scripts/workstate": frozenset({"evals"}),
|
|
133
|
+
"Makefile.d": frozenset({"evals.mk"}),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Per-agent surfaces written by the generator into the target as real
|
|
137
|
+
# directories (not symlinks). Bootstrap ensures these exist as real
|
|
138
|
+
# dirs before the generator runs; pre-existing symlinks pointing into
|
|
139
|
+
# .agentic/remote/ (left over from the legacy overlay model) are
|
|
140
|
+
# replaced. Recorded in the manifest with ``source: "generated"``.
|
|
141
|
+
#
|
|
142
|
+
# ``.claude/skills``, ``.codex/skills``, and ``.claude/commands`` were
|
|
143
|
+
# dropped when the generated plugin tree became canonical; it
|
|
144
|
+
# (``.agentic/generated/plugins/workstate-system/base/{claude,codex}/``)
|
|
145
|
+
# now owns the Claude/Codex SKILL.md surface and command discovery. The
|
|
146
|
+
# legacy generator path emits Copilot prompts and the codex-command-router
|
|
147
|
+
# from the manifest path; everything else flows through the plugin
|
|
148
|
+
# marketplace pin.
|
|
149
|
+
GENERATED_SURFACES: tuple[str, ...] = (
|
|
150
|
+
".github/prompts",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
PLUGIN_NAME = "workstate-system"
|
|
154
|
+
PLUGIN_MARKETPLACE_NAME = "workstate-marketplace"
|
|
155
|
+
PLUGIN_OWNER_NAME = "workstate maintainers"
|
|
156
|
+
PLUGIN_DESCRIPTION = (
|
|
157
|
+
"Cross-harness workstate-system plugin: portable workflow skills "
|
|
158
|
+
"(SKILL.md) plus uvx-stdio MCP servers (workstate-handoff-mcp, workstate-orchestrator-mcp)."
|
|
159
|
+
)
|
|
160
|
+
PLUGIN_GENERATED_ROOT: tuple[str, ...] = (
|
|
161
|
+
".agentic",
|
|
162
|
+
"generated",
|
|
163
|
+
"plugins",
|
|
164
|
+
PLUGIN_NAME,
|
|
165
|
+
)
|
|
166
|
+
PLUGIN_OVERRIDE_ROOT: tuple[str, ...] = ("workstate-overrides", PLUGIN_NAME)
|
|
167
|
+
PLUGIN_OVERRIDE_MANIFEST = "overrides.yaml"
|
|
168
|
+
PLUGIN_OVERRIDE_LOCK = "overrides.lock.json"
|
|
169
|
+
CLAUDE_MARKETPLACE_PATH = Path(".claude-plugin") / "marketplace.json"
|
|
170
|
+
CLAUDE_SETTINGS_PATH = Path(".claude") / "settings.json"
|
|
171
|
+
CODEX_MARKETPLACE_PATH = Path(".agents") / "plugins" / "marketplace.json"
|
|
172
|
+
CODEX_CONFIG_PATH = Path(".codex") / "config.toml"
|
|
173
|
+
PLUGIN_SELECTOR = f"{PLUGIN_NAME}@{PLUGIN_MARKETPLACE_NAME}"
|
|
174
|
+
|
|
175
|
+
# Path to the generator script inside the cloned overlay.
|
|
176
|
+
GENERATOR_SCRIPT = "scripts/generate_agent_workflows.py"
|
|
177
|
+
GENERATOR_MANIFEST = "config/agent-workflows/portable_commands.json"
|
|
178
|
+
GENERATOR_SKILLS_SOURCE = "skills"
|
|
179
|
+
|
|
180
|
+
# Lifecycle profile: hoist the lifecycle Make fragment and the Python runner
|
|
181
|
+
# package into the consumer overlay.
|
|
182
|
+
# Source paths are resolved through ``_resolve_in_clone`` so they pick
|
|
183
|
+
# up the ``packages/workstate-system/`` prefix in the monorepo layout and
|
|
184
|
+
# fall back to a flat layout for hoisted fixture remotes. Destination
|
|
185
|
+
# paths are flat under the consumer root because the runner/Makefile
|
|
186
|
+
# fragment must be reachable from a vanilla consumer with no monorepo
|
|
187
|
+
# packaging knowledge.
|
|
188
|
+
LIFECYCLE_HOISTS: tuple[tuple[str, str], ...] = (
|
|
189
|
+
("Makefile.d/lifecycle.mk", "Makefile.d/lifecycle.mk"),
|
|
190
|
+
("scripts/workstate/lifecycle", "scripts/workstate/lifecycle"),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Sentinel block managed by ``_ensure_consumer_makefile_include`` so we
|
|
194
|
+
# can recognize and uninstall our edit without clobbering user content.
|
|
195
|
+
LIFECYCLE_INCLUDE_SENTINEL_BEGIN = "# >>> WORKSTATE_BOOTSTRAP LIFECYCLE INCLUDE >>>"
|
|
196
|
+
LIFECYCLE_INCLUDE_SENTINEL_END = "# <<< WORKSTATE_BOOTSTRAP LIFECYCLE INCLUDE <<<"
|
|
197
|
+
LEGACY_LIFECYCLE_INCLUDE_SENTINEL_BEGIN = "# >>> AGENTIC_BOOTSTRAP LIFECYCLE INCLUDE >>>"
|
|
198
|
+
LIFECYCLE_INCLUDE_DIRECTIVE = "-include Makefile.d/*.mk"
|
|
199
|
+
LIFECYCLE_TARGET_NAMES = frozenset(
|
|
200
|
+
{
|
|
201
|
+
"task-start",
|
|
202
|
+
"task-finish",
|
|
203
|
+
"context",
|
|
204
|
+
"slice-start",
|
|
205
|
+
"slice-commit",
|
|
206
|
+
"review-ready",
|
|
207
|
+
"close-check",
|
|
208
|
+
"handoff-close-check",
|
|
209
|
+
"plan-review",
|
|
210
|
+
"plan-analyze",
|
|
211
|
+
"review-run",
|
|
212
|
+
"handoff-review-run",
|
|
213
|
+
"status",
|
|
214
|
+
"tasks",
|
|
215
|
+
"doctor",
|
|
216
|
+
"project-events-replay",
|
|
217
|
+
"tasks-gc",
|
|
218
|
+
"dashboard",
|
|
219
|
+
"format",
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Profile contract. ``all`` is the default for both the library
|
|
224
|
+
# ``install()`` API and the CLI, so a no-argument ``workstate-bootstrap
|
|
225
|
+
# install`` materializes the full surface set out of the box. ``minimal``
|
|
226
|
+
# and ``lifecycle`` remain opt-in.
|
|
227
|
+
PROFILE_MINIMAL = "minimal"
|
|
228
|
+
PROFILE_LIFECYCLE = "lifecycle"
|
|
229
|
+
PROFILE_ALL = "all"
|
|
230
|
+
SUPPORTED_PROFILES: frozenset[str] = frozenset(
|
|
231
|
+
{PROFILE_MINIMAL, PROFILE_LIFECYCLE, PROFILE_ALL}
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Built-in managed-server map. The two Workstate MCP servers ship from this
|
|
235
|
+
# repo and are runnable via ``uvx``. Package
|
|
236
|
+
# specs are pinned to the latest coordinated release so consumer repos do
|
|
237
|
+
# not drift when PyPI advances independently of their overlay tag. Used
|
|
238
|
+
# when callers pass ``mcp_servers="default"`` or, in the CLI, when
|
|
239
|
+
# ``--mcp-servers`` is omitted and ``--no-mcp-servers`` is not set.
|
|
240
|
+
# Operators wanting a custom managed map keep providing a JSON file via
|
|
241
|
+
# ``--mcp-servers <path>``.
|
|
242
|
+
DEFAULT_MCP_SERVERS: dict[str, dict[str, Any]] = {
|
|
243
|
+
"workstate-handoff-mcp": {
|
|
244
|
+
"type": "stdio",
|
|
245
|
+
"command": "uvx",
|
|
246
|
+
"args": ["mcp-workstate-handoff@0.11.5", "--workspace-root", ".", "serve-stdio"],
|
|
247
|
+
},
|
|
248
|
+
"workstate-orchestrator-mcp": {
|
|
249
|
+
"type": "stdio",
|
|
250
|
+
"command": "uvx",
|
|
251
|
+
"args": ["mcp-workstate-orchestrator@0.4.7", "--workspace-root", ".", "serve-stdio"],
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _local_handoff_project_candidates(package_ref: str) -> tuple[tuple[str, str], ...]:
|
|
257
|
+
if package_ref.startswith("mcp-agent-handoff"):
|
|
258
|
+
return (
|
|
259
|
+
("packages/mcp-agent-handoff", "mcp-agent-handoff"),
|
|
260
|
+
("packages/mcp-workstate-handoff", "mcp-workstate-handoff"),
|
|
261
|
+
)
|
|
262
|
+
return (
|
|
263
|
+
("packages/mcp-workstate-handoff", "mcp-workstate-handoff"),
|
|
264
|
+
("packages/mcp-agent-handoff", "mcp-agent-handoff"),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _build_local_handoff_retry_cmd(target: Path, cmd: list[str]) -> list[str] | None:
|
|
269
|
+
if not cmd or cmd[0] != "uvx":
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
use_from = len(cmd) >= 4 and cmd[1] == "--from"
|
|
273
|
+
if use_from:
|
|
274
|
+
package_ref = cmd[2]
|
|
275
|
+
tail = cmd[3:]
|
|
276
|
+
elif len(cmd) >= 2:
|
|
277
|
+
package_ref = cmd[1]
|
|
278
|
+
tail = cmd[2:]
|
|
279
|
+
else:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
if not package_ref.startswith(("mcp-workstate-handoff", "mcp-agent-handoff")):
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
clone = target.joinpath(*CLONE_SUBDIR)
|
|
286
|
+
for relative_path, cli_name in _local_handoff_project_candidates(package_ref):
|
|
287
|
+
project = clone / relative_path
|
|
288
|
+
if not (project / "pyproject.toml").is_file():
|
|
289
|
+
continue
|
|
290
|
+
base = ["uv", "run", "--project", str(project)]
|
|
291
|
+
if use_from:
|
|
292
|
+
return [*base, *tail]
|
|
293
|
+
return [*base, cli_name, *tail]
|
|
294
|
+
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _resolve_local_mcp_project(
|
|
299
|
+
target: Path,
|
|
300
|
+
candidates: tuple[tuple[str, str], ...],
|
|
301
|
+
) -> tuple[str, str] | None:
|
|
302
|
+
clone = target.joinpath(*CLONE_SUBDIR)
|
|
303
|
+
for relative_path, cli_name in candidates:
|
|
304
|
+
project = clone / relative_path
|
|
305
|
+
if not (project / "pyproject.toml").is_file():
|
|
306
|
+
continue
|
|
307
|
+
return project.relative_to(target).as_posix(), cli_name
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _build_local_default_mcp_servers(target: Path) -> dict[str, dict[str, Any]] | None:
|
|
312
|
+
handoff = _resolve_local_mcp_project(
|
|
313
|
+
target,
|
|
314
|
+
(
|
|
315
|
+
("packages/mcp-workstate-handoff", "mcp-workstate-handoff"),
|
|
316
|
+
("packages/mcp-agent-handoff", "mcp-agent-handoff"),
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
orchestrator = _resolve_local_mcp_project(
|
|
320
|
+
target,
|
|
321
|
+
(
|
|
322
|
+
("packages/mcp-workstate-orchestrator", "mcp-workstate-orchestrator"),
|
|
323
|
+
("packages/mcp-agent-orchestrator", "mcp-agent-orchestrator"),
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
if handoff is None or orchestrator is None:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
handoff_project, handoff_cli = handoff
|
|
330
|
+
orchestrator_project, orchestrator_cli = orchestrator
|
|
331
|
+
return {
|
|
332
|
+
"workstate-handoff-mcp": {
|
|
333
|
+
"type": "stdio",
|
|
334
|
+
"command": "uv",
|
|
335
|
+
"args": [
|
|
336
|
+
"run",
|
|
337
|
+
"--project",
|
|
338
|
+
handoff_project,
|
|
339
|
+
handoff_cli,
|
|
340
|
+
"--workspace-root",
|
|
341
|
+
".",
|
|
342
|
+
"serve-stdio",
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
"workstate-orchestrator-mcp": {
|
|
346
|
+
"type": "stdio",
|
|
347
|
+
"command": "uv",
|
|
348
|
+
"args": [
|
|
349
|
+
"run",
|
|
350
|
+
"--project",
|
|
351
|
+
orchestrator_project,
|
|
352
|
+
orchestrator_cli,
|
|
353
|
+
"--workspace-root",
|
|
354
|
+
".",
|
|
355
|
+
"serve-stdio",
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _resolve_install_mcp_servers(
|
|
362
|
+
target: Path,
|
|
363
|
+
remote_ref: str,
|
|
364
|
+
mcp_servers: Mapping[str, Mapping[str, Any]] | None,
|
|
365
|
+
) -> Mapping[str, Mapping[str, Any]] | None:
|
|
366
|
+
if mcp_servers is not DEFAULT_MCP_SERVERS:
|
|
367
|
+
return mcp_servers
|
|
368
|
+
if remote_ref.startswith("v"):
|
|
369
|
+
return mcp_servers
|
|
370
|
+
return _build_local_default_mcp_servers(target) or mcp_servers
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class BootstrapManifestValidationError(RuntimeError):
|
|
374
|
+
"""Raised when the install manifest fails the cross-repo wire-shape contract."""
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class RemoteUrlMismatchError(RuntimeError):
|
|
378
|
+
"""Raised when an existing ``.agentic/remote`` clone tracks a different
|
|
379
|
+
``origin`` URL than the one passed to ``install``.
|
|
380
|
+
|
|
381
|
+
Silently rewriting the manifest while leaving the on-disk clone pointed at
|
|
382
|
+
the old origin would make ``.workstate-bootstrap.json`` lie about provenance.
|
|
383
|
+
Operators get an actionable error instead.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class OverrideResetRequiresBackupError(RuntimeError):
|
|
388
|
+
"""Raised when ``reset_overrides`` would delete overrides from a dirty
|
|
389
|
+
git worktree without an explicit backup preflight.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _migrate_legacy_manifest(target: Path) -> Path | None:
|
|
394
|
+
"""One-shot rename of legacy ``.workstate-overlay.json`` to ``.workstate-bootstrap.json``.
|
|
395
|
+
|
|
396
|
+
Renames only when the legacy file looks like a bootstrap manifest
|
|
397
|
+
(top-level dict with a list ``surfaces`` key) so consumer-owned files
|
|
398
|
+
that happen to share the legacy name are not touched. Prefers
|
|
399
|
+
``git mv`` when ``target`` is a git worktree so the rename is tracked
|
|
400
|
+
in history; falls back to ``Path.rename`` otherwise. Returns the new
|
|
401
|
+
path on success, ``None`` when no migration was needed or the legacy
|
|
402
|
+
file did not match the bootstrap shape.
|
|
403
|
+
"""
|
|
404
|
+
legacy = target / LEGACY_OVERLAY_MANIFEST_NAME
|
|
405
|
+
canonical = target / BOOTSTRAP_MANIFEST_NAME
|
|
406
|
+
if not legacy.is_file() or canonical.exists():
|
|
407
|
+
return None
|
|
408
|
+
try:
|
|
409
|
+
data = json.loads(legacy.read_text())
|
|
410
|
+
except (OSError, json.JSONDecodeError):
|
|
411
|
+
return None
|
|
412
|
+
if not isinstance(data, dict) or not isinstance(data.get("surfaces"), list):
|
|
413
|
+
return None
|
|
414
|
+
try:
|
|
415
|
+
_git("mv", LEGACY_OVERLAY_MANIFEST_NAME, BOOTSTRAP_MANIFEST_NAME, cwd=target)
|
|
416
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
417
|
+
legacy.rename(canonical)
|
|
418
|
+
return canonical
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _load_existing_manifest_remote_url(target: Path) -> str | None:
|
|
422
|
+
for name in (BOOTSTRAP_MANIFEST_NAME, LEGACY_OVERLAY_MANIFEST_NAME):
|
|
423
|
+
manifest_path = target / name
|
|
424
|
+
if not manifest_path.is_file():
|
|
425
|
+
continue
|
|
426
|
+
try:
|
|
427
|
+
payload = json.loads(manifest_path.read_text())
|
|
428
|
+
except (OSError, json.JSONDecodeError):
|
|
429
|
+
continue
|
|
430
|
+
if not isinstance(payload, dict):
|
|
431
|
+
continue
|
|
432
|
+
remote_url = payload.get("remote_url")
|
|
433
|
+
if isinstance(remote_url, str) and remote_url:
|
|
434
|
+
return remote_url
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _prepare_state_for_remote_switch(
|
|
439
|
+
target: Path,
|
|
440
|
+
remote_url: str,
|
|
441
|
+
) -> tuple[str | None, str | None]:
|
|
442
|
+
existing_remote_url = _load_existing_manifest_remote_url(target)
|
|
443
|
+
if existing_remote_url is None or existing_remote_url == remote_url:
|
|
444
|
+
return remote_url, None
|
|
445
|
+
|
|
446
|
+
state_dir = target / ".task-state"
|
|
447
|
+
backup_path: str | None = None
|
|
448
|
+
if state_dir.exists():
|
|
449
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
450
|
+
backup_root = target / ".agentic" / "state-backups" / stamp
|
|
451
|
+
archive_target = backup_root / state_dir.name
|
|
452
|
+
archive_target.parent.mkdir(parents=True, exist_ok=True)
|
|
453
|
+
shutil.move(str(state_dir), str(archive_target))
|
|
454
|
+
backup_path = backup_root.relative_to(target).as_posix()
|
|
455
|
+
|
|
456
|
+
# The old adjacent bootstrap manifest still points at the prior remote
|
|
457
|
+
# until install writes the new one below. Skip the reuse guard for this
|
|
458
|
+
# init only after moving the old runtime state out of the way.
|
|
459
|
+
return None, backup_path
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _git_worktree_is_dirty(target: Path) -> bool:
|
|
463
|
+
if not (target / ".git").exists():
|
|
464
|
+
return False
|
|
465
|
+
return bool(
|
|
466
|
+
_git(
|
|
467
|
+
"status",
|
|
468
|
+
"--short",
|
|
469
|
+
"--",
|
|
470
|
+
".",
|
|
471
|
+
":(exclude).agentic/remote",
|
|
472
|
+
cwd=target,
|
|
473
|
+
).strip()
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _prune_empty_parent_dirs(root: Path, stop: Path) -> None:
|
|
478
|
+
current = root.resolve()
|
|
479
|
+
stop = stop.resolve()
|
|
480
|
+
try:
|
|
481
|
+
current.relative_to(stop)
|
|
482
|
+
except ValueError:
|
|
483
|
+
return
|
|
484
|
+
while current != stop:
|
|
485
|
+
try:
|
|
486
|
+
current.rmdir()
|
|
487
|
+
except OSError:
|
|
488
|
+
return
|
|
489
|
+
current = current.parent
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _reset_plugin_overrides(
|
|
493
|
+
target: Path,
|
|
494
|
+
override_root: Path | None,
|
|
495
|
+
*,
|
|
496
|
+
reset_overrides: bool,
|
|
497
|
+
backup_overrides: bool,
|
|
498
|
+
) -> tuple[Path | None, str | None]:
|
|
499
|
+
if not reset_overrides or override_root is None:
|
|
500
|
+
return override_root, None
|
|
501
|
+
|
|
502
|
+
override_root = override_root.resolve()
|
|
503
|
+
if _git_worktree_is_dirty(target) and not backup_overrides:
|
|
504
|
+
raise OverrideResetRequiresBackupError(
|
|
505
|
+
"refusing to reset plugin overrides from a dirty git worktree without "
|
|
506
|
+
"backup_overrides=True; commit/stash changes first or opt into backup preflight"
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
backup_path: str | None = None
|
|
510
|
+
if backup_overrides:
|
|
511
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
512
|
+
backup_root = target / ".agentic" / "override-backups" / stamp
|
|
513
|
+
archive_target = backup_root / override_root.name
|
|
514
|
+
archive_target.parent.mkdir(parents=True, exist_ok=True)
|
|
515
|
+
shutil.copytree(override_root, archive_target)
|
|
516
|
+
backup_path = backup_root.relative_to(target).as_posix()
|
|
517
|
+
|
|
518
|
+
shutil.rmtree(override_root)
|
|
519
|
+
_prune_empty_parent_dirs(override_root.parent, target)
|
|
520
|
+
return None, backup_path
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _git(*args: str, cwd: Path | None = None) -> str:
|
|
524
|
+
"""Run ``git`` with the given args, returning stripped stdout."""
|
|
525
|
+
cmd = ["git"]
|
|
526
|
+
if cwd is not None:
|
|
527
|
+
cmd.extend(["-C", str(cwd)])
|
|
528
|
+
cmd.extend(args)
|
|
529
|
+
result = subprocess.run(
|
|
530
|
+
cmd,
|
|
531
|
+
check=True,
|
|
532
|
+
capture_output=True,
|
|
533
|
+
text=True,
|
|
534
|
+
timeout=120,
|
|
535
|
+
)
|
|
536
|
+
return result.stdout.strip()
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _resolve_ref_to_sha(clone: Path, remote_ref: str) -> str:
|
|
540
|
+
"""Resolve ``remote_ref`` against the just-fetched clone, preferring the
|
|
541
|
+
fresh remote-tracking branch over any stale local ref.
|
|
542
|
+
|
|
543
|
+
Resolution order:
|
|
544
|
+
|
|
545
|
+
1. ``refs/remotes/origin/<ref>`` — picks up freshly fetched branch tips
|
|
546
|
+
and avoids the stale local-branch trap that ``git checkout --detach
|
|
547
|
+
<branch>`` falls into after ``fetch``.
|
|
548
|
+
2. ``refs/tags/<ref>`` — tag refs.
|
|
549
|
+
3. ``<ref>`` raw — last-resort for SHAs and exotic refspecs.
|
|
550
|
+
"""
|
|
551
|
+
candidates = (
|
|
552
|
+
f"refs/remotes/origin/{remote_ref}",
|
|
553
|
+
f"refs/tags/{remote_ref}",
|
|
554
|
+
remote_ref,
|
|
555
|
+
)
|
|
556
|
+
for candidate in candidates:
|
|
557
|
+
try:
|
|
558
|
+
return _git("rev-parse", "--verify", f"{candidate}^{{commit}}", cwd=clone)
|
|
559
|
+
except subprocess.CalledProcessError:
|
|
560
|
+
continue
|
|
561
|
+
raise RuntimeError(
|
|
562
|
+
f"could not resolve remote_ref {remote_ref!r} in {clone} "
|
|
563
|
+
"(tried remote-tracking branch, tag, and raw ref)"
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _resolve_in_clone(clone: Path, relpath: str) -> Path:
|
|
568
|
+
"""Resolve a surface/asset path against the clone.
|
|
569
|
+
|
|
570
|
+
Probes ``<clone>/packages/workstate-system/<relpath>`` first (the
|
|
571
|
+
workstate layout) and falls back to ``<clone>/<relpath>``
|
|
572
|
+
for legacy hoisted overlays. Returns the nested path when neither exists
|
|
573
|
+
so callers can use ``.exists()`` as the discriminator.
|
|
574
|
+
"""
|
|
575
|
+
nested = clone / WORKSTATE_SYSTEM_SUBDIR / relpath
|
|
576
|
+
if nested.exists():
|
|
577
|
+
return nested
|
|
578
|
+
root = clone / relpath
|
|
579
|
+
if root.exists():
|
|
580
|
+
return root
|
|
581
|
+
return nested
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _materialize_one_symlink(
|
|
585
|
+
rel: str,
|
|
586
|
+
remote_path: Path,
|
|
587
|
+
target_path: Path,
|
|
588
|
+
clone_resolved: Path,
|
|
589
|
+
remote_subtree_prefix: str,
|
|
590
|
+
) -> dict[str, str]:
|
|
591
|
+
"""Materialize one ``target_path -> remote_path`` relative symlink.
|
|
592
|
+
|
|
593
|
+
Encapsulates the idempotency / repoint / foreign-precedence rules so it
|
|
594
|
+
can be applied to a whole surface or to a single carved child. Returns the
|
|
595
|
+
manifest entry for ``rel``:
|
|
596
|
+
|
|
597
|
+
- Target absent: create parent, symlink, record ``source='shared'``.
|
|
598
|
+
- Target already a symlink resolving to the current source: leave it;
|
|
599
|
+
record ``source='shared'`` (idempotent rerun path).
|
|
600
|
+
- Target already a symlink lexically under our clone but not resolving to
|
|
601
|
+
the current source: repoint and record ``source='shared'``.
|
|
602
|
+
- Target a foreign symlink, or a real file/dir: leave it untouched and
|
|
603
|
+
record ``source='local'`` so overlay precedence is honored.
|
|
604
|
+
"""
|
|
605
|
+
expected_rel = os.path.relpath(remote_path, target_path.parent)
|
|
606
|
+
target_is_directory = remote_path.is_dir()
|
|
607
|
+
|
|
608
|
+
if target_path.is_symlink():
|
|
609
|
+
raw_target = os.readlink(target_path)
|
|
610
|
+
if os.path.isabs(raw_target):
|
|
611
|
+
abs_target_str = os.path.normpath(raw_target)
|
|
612
|
+
else:
|
|
613
|
+
abs_target_str = os.path.normpath(
|
|
614
|
+
os.path.join(str(target_path.parent), raw_target)
|
|
615
|
+
)
|
|
616
|
+
try:
|
|
617
|
+
resolved = target_path.resolve(strict=False)
|
|
618
|
+
except OSError:
|
|
619
|
+
resolved = None
|
|
620
|
+
resolves_to_expected = (
|
|
621
|
+
resolved is not None and resolved == remote_path.resolve()
|
|
622
|
+
)
|
|
623
|
+
lexically_in_remote_subtree = (
|
|
624
|
+
abs_target_str == str(clone_resolved)
|
|
625
|
+
or abs_target_str.startswith(remote_subtree_prefix)
|
|
626
|
+
)
|
|
627
|
+
if resolves_to_expected:
|
|
628
|
+
return {"path": rel, "source": "shared"}
|
|
629
|
+
if lexically_in_remote_subtree:
|
|
630
|
+
# Stale or broken pointer into our own remote subtree —
|
|
631
|
+
# repoint to the current canonical location.
|
|
632
|
+
target_path.unlink()
|
|
633
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
634
|
+
target_path.symlink_to(expected_rel, target_is_directory=target_is_directory)
|
|
635
|
+
print(f"repointed: {rel}")
|
|
636
|
+
return {"path": rel, "source": "shared"}
|
|
637
|
+
# Foreign symlink — local content takes precedence.
|
|
638
|
+
return {"path": rel, "source": "local"}
|
|
639
|
+
|
|
640
|
+
if target_path.exists():
|
|
641
|
+
return {"path": rel, "source": "local"}
|
|
642
|
+
|
|
643
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
644
|
+
target_path.symlink_to(expected_rel, target_is_directory=target_is_directory)
|
|
645
|
+
return {"path": rel, "source": "shared"}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _raw_symlink_target_path(link_path: Path) -> str:
|
|
649
|
+
raw_target = os.readlink(link_path)
|
|
650
|
+
if os.path.isabs(raw_target):
|
|
651
|
+
return os.path.normpath(raw_target)
|
|
652
|
+
return os.path.normpath(os.path.join(str(link_path.parent), raw_target))
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _points_into_remote_subtree(
|
|
656
|
+
abs_target_str: str,
|
|
657
|
+
clone_resolved: Path,
|
|
658
|
+
remote_subtree_prefix: str,
|
|
659
|
+
) -> bool:
|
|
660
|
+
return abs_target_str == str(clone_resolved) or abs_target_str.startswith(
|
|
661
|
+
remote_subtree_prefix
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _remove_bootstrap_owned_excluded_child(
|
|
666
|
+
child_path: Path,
|
|
667
|
+
clone_resolved: Path,
|
|
668
|
+
remote_subtree_prefix: str,
|
|
669
|
+
) -> None:
|
|
670
|
+
if not child_path.is_symlink():
|
|
671
|
+
return
|
|
672
|
+
if _points_into_remote_subtree(
|
|
673
|
+
_raw_symlink_target_path(child_path),
|
|
674
|
+
clone_resolved,
|
|
675
|
+
remote_subtree_prefix,
|
|
676
|
+
):
|
|
677
|
+
child_path.unlink()
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _lifecycle_hoist_children(surface: str) -> frozenset[str]:
|
|
681
|
+
"""Child names of ``surface`` that :data:`LIFECYCLE_HOISTS` owns.
|
|
682
|
+
|
|
683
|
+
A carved surface must not symlink these children: the lifecycle hoist
|
|
684
|
+
copies them as real files later in ``install()``, and recording a
|
|
685
|
+
``source='shared'`` symlink entry here would collide with that pass's
|
|
686
|
+
``source='lifecycle'`` entry for the same path.
|
|
687
|
+
"""
|
|
688
|
+
children: set[str] = set()
|
|
689
|
+
for _src_rel, dest_rel in LIFECYCLE_HOISTS:
|
|
690
|
+
parent, _, child = dest_rel.rpartition("/")
|
|
691
|
+
if parent == surface and child:
|
|
692
|
+
children.add(child)
|
|
693
|
+
return frozenset(children)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _materialize_carved_surface(
|
|
697
|
+
surface: str,
|
|
698
|
+
remote_path: Path,
|
|
699
|
+
target_path: Path,
|
|
700
|
+
clone_resolved: Path,
|
|
701
|
+
remote_subtree_prefix: str,
|
|
702
|
+
) -> list[dict[str, str]]:
|
|
703
|
+
"""Materialize a SHARED_SURFACE that has excluded children.
|
|
704
|
+
|
|
705
|
+
The parent becomes a real directory and each child is symlinked
|
|
706
|
+
individually, except: children named in ``SURFACE_CHILD_EXCLUSIONS``
|
|
707
|
+
(carved out — never appear in the consumer tree) and children that
|
|
708
|
+
:data:`LIFECYCLE_HOISTS` copies separately. A legacy whole-directory
|
|
709
|
+
symlink into our own clone is replaced with a real directory so the
|
|
710
|
+
carve can take effect on upgrade; a foreign symlink is left untouched
|
|
711
|
+
(local precedence). Returns one manifest entry per materialized child.
|
|
712
|
+
"""
|
|
713
|
+
excluded = SURFACE_CHILD_EXCLUSIONS[surface] | _lifecycle_hoist_children(surface)
|
|
714
|
+
|
|
715
|
+
if target_path.is_symlink():
|
|
716
|
+
if _points_into_remote_subtree(
|
|
717
|
+
_raw_symlink_target_path(target_path),
|
|
718
|
+
clone_resolved,
|
|
719
|
+
remote_subtree_prefix,
|
|
720
|
+
):
|
|
721
|
+
# Legacy whole-directory symlink into our own clone — replace
|
|
722
|
+
# with a real directory so the excluded children can be carved.
|
|
723
|
+
target_path.unlink()
|
|
724
|
+
print(f"carved: {surface}")
|
|
725
|
+
else:
|
|
726
|
+
# Foreign symlink: local content wins, leave untouched.
|
|
727
|
+
return [{"path": surface, "source": "local"}]
|
|
728
|
+
elif target_path.exists() and not target_path.is_dir():
|
|
729
|
+
# A real file where a directory surface is expected — foreign/local.
|
|
730
|
+
return [{"path": surface, "source": "local"}]
|
|
731
|
+
|
|
732
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
733
|
+
|
|
734
|
+
for child_name in excluded:
|
|
735
|
+
_remove_bootstrap_owned_excluded_child(
|
|
736
|
+
target_path / child_name,
|
|
737
|
+
clone_resolved,
|
|
738
|
+
remote_subtree_prefix,
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
entries: list[dict[str, str]] = []
|
|
742
|
+
for child in sorted(remote_path.iterdir(), key=lambda p: p.name):
|
|
743
|
+
if child.name in excluded:
|
|
744
|
+
continue
|
|
745
|
+
entries.append(
|
|
746
|
+
_materialize_one_symlink(
|
|
747
|
+
f"{surface}/{child.name}",
|
|
748
|
+
child,
|
|
749
|
+
target_path / child.name,
|
|
750
|
+
clone_resolved,
|
|
751
|
+
remote_subtree_prefix,
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
return entries
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _materialize_surfaces(target: Path, clone: Path) -> list[dict[str, str]]:
|
|
758
|
+
"""Symlink each known shared surface from ``clone`` into ``target``.
|
|
759
|
+
|
|
760
|
+
Surfaces absent in the clone are skipped silently (not recorded). A
|
|
761
|
+
surface listed in :data:`SURFACE_CHILD_EXCLUSIONS` is materialized
|
|
762
|
+
per-child via :func:`_materialize_carved_surface` so its excluded
|
|
763
|
+
children stay absent; every other surface is a single whole-directory
|
|
764
|
+
symlink via :func:`_materialize_one_symlink`. See those helpers for the
|
|
765
|
+
idempotency / repoint / foreign-precedence rules.
|
|
766
|
+
"""
|
|
767
|
+
|
|
768
|
+
materialized: list[dict[str, str]] = []
|
|
769
|
+
clone_resolved = clone.resolve()
|
|
770
|
+
remote_subtree_prefix = str(clone_resolved) + os.sep
|
|
771
|
+
|
|
772
|
+
for surface in SHARED_SURFACES:
|
|
773
|
+
remote_path = _resolve_in_clone(clone, surface)
|
|
774
|
+
if not remote_path.exists():
|
|
775
|
+
continue
|
|
776
|
+
|
|
777
|
+
if surface in SURFACE_CHILD_EXCLUSIONS:
|
|
778
|
+
materialized.extend(
|
|
779
|
+
_materialize_carved_surface(
|
|
780
|
+
surface,
|
|
781
|
+
remote_path,
|
|
782
|
+
target / surface,
|
|
783
|
+
clone_resolved,
|
|
784
|
+
remote_subtree_prefix,
|
|
785
|
+
)
|
|
786
|
+
)
|
|
787
|
+
continue
|
|
788
|
+
|
|
789
|
+
materialized.append(
|
|
790
|
+
_materialize_one_symlink(
|
|
791
|
+
surface,
|
|
792
|
+
remote_path,
|
|
793
|
+
target / surface,
|
|
794
|
+
clone_resolved,
|
|
795
|
+
remote_subtree_prefix,
|
|
796
|
+
)
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
return materialized
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _prepare_generated_surfaces(target: Path, clone: Path) -> list[dict[str, str]]:
|
|
803
|
+
"""Ensure each per-agent generated surface exists as a real directory.
|
|
804
|
+
|
|
805
|
+
Pre-existing symlinks pointing into the clone (left over from the
|
|
806
|
+
pre-Plan-0002 overlay model where these surfaces were shared
|
|
807
|
+
symlinks) are replaced with empty directories so the generator can
|
|
808
|
+
write into them. Pre-existing real local content is preserved —
|
|
809
|
+
the operator may have intentionally placed local overrides there;
|
|
810
|
+
the generator's per-file write logic will only replace the files
|
|
811
|
+
it owns.
|
|
812
|
+
"""
|
|
813
|
+
materialized: list[dict[str, str]] = []
|
|
814
|
+
clone_resolved = clone.resolve()
|
|
815
|
+
|
|
816
|
+
for surface in GENERATED_SURFACES:
|
|
817
|
+
target_path = target / surface
|
|
818
|
+
|
|
819
|
+
if target_path.is_symlink():
|
|
820
|
+
try:
|
|
821
|
+
resolved = target_path.resolve(strict=False)
|
|
822
|
+
except OSError:
|
|
823
|
+
resolved = None
|
|
824
|
+
points_into_clone = (
|
|
825
|
+
resolved is not None
|
|
826
|
+
and str(resolved).startswith(str(clone_resolved) + os.sep)
|
|
827
|
+
)
|
|
828
|
+
broken = resolved is not None and not target_path.exists()
|
|
829
|
+
if points_into_clone or broken:
|
|
830
|
+
# Legacy overlay symlink, or a dangling symlink whose
|
|
831
|
+
# target is gone — replace with a real directory so the
|
|
832
|
+
# generator can write into it. (A dangling symlink also
|
|
833
|
+
# blocks the mkdir below, since lexists() is True.)
|
|
834
|
+
target_path.unlink()
|
|
835
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
836
|
+
# Foreign live symlinks are left alone (operator chose them);
|
|
837
|
+
# the generator will write through them into wherever they point.
|
|
838
|
+
|
|
839
|
+
elif not target_path.exists():
|
|
840
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
841
|
+
|
|
842
|
+
materialized.append({"path": surface, "source": "generated"})
|
|
843
|
+
|
|
844
|
+
return materialized
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _prepare_plugin_generated_surfaces(
|
|
848
|
+
target: Path, clone: Path, override_root: Path | None
|
|
849
|
+
) -> list[dict[str, str]]:
|
|
850
|
+
"""Record generated plugin trees that install materializes for this target."""
|
|
851
|
+
generator_script = _resolve_in_clone(clone, GENERATOR_SCRIPT)
|
|
852
|
+
manifest_path = _resolve_in_clone(clone, GENERATOR_MANIFEST)
|
|
853
|
+
if not generator_script.is_file() or not manifest_path.is_file():
|
|
854
|
+
return []
|
|
855
|
+
|
|
856
|
+
entries = [{"path": Path(*PLUGIN_GENERATED_ROOT, "base").as_posix(), "source": "generated"}]
|
|
857
|
+
if override_root is not None:
|
|
858
|
+
entries.append(
|
|
859
|
+
{
|
|
860
|
+
"path": Path(*PLUGIN_GENERATED_ROOT, "effective").as_posix(),
|
|
861
|
+
"source": "generated",
|
|
862
|
+
}
|
|
863
|
+
)
|
|
864
|
+
return entries
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _plugin_tree_out(target: Path, kind: str) -> Path:
|
|
868
|
+
return target.joinpath(*PLUGIN_GENERATED_ROOT, kind)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _plugin_override_root_from_manifest(
|
|
872
|
+
target: Path, manifest: Mapping[str, object] | None
|
|
873
|
+
) -> Path | None:
|
|
874
|
+
if manifest is None:
|
|
875
|
+
return None
|
|
876
|
+
raw_path = manifest.get("plugin_overrides_path")
|
|
877
|
+
if not isinstance(raw_path, str) or not raw_path:
|
|
878
|
+
return None
|
|
879
|
+
candidate = Path(raw_path)
|
|
880
|
+
if not candidate.is_absolute():
|
|
881
|
+
candidate = target / candidate
|
|
882
|
+
candidate = candidate.resolve()
|
|
883
|
+
if (candidate / PLUGIN_OVERRIDE_MANIFEST).is_file():
|
|
884
|
+
return candidate
|
|
885
|
+
return None
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _plugin_override_root_manifest_path(target: Path, override_root: Path | None) -> str | None:
|
|
889
|
+
if override_root is None:
|
|
890
|
+
return None
|
|
891
|
+
try:
|
|
892
|
+
return override_root.relative_to(target).as_posix()
|
|
893
|
+
except ValueError:
|
|
894
|
+
return override_root.as_posix()
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _discover_plugin_override_root(
|
|
898
|
+
target: Path,
|
|
899
|
+
*,
|
|
900
|
+
manifest: Mapping[str, object] | None = None,
|
|
901
|
+
plugin_overrides: Path | None = None,
|
|
902
|
+
) -> Path | None:
|
|
903
|
+
if plugin_overrides is not None:
|
|
904
|
+
candidate = Path(plugin_overrides).expanduser().resolve()
|
|
905
|
+
manifest_path = candidate / PLUGIN_OVERRIDE_MANIFEST
|
|
906
|
+
if not manifest_path.is_file():
|
|
907
|
+
raise FileNotFoundError(
|
|
908
|
+
f"plugin override manifest not found: {manifest_path}"
|
|
909
|
+
)
|
|
910
|
+
return candidate
|
|
911
|
+
|
|
912
|
+
manifest_root = _plugin_override_root_from_manifest(target, manifest)
|
|
913
|
+
if manifest_root is not None:
|
|
914
|
+
return manifest_root
|
|
915
|
+
|
|
916
|
+
override_root = target.joinpath(*PLUGIN_OVERRIDE_ROOT)
|
|
917
|
+
if (override_root / PLUGIN_OVERRIDE_MANIFEST).is_file():
|
|
918
|
+
return override_root
|
|
919
|
+
return None
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _relative_plugin_tree_path(kind: str, harness: str) -> str:
|
|
923
|
+
return f"./{Path(*PLUGIN_GENERATED_ROOT, kind, harness).as_posix()}"
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _write_json_file(
|
|
927
|
+
path: Path, payload: dict[str, Any], *, manifest_path: str | None = None
|
|
928
|
+
) -> dict[str, str]:
|
|
929
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
930
|
+
content = json.dumps(payload, indent=2) + "\n"
|
|
931
|
+
manifest_entry_path = manifest_path or path.as_posix()
|
|
932
|
+
if path.exists():
|
|
933
|
+
previous = path.read_text()
|
|
934
|
+
if previous == content:
|
|
935
|
+
return {"path": manifest_entry_path, "action": "unchanged"}
|
|
936
|
+
path.write_text(content)
|
|
937
|
+
return {"path": manifest_entry_path, "action": "updated"}
|
|
938
|
+
path.write_text(content)
|
|
939
|
+
return {"path": manifest_entry_path, "action": "created"}
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _render_plugin_override_lock(override_root: Path, remote_sha: str) -> str:
|
|
943
|
+
from workstate_protocol.bootstrap import PluginOverrideLock, PluginOverrideManifest
|
|
944
|
+
|
|
945
|
+
raw_payload = yaml.safe_load((override_root / PLUGIN_OVERRIDE_MANIFEST).read_text()) or {}
|
|
946
|
+
manifest = PluginOverrideManifest.model_validate(raw_payload)
|
|
947
|
+
components: list[dict[str, str]] = []
|
|
948
|
+
|
|
949
|
+
for name, override in sorted(manifest.components.skills.items()):
|
|
950
|
+
entry: dict[str, str] = {
|
|
951
|
+
"component_kind": "skill",
|
|
952
|
+
"name": name,
|
|
953
|
+
"mode": override.mode,
|
|
954
|
+
}
|
|
955
|
+
if override.path is not None:
|
|
956
|
+
entry["local_path"] = override.path
|
|
957
|
+
if override.upstream_digest is not None:
|
|
958
|
+
entry["upstream_digest"] = override.upstream_digest
|
|
959
|
+
components.append(entry)
|
|
960
|
+
|
|
961
|
+
for name, override in sorted(manifest.components.mcp_servers.items()):
|
|
962
|
+
entry = {
|
|
963
|
+
"component_kind": "mcp_server",
|
|
964
|
+
"name": name,
|
|
965
|
+
"mode": override.mode,
|
|
966
|
+
}
|
|
967
|
+
if override.patch_path is not None:
|
|
968
|
+
entry["patch_path"] = override.patch_path
|
|
969
|
+
components.append(entry)
|
|
970
|
+
|
|
971
|
+
payload = PluginOverrideLock.model_validate(
|
|
972
|
+
{
|
|
973
|
+
"schema_version": 1,
|
|
974
|
+
"plugin": manifest.plugin,
|
|
975
|
+
"base_remote_sha": remote_sha,
|
|
976
|
+
"components": components,
|
|
977
|
+
}
|
|
978
|
+
).model_dump(mode="json")
|
|
979
|
+
return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _write_plugin_override_lock(override_root: Path | None, remote_sha: str) -> None:
|
|
983
|
+
if override_root is None:
|
|
984
|
+
return
|
|
985
|
+
lock_path = override_root / PLUGIN_OVERRIDE_LOCK
|
|
986
|
+
lock_path.write_text(_render_plugin_override_lock(override_root, remote_sha))
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _write_plugin_pins(
|
|
990
|
+
target: Path,
|
|
991
|
+
override_root: Path | None = None,
|
|
992
|
+
*,
|
|
993
|
+
include_codex_activation: bool = True,
|
|
994
|
+
) -> list[dict[str, str]]:
|
|
995
|
+
plugin_tree_kind = "effective" if override_root is not None else "base"
|
|
996
|
+
|
|
997
|
+
claude_marketplace = {
|
|
998
|
+
"name": PLUGIN_MARKETPLACE_NAME,
|
|
999
|
+
"owner": {"name": PLUGIN_OWNER_NAME},
|
|
1000
|
+
"plugins": [
|
|
1001
|
+
{
|
|
1002
|
+
"name": PLUGIN_NAME,
|
|
1003
|
+
"source": _relative_plugin_tree_path(plugin_tree_kind, "claude"),
|
|
1004
|
+
"description": PLUGIN_DESCRIPTION,
|
|
1005
|
+
}
|
|
1006
|
+
],
|
|
1007
|
+
}
|
|
1008
|
+
codex_marketplace = {
|
|
1009
|
+
"name": PLUGIN_MARKETPLACE_NAME,
|
|
1010
|
+
"interface": {"displayName": "Workstate Marketplace"},
|
|
1011
|
+
"owner": {"name": PLUGIN_OWNER_NAME},
|
|
1012
|
+
"plugins": [
|
|
1013
|
+
{
|
|
1014
|
+
"name": PLUGIN_NAME,
|
|
1015
|
+
"source": {
|
|
1016
|
+
"source": "local",
|
|
1017
|
+
"path": _relative_plugin_tree_path(plugin_tree_kind, "codex"),
|
|
1018
|
+
},
|
|
1019
|
+
"policy": {
|
|
1020
|
+
"installation": "AVAILABLE",
|
|
1021
|
+
"authentication": "ON_INSTALL",
|
|
1022
|
+
},
|
|
1023
|
+
"category": "Productivity",
|
|
1024
|
+
"description": PLUGIN_DESCRIPTION,
|
|
1025
|
+
}
|
|
1026
|
+
],
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
settings_path = target / CLAUDE_SETTINGS_PATH
|
|
1030
|
+
if settings_path.exists():
|
|
1031
|
+
current_settings = json.loads(settings_path.read_text())
|
|
1032
|
+
if not isinstance(current_settings, dict):
|
|
1033
|
+
raise ValueError(f"{settings_path} must contain a JSON object")
|
|
1034
|
+
else:
|
|
1035
|
+
current_settings = {}
|
|
1036
|
+
_deep_merge(
|
|
1037
|
+
current_settings,
|
|
1038
|
+
{
|
|
1039
|
+
"extraKnownMarketplaces": {
|
|
1040
|
+
PLUGIN_MARKETPLACE_NAME: {
|
|
1041
|
+
"source": {
|
|
1042
|
+
"source": "directory",
|
|
1043
|
+
"path": ".",
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
)
|
|
1049
|
+
enabled_plugins = current_settings.setdefault("enabledPlugins", {})
|
|
1050
|
+
if not isinstance(enabled_plugins, dict):
|
|
1051
|
+
raise ValueError(f"{settings_path} enabledPlugins must contain a JSON object")
|
|
1052
|
+
enabled_plugins.setdefault(PLUGIN_SELECTOR, True)
|
|
1053
|
+
|
|
1054
|
+
entries = [
|
|
1055
|
+
_write_json_file(
|
|
1056
|
+
target / CLAUDE_MARKETPLACE_PATH,
|
|
1057
|
+
claude_marketplace,
|
|
1058
|
+
manifest_path=CLAUDE_MARKETPLACE_PATH.as_posix(),
|
|
1059
|
+
),
|
|
1060
|
+
_write_json_file(
|
|
1061
|
+
settings_path,
|
|
1062
|
+
current_settings,
|
|
1063
|
+
manifest_path=CLAUDE_SETTINGS_PATH.as_posix(),
|
|
1064
|
+
),
|
|
1065
|
+
_write_json_file(
|
|
1066
|
+
target / CODEX_MARKETPLACE_PATH,
|
|
1067
|
+
codex_marketplace,
|
|
1068
|
+
manifest_path=CODEX_MARKETPLACE_PATH.as_posix(),
|
|
1069
|
+
),
|
|
1070
|
+
]
|
|
1071
|
+
if include_codex_activation:
|
|
1072
|
+
entries.append(_write_codex_plugin_activation_config(target))
|
|
1073
|
+
return entries
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _render_codex_plugin_activation_config(target: Path) -> bytes:
|
|
1077
|
+
"""Render repo-local Codex plugin activation without touching user state.
|
|
1078
|
+
|
|
1079
|
+
``codex plugin add`` persists activation in ``~/.codex/config.toml`` and
|
|
1080
|
+
the user plugin cache. Bootstrap keeps this project-scoped instead: the
|
|
1081
|
+
repo config points Codex at the checked-in local marketplace and enables
|
|
1082
|
+
the generated ``workstate-system`` plugin when the operator has not
|
|
1083
|
+
explicitly disabled it.
|
|
1084
|
+
"""
|
|
1085
|
+
path = target / CODEX_CONFIG_PATH
|
|
1086
|
+
if path.exists():
|
|
1087
|
+
try:
|
|
1088
|
+
doc = tomlkit.parse(path.read_text())
|
|
1089
|
+
except (tomlkit.exceptions.TOMLKitError, UnicodeDecodeError):
|
|
1090
|
+
doc = tomlkit.document()
|
|
1091
|
+
else:
|
|
1092
|
+
doc = tomlkit.document()
|
|
1093
|
+
|
|
1094
|
+
marketplaces = doc.get("marketplaces")
|
|
1095
|
+
if not isinstance(marketplaces, dict):
|
|
1096
|
+
marketplaces = tomlkit.table(is_super_table=True)
|
|
1097
|
+
doc["marketplaces"] = marketplaces
|
|
1098
|
+
marketplace = tomlkit.table()
|
|
1099
|
+
marketplace["source_type"] = "local"
|
|
1100
|
+
marketplace["source"] = "."
|
|
1101
|
+
marketplaces[PLUGIN_MARKETPLACE_NAME] = marketplace
|
|
1102
|
+
|
|
1103
|
+
plugins = doc.get("plugins")
|
|
1104
|
+
if not isinstance(plugins, dict):
|
|
1105
|
+
plugins = tomlkit.table(is_super_table=True)
|
|
1106
|
+
doc["plugins"] = plugins
|
|
1107
|
+
existing_plugin = plugins.get(PLUGIN_SELECTOR)
|
|
1108
|
+
if isinstance(existing_plugin, dict):
|
|
1109
|
+
plugin_table = existing_plugin
|
|
1110
|
+
else:
|
|
1111
|
+
plugin_table = tomlkit.table()
|
|
1112
|
+
plugins[PLUGIN_SELECTOR] = plugin_table
|
|
1113
|
+
if "enabled" not in plugin_table:
|
|
1114
|
+
plugin_table["enabled"] = True
|
|
1115
|
+
|
|
1116
|
+
return tomlkit.dumps(doc).encode("utf-8")
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _write_codex_plugin_activation_config(target: Path) -> dict[str, str]:
|
|
1120
|
+
path = target / CODEX_CONFIG_PATH
|
|
1121
|
+
existed = path.exists()
|
|
1122
|
+
rendered = _render_codex_plugin_activation_config(target)
|
|
1123
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1124
|
+
path.write_bytes(rendered)
|
|
1125
|
+
return {
|
|
1126
|
+
"path": CODEX_CONFIG_PATH.as_posix(),
|
|
1127
|
+
"action": "merged" if existed else "created",
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _append_config_entry(entries: list[dict[str, str]], entry: dict[str, str]) -> None:
|
|
1132
|
+
"""Append a manifest config entry, coalescing duplicate managed paths."""
|
|
1133
|
+
path = entry.get("path")
|
|
1134
|
+
for existing in entries:
|
|
1135
|
+
if existing.get("path") == path:
|
|
1136
|
+
existing["action"] = "merged"
|
|
1137
|
+
return
|
|
1138
|
+
entries.append(entry)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _run_generator(
|
|
1142
|
+
target: Path, clone: Path, remote_sha: str, override_root: Path | None = None
|
|
1143
|
+
) -> None:
|
|
1144
|
+
"""Invoke the agent-workflow generator against the target.
|
|
1145
|
+
|
|
1146
|
+
Uses the generator + manifest + skills source from the overlay
|
|
1147
|
+
clone. Writes per-agent surfaces into the target via the
|
|
1148
|
+
generator's ``--target`` convenience flag.
|
|
1149
|
+
"""
|
|
1150
|
+
generator_script = _resolve_in_clone(clone, GENERATOR_SCRIPT)
|
|
1151
|
+
manifest_path = _resolve_in_clone(clone, GENERATOR_MANIFEST)
|
|
1152
|
+
skills_source = _resolve_in_clone(clone, GENERATOR_SKILLS_SOURCE)
|
|
1153
|
+
|
|
1154
|
+
if not generator_script.is_file():
|
|
1155
|
+
# Older overlays don't ship the generator. That's acceptable when
|
|
1156
|
+
# bootstrapping from a legacy ref; emit nothing rather than fail.
|
|
1157
|
+
return
|
|
1158
|
+
|
|
1159
|
+
cmd = [
|
|
1160
|
+
sys.executable,
|
|
1161
|
+
str(generator_script),
|
|
1162
|
+
"--manifest",
|
|
1163
|
+
str(manifest_path),
|
|
1164
|
+
"--skills-source-root",
|
|
1165
|
+
str(skills_source),
|
|
1166
|
+
"--target",
|
|
1167
|
+
str(target),
|
|
1168
|
+
]
|
|
1169
|
+
subprocess.run(cmd, check=True, cwd=str(clone), timeout=120)
|
|
1170
|
+
|
|
1171
|
+
base_plugin_cmd = [
|
|
1172
|
+
sys.executable,
|
|
1173
|
+
str(generator_script),
|
|
1174
|
+
"--mode=plugin",
|
|
1175
|
+
"--manifest",
|
|
1176
|
+
str(manifest_path),
|
|
1177
|
+
"--skills-source-root",
|
|
1178
|
+
str(skills_source),
|
|
1179
|
+
"--plugin-out",
|
|
1180
|
+
str(_plugin_tree_out(target, "base")),
|
|
1181
|
+
]
|
|
1182
|
+
subprocess.run(base_plugin_cmd, check=True, cwd=str(clone), timeout=120)
|
|
1183
|
+
|
|
1184
|
+
if override_root is None:
|
|
1185
|
+
return
|
|
1186
|
+
|
|
1187
|
+
effective_plugin_cmd = [
|
|
1188
|
+
sys.executable,
|
|
1189
|
+
str(generator_script),
|
|
1190
|
+
"--mode=plugin",
|
|
1191
|
+
"--manifest",
|
|
1192
|
+
str(manifest_path),
|
|
1193
|
+
"--skills-source-root",
|
|
1194
|
+
str(skills_source),
|
|
1195
|
+
"--plugin-out",
|
|
1196
|
+
str(_plugin_tree_out(target, "effective")),
|
|
1197
|
+
"--plugin-overrides",
|
|
1198
|
+
str(override_root),
|
|
1199
|
+
"--plugin-base-remote-sha",
|
|
1200
|
+
remote_sha,
|
|
1201
|
+
]
|
|
1202
|
+
subprocess.run(effective_plugin_cmd, check=True, cwd=str(clone), timeout=120)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _install_lifecycle_profile(
|
|
1206
|
+
target: Path, clone: Path
|
|
1207
|
+
) -> list[dict[str, str]]:
|
|
1208
|
+
"""Hoist the lifecycle Make fragment + runner into ``target``.
|
|
1209
|
+
|
|
1210
|
+
Each entry in :data:`LIFECYCLE_HOISTS` is resolved against the clone
|
|
1211
|
+
(preferring the ``packages/workstate-system/`` layout, falling back to
|
|
1212
|
+
a flat layout for hoisted fixture remotes), then copied to the
|
|
1213
|
+
consumer at the destination relpath. Files use ``shutil.copy2``;
|
|
1214
|
+
directories use ``shutil.copytree`` with ``dirs_exist_ok=True`` so
|
|
1215
|
+
re-runs are idempotent. Sources missing in the clone are skipped
|
|
1216
|
+
silently for older overlay refs.
|
|
1217
|
+
"""
|
|
1218
|
+
entries: list[dict[str, str]] = []
|
|
1219
|
+
for src_rel, dest_rel in LIFECYCLE_HOISTS:
|
|
1220
|
+
src = _resolve_in_clone(clone, src_rel)
|
|
1221
|
+
if not src.exists():
|
|
1222
|
+
continue
|
|
1223
|
+
dest = target / dest_rel
|
|
1224
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1225
|
+
# Under ``--profile all``, the shared-overlay materialization may
|
|
1226
|
+
# already have linked ``dest`` to the same
|
|
1227
|
+
# file inside the clone (Makefile.d/ and scripts/workstate/ ride
|
|
1228
|
+
# on the overlay symlink path). When dest already resolves to
|
|
1229
|
+
# src, ``shutil.copy2``/``copytree`` would raise
|
|
1230
|
+
# ``SameFileError``. Treat the existing symlink as the canonical
|
|
1231
|
+
# materialization and record the surface entry without copying.
|
|
1232
|
+
if dest.exists() and dest.resolve() == src.resolve():
|
|
1233
|
+
entries.append({"path": dest_rel, "source": "lifecycle"})
|
|
1234
|
+
continue
|
|
1235
|
+
if src.is_dir():
|
|
1236
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
1237
|
+
else:
|
|
1238
|
+
shutil.copy2(src, dest)
|
|
1239
|
+
entries.append({"path": dest_rel, "source": "lifecycle"})
|
|
1240
|
+
return entries
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _ensure_consumer_makefile_include(target: Path) -> dict[str, str] | None:
|
|
1244
|
+
"""Idempotently inject the lifecycle ``-include`` directive into
|
|
1245
|
+
``<target>/Makefile``.
|
|
1246
|
+
|
|
1247
|
+
Wraps the directive in a sentinel-bracketed block so re-runs don't
|
|
1248
|
+
duplicate, and a future uninstall can excise it cleanly. When the
|
|
1249
|
+
sentinel block is already present the file is left untouched and
|
|
1250
|
+
``action='already_present'`` is returned. When the consumer already
|
|
1251
|
+
declares lifecycle target names, the file is also left untouched so
|
|
1252
|
+
bootstrap does not inject a wildcard include that overrides
|
|
1253
|
+
repo-owned recipes. When the consumer has no Makefile, one is
|
|
1254
|
+
created containing only the sentinel block.
|
|
1255
|
+
"""
|
|
1256
|
+
makefile = target / "Makefile"
|
|
1257
|
+
block = (
|
|
1258
|
+
f"{LIFECYCLE_INCLUDE_SENTINEL_BEGIN}\n"
|
|
1259
|
+
f"{LIFECYCLE_INCLUDE_DIRECTIVE}\n"
|
|
1260
|
+
f"{LIFECYCLE_INCLUDE_SENTINEL_END}\n"
|
|
1261
|
+
)
|
|
1262
|
+
if not makefile.exists():
|
|
1263
|
+
makefile.write_text(block)
|
|
1264
|
+
return {"path": "Makefile", "action": "created"}
|
|
1265
|
+
existing = makefile.read_text()
|
|
1266
|
+
if LIFECYCLE_INCLUDE_SENTINEL_BEGIN in existing or LEGACY_LIFECYCLE_INCLUDE_SENTINEL_BEGIN in existing:
|
|
1267
|
+
return {"path": "Makefile", "action": "already_present"}
|
|
1268
|
+
if _makefile_declares_lifecycle_targets(existing):
|
|
1269
|
+
return {"path": "Makefile", "action": "skipped_existing_lifecycle_targets"}
|
|
1270
|
+
sep = "" if existing.endswith("\n") else "\n"
|
|
1271
|
+
makefile.write_text(existing + sep + block)
|
|
1272
|
+
return {"path": "Makefile", "action": "appended"}
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _makefile_declares_lifecycle_targets(text: str) -> bool:
|
|
1276
|
+
"""Return true when user-owned Makefile text already defines lifecycle recipes."""
|
|
1277
|
+
for raw_line in text.splitlines():
|
|
1278
|
+
if not raw_line or raw_line[0].isspace():
|
|
1279
|
+
continue
|
|
1280
|
+
line = raw_line.strip()
|
|
1281
|
+
if not line or line.startswith("#") or ":" not in line:
|
|
1282
|
+
continue
|
|
1283
|
+
before, after = line.split(":", 1)
|
|
1284
|
+
if after.lstrip().startswith("="):
|
|
1285
|
+
continue
|
|
1286
|
+
for token in before.split():
|
|
1287
|
+
if token in LIFECYCLE_TARGET_NAMES:
|
|
1288
|
+
return True
|
|
1289
|
+
return False
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def install(
|
|
1293
|
+
*,
|
|
1294
|
+
target: Path,
|
|
1295
|
+
remote_url: str,
|
|
1296
|
+
remote_ref: str,
|
|
1297
|
+
mcp_servers: Mapping[str, Mapping[str, Any]] | str | None = None,
|
|
1298
|
+
plugin_overrides: Path | None = None,
|
|
1299
|
+
reset_overrides: bool = False,
|
|
1300
|
+
backup_overrides: bool = False,
|
|
1301
|
+
enforce_required_surfaces: bool = False,
|
|
1302
|
+
profile: str = PROFILE_ALL,
|
|
1303
|
+
install_claude_stop_hook: bool = False,
|
|
1304
|
+
install_claude_stop_hook_local: bool = False,
|
|
1305
|
+
install_codex_stop_hook: bool = False,
|
|
1306
|
+
install_vscode_stop_hook: bool = False,
|
|
1307
|
+
) -> dict[str, object]:
|
|
1308
|
+
"""Clone the shared workstate-system remote, materialize overlay surfaces,
|
|
1309
|
+
write consumer-tool configs, and write the overlay manifest.
|
|
1310
|
+
|
|
1311
|
+
Args:
|
|
1312
|
+
target: Consumer repository root. Must already exist.
|
|
1313
|
+
remote_url: Git URL for the shared workstate-system remote.
|
|
1314
|
+
remote_ref: Tag, branch, or SHA to check out (e.g. ``"v0.1.0"``).
|
|
1315
|
+
mcp_servers: Mapping of ``<server_name> -> {command, args, env}`` to
|
|
1316
|
+
register in ``.mcp.json``, ``.vscode/mcp.json``, and
|
|
1317
|
+
``.codex/config.toml``. Pass the sentinel string ``"default"``
|
|
1318
|
+
to use :data:`DEFAULT_MCP_SERVERS` (the two MCP servers shipped
|
|
1319
|
+
by this monorepo). When ``None``, the three file-writers are
|
|
1320
|
+
skipped. ``core.hooksPath`` is set independently whenever the
|
|
1321
|
+
target is a git repo.
|
|
1322
|
+
plugin_overrides: Optional explicit plugin override root. When set,
|
|
1323
|
+
bootstrap composes the effective plugin tree from this root and
|
|
1324
|
+
records the resolved path in the manifest for later doctor /
|
|
1325
|
+
update / repair runs.
|
|
1326
|
+
reset_overrides: When True, remove the resolved plugin override root
|
|
1327
|
+
before regeneration so marketplace pins fall back to the base
|
|
1328
|
+
plugin tree.
|
|
1329
|
+
backup_overrides: When True together with ``reset_overrides``, archive
|
|
1330
|
+
the override root under ``.agentic/override-backups/<timestamp>/``
|
|
1331
|
+
before removal.
|
|
1332
|
+
enforce_required_surfaces: When True, refuse the install if any
|
|
1333
|
+
surface declared as required by the manifest fails to
|
|
1334
|
+
materialize. Defaults to False (warn-only).
|
|
1335
|
+
profile: Install profile selecting how much overlay surface to
|
|
1336
|
+
materialize. One of :data:`PROFILE_MINIMAL`,
|
|
1337
|
+
:data:`PROFILE_LIFECYCLE`, or :data:`PROFILE_ALL` (default).
|
|
1338
|
+
install_claude_stop_hook: When True, write the shared, checked-in
|
|
1339
|
+
Claude stop-hook wiring at ``.claude/settings.json``. Off by
|
|
1340
|
+
default; no file is touched unless the operator opts in.
|
|
1341
|
+
install_claude_stop_hook_local: When True, write the user-owned,
|
|
1342
|
+
gitignored Claude stop-hook wiring at
|
|
1343
|
+
``.claude/settings.local.json``. Off by default.
|
|
1344
|
+
install_codex_stop_hook: When True, write the Codex CLI harness
|
|
1345
|
+
stop-hook wiring at ``.codex/hooks/stop.json``. Off by default.
|
|
1346
|
+
install_vscode_stop_hook: When True, write the VS Code harness
|
|
1347
|
+
stop-hook wiring at ``.vscode/workstate-stop-hooks.json``. Off
|
|
1348
|
+
by default.
|
|
1349
|
+
|
|
1350
|
+
Returns:
|
|
1351
|
+
The manifest dict that was written to ``<target>/.workstate-bootstrap.json``.
|
|
1352
|
+
|
|
1353
|
+
Raises:
|
|
1354
|
+
FileNotFoundError: ``target`` does not exist.
|
|
1355
|
+
FileExistsError: ``<target>/.agentic/remote`` exists but is not a git clone.
|
|
1356
|
+
RemoteUrlMismatchError: existing clone tracks a different ``origin`` URL.
|
|
1357
|
+
subprocess.CalledProcessError: ``git`` command failed.
|
|
1358
|
+
"""
|
|
1359
|
+
if profile not in SUPPORTED_PROFILES:
|
|
1360
|
+
raise ValueError(
|
|
1361
|
+
f"profile={profile!r} is not a recognized install profile; "
|
|
1362
|
+
f"expected one of {sorted(SUPPORTED_PROFILES)!r}."
|
|
1363
|
+
)
|
|
1364
|
+
|
|
1365
|
+
target = Path(target).resolve()
|
|
1366
|
+
if not target.is_dir():
|
|
1367
|
+
raise FileNotFoundError(f"target directory does not exist: {target}")
|
|
1368
|
+
|
|
1369
|
+
_migrate_legacy_manifest(target)
|
|
1370
|
+
|
|
1371
|
+
if isinstance(mcp_servers, str):
|
|
1372
|
+
if mcp_servers != "default":
|
|
1373
|
+
raise ValueError(
|
|
1374
|
+
f"mcp_servers={mcp_servers!r} is not a recognized sentinel; "
|
|
1375
|
+
"pass a mapping, the literal 'default', or None."
|
|
1376
|
+
)
|
|
1377
|
+
mcp_servers = DEFAULT_MCP_SERVERS
|
|
1378
|
+
|
|
1379
|
+
clone = target.joinpath(*CLONE_SUBDIR)
|
|
1380
|
+
|
|
1381
|
+
if (clone / ".git").exists():
|
|
1382
|
+
existing_origin = _git("remote", "get-url", "origin", cwd=clone)
|
|
1383
|
+
if existing_origin != remote_url:
|
|
1384
|
+
raise RemoteUrlMismatchError(
|
|
1385
|
+
f"{clone} already tracks origin {existing_origin!r}, "
|
|
1386
|
+
f"but install was called with remote_url={remote_url!r}. "
|
|
1387
|
+
"Move or remove .agentic/remote (or pass the original URL) to "
|
|
1388
|
+
"switch overlays."
|
|
1389
|
+
)
|
|
1390
|
+
_git("fetch", "--tags", "--prune", "--force", "origin", cwd=clone)
|
|
1391
|
+
else:
|
|
1392
|
+
clone.parent.mkdir(parents=True, exist_ok=True)
|
|
1393
|
+
if clone.exists():
|
|
1394
|
+
raise FileExistsError(
|
|
1395
|
+
f"{clone} exists but is not a git clone. "
|
|
1396
|
+
"Move or remove it before re-running install."
|
|
1397
|
+
)
|
|
1398
|
+
_git("clone", "--branch", remote_ref, remote_url, str(clone))
|
|
1399
|
+
|
|
1400
|
+
sha = _resolve_ref_to_sha(clone, remote_ref)
|
|
1401
|
+
if len(sha) != 40:
|
|
1402
|
+
raise RuntimeError(f"unexpected sha shape from git rev-parse: {sha!r}")
|
|
1403
|
+
|
|
1404
|
+
_git("checkout", "--detach", sha, cwd=clone)
|
|
1405
|
+
mcp_servers = _resolve_install_mcp_servers(target, remote_ref, mcp_servers)
|
|
1406
|
+
init_state_expected_remote_url = remote_url
|
|
1407
|
+
state_backup_path: str | None = None
|
|
1408
|
+
if mcp_servers:
|
|
1409
|
+
init_state_expected_remote_url, state_backup_path = _prepare_state_for_remote_switch(
|
|
1410
|
+
target,
|
|
1411
|
+
remote_url,
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
override_root = _discover_plugin_override_root(
|
|
1415
|
+
target,
|
|
1416
|
+
plugin_overrides=plugin_overrides,
|
|
1417
|
+
)
|
|
1418
|
+
override_root, override_backup_path = _reset_plugin_overrides(
|
|
1419
|
+
target,
|
|
1420
|
+
override_root,
|
|
1421
|
+
reset_overrides=reset_overrides,
|
|
1422
|
+
backup_overrides=backup_overrides,
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
surfaces: list[dict[str, str]] = []
|
|
1426
|
+
configs: list[dict[str, str]] = []
|
|
1427
|
+
|
|
1428
|
+
if profile == PROFILE_ALL:
|
|
1429
|
+
surfaces.extend(_materialize_surfaces(target, clone))
|
|
1430
|
+
surfaces.extend(_prepare_generated_surfaces(target, clone))
|
|
1431
|
+
plugin_surfaces = _prepare_plugin_generated_surfaces(target, clone, override_root)
|
|
1432
|
+
surfaces.extend(plugin_surfaces)
|
|
1433
|
+
|
|
1434
|
+
# Required-surfaces refusal keeps consumers from ending up with a
|
|
1435
|
+
# half-installed harness when required hooks are missing. Run BEFORE the
|
|
1436
|
+
# generator, config writers, and init-state so a failing install
|
|
1437
|
+
# cannot leave generated artifacts, .mcp.json, or .task-state/
|
|
1438
|
+
# behind on disk.
|
|
1439
|
+
materialized_paths = {entry["path"] for entry in surfaces if isinstance(entry, dict)}
|
|
1440
|
+
if enforce_required_surfaces and "scripts/hooks" not in materialized_paths:
|
|
1441
|
+
raise BootstrapManifestValidationError(
|
|
1442
|
+
"refusing to declare install successful: required surface 'scripts/hooks' "
|
|
1443
|
+
"was not materialized. Bootstrap-installed hooks are part of the harness "
|
|
1444
|
+
"contract; without them, target-side guardrails do not run. "
|
|
1445
|
+
"Set enforce_required_surfaces=False to bypass for non-standard remotes."
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
_run_generator(target, clone, sha, override_root)
|
|
1449
|
+
if plugin_surfaces:
|
|
1450
|
+
_write_plugin_override_lock(override_root, sha)
|
|
1451
|
+
configs.extend(
|
|
1452
|
+
_write_plugin_pins(
|
|
1453
|
+
target,
|
|
1454
|
+
override_root,
|
|
1455
|
+
include_codex_activation=False,
|
|
1456
|
+
)
|
|
1457
|
+
)
|
|
1458
|
+
configs.extend(_write_configs(target, mcp_servers, include_hooks=False))
|
|
1459
|
+
if plugin_surfaces:
|
|
1460
|
+
_append_config_entry(configs, _write_codex_plugin_activation_config(target))
|
|
1461
|
+
_run_init_state(
|
|
1462
|
+
target,
|
|
1463
|
+
mcp_servers,
|
|
1464
|
+
expected_remote_url=init_state_expected_remote_url,
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
# ``all`` also performs the lifecycle hoist so a consumer that ships the
|
|
1468
|
+
# lifecycle-referencing skills (branch-
|
|
1469
|
+
# lifecycle / tdd / incremental-implementation / branch-review /
|
|
1470
|
+
# handoff-lifecycle and the body-only references in auto-fix /
|
|
1471
|
+
# review-parallel / investigate) also receives the matching
|
|
1472
|
+
# ``Makefile.d/lifecycle.mk`` + ``scripts/workstate/lifecycle/``
|
|
1473
|
+
# runner that defines those targets. ``--profile lifecycle``
|
|
1474
|
+
# remains the dedicated lean profile (no skills, lifecycle only);
|
|
1475
|
+
# ``--profile minimal`` is unchanged. Both hoist helpers below are
|
|
1476
|
+
# idempotent on rerun.
|
|
1477
|
+
if profile in (PROFILE_ALL, PROFILE_LIFECYCLE):
|
|
1478
|
+
surfaces.extend(_install_lifecycle_profile(target, clone))
|
|
1479
|
+
include_entry = _ensure_consumer_makefile_include(target)
|
|
1480
|
+
if include_entry is not None:
|
|
1481
|
+
configs.append(include_entry)
|
|
1482
|
+
|
|
1483
|
+
hooks_entry = _set_git_hooks_path(target)
|
|
1484
|
+
if hooks_entry is not None:
|
|
1485
|
+
configs.append(hooks_entry)
|
|
1486
|
+
|
|
1487
|
+
active_flags: set[str] = set()
|
|
1488
|
+
if install_claude_stop_hook:
|
|
1489
|
+
active_flags.add("--install-claude-stop-hook")
|
|
1490
|
+
if install_claude_stop_hook_local:
|
|
1491
|
+
active_flags.add("--install-claude-stop-hook-local")
|
|
1492
|
+
if install_codex_stop_hook:
|
|
1493
|
+
active_flags.add("--install-codex-stop-hook")
|
|
1494
|
+
if install_vscode_stop_hook:
|
|
1495
|
+
active_flags.add("--install-vscode-stop-hook")
|
|
1496
|
+
configs.extend(
|
|
1497
|
+
_walk_hook_adapters(
|
|
1498
|
+
manifest=_load_portable_manifest(clone),
|
|
1499
|
+
clone=clone,
|
|
1500
|
+
target=target,
|
|
1501
|
+
profile=profile,
|
|
1502
|
+
active_flags=active_flags,
|
|
1503
|
+
)
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
manifest: dict[str, object] = _build_install_manifest(
|
|
1507
|
+
remote_url=remote_url,
|
|
1508
|
+
remote_ref=remote_ref,
|
|
1509
|
+
remote_sha=sha,
|
|
1510
|
+
profile=profile,
|
|
1511
|
+
surfaces=surfaces,
|
|
1512
|
+
configs=configs,
|
|
1513
|
+
mcp_servers=mcp_servers,
|
|
1514
|
+
plugin_overrides_path=_plugin_override_root_manifest_path(target, override_root),
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
# Wire-shape validation: refuse to write a manifest that does not
|
|
1518
|
+
# validate against workstate_protocol.BootstrapManifest. This is the
|
|
1519
|
+
# install-time schema check for bootstrap manifests.
|
|
1520
|
+
# When workstate-protocol is not installed (partial migrations), we
|
|
1521
|
+
# warn but still write — the manifest contract is best-effort
|
|
1522
|
+
# until the protocol is mandatory.
|
|
1523
|
+
try:
|
|
1524
|
+
from workstate_protocol import BootstrapManifest # type: ignore[import-not-found]
|
|
1525
|
+
|
|
1526
|
+
BootstrapManifest.model_validate(manifest)
|
|
1527
|
+
except ImportError:
|
|
1528
|
+
pass
|
|
1529
|
+
except Exception as exc: # noqa: BLE001
|
|
1530
|
+
raise BootstrapManifestValidationError(
|
|
1531
|
+
f"refusing to write {BOOTSTRAP_MANIFEST_NAME}: workstate_protocol.BootstrapManifest "
|
|
1532
|
+
f"validation failed: {exc}"
|
|
1533
|
+
) from exc
|
|
1534
|
+
|
|
1535
|
+
manifest_path = target / BOOTSTRAP_MANIFEST_NAME
|
|
1536
|
+
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
|
|
1537
|
+
|
|
1538
|
+
result = dict(manifest)
|
|
1539
|
+
if override_backup_path is not None:
|
|
1540
|
+
result["override_backup_path"] = override_backup_path
|
|
1541
|
+
if state_backup_path is not None:
|
|
1542
|
+
result["state_backup_path"] = state_backup_path
|
|
1543
|
+
return result
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
# ---------------------------------------------------------------------------
|
|
1547
|
+
# Config writers
|
|
1548
|
+
# ---------------------------------------------------------------------------
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
HOOKS_PATH_VALUE = "scripts/hooks/git"
|
|
1552
|
+
"""Workspace-relative ``core.hooksPath`` value.
|
|
1553
|
+
|
|
1554
|
+
Points at the ``git/`` subdirectory of the materialized ``scripts/hooks``
|
|
1555
|
+
surface. The parent directory ships Python helpers, ``.sh`` utilities,
|
|
1556
|
+
and tests alongside the actual hook scripts; pointing git at the parent
|
|
1557
|
+
makes git look for hook files by name (``post-checkout`` etc.) at a path
|
|
1558
|
+
where they do not exist, so it silently resolves nothing. The named
|
|
1559
|
+
hooks themselves live at ``scripts/hooks/git/<name>``.
|
|
1560
|
+
|
|
1561
|
+
Single-line invariant: the on-disk hook layout and this value MUST agree.
|
|
1562
|
+
The install rehearsal pins both halves of that contract.
|
|
1563
|
+
"""
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def _deep_merge(dst: dict[str, Any], src: Mapping[str, Any]) -> dict[str, Any]:
|
|
1567
|
+
"""Recursively merge ``src`` into ``dst`` and return ``dst``.
|
|
1568
|
+
|
|
1569
|
+
Dict-into-dict merges recurse. Any non-dict value in ``src`` (including
|
|
1570
|
+
lists) replaces the corresponding key in ``dst`` outright — list-concat
|
|
1571
|
+
semantics would silently grow user config across reruns.
|
|
1572
|
+
"""
|
|
1573
|
+
for key, value in src.items():
|
|
1574
|
+
existing = dst.get(key)
|
|
1575
|
+
if isinstance(existing, dict) and isinstance(value, Mapping):
|
|
1576
|
+
_deep_merge(existing, value)
|
|
1577
|
+
elif isinstance(value, Mapping):
|
|
1578
|
+
new_dict: dict[str, Any] = {}
|
|
1579
|
+
_deep_merge(new_dict, value)
|
|
1580
|
+
dst[key] = new_dict
|
|
1581
|
+
else:
|
|
1582
|
+
dst[key] = value
|
|
1583
|
+
return dst
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def _write_configs(
|
|
1587
|
+
target: Path,
|
|
1588
|
+
mcp_servers: Mapping[str, Mapping[str, Any]] | None,
|
|
1589
|
+
*,
|
|
1590
|
+
include_hooks: bool = True,
|
|
1591
|
+
) -> list[dict[str, str]]:
|
|
1592
|
+
"""Run the four post-install config writers and return per-surface entries
|
|
1593
|
+
suitable for ``manifest['configs']``.
|
|
1594
|
+
|
|
1595
|
+
The three file-writers run only when ``mcp_servers`` is provided. The git
|
|
1596
|
+
``core.hooksPath`` writer runs whenever the target looks like a git repo.
|
|
1597
|
+
"""
|
|
1598
|
+
entries: list[dict[str, str]] = []
|
|
1599
|
+
|
|
1600
|
+
if mcp_servers:
|
|
1601
|
+
entries.append(_write_mcp_json(target, mcp_servers))
|
|
1602
|
+
entries.append(_write_vscode_mcp_json(target, mcp_servers))
|
|
1603
|
+
entries.append(_write_codex_config(target, mcp_servers))
|
|
1604
|
+
|
|
1605
|
+
if include_hooks:
|
|
1606
|
+
hooks_entry = _set_git_hooks_path(target)
|
|
1607
|
+
if hooks_entry is not None:
|
|
1608
|
+
entries.append(hooks_entry)
|
|
1609
|
+
|
|
1610
|
+
return entries
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def _run_init_state(
|
|
1614
|
+
target: Path,
|
|
1615
|
+
mcp_servers: Mapping[str, Mapping[str, Any]] | None,
|
|
1616
|
+
*,
|
|
1617
|
+
expected_remote_url: str | None = None,
|
|
1618
|
+
) -> None:
|
|
1619
|
+
if not mcp_servers:
|
|
1620
|
+
return
|
|
1621
|
+
|
|
1622
|
+
spec = mcp_servers.get("workstate-handoff-mcp") or mcp_servers.get("agent-handoff-mcp")
|
|
1623
|
+
if spec is None:
|
|
1624
|
+
return
|
|
1625
|
+
|
|
1626
|
+
command = spec.get("command")
|
|
1627
|
+
if not isinstance(command, str) or not command:
|
|
1628
|
+
raise ValueError("workstate-handoff-mcp config must include a non-empty command")
|
|
1629
|
+
|
|
1630
|
+
raw_args = spec.get("args", [])
|
|
1631
|
+
if not isinstance(raw_args, list) or not all(isinstance(arg, str) for arg in raw_args):
|
|
1632
|
+
raise ValueError("workstate-handoff-mcp config args must be a list[str]")
|
|
1633
|
+
|
|
1634
|
+
args = list(raw_args)
|
|
1635
|
+
raw_env = spec.get("env")
|
|
1636
|
+
env_has_state_dir = isinstance(raw_env, Mapping) and (
|
|
1637
|
+
"WORKSTATE_HANDOFF_STATE_DIR" in raw_env or "AGENT_HANDOFF_STATE_DIR" in raw_env
|
|
1638
|
+
)
|
|
1639
|
+
if args and args[-1] in {"serve-stdio", "serve-http", "init-state"}:
|
|
1640
|
+
args = args[:-1]
|
|
1641
|
+
if not any(arg == "--workspace-root" or arg.startswith("--workspace-root=") for arg in args):
|
|
1642
|
+
args.extend(["--workspace-root", str(target)])
|
|
1643
|
+
if (
|
|
1644
|
+
not any(arg == "--state-dir" or arg.startswith("--state-dir=") for arg in args)
|
|
1645
|
+
and not env_has_state_dir
|
|
1646
|
+
):
|
|
1647
|
+
args.extend(["--state-dir", str(target / ".task-state")])
|
|
1648
|
+
args.append("init-state")
|
|
1649
|
+
if expected_remote_url is not None:
|
|
1650
|
+
args.extend(["--expected-remote-url", expected_remote_url])
|
|
1651
|
+
|
|
1652
|
+
cmd = [command, *args]
|
|
1653
|
+
env = os.environ.copy()
|
|
1654
|
+
if raw_env is not None:
|
|
1655
|
+
if not isinstance(raw_env, Mapping) or not all(
|
|
1656
|
+
isinstance(key, str) and isinstance(value, str) for key, value in raw_env.items()
|
|
1657
|
+
):
|
|
1658
|
+
raise ValueError("workstate-handoff-mcp config env must be a mapping[str, str]")
|
|
1659
|
+
env.update(raw_env)
|
|
1660
|
+
|
|
1661
|
+
try:
|
|
1662
|
+
subprocess.run(
|
|
1663
|
+
cmd,
|
|
1664
|
+
check=True,
|
|
1665
|
+
capture_output=True,
|
|
1666
|
+
text=True,
|
|
1667
|
+
cwd=str(target),
|
|
1668
|
+
env=env,
|
|
1669
|
+
timeout=120,
|
|
1670
|
+
)
|
|
1671
|
+
except subprocess.CalledProcessError as exc:
|
|
1672
|
+
retry_cmd = _build_local_handoff_retry_cmd(target, cmd)
|
|
1673
|
+
if retry_cmd is None:
|
|
1674
|
+
raise
|
|
1675
|
+
try:
|
|
1676
|
+
subprocess.run(
|
|
1677
|
+
retry_cmd,
|
|
1678
|
+
check=True,
|
|
1679
|
+
capture_output=True,
|
|
1680
|
+
text=True,
|
|
1681
|
+
cwd=str(target),
|
|
1682
|
+
env=env,
|
|
1683
|
+
timeout=120,
|
|
1684
|
+
)
|
|
1685
|
+
except subprocess.CalledProcessError as retry_exc:
|
|
1686
|
+
raise retry_exc from exc
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def _render_mcp_json(
|
|
1690
|
+
target: Path,
|
|
1691
|
+
mcp_servers: Mapping[str, Mapping[str, Any]],
|
|
1692
|
+
*,
|
|
1693
|
+
prune_names: Iterable[str] = (),
|
|
1694
|
+
) -> bytes:
|
|
1695
|
+
"""Pure render half of the .mcp.json seam: read the existing file
|
|
1696
|
+
(if any), deep-merge managed servers under ``mcpServers``, and return
|
|
1697
|
+
the bytes that ``_write_mcp_json`` would persist. No filesystem
|
|
1698
|
+
mutation.
|
|
1699
|
+
|
|
1700
|
+
``prune_names`` are server names to remove from the existing
|
|
1701
|
+
``mcpServers`` block before the merge — driven by
|
|
1702
|
+
``sync_mcp_configs(prune_removed_managed=True)`` reading the
|
|
1703
|
+
ledger's previously-managed provenance. Default ``()`` keeps the
|
|
1704
|
+
install path's behavior byte-identical."""
|
|
1705
|
+
path = target / ".mcp.json"
|
|
1706
|
+
doc: dict[str, Any] = _load_json_or_empty(path)
|
|
1707
|
+
if prune_names:
|
|
1708
|
+
servers = doc.get("mcpServers")
|
|
1709
|
+
if isinstance(servers, dict):
|
|
1710
|
+
for name in prune_names:
|
|
1711
|
+
servers.pop(name, None)
|
|
1712
|
+
incoming = {"mcpServers": {name: dict(spec) for name, spec in mcp_servers.items()}}
|
|
1713
|
+
_deep_merge(doc, incoming)
|
|
1714
|
+
return (json.dumps(doc, indent=2) + "\n").encode("utf-8")
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _load_json_or_empty(path: Path) -> dict[str, Any]:
|
|
1718
|
+
"""Return parsed JSON or ``{}`` when the file is missing or malformed.
|
|
1719
|
+
|
|
1720
|
+
Managed surfaces are this tool's own output. If the file is invalid
|
|
1721
|
+
JSON (interrupted prior write, hand edit), treat it as empty so the
|
|
1722
|
+
next reconcile rewrites it cleanly instead of letting JSONDecodeError
|
|
1723
|
+
escape through doctor / mcp-sync. Third-party preservation is
|
|
1724
|
+
impossible in that case (the existing content is already lost).
|
|
1725
|
+
"""
|
|
1726
|
+
if not path.exists():
|
|
1727
|
+
return {}
|
|
1728
|
+
try:
|
|
1729
|
+
loaded = json.loads(path.read_text())
|
|
1730
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
1731
|
+
return {}
|
|
1732
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
def _write_mcp_json(
|
|
1736
|
+
target: Path,
|
|
1737
|
+
mcp_servers: Mapping[str, Mapping[str, Any]],
|
|
1738
|
+
*,
|
|
1739
|
+
prune_names: Iterable[str] = (),
|
|
1740
|
+
) -> dict[str, str]:
|
|
1741
|
+
"""Deep-merge managed servers into ``<target>/.mcp.json`` under
|
|
1742
|
+
``mcpServers``. Preserves all other keys and other servers."""
|
|
1743
|
+
path = target / ".mcp.json"
|
|
1744
|
+
existed = path.exists()
|
|
1745
|
+
rendered = _render_mcp_json(target, mcp_servers, prune_names=prune_names)
|
|
1746
|
+
path.write_bytes(rendered)
|
|
1747
|
+
return {"path": ".mcp.json", "action": "merged" if existed else "created"}
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
def _render_vscode_mcp_json(
|
|
1751
|
+
target: Path,
|
|
1752
|
+
mcp_servers: Mapping[str, Mapping[str, Any]],
|
|
1753
|
+
*,
|
|
1754
|
+
prune_names: Iterable[str] = (),
|
|
1755
|
+
) -> bytes:
|
|
1756
|
+
"""Pure render half of the .vscode/mcp.json seam: read the existing
|
|
1757
|
+
file (if any), deep-merge managed servers under ``servers``, and
|
|
1758
|
+
return the bytes that ``_write_vscode_mcp_json`` would persist. No
|
|
1759
|
+
filesystem mutation (the ``.vscode/`` directory is created by the
|
|
1760
|
+
write half).
|
|
1761
|
+
|
|
1762
|
+
``prune_names`` removes those entries from the existing ``servers``
|
|
1763
|
+
block before the merge; see ``_render_mcp_json`` for the contract."""
|
|
1764
|
+
path = target / ".vscode" / "mcp.json"
|
|
1765
|
+
doc: dict[str, Any] = _load_json_or_empty(path)
|
|
1766
|
+
if prune_names:
|
|
1767
|
+
servers = doc.get("servers")
|
|
1768
|
+
if isinstance(servers, dict):
|
|
1769
|
+
for name in prune_names:
|
|
1770
|
+
servers.pop(name, None)
|
|
1771
|
+
incoming = {"servers": {name: dict(spec) for name, spec in mcp_servers.items()}}
|
|
1772
|
+
_deep_merge(doc, incoming)
|
|
1773
|
+
return (json.dumps(doc, indent=2) + "\n").encode("utf-8")
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def _write_vscode_mcp_json(
|
|
1777
|
+
target: Path,
|
|
1778
|
+
mcp_servers: Mapping[str, Mapping[str, Any]],
|
|
1779
|
+
*,
|
|
1780
|
+
prune_names: Iterable[str] = (),
|
|
1781
|
+
) -> dict[str, str]:
|
|
1782
|
+
"""Deep-merge managed servers into ``<target>/.vscode/mcp.json`` under
|
|
1783
|
+
``servers``. Creates the ``.vscode`` directory if absent."""
|
|
1784
|
+
path = target / ".vscode" / "mcp.json"
|
|
1785
|
+
existed = path.exists()
|
|
1786
|
+
rendered = _render_vscode_mcp_json(target, mcp_servers, prune_names=prune_names)
|
|
1787
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1788
|
+
path.write_bytes(rendered)
|
|
1789
|
+
return {"path": ".vscode/mcp.json", "action": "merged" if existed else "created"}
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
def _render_codex_config(
|
|
1793
|
+
target: Path,
|
|
1794
|
+
mcp_servers: Mapping[str, Mapping[str, Any]],
|
|
1795
|
+
*,
|
|
1796
|
+
prune_names: Iterable[str] = (),
|
|
1797
|
+
) -> bytes:
|
|
1798
|
+
"""Pure render half of the .codex/config.toml seam: read the existing
|
|
1799
|
+
TOML (if any), replace the ``[mcp_servers.<name>]`` tables for each
|
|
1800
|
+
managed server while preserving every other key and comment, and
|
|
1801
|
+
return the bytes that ``_write_codex_config`` would persist. No
|
|
1802
|
+
filesystem mutation (the ``.codex/`` directory is created by the
|
|
1803
|
+
write half).
|
|
1804
|
+
|
|
1805
|
+
``prune_names`` removes those tables from ``[mcp_servers]`` before
|
|
1806
|
+
the managed tables are added; see ``_render_mcp_json`` for the
|
|
1807
|
+
contract."""
|
|
1808
|
+
path = target / ".codex" / "config.toml"
|
|
1809
|
+
if path.exists():
|
|
1810
|
+
try:
|
|
1811
|
+
doc = tomlkit.parse(path.read_text())
|
|
1812
|
+
except (tomlkit.exceptions.TOMLKitError, UnicodeDecodeError):
|
|
1813
|
+
doc = tomlkit.document()
|
|
1814
|
+
else:
|
|
1815
|
+
doc = tomlkit.document()
|
|
1816
|
+
|
|
1817
|
+
if "mcp_servers" not in doc:
|
|
1818
|
+
doc["mcp_servers"] = tomlkit.table(is_super_table=True)
|
|
1819
|
+
servers_table = doc["mcp_servers"]
|
|
1820
|
+
|
|
1821
|
+
if prune_names:
|
|
1822
|
+
for name in prune_names:
|
|
1823
|
+
if name in servers_table:
|
|
1824
|
+
del servers_table[name]
|
|
1825
|
+
|
|
1826
|
+
for name, spec in mcp_servers.items():
|
|
1827
|
+
new_table = tomlkit.table()
|
|
1828
|
+
for spec_key, spec_value in spec.items():
|
|
1829
|
+
new_table[spec_key] = spec_value
|
|
1830
|
+
servers_table[name] = new_table
|
|
1831
|
+
|
|
1832
|
+
return tomlkit.dumps(doc).encode("utf-8")
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
def _write_codex_config(
|
|
1836
|
+
target: Path,
|
|
1837
|
+
mcp_servers: Mapping[str, Mapping[str, Any]],
|
|
1838
|
+
*,
|
|
1839
|
+
prune_names: Iterable[str] = (),
|
|
1840
|
+
) -> dict[str, str]:
|
|
1841
|
+
"""Replace the ``[mcp_servers.<name>]`` tables in
|
|
1842
|
+
``<target>/.codex/config.toml`` for each managed server, leaving every
|
|
1843
|
+
other root key, table, and comment untouched (tomlkit round-trip)."""
|
|
1844
|
+
path = target / ".codex" / "config.toml"
|
|
1845
|
+
existed = path.exists()
|
|
1846
|
+
rendered = _render_codex_config(target, mcp_servers, prune_names=prune_names)
|
|
1847
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1848
|
+
path.write_bytes(rendered)
|
|
1849
|
+
return {"path": ".codex/config.toml", "action": "merged" if existed else "created"}
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def _set_git_hooks_path(target: Path) -> dict[str, str] | None:
|
|
1853
|
+
"""If ``target`` is a git repo, set ``core.hooksPath`` to
|
|
1854
|
+
``scripts/hooks/git`` (under the materialized ``scripts/hooks``
|
|
1855
|
+
symlink) and return a manifest entry. Otherwise return ``None``
|
|
1856
|
+
(silent skip).
|
|
1857
|
+
|
|
1858
|
+
See ``HOOKS_PATH_VALUE`` for why the path includes the ``git/``
|
|
1859
|
+
subdirectory.
|
|
1860
|
+
"""
|
|
1861
|
+
if not (target / ".git").exists():
|
|
1862
|
+
return None
|
|
1863
|
+
_git("config", "core.hooksPath", HOOKS_PATH_VALUE, cwd=target)
|
|
1864
|
+
return {"path": "core.hooksPath", "action": "set"}
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
# Manifest-driven hook walker.
|
|
1868
|
+
#
|
|
1869
|
+
# ``portable_commands.json`` (schema v2) is the single source of truth
|
|
1870
|
+
# for the per-harness adapter rows that materialize bootstrap-owned
|
|
1871
|
+
# hooks. The walker reads the manifest from the cloned overlay, filters
|
|
1872
|
+
# by install profile and the active set of opt-in flags, verifies the
|
|
1873
|
+
# hook's ``required_artifacts`` exist in the clone, and dispatches each
|
|
1874
|
+
# selected adapter on its ``patch.operation``. The previous single-harness
|
|
1875
|
+
# writer (``_write_claude_settings_hooks``) is replaced by this table-driven
|
|
1876
|
+
# walk so new harnesses (Codex, VS Code, etc.) can be
|
|
1877
|
+
# added by appending adapter rows to the manifest rather than by
|
|
1878
|
+
# growing bespoke writers in this module.
|
|
1879
|
+
#
|
|
1880
|
+
# Adapter target strings are NEVER hardcoded here — every ``.claude/...``
|
|
1881
|
+
# / ``.codex/...`` path comes from the manifest. The walker only knows
|
|
1882
|
+
# how to dispatch operations.
|
|
1883
|
+
|
|
1884
|
+
_TEMPLATE_CONSUMER_ROOT = "{{consumer_root}}"
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
def _load_portable_manifest(clone: Path) -> dict[str, Any]:
|
|
1888
|
+
"""Read the v2 portable-commands manifest out of the clone.
|
|
1889
|
+
|
|
1890
|
+
Returns ``{}`` when the manifest is absent (older overlays that
|
|
1891
|
+
predate schema v2) so the walker becomes a noop instead of raising.
|
|
1892
|
+
"""
|
|
1893
|
+
manifest_path = _resolve_in_clone(clone, GENERATOR_MANIFEST)
|
|
1894
|
+
if not manifest_path.is_file():
|
|
1895
|
+
return {}
|
|
1896
|
+
return json.loads(manifest_path.read_text())
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
def _render_template(value: Any, *, target: Path) -> Any:
|
|
1900
|
+
"""Recursively substitute ``{{consumer_root}}`` with the resolved
|
|
1901
|
+
consumer-root path inside the adapter ``entry`` template."""
|
|
1902
|
+
if isinstance(value, str):
|
|
1903
|
+
return value.replace(_TEMPLATE_CONSUMER_ROOT, str(target))
|
|
1904
|
+
if isinstance(value, Mapping):
|
|
1905
|
+
return {k: _render_template(v, target=target) for k, v in value.items()}
|
|
1906
|
+
if isinstance(value, list):
|
|
1907
|
+
return [_render_template(v, target=target) for v in value]
|
|
1908
|
+
return value
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
def _resolve_dotted_path(doc: dict[str, Any], json_path: str) -> tuple[dict[str, Any], str]:
|
|
1912
|
+
"""Resolve a closed-set JSONPath like ``$.hooks.Stop`` to the parent
|
|
1913
|
+
container and the leaf key, creating intermediate dicts as needed.
|
|
1914
|
+
|
|
1915
|
+
The walker only dispatches array-merge patches today, so this parser
|
|
1916
|
+
is intentionally narrow: ``$.<seg>(.<seg>)*`` with object-keyed
|
|
1917
|
+
segments. Anything else raises ``ValueError`` rather than silently
|
|
1918
|
+
accepting a path the dispatcher can't honour.
|
|
1919
|
+
"""
|
|
1920
|
+
if not json_path.startswith("$."):
|
|
1921
|
+
raise ValueError(f"unsupported json_path {json_path!r}; must start with '$.'")
|
|
1922
|
+
segments = json_path[2:].split(".")
|
|
1923
|
+
if not segments or not all(segments):
|
|
1924
|
+
raise ValueError(f"unsupported json_path {json_path!r}; empty segment")
|
|
1925
|
+
parent: dict[str, Any] = doc
|
|
1926
|
+
for seg in segments[:-1]:
|
|
1927
|
+
nxt = parent.setdefault(seg, {})
|
|
1928
|
+
if not isinstance(nxt, dict):
|
|
1929
|
+
raise ValueError(
|
|
1930
|
+
f"refusing to merge: {seg!r} along {json_path!r} is not an object"
|
|
1931
|
+
)
|
|
1932
|
+
parent = nxt
|
|
1933
|
+
return parent, segments[-1]
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
def _apply_merge_array_entry(adapter: Mapping[str, Any], *, target: Path) -> dict[str, str]:
|
|
1937
|
+
"""Idempotently merge a managed entry into an array container in a
|
|
1938
|
+
JSON settings file. ``match_key`` identifies prior managed entries
|
|
1939
|
+
for replacement-in-place; everything else is preserved verbatim.
|
|
1940
|
+
"""
|
|
1941
|
+
patch = adapter["patch"]
|
|
1942
|
+
target_rel = adapter["target"]
|
|
1943
|
+
settings_path = target / target_rel
|
|
1944
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1945
|
+
|
|
1946
|
+
existed = settings_path.exists()
|
|
1947
|
+
doc: dict[str, Any] = json.loads(settings_path.read_text()) if existed else {}
|
|
1948
|
+
if not isinstance(doc, dict):
|
|
1949
|
+
raise ValueError(
|
|
1950
|
+
f"refusing to merge hook into {settings_path}: "
|
|
1951
|
+
"existing JSON document is not an object"
|
|
1952
|
+
)
|
|
1953
|
+
|
|
1954
|
+
parent, leaf_key = _resolve_dotted_path(doc, patch["json_path"])
|
|
1955
|
+
array_raw = parent.get(leaf_key, [])
|
|
1956
|
+
if not isinstance(array_raw, list):
|
|
1957
|
+
raise ValueError(
|
|
1958
|
+
f"refusing to merge hook into {settings_path}: "
|
|
1959
|
+
f"{patch['json_path']!r} is not a list"
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
managed_entry = _render_template(patch["entry"], target=target)
|
|
1963
|
+
match_key = patch["match_key"]
|
|
1964
|
+
if not isinstance(managed_entry, Mapping) or match_key not in managed_entry:
|
|
1965
|
+
raise ValueError(
|
|
1966
|
+
f"adapter entry missing match_key {match_key!r}: {managed_entry!r}"
|
|
1967
|
+
)
|
|
1968
|
+
match_value = managed_entry[match_key]
|
|
1969
|
+
|
|
1970
|
+
new_array: list[Any] = []
|
|
1971
|
+
replaced = False
|
|
1972
|
+
matched_existing = False
|
|
1973
|
+
for item in array_raw:
|
|
1974
|
+
if isinstance(item, Mapping) and item.get(match_key) == match_value:
|
|
1975
|
+
matched_existing = True
|
|
1976
|
+
if not replaced:
|
|
1977
|
+
new_array.append(managed_entry)
|
|
1978
|
+
replaced = True
|
|
1979
|
+
continue
|
|
1980
|
+
new_array.append(item)
|
|
1981
|
+
if not replaced:
|
|
1982
|
+
new_array.append(managed_entry)
|
|
1983
|
+
|
|
1984
|
+
parent[leaf_key] = new_array
|
|
1985
|
+
settings_path.write_text(json.dumps(doc, indent=2) + "\n")
|
|
1986
|
+
|
|
1987
|
+
if not existed:
|
|
1988
|
+
action = "created"
|
|
1989
|
+
elif matched_existing:
|
|
1990
|
+
action = "noop"
|
|
1991
|
+
else:
|
|
1992
|
+
action = "merged"
|
|
1993
|
+
return {"path": target_rel, "action": action}
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
_ADAPTER_DISPATCH: dict[str, Any] = {
|
|
1997
|
+
"merge_array_entry": _apply_merge_array_entry,
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def _walk_hook_adapters(
|
|
2002
|
+
*,
|
|
2003
|
+
manifest: Mapping[str, Any],
|
|
2004
|
+
clone: Path,
|
|
2005
|
+
target: Path,
|
|
2006
|
+
profile: str,
|
|
2007
|
+
active_flags: set[str],
|
|
2008
|
+
) -> list[dict[str, str]]:
|
|
2009
|
+
"""Walk ``manifest.hooks`` and apply each adapter whose opt_in_flag is
|
|
2010
|
+
in ``active_flags``. Hooks whose ``profiles`` do not include the
|
|
2011
|
+
active install profile are skipped. When at least one adapter is
|
|
2012
|
+
selected, every ``required_artifacts`` row is verified to exist in
|
|
2013
|
+
the clone before any file is touched — opting in to a hook whose
|
|
2014
|
+
artifacts are missing is a hard fail."""
|
|
2015
|
+
configs: list[dict[str, str]] = []
|
|
2016
|
+
if not isinstance(manifest, Mapping):
|
|
2017
|
+
return configs
|
|
2018
|
+
hooks = manifest.get("hooks")
|
|
2019
|
+
if not isinstance(hooks, list):
|
|
2020
|
+
return configs
|
|
2021
|
+
|
|
2022
|
+
for hook in hooks:
|
|
2023
|
+
if not isinstance(hook, Mapping):
|
|
2024
|
+
continue
|
|
2025
|
+
hook_profiles = hook.get("profiles") or []
|
|
2026
|
+
if profile not in hook_profiles and "all" not in hook_profiles:
|
|
2027
|
+
continue
|
|
2028
|
+
adapters = hook.get("adapters") or []
|
|
2029
|
+
selected = [
|
|
2030
|
+
a for a in adapters
|
|
2031
|
+
if isinstance(a, Mapping) and a.get("opt_in_flag") in active_flags
|
|
2032
|
+
]
|
|
2033
|
+
if not selected:
|
|
2034
|
+
continue
|
|
2035
|
+
# Required-artifacts gate: refuse to silently skip a user-requested
|
|
2036
|
+
# hook just because the overlay clone is missing the script.
|
|
2037
|
+
for artifact in hook.get("required_artifacts") or []:
|
|
2038
|
+
consumer_path = artifact.get("consumer_path") if isinstance(artifact, Mapping) else None
|
|
2039
|
+
if not consumer_path:
|
|
2040
|
+
continue
|
|
2041
|
+
resolved = _resolve_in_clone(clone, consumer_path)
|
|
2042
|
+
if not resolved.is_file():
|
|
2043
|
+
raise RuntimeError(
|
|
2044
|
+
f"hook {hook.get('hook_id')!r}: required artifact "
|
|
2045
|
+
f"{consumer_path!r} is missing in the overlay clone "
|
|
2046
|
+
f"(expected at {resolved}). Cannot honour the opt-in."
|
|
2047
|
+
)
|
|
2048
|
+
for adapter in selected:
|
|
2049
|
+
op = adapter["patch"]["operation"]
|
|
2050
|
+
handler = _ADAPTER_DISPATCH.get(op)
|
|
2051
|
+
if handler is None:
|
|
2052
|
+
raise NotImplementedError(
|
|
2053
|
+
f"hook {hook.get('hook_id')!r}: unknown adapter "
|
|
2054
|
+
f"patch operation {op!r}"
|
|
2055
|
+
)
|
|
2056
|
+
configs.append(handler(adapter, target=target))
|
|
2057
|
+
return configs
|