workstate-bootstrap 0.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """workstate-bootstrap: hoist the shared workstate-system surface into consumer repos."""
2
+
3
+ from workstate_bootstrap.install import install
4
+
5
+ __all__ = ["install"]
6
+ __version__ = "0.5.2"
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m workstate_bootstrap``."""
2
+
3
+ from workstate_bootstrap.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,610 @@
1
+ """Console entrypoint for ``workstate-bootstrap``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Mapping
10
+
11
+ from workstate_bootstrap.install import (
12
+ DEFAULT_MCP_SERVERS,
13
+ PROFILE_ALL,
14
+ PROFILE_LIFECYCLE,
15
+ PROFILE_MINIMAL,
16
+ install,
17
+ )
18
+ from workstate_bootstrap.mcp_sync import (
19
+ DEFAULT_SURFACES,
20
+ SUPPORTED_SURFACES,
21
+ SyncReport,
22
+ sync_mcp_configs,
23
+ )
24
+ from workstate_bootstrap.subcommands import doctor, repair, status, update
25
+
26
+ # implementation note: CLI default flips to ``minimal``. The library
27
+ # ``install()`` API keeps ``profile="all"`` for back-compat with
28
+ # pre-Plan-0009 callers.
29
+ INSTALL_PROFILE_CHOICES: tuple[str, ...] = (PROFILE_MINIMAL, PROFILE_LIFECYCLE, PROFILE_ALL)
30
+ # WORKSTATE-REF-56 implementation note: flipped from PROFILE_MINIMAL back to PROFILE_ALL so
31
+ # a no-argument ``workstate-bootstrap install`` materializes the full
32
+ # surface set out of the box. ``--profile minimal`` and
33
+ # ``--profile lifecycle`` remain opt-in for lean installs.
34
+ INSTALL_PROFILE_DEFAULT: str = PROFILE_ALL
35
+
36
+
37
+ def _resolve_mcp_servers(
38
+ raw: str | None,
39
+ *,
40
+ no_servers: bool = False,
41
+ default_when_unset: bool = False,
42
+ ) -> Mapping[str, Mapping[str, Any]] | None:
43
+ """Resolve the ``--mcp-servers`` argument into a server map (or None).
44
+
45
+ - ``no_servers=True``: explicit opt-out → ``None`` (no config files written).
46
+ - ``raw is None``: behavior depends on ``default_when_unset``. ``install``
47
+ passes ``True`` so an unset flag falls back to :data:`DEFAULT_MCP_SERVERS`
48
+ (implementation note step 2a — single-command, no-hand-edits install). ``doctor`` /
49
+ ``update`` / ``repair`` pass ``False`` so an unset flag means
50
+ "don't check / refresh configs at all".
51
+ - ``raw == "default"``: use :data:`DEFAULT_MCP_SERVERS`.
52
+ - ``raw`` is a path: load JSON. Accepts ``{"mcpServers": {...}}`` or a
53
+ flat mapping.
54
+ """
55
+ if no_servers:
56
+ return None
57
+ if raw is None:
58
+ return DEFAULT_MCP_SERVERS if default_when_unset else None
59
+ if raw == "default":
60
+ return DEFAULT_MCP_SERVERS
61
+ doc = json.loads(Path(raw).read_text())
62
+ if isinstance(doc, dict) and "mcpServers" in doc:
63
+ return doc["mcpServers"]
64
+ return doc
65
+
66
+
67
+ DEFAULT_REMOTE_URL = "git@github.com:darce/workstate.git"
68
+ DEFAULT_REMOTE_REF = "main"
69
+
70
+
71
+ def _build_parser() -> argparse.ArgumentParser:
72
+ parser = argparse.ArgumentParser(
73
+ prog="workstate-bootstrap",
74
+ description=(
75
+ "Hoist the shared workstate-system surface into a consumer repo. "
76
+ "Future subcommands: doctor, repair, update."
77
+ ),
78
+ )
79
+ sub = parser.add_subparsers(dest="command", required=True)
80
+
81
+ p_install = sub.add_parser(
82
+ "install",
83
+ help="Clone the shared workstate-system remote and write the overlay manifest.",
84
+ )
85
+ p_install.add_argument(
86
+ "--target",
87
+ type=Path,
88
+ required=True,
89
+ help="Consumer repository root. Must already exist.",
90
+ )
91
+ p_install.add_argument(
92
+ "--remote-url",
93
+ default=DEFAULT_REMOTE_URL,
94
+ help=f"Git URL for the shared workstate-system remote (default: {DEFAULT_REMOTE_URL}).",
95
+ )
96
+ p_install.add_argument(
97
+ "--remote-ref",
98
+ default=DEFAULT_REMOTE_REF,
99
+ help=f"Tag or branch to check out (default: {DEFAULT_REMOTE_REF}).",
100
+ )
101
+ p_install.add_argument(
102
+ "--mcp-servers",
103
+ default=None,
104
+ help=(
105
+ "Either the literal 'default' (or omit the flag) to register the "
106
+ "monorepo's two managed MCP servers (mcp-workstate-handoff, "
107
+ "mcp-workstate-orchestrator) via uvx, or a path to a JSON file "
108
+ "carrying a custom mapping. Accepts {\"mcpServers\": {...}} or a "
109
+ "flat mapping. Writes .mcp.json / .vscode/mcp.json / "
110
+ ".codex/config.toml. Use --no-mcp-servers to opt out entirely."
111
+ ),
112
+ )
113
+ p_install.add_argument(
114
+ "--no-mcp-servers",
115
+ action="store_true",
116
+ help=(
117
+ "Opt out of writing .mcp.json / .vscode/mcp.json / "
118
+ ".codex/config.toml. Use when bootstrapping a target that manages "
119
+ "MCP servers separately."
120
+ ),
121
+ )
122
+ p_install.add_argument(
123
+ "--no-enforce-required-surfaces",
124
+ action="store_true",
125
+ help=(
126
+ "Skip the required-surfaces refusal (currently scripts/hooks). "
127
+ "Use only when bootstrapping from a non-standard remote that "
128
+ "intentionally does not ship the harness hooks; the default is "
129
+ "to refuse install in that case so target-side guardrails cannot "
130
+ "silently no-op."
131
+ ),
132
+ )
133
+ p_install.add_argument(
134
+ "--plugin-overrides",
135
+ type=Path,
136
+ default=None,
137
+ help=(
138
+ "Optional explicit plugin override root. Defaults to auto-discovery "
139
+ "at workstate-overrides/workstate-system/ when omitted."
140
+ ),
141
+ )
142
+ p_install.add_argument(
143
+ "--reset-overrides",
144
+ action="store_true",
145
+ help=(
146
+ "Remove the resolved plugin override root before regenerating the "
147
+ "plugin trees. Refuses on a dirty git worktree unless --backup is set."
148
+ ),
149
+ )
150
+ p_install.add_argument(
151
+ "--backup",
152
+ action="store_true",
153
+ help=(
154
+ "Archive plugin overrides under .agentic/override-backups/<timestamp>/ "
155
+ "before a reset-overrides removal."
156
+ ),
157
+ )
158
+ p_install.add_argument(
159
+ "--profile",
160
+ choices=INSTALL_PROFILE_CHOICES,
161
+ default=INSTALL_PROFILE_DEFAULT,
162
+ help=(
163
+ "Install profile. 'all' (default, WORKSTATE-REF-56 implementation note) materializes "
164
+ "per-agent surfaces, runs the workflow generator, writes MCP "
165
+ "config surfaces, and performs the lifecycle hoist. 'minimal' "
166
+ "clones the remote, writes the manifest, and sets core.hooksPath "
167
+ "only — no per-agent surfaces, no generator, no consumer-tool "
168
+ "config writers. 'lifecycle' adds the hoisted Make fragment + "
169
+ "runner package and injects '-include Makefile.d/*.mk' into the "
170
+ "consumer Makefile."
171
+ ),
172
+ )
173
+ p_install.add_argument(
174
+ "--install-claude-stop-hook",
175
+ action="store_true",
176
+ help=(
177
+ "Opt in to writing the bootstrap-managed Stop hook into the "
178
+ "shared <target>/.claude/settings.json (checked into git). The "
179
+ "adapter rendered is the one declared under hook 'compact-session' "
180
+ "in config/agent-workflows/portable_commands.json. Off by default."
181
+ ),
182
+ )
183
+ p_install.add_argument(
184
+ "--install-claude-stop-hook-local",
185
+ action="store_true",
186
+ help=(
187
+ "Opt in to writing the bootstrap-managed Stop hook into the "
188
+ "user-owned <target>/.claude/settings.local.json (gitignored). "
189
+ "Reversible by deleting the file. Off by default."
190
+ ),
191
+ )
192
+ p_install.add_argument(
193
+ "--install-codex-stop-hook",
194
+ action="store_true",
195
+ help=(
196
+ "Opt in to writing the bootstrap-managed Stop hook into "
197
+ "<target>/.codex/hooks/stop.json (Codex CLI harness). The "
198
+ "adapter rendered is the codex adapter declared under hook "
199
+ "'compact-session' in config/agent-workflows/portable_commands.json. "
200
+ "Off by default."
201
+ ),
202
+ )
203
+ p_install.add_argument(
204
+ "--install-vscode-stop-hook",
205
+ action="store_true",
206
+ help=(
207
+ "Opt in to writing the bootstrap-managed Stop hook into "
208
+ "<target>/.vscode/workstate-stop-hooks.json (VS Code harness). "
209
+ "The adapter rendered is the vscode adapter declared under "
210
+ "hook 'compact-session' in config/agent-workflows/portable_commands.json. "
211
+ "Off by default."
212
+ ),
213
+ )
214
+
215
+ p_status = sub.add_parser(
216
+ "status",
217
+ help="Print a summary of the installed overlay manifest.",
218
+ )
219
+ p_status.add_argument(
220
+ "--target",
221
+ type=Path,
222
+ required=True,
223
+ help="Consumer repository root that was previously installed.",
224
+ )
225
+
226
+ p_doctor = sub.add_parser(
227
+ "doctor",
228
+ help="Check the installed overlay for drift. Exit 1 when findings exist.",
229
+ )
230
+ p_doctor.add_argument(
231
+ "--target",
232
+ type=Path,
233
+ required=True,
234
+ help="Consumer repository root that was previously installed.",
235
+ )
236
+ p_doctor.add_argument(
237
+ "--mcp-servers",
238
+ default=None,
239
+ help=(
240
+ "Either 'default' for the monorepo's managed-server map, or a "
241
+ "path to a JSON file. When set, config drift is checked."
242
+ ),
243
+ )
244
+ p_doctor.add_argument(
245
+ "--plugin-overrides",
246
+ type=Path,
247
+ default=None,
248
+ help=(
249
+ "Optional explicit plugin override root. Defaults to the path "
250
+ "recorded in the bootstrap manifest or auto-discovery at "
251
+ "workstate-overrides/workstate-system/."
252
+ ),
253
+ )
254
+
255
+ p_update = sub.add_parser(
256
+ "update",
257
+ help="Re-run install at a new remote ref against an existing overlay.",
258
+ )
259
+ p_update.add_argument(
260
+ "--target",
261
+ type=Path,
262
+ required=True,
263
+ help="Consumer repository root that was previously installed.",
264
+ )
265
+ p_update.add_argument(
266
+ "--remote-ref",
267
+ required=True,
268
+ help="New git ref (tag/branch/sha) to update the overlay to.",
269
+ )
270
+ p_update.add_argument(
271
+ "--remote-url",
272
+ default=None,
273
+ help="Override remote URL. Defaults to the value in the existing manifest.",
274
+ )
275
+ p_update.add_argument(
276
+ "--mcp-servers",
277
+ default=None,
278
+ help=(
279
+ "Either 'default' for the monorepo's managed-server map, or a "
280
+ "path to a JSON file. When set, configs are refreshed."
281
+ ),
282
+ )
283
+ p_update.add_argument(
284
+ "--plugin-overrides",
285
+ type=Path,
286
+ default=None,
287
+ help=(
288
+ "Optional explicit plugin override root. Defaults to the path "
289
+ "recorded in the bootstrap manifest or auto-discovery at "
290
+ "workstate-overrides/workstate-system/."
291
+ ),
292
+ )
293
+ p_update.add_argument(
294
+ "--reset-overrides",
295
+ action="store_true",
296
+ help=(
297
+ "Remove the resolved plugin override root before regenerating the "
298
+ "updated plugin trees. Refuses on a dirty git worktree unless "
299
+ "--backup is set."
300
+ ),
301
+ )
302
+ p_update.add_argument(
303
+ "--backup",
304
+ action="store_true",
305
+ help=(
306
+ "Archive plugin overrides under .agentic/override-backups/<timestamp>/ "
307
+ "before a reset-overrides removal."
308
+ ),
309
+ )
310
+ p_update.add_argument(
311
+ "--no-enforce-required-surfaces",
312
+ action="store_true",
313
+ help=(
314
+ "Skip the required-surfaces refusal during update. Use only for "
315
+ "non-standard remotes or narrow tests that intentionally omit "
316
+ "scripts/hooks."
317
+ ),
318
+ )
319
+
320
+ p_mcp_sync = sub.add_parser(
321
+ "mcp-sync",
322
+ help=(
323
+ "Reconcile .mcp.json / .vscode/mcp.json / .codex/config.toml "
324
+ "and the ledger's mcp_servers block from a managed-server map. "
325
+ "Does NOT fetch the remote, regenerate skills, or run init-state."
326
+ ),
327
+ )
328
+ p_mcp_sync.add_argument(
329
+ "--target",
330
+ type=Path,
331
+ required=True,
332
+ help="Consumer repository root that was previously installed.",
333
+ )
334
+ p_mcp_sync.add_argument(
335
+ "--mcp-servers",
336
+ required=True,
337
+ help=(
338
+ "Either 'default' for the monorepo's managed-server map, or a "
339
+ "path to a JSON file. Accepts {\"mcpServers\": {...}} or a flat "
340
+ "mapping."
341
+ ),
342
+ )
343
+ mode = p_mcp_sync.add_mutually_exclusive_group()
344
+ mode.add_argument(
345
+ "--check",
346
+ action="store_true",
347
+ help=(
348
+ "Report drift without writing. Exit 0 if clean, 1 if any "
349
+ "surface drifts."
350
+ ),
351
+ )
352
+ mode.add_argument(
353
+ "--apply",
354
+ action="store_true",
355
+ help=(
356
+ "Rewrite drifted surfaces and the ledger mcp_servers block. "
357
+ "Default action when neither --check nor --apply is given."
358
+ ),
359
+ )
360
+ p_mcp_sync.add_argument(
361
+ "--prune-removed-managed",
362
+ action="store_true",
363
+ help=(
364
+ "Drop launchers from the surfaces whose names appear in the "
365
+ "ledger's previously-managed list but NOT in the resolved map. "
366
+ "Third-party launchers (absent from the ledger) are never "
367
+ "pruned. On legacy targets without the ledger block this is a "
368
+ "no-op for the first run; the block is seeded on this pass so "
369
+ "the next run has provenance."
370
+ ),
371
+ )
372
+ p_mcp_sync.add_argument(
373
+ "--surfaces",
374
+ nargs="+",
375
+ choices=sorted(SUPPORTED_SURFACES),
376
+ default=list(DEFAULT_SURFACES),
377
+ metavar="SURFACE",
378
+ help=(
379
+ "Subset of surfaces to reconcile. Default: claude vscode codex."
380
+ ),
381
+ )
382
+ p_mcp_sync.add_argument(
383
+ "--json",
384
+ dest="emit_json",
385
+ action="store_true",
386
+ help=(
387
+ "Emit the SyncReport as JSON on stdout. Schema: "
388
+ "{surfaces: [{name, path, drift, action}], "
389
+ "preserved_third_party: [...], pruned_managed: [...], "
390
+ "ledger_mcp_servers: [...], exit_code: int}."
391
+ ),
392
+ )
393
+
394
+ p_repair = sub.add_parser(
395
+ "repair",
396
+ help="Restore drifted overlay surfaces flagged by `doctor`.",
397
+ )
398
+ p_repair.add_argument(
399
+ "--target",
400
+ type=Path,
401
+ required=True,
402
+ help="Consumer repository root that was previously installed.",
403
+ )
404
+ p_repair.add_argument(
405
+ "--force-dirty",
406
+ action="store_true",
407
+ help=(
408
+ "Replace surfaces that contain real local content. "
409
+ "Without this flag, dirty surfaces are skipped (rg-017)."
410
+ ),
411
+ )
412
+ p_repair.add_argument(
413
+ "--mcp-servers",
414
+ default=None,
415
+ help=(
416
+ "Either 'default' for the monorepo's managed-server map, or a "
417
+ "path to a JSON file. When set, config drift is also repaired."
418
+ ),
419
+ )
420
+ p_repair.add_argument(
421
+ "--plugin-overrides",
422
+ type=Path,
423
+ default=None,
424
+ help=(
425
+ "Optional explicit plugin override root. Defaults to the path "
426
+ "recorded in the bootstrap manifest or auto-discovery at "
427
+ "workstate-overrides/workstate-system/."
428
+ ),
429
+ )
430
+ return parser
431
+
432
+
433
+ def main(argv: list[str] | None = None) -> int:
434
+ parser = _build_parser()
435
+ args = parser.parse_args(argv)
436
+
437
+ if args.command == "install":
438
+ manifest = install(
439
+ target=args.target,
440
+ remote_url=args.remote_url,
441
+ remote_ref=args.remote_ref,
442
+ mcp_servers=_resolve_mcp_servers(
443
+ args.mcp_servers,
444
+ no_servers=args.no_mcp_servers,
445
+ default_when_unset=True,
446
+ ),
447
+ plugin_overrides=args.plugin_overrides,
448
+ reset_overrides=args.reset_overrides,
449
+ backup_overrides=args.backup,
450
+ enforce_required_surfaces=not args.no_enforce_required_surfaces,
451
+ profile=args.profile,
452
+ install_claude_stop_hook=args.install_claude_stop_hook,
453
+ install_claude_stop_hook_local=args.install_claude_stop_hook_local,
454
+ install_codex_stop_hook=args.install_codex_stop_hook,
455
+ install_vscode_stop_hook=args.install_vscode_stop_hook,
456
+ )
457
+ print(
458
+ f"installed workstate-system overlay: "
459
+ f"{args.remote_url}@{manifest['remote_sha']} -> {args.target}",
460
+ file=sys.stdout,
461
+ )
462
+ if isinstance(manifest.get("override_backup_path"), str):
463
+ print(f"override backup: {manifest['override_backup_path']}", file=sys.stdout)
464
+ if isinstance(manifest.get("state_backup_path"), str):
465
+ print(f"state backup: {manifest['state_backup_path']}", file=sys.stdout)
466
+ return 0
467
+
468
+ if args.command == "status":
469
+ try:
470
+ summary = status(target=args.target)
471
+ except FileNotFoundError as exc:
472
+ print(str(exc), file=sys.stderr)
473
+ return 1
474
+ sys.stdout.write(summary)
475
+ return 0
476
+
477
+ if args.command == "doctor":
478
+ try:
479
+ findings = doctor(
480
+ target=args.target,
481
+ mcp_servers=_resolve_mcp_servers(args.mcp_servers),
482
+ plugin_overrides=args.plugin_overrides,
483
+ )
484
+ except FileNotFoundError as exc:
485
+ print(str(exc), file=sys.stderr)
486
+ return 1
487
+ if not findings:
488
+ print("doctor: no drift detected.", file=sys.stdout)
489
+ return 0
490
+ for f in findings:
491
+ print(f"{f['kind']}: {f['path']}", file=sys.stdout)
492
+ return 1
493
+
494
+ if args.command == "update":
495
+ try:
496
+ manifest = update(
497
+ target=args.target,
498
+ remote_ref=args.remote_ref,
499
+ remote_url=args.remote_url,
500
+ mcp_servers=_resolve_mcp_servers(args.mcp_servers),
501
+ plugin_overrides=args.plugin_overrides,
502
+ reset_overrides=args.reset_overrides,
503
+ backup_overrides=args.backup,
504
+ enforce_required_surfaces=not args.no_enforce_required_surfaces,
505
+ )
506
+ except FileNotFoundError as exc:
507
+ print(str(exc), file=sys.stderr)
508
+ return 1
509
+ print(
510
+ f"update: refreshed overlay at {manifest['remote_ref']} "
511
+ f"(sha={manifest['remote_sha'][:12]}).",
512
+ file=sys.stdout,
513
+ )
514
+ if isinstance(manifest.get("override_backup_path"), str):
515
+ print(f"override backup: {manifest['override_backup_path']}", file=sys.stdout)
516
+ return 0
517
+
518
+ if args.command == "repair":
519
+ try:
520
+ report = repair(
521
+ target=args.target,
522
+ force_dirty=args.force_dirty,
523
+ mcp_servers=_resolve_mcp_servers(args.mcp_servers),
524
+ plugin_overrides=args.plugin_overrides,
525
+ )
526
+ except FileNotFoundError as exc:
527
+ print(str(exc), file=sys.stderr)
528
+ return 1
529
+ for f in report["repaired"]:
530
+ print(f"repaired {f['kind']}: {f['path']}", file=sys.stdout)
531
+ for f in report["skipped"]:
532
+ print(
533
+ f"skipped {f['kind']}: {f['path']} "
534
+ "(re-run with --force-dirty to overwrite)",
535
+ file=sys.stdout,
536
+ )
537
+ if not report["repaired"] and not report["skipped"]:
538
+ print("repair: no drift detected.", file=sys.stdout)
539
+ return 0
540
+
541
+ if args.command == "mcp-sync":
542
+ try:
543
+ servers = _resolve_mcp_servers(args.mcp_servers)
544
+ except (FileNotFoundError, json.JSONDecodeError) as exc:
545
+ print(f"mcp-sync: --mcp-servers: {exc}", file=sys.stderr)
546
+ return 2
547
+ if servers is None:
548
+ print(
549
+ "mcp-sync: --mcp-servers must resolve to a server mapping.",
550
+ file=sys.stderr,
551
+ )
552
+ return 2
553
+ try:
554
+ report = sync_mcp_configs(
555
+ args.target,
556
+ servers,
557
+ surfaces=tuple(args.surfaces),
558
+ check_only=args.check,
559
+ prune_removed_managed=args.prune_removed_managed,
560
+ )
561
+ except FileNotFoundError as exc:
562
+ print(f"mcp-sync: {exc}", file=sys.stderr)
563
+ return 2
564
+ if args.emit_json:
565
+ print(json.dumps(_sync_report_to_dict(report), indent=2))
566
+ else:
567
+ _print_sync_report(report, check_only=args.check)
568
+ return report.exit_code
569
+
570
+ # argparse with required=True prevents this branch from being reachable.
571
+ parser.error(f"unknown command: {args.command!r}")
572
+ return 2 # pragma: no cover
573
+
574
+
575
+ def _sync_report_to_dict(report: SyncReport) -> dict[str, Any]:
576
+ return {
577
+ "surfaces": [
578
+ {
579
+ "name": s.name,
580
+ "path": s.path,
581
+ "drift": s.drift,
582
+ "action": s.action,
583
+ }
584
+ for s in report.surfaces
585
+ ],
586
+ "preserved_third_party": list(report.preserved_third_party),
587
+ "pruned_managed": list(report.pruned_managed),
588
+ "ledger_mcp_servers": list(report.ledger_mcp_servers),
589
+ "exit_code": report.exit_code,
590
+ }
591
+
592
+
593
+ def _print_sync_report(report: SyncReport, *, check_only: bool) -> None:
594
+ mode = "check" if check_only else "apply"
595
+ print(f"mcp-sync ({mode}):")
596
+ for s in report.surfaces:
597
+ marker = "*" if s.drift else " "
598
+ print(f" {marker} {s.name:<7} {s.path:<22} {s.action}")
599
+ if report.preserved_third_party:
600
+ print(f" preserved third-party: {', '.join(report.preserved_third_party)}")
601
+ if report.pruned_managed:
602
+ print(f" pruned removed-managed: {', '.join(report.pruned_managed)}")
603
+ if not check_only and report.ledger_mcp_servers:
604
+ print(
605
+ f" ledger mcp_servers: {', '.join(report.ledger_mcp_servers)}"
606
+ )
607
+
608
+
609
+ if __name__ == "__main__": # pragma: no cover
610
+ raise SystemExit(main())