domains.py python
1,352 lines 52.6 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """muse domains — domain plugin dashboard, scaffold wizard, and protocol validator.
2
3 Output (default — no flags)::
4
5 ╔══════════════════════════════════════════════════════════════╗
6 ║ Muse Domain Plugin Dashboard ║
7 ╚══════════════════════════════════════════════════════════════╝
8
9 Registered domains: 2
10 ─────────────────────────────────────────────────────────────
11
12 ● code (active repo domain)
13 Module: plugins/code/plugin.py
14 Capabilities: Typed Deltas · Domain Schema · OT Merge
15 Schema: v1.0 · top_level: record · merge_mode: three_way
16 Dimensions: file, symbol, dependency
17 Description: Version source code as structured multidimensio
18
19 ○ scaffold
20 Module: plugins/scaffold/plugin.py
21 Capabilities: Typed Deltas · Domain Schema · OT Merge · CRDT
22 Schema: v1.0 · top_level: record · merge_mode: three_way
23 Dimensions: primary, metadata
24 Description: Scaffold domain plugin — copy-paste template fo
25
26 ─────────────────────────────────────────────────────────────
27 To scaffold a new domain:
28 muse domains --new <name>
29 ─────────────────────────────────────────────────────────────
30
31 Subcommands::
32
33 muse domains publish --author <slug> --slug <slug> ...
34 Publish a domain plugin to the MuseHub marketplace.
35
36 muse domains info <name>
37 Show full info for a single registered domain.
38
39 muse domains use <name>
40 Switch the current repository's active domain.
41
42 muse domains validate [<name>]
43 Verify a domain plugin correctly implements the MuseDomainPlugin protocol.
44
45 --json emits machine-readable JSON for every subcommand.
46
47 --new <name> scaffolds a new domain plugin directory from the scaffold template.
48 Name must match ``^[a-z][a-z0-9_-]{0,63}$`` and must not be an existing plugin name.
49 """
50
51 from __future__ import annotations
52
53 import argparse
54 import json
55 import logging
56 import pathlib
57 import re
58 import shutil
59 import sys
60 import urllib.error
61 import urllib.parse
62 import urllib.request
63 from typing import TYPE_CHECKING, Literal, TypedDict
64
65 from muse.cli.config import get_signing_identity, get_hub_url
66 from muse.core.repo import find_repo_root
67 from muse.core.store import write_text_atomic
68 from muse.domain import CRDTPlugin, MuseDomainPlugin, StructuredMergePlugin
69 from muse.plugins.registry import _REGISTRY
70 from muse.core.validation import sanitize_display
71
72 logger = logging.getLogger(__name__)
73
74 # ---------------------------------------------------------------------------
75 # Constants
76 # ---------------------------------------------------------------------------
77
78 _DEFAULT_DOMAIN = "code"
79 _ALLOWED_PUBLISH_SCHEMES = frozenset({"http", "https"})
80 _MAX_RESPONSE_BYTES = 4 * 1024 * 1024 # 4 MiB — prevent OOM from malicious servers
81 _PUBLISH_TIMEOUT = 15 # seconds
82 _WIDTH = 62
83
84 # Domain names must be lowercase alphanumeric with hyphens or underscores.
85 # The anchor prevents path traversal via "../evil".
86 _DOMAIN_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
87
88 # ---------------------------------------------------------------------------
89 # Internal types — capabilities / JSON output
90 # ---------------------------------------------------------------------------
91
92 _CapabilityLabel = Literal["Typed Deltas", "Domain Schema", "OT Merge", "CRDT"]
93
94
95 class _DimensionDef(TypedDict):
96 """One semantic dimension exported by a domain plugin."""
97
98 name: str
99 description: str
100
101
102 class _SchemaInfoJson(TypedDict):
103 """Schema block embedded in a domain's JSON entry.
104
105 Absent when the plugin does not implement ``schema()`` — agents should
106 check ``"schema" in entry`` before accessing.
107 """
108
109 schema_version: str
110 merge_mode: str
111 description: str
112 dimensions: list[_DimensionDef]
113
114
115 class _DomainEntryJsonBase(TypedDict):
116 """Required keys present on every domain JSON entry."""
117
118 domain: str
119 module_path: str
120 capabilities: list[str]
121 active: bool
122
123
124 class _DomainEntryJson(_DomainEntryJsonBase, total=False):
125 """Full per-domain JSON entry — ``schema`` is omitted when unavailable."""
126
127 schema: _SchemaInfoJson
128
129
130 class _ScaffoldJson(TypedDict):
131 """Structured result from ``muse domains --new <name> --json``."""
132
133 name: str
134 class_name: str
135 path: str
136 status: str
137
138
139 class _UseJson(TypedDict):
140 """Result from ``muse domains use <name> --json``."""
141
142 domain: str
143 repo: str
144 status: str
145
146
147 class _ValidateCheckJson(TypedDict):
148 """One protocol compliance check from ``muse domains validate``."""
149
150 name: str
151 ok: bool
152 detail: str
153
154
155 class _ValidateJson(TypedDict):
156 """Full result from ``muse domains validate [<name>] --json``."""
157
158 domain: str
159 ok: bool
160 checks: list[_ValidateCheckJson]
161
162
163 # ---------------------------------------------------------------------------
164 # Publish-specific types (MuseHub wire format)
165 # ---------------------------------------------------------------------------
166
167
168 class _Capabilities(TypedDict, total=False):
169 """Capability manifest sent to MuseHub on domain publish.
170
171 ``total=False`` because MuseHub accepts partial manifests and fills
172 defaults. When derived from a plugin ``schema()``, ``dimensions`` and
173 ``merge_semantics`` are always populated.
174 """
175
176 dimensions: list[_DimensionDef]
177 artifact_types: list[str]
178 merge_semantics: str
179 supported_commands: list[str]
180
181
182 class _PublishPayload(TypedDict):
183 """Wire payload for ``POST /api/v1/domains`` on MuseHub."""
184
185 author_slug: str
186 slug: str
187 display_name: str
188 description: str
189 capabilities: _Capabilities
190 viewer_type: str
191 version: str
192
193
194 class _PublishResponse(TypedDict):
195 """Parsed response body from ``POST /api/v1/domains``.
196
197 All keys are guaranteed present — ``_post_json`` normalises missing keys
198 to empty strings so callers never hit ``KeyError``.
199 """
200
201 domain_id: str
202 scoped_id: str
203 manifest_hash: str
204
205
206 def _plugin_module_path(name: str) -> str:
207 """Return the display-friendly module path for a plugin.
208
209 Args:
210 name: Domain name string (key in the registry).
211
212 Returns:
213 Path string like ``plugins/code/plugin.py``.
214 """
215 return f"plugins/{name}/plugin.py"
216
217
218 # ---------------------------------------------------------------------------
219 # Helpers — repository context
220 # ---------------------------------------------------------------------------
221
222
223 def _active_domain(ctx: pathlib.Path | None) -> str | None:
224 """Return the domain name of the repository at *ctx*, or ``None``.
225
226 Returns ``None`` — not a default domain name — when no repo is found so
227 callers can distinguish "inside a repo with an explicit domain" from "not
228 inside any repo at all".
229
230 Args:
231 ctx: Repo root path or ``None`` when not inside a repo.
232
233 Returns:
234 Domain name string or ``None``.
235 """
236 if ctx is None:
237 return None
238 repo_json = ctx / ".muse" / "repo.json"
239 if not repo_json.exists():
240 return None
241 try:
242 data = json.loads(repo_json.read_text(encoding="utf-8"))
243 domain = data.get("domain")
244 return str(domain) if domain else _DEFAULT_DOMAIN
245 except (OSError, json.JSONDecodeError):
246 return None
247
248
249 def _find_repo_root() -> pathlib.Path | None:
250 """Find the current repository root.
251
252 Returns:
253 The repo root :class:`pathlib.Path`, or ``None`` when not inside a repo.
254 """
255 return find_repo_root()
256
257
258 # ---------------------------------------------------------------------------
259 # Helpers — validation
260 # ---------------------------------------------------------------------------
261
262
263 def _validate_domain_name(name: str) -> None:
264 """Raise ``SystemExit(1)`` if *name* is not a safe domain name.
265
266 Rules:
267 - Must match ``^[a-z][a-z0-9_-]{0,63}$`` — blocks path traversal
268 sequences such as ``../evil`` and shell-special characters.
269 - Must not be ``"scaffold"`` — that is the built-in template directory.
270
271 Args:
272 name: The raw domain name supplied by the user.
273 """
274 if not _DOMAIN_NAME_RE.match(name):
275 print(
276 f"❌ Invalid domain name {sanitize_display(name)!r}.\n"
277 " Names must start with a lowercase letter, contain only "
278 "lowercase letters, digits, hyphens, or underscores, and be "
279 "at most 64 characters.",
280 file=sys.stderr,
281 )
282 raise SystemExit(1)
283 if name == "scaffold":
284 print(
285 "❌ 'scaffold' is the built-in template — choose a different name.",
286 file=sys.stderr,
287 )
288 raise SystemExit(1)
289
290
291 def _validate_publish_url(url: str) -> None:
292 """Raise ``SystemExit(1)`` if *url* has a non-HTTP/HTTPS scheme.
293
294 Prevents SSRF via ``file://``, ``ftp://``, ``javascript:``, etc.
295
296 Args:
297 url: The resolved hub URL to validate.
298 """
299 parsed = urllib.parse.urlparse(url)
300 if parsed.scheme not in _ALLOWED_PUBLISH_SCHEMES:
301 print(
302 f"❌ Blocked hub URL scheme {sanitize_display(parsed.scheme)!r} — "
303 "only http:// and https:// are allowed.",
304 file=sys.stderr,
305 )
306 raise SystemExit(1)
307
308
309 # ---------------------------------------------------------------------------
310 # JSON emitter
311 # ---------------------------------------------------------------------------
312
313
314 def _build_entry(domain_name: str, plugin: MuseDomainPlugin, active_domain: str | None) -> _DomainEntryJson:
315 """Build a single ``_DomainEntryJson`` for *domain_name*.
316
317 Calls ``plugin.schema()`` once and reuses the result for both the
318 capabilities check and the schema block — avoids the double-call that
319 was present in earlier code.
320
321 Args:
322 domain_name: Registry key.
323 plugin: Registered plugin instance.
324 active_domain: The current repo's domain (for the ``active`` flag).
325
326 Returns:
327 A fully populated ``_DomainEntryJson`` with ``schema`` omitted when
328 the plugin raises ``NotImplementedError``.
329 """
330 caps: list[_CapabilityLabel] = ["Typed Deltas"]
331 schema_result: _SchemaInfoJson | None = None
332
333 try:
334 s = plugin.schema()
335 caps.append("Domain Schema")
336 if isinstance(plugin, StructuredMergePlugin):
337 caps.append("OT Merge")
338 if isinstance(plugin, CRDTPlugin):
339 caps.append("CRDT")
340 schema_result = _SchemaInfoJson(
341 schema_version=str(s["schema_version"]),
342 merge_mode=s["merge_mode"],
343 description=s["description"],
344 dimensions=[
345 _DimensionDef(name=d["name"], description=d["description"])
346 for d in s["dimensions"]
347 ],
348 )
349 except NotImplementedError:
350 pass
351
352 entry = _DomainEntryJson(
353 domain=domain_name,
354 module_path=_plugin_module_path(domain_name),
355 capabilities=list(caps),
356 active=domain_name == active_domain,
357 )
358 if schema_result is not None:
359 entry["schema"] = schema_result
360 return entry
361
362
363 def _emit_json(active_domain: str | None) -> None:
364 """Print all registered domains and their capabilities as JSON to stdout.
365
366 Output schema (one element per registered domain)::
367
368 [
369 {
370 "domain": "code",
371 "module_path": "plugins/code/plugin.py",
372 "capabilities": ["Typed Deltas", "Domain Schema", "OT Merge"],
373 "active": true,
374 "schema": {
375 "schema_version": "1.0",
376 "merge_mode": "three_way",
377 "description": "...",
378 "dimensions": [{"name": "file", "description": "..."}, ...]
379 }
380 },
381 ...
382 ]
383
384 ``schema`` is absent when the plugin does not implement ``schema()``.
385 ``active`` is a boolean — ``true`` when this domain matches the current
386 repository's configured domain.
387
388 Args:
389 active_domain: The domain of the current repo, or ``None``.
390 """
391 result: list[_DomainEntryJson] = [
392 _build_entry(name, plugin, active_domain)
393 for name, plugin in sorted(_REGISTRY.items())
394 ]
395 print(json.dumps(result, indent=2))
396
397
398 # ---------------------------------------------------------------------------
399 # Human-readable dashboard
400 # ---------------------------------------------------------------------------
401
402
403 def _box_line(text: str) -> str:
404 """Center *text* inside a box line of width ``_WIDTH``."""
405 inner = _WIDTH - 2
406 padded = text.center(inner)
407 return f"║{padded}║"
408
409
410 def _hr() -> str:
411 """Return a horizontal rule of width ``_WIDTH``."""
412 return "─" * _WIDTH
413
414
415 def _print_dashboard(active_domain: str | None) -> None:
416 """Print the human-readable domain dashboard to stdout.
417
418 Args:
419 active_domain: Domain of the current repo (highlighted with ●), or ``None``.
420 """
421 print("╔" + "═" * (_WIDTH - 2) + "╗")
422 print(_box_line("Muse Domain Plugin Dashboard"))
423 print("╚" + "═" * (_WIDTH - 2) + "╝")
424 print("")
425
426 count = len(_REGISTRY)
427 print(f"Registered domains: {count}")
428 print(_hr())
429
430 for domain_name, plugin in sorted(_REGISTRY.items()):
431 is_active = domain_name == active_domain
432 bullet = "●" if is_active else "○"
433 safe_name = sanitize_display(domain_name)
434
435 print("")
436 active_suffix = " (active repo domain)" if is_active else ""
437 print(f" {bullet} {safe_name}{active_suffix}")
438 print(f" Module: {_plugin_module_path(domain_name)}")
439
440 # Call schema() once; reuse for capabilities and schema display.
441 caps: list[_CapabilityLabel] = ["Typed Deltas"]
442 try:
443 s = plugin.schema()
444 caps.append("Domain Schema")
445 if isinstance(plugin, StructuredMergePlugin):
446 caps.append("OT Merge")
447 if isinstance(plugin, CRDTPlugin):
448 caps.append("CRDT")
449
450 print(f" Capabilities: {' · '.join(caps)}")
451
452 dim_names = [d["name"] for d in s["dimensions"]]
453 top_kind = s["top_level"]["kind"]
454 print(
455 f" Schema: v{s['schema_version']} · "
456 f"top_level: {top_kind} · merge_mode: {s['merge_mode']}"
457 )
458 print(f" Dimensions: {', '.join(dim_names)}")
459 print(f" Description: {sanitize_display(s['description'][:55])}")
460 except NotImplementedError:
461 print(f" Capabilities: {' · '.join(caps)}")
462 print(" Schema: (not declared)")
463
464 print("")
465 print(_hr())
466 print("To scaffold a new domain:")
467 print(" muse domains --new <name>")
468 print("To inspect a specific domain:")
469 print(" muse domains info <name>")
470 print("To switch the current repo's domain:")
471 print(" muse domains use <name>")
472 print("To validate protocol compliance:")
473 print(" muse domains validate [<name>]")
474 print("To see machine-readable output:")
475 print(" muse domains --json")
476 print("See docs/guide/plugin-authoring-guide.md for the full walkthrough.")
477 print(_hr())
478
479
480 # ---------------------------------------------------------------------------
481 # Scaffold wizard
482 # ---------------------------------------------------------------------------
483
484
485 def _scaffold_new_domain(name: str, as_json: bool) -> None:
486 """Create a new plugin directory by copying the scaffold template.
487
488 Copies ``muse/plugins/scaffold/`` to ``muse/plugins/<name>/``, then
489 renames ``ScaffoldPlugin`` to ``<Name>Plugin`` in the source files.
490 ``__pycache__`` and bytecode are excluded from the copy.
491
492 Security: *name* must pass ``_validate_domain_name()`` before this
493 function is called — the function asserts the constraint and exits early
494 if it detects a path traversal attempt or reserved name.
495
496 Args:
497 name: The new domain name (validated before this call).
498 as_json: When ``True`` emit a ``_ScaffoldJson`` dict to stdout.
499 """
500 scaffold_src = pathlib.Path(__file__).parents[2] / "plugins" / "scaffold"
501 dest = pathlib.Path(__file__).parents[2] / "plugins" / name
502
503 if dest.exists():
504 print(
505 f"❌ Plugin directory already exists: {sanitize_display(str(dest))}",
506 file=sys.stderr,
507 )
508 raise SystemExit(1)
509
510 if not scaffold_src.exists():
511 print(
512 "❌ Scaffold source not found. Make sure muse/plugins/scaffold/ exists.",
513 file=sys.stderr,
514 )
515 raise SystemExit(1)
516
517 shutil.copytree(
518 str(scaffold_src),
519 str(dest),
520 ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"),
521 )
522
523 class_name = "".join(part.capitalize() for part in name.split("_")) + "Plugin"
524
525 for py_file in dest.glob("*.py"):
526 text = py_file.read_text(encoding="utf-8")
527 text = text.replace("ScaffoldPlugin", class_name)
528 text = text.replace('_DOMAIN_NAME = "scaffold"', f'_DOMAIN_NAME = "{name}"')
529 text = text.replace(
530 "Scaffold domain plugin — copy-paste template for a new Muse domain.",
531 f"{class_name} — Muse domain plugin for the {name!r} domain.",
532 )
533 py_file.write_text(text, encoding="utf-8")
534
535 if as_json:
536 result = _ScaffoldJson(
537 name=name,
538 class_name=class_name,
539 path=f"muse/plugins/{name}/",
540 status="ok",
541 )
542 print(json.dumps(result, indent=2))
543 return
544
545 print(f"✅ Scaffolded new domain plugin: muse/plugins/{sanitize_display(name)}/")
546 print(f" Class name: {sanitize_display(class_name)}")
547 print("")
548 print("Next steps:")
549 print(f" 1. Implement every NotImplementedError in muse/plugins/{sanitize_display(name)}/plugin.py")
550 print(" 2. Register the plugin in muse/plugins/registry.py:")
551 print(f' from muse.plugins.{sanitize_display(name)}.plugin import {sanitize_display(class_name)}')
552 print(f' _REGISTRY["{sanitize_display(name)}"] = {sanitize_display(class_name)}()')
553 print(f' 3. muse init --domain {sanitize_display(name)}')
554 print(" 4. See docs/guide/plugin-authoring-guide.md for the full walkthrough")
555
556
557 # ---------------------------------------------------------------------------
558 # Publish subcommand
559 # ---------------------------------------------------------------------------
560
561
562 def _post_json(url: str, payload: _PublishPayload, signing: "SigningIdentity") -> _PublishResponse:
563 """HTTP POST *payload* as JSON to *url* authenticated with MSign.
564
565 Uses :mod:`urllib.request` (no third-party dependencies) with a
566 ``Content-Type: application/json`` body and ``Authorization: MSign``
567 header. The timeout is :data:`_PUBLISH_TIMEOUT` seconds and the response
568 body is capped at :data:`_MAX_RESPONSE_BYTES` to prevent OOM.
569
570 The caller is responsible for validating *url*'s scheme via
571 ``_validate_publish_url`` before invoking this function.
572
573 Args:
574 url: Full endpoint URL with a validated http/https scheme.
575 payload: Typed publish payload — serialised verbatim to JSON.
576 signing: :class:`~muse.core.transport.SigningIdentity` from identity.toml.
577
578 Returns:
579 Parsed ``_PublishResponse`` with ``domain_id``, ``scoped_id``, and
580 ``manifest_hash`` from the server. Missing keys are normalised to
581 empty strings.
582
583 Raises:
584 urllib.error.HTTPError: on non-2xx HTTP responses.
585 urllib.error.URLError: on DNS/connection failure.
586 ValueError: when the response body is not a JSON object.
587 """
588 from muse.core.transport import SigningIdentity, build_msign_header
589
590 body = json.dumps(payload).encode()
591 req = urllib.request.Request(
592 url,
593 data=body,
594 headers={
595 "Content-Type": "application/json",
596 "Accept": "application/json",
597 "Authorization": build_msign_header(signing, "POST", url, body),
598 },
599 method="POST",
600 )
601 with urllib.request.urlopen(req, timeout=_PUBLISH_TIMEOUT) as resp: # noqa: S310
602 raw = resp.read(_MAX_RESPONSE_BYTES).decode(errors="replace")
603 parsed = json.loads(raw)
604 if not isinstance(parsed, dict):
605 raise ValueError(f"Expected JSON object from server, got: {type(parsed).__name__}")
606 return _PublishResponse(
607 domain_id=str(parsed.get("domain_id") or ""),
608 scoped_id=str(parsed.get("scoped_id") or ""),
609 manifest_hash=str(parsed.get("manifest_hash") or ""),
610 )
611
612
613 def run_publish(args: argparse.Namespace) -> None:
614 """Publish a Muse domain plugin to the MuseHub marketplace.
615
616 Registers ``@{author}/{slug}`` so agents and users can discover and install
617 the domain via ``musehub_list_domains`` and ``muse domains``.
618
619 Capabilities are read from the active domain plugin's ``schema()`` when
620 ``--capabilities`` is omitted — so you can run this command from inside a
621 repo that uses the domain you want to publish.
622
623 Security:
624
625 - The hub URL scheme is validated before any network request — ``file://``,
626 ``ftp://``, and similar non-HTTP/HTTPS schemes are rejected (SSRF guard).
627 - The response body is capped at 4 MiB to prevent OOM from malicious servers.
628 - All server-returned values are passed through ``sanitize_display()`` before
629 appearing in human-readable output.
630 - The signing identity is never logged or printed.
631
632 JSON output fields (``--json`` / ``-j``)
633 -----------------------------------------
634 ``domain_id``
635 Hub-assigned opaque identifier for the registered domain.
636 ``scoped_id``
637 Canonical scoped name in ``@{author}/{slug}`` form, e.g.
638 ``"@gabriel/genomics"``. Use this value in subsequent
639 ``musehub_get_domain`` or ``musehub_create_repo`` calls.
640 ``manifest_hash``
641 Content hash of the published capability manifest, e.g.
642 ``"sha256:abc123"``. Useful for auditing and reproducibility.
643
644 Exit codes
645 ----------
646 0 — domain published successfully
647 1 — auth token missing, invalid hub URL scheme, bad ``--capabilities``
648 JSON, plugin schema unavailable, HTTP error, or connection failure
649 2 — not inside a Muse repository (only when ``--hub`` is not provided
650 and no config.toml hub URL is set)
651
652 Example::
653
654 muse domains publish \\
655 --author gabriel --slug genomics \\
656 --name "Genomics" \\
657 --description "Version DNA sequences as multidimensional state" \\
658 --viewer-type genome
659
660 muse domains publish --author gabriel --slug spatial \\
661 --name "Spatial 3D" \\
662 --description "Version 3-D scenes as structured multidimensional commits" \\
663 --viewer-type spatial \\
664 --capabilities '{"dimensions":[{"name":"geometry","description":"Mesh data"}],...}'
665 """
666 author_slug: str = args.author_slug
667 slug: str = args.slug
668 display_name: str = args.display_name
669 description: str = args.description
670 viewer_type: str = args.viewer_type
671 version: str = args.version
672 capabilities_json: str | None = args.capabilities_json
673 hub_url: str | None = args.hub_url
674 as_json: bool = args.as_json
675
676 # ── Resolve hub URL and validate scheme ────────────────────────────────────
677 repo_root = find_repo_root()
678 resolved_hub = hub_url or get_hub_url(repo_root) or "https://musehub.ai"
679 resolved_hub = resolved_hub.rstrip("/")
680 _validate_publish_url(resolved_hub)
681
682 token = get_signing_identity(repo_root)
683 if not token:
684 print(
685 "❌ No signing identity found. Run:\n"
686 " muse auth keygen --hub <url>\n"
687 " muse auth register --hub <url> --handle <your-handle>",
688 file=sys.stderr,
689 )
690 raise SystemExit(1)
691
692 # ── Build capabilities manifest ────────────────────────────────────────────
693 capabilities: _Capabilities
694 if capabilities_json is not None:
695 try:
696 raw_caps = json.loads(capabilities_json)
697 if not isinstance(raw_caps, dict):
698 raise ValueError("capabilities JSON must be an object")
699 capabilities = _Capabilities(
700 dimensions=[
701 _DimensionDef(name=str(d.get("name", "")), description=str(d.get("description", "")))
702 for d in raw_caps.get("dimensions", [])
703 if isinstance(d, dict)
704 ],
705 artifact_types=[str(a) for a in raw_caps.get("artifact_types", []) if isinstance(a, str)],
706 merge_semantics=str(raw_caps.get("merge_semantics", "three_way")),
707 supported_commands=[str(c) for c in raw_caps.get("supported_commands", []) if isinstance(c, str)],
708 )
709 except (json.JSONDecodeError, ValueError) as exc:
710 print(f"❌ --capabilities is not valid JSON: {exc}", file=sys.stderr)
711 raise SystemExit(1) from exc
712 else:
713 # Derive from the active domain plugin schema when available.
714 active_domain_name: str | None = None
715 if repo_root is not None:
716 repo_json = repo_root / ".muse" / "repo.json"
717 try:
718 active_domain_name = json.loads(repo_json.read_text(encoding="utf-8")).get("domain")
719 except (OSError, json.JSONDecodeError):
720 pass
721
722 plugin = _REGISTRY.get(active_domain_name or "") if active_domain_name else None
723 capabilities_ok = False
724 if plugin is not None:
725 try:
726 schema = plugin.schema()
727 capabilities = _Capabilities(
728 dimensions=[
729 _DimensionDef(name=d["name"], description=d["description"])
730 for d in schema["dimensions"]
731 ],
732 artifact_types=[],
733 merge_semantics=schema["merge_mode"],
734 supported_commands=["commit", "diff", "merge", "log", "status"],
735 )
736 capabilities_ok = True
737 except NotImplementedError:
738 capabilities = _Capabilities()
739 else:
740 capabilities = _Capabilities()
741
742 if not capabilities_ok:
743 print(
744 "⚠️ Could not derive capabilities from active plugin. "
745 "Provide --capabilities '<json>' to set them explicitly.",
746 file=sys.stderr,
747 )
748 print(
749 " Required keys: dimensions, artifact_types, merge_semantics, supported_commands",
750 file=sys.stderr,
751 )
752 raise SystemExit(1)
753
754 # ── POST to MuseHub ────────────────────────────────────────────────────────
755 endpoint = f"{resolved_hub}/api/v1/domains"
756 payload = _PublishPayload(
757 author_slug=author_slug,
758 slug=slug,
759 display_name=display_name,
760 description=description,
761 capabilities=capabilities,
762 viewer_type=viewer_type,
763 version=version,
764 )
765
766 try:
767 result = _post_json(endpoint, payload, token)
768 except urllib.error.HTTPError as exc:
769 body = exc.read().decode(errors="replace")
770 if exc.code == 409:
771 print(
772 f"❌ Domain '@{sanitize_display(author_slug)}/{sanitize_display(slug)}' "
773 "is already registered. Use a different slug or bump the version.",
774 file=sys.stderr,
775 )
776 elif exc.code == 401:
777 print("❌ Authentication failed — is your MuseHub token valid?", file=sys.stderr)
778 else:
779 print(f"❌ MuseHub returned HTTP {exc.code}: {sanitize_display(body[:200])}", file=sys.stderr)
780 raise SystemExit(1) from exc
781 except urllib.error.URLError as exc:
782 print(
783 f"❌ Could not reach MuseHub at {sanitize_display(resolved_hub)}: "
784 f"{sanitize_display(str(exc.reason))}",
785 file=sys.stderr,
786 )
787 raise SystemExit(1) from exc
788 except ValueError as exc:
789 print(f"❌ Unexpected response from MuseHub: {sanitize_display(str(exc))}", file=sys.stderr)
790 raise SystemExit(1) from exc
791
792 # ── Emit result ────────────────────────────────────────────────────────────
793 if as_json:
794 print(json.dumps(result))
795 return
796
797 scoped_id = sanitize_display(result.get("scoped_id") or f"@{author_slug}/{slug}")
798 manifest_hash = sanitize_display(result.get("manifest_hash") or "")
799 safe_hub = sanitize_display(resolved_hub)
800 safe_author = sanitize_display(author_slug)
801 safe_slug = sanitize_display(slug)
802 print(f"✅ Domain published: {scoped_id}")
803 print(f" manifest_hash: {manifest_hash}")
804 print(f" Discoverable at: {safe_hub}/domains/@{safe_author}/{safe_slug}")
805 print("")
806 print("Agents can now use it:")
807 print(f' musehub_get_domain(scoped_id="{scoped_id}")')
808 print(f' musehub_create_repo(domain="{scoped_id}", ...)')
809
810
811 # ---------------------------------------------------------------------------
812 # Info subcommand — targeted per-domain query (agent-native)
813 # ---------------------------------------------------------------------------
814
815
816 def run_info(args: argparse.Namespace) -> None:
817 """Show full information for a single registered domain plugin.
818
819 Looks up *name* in the in-process plugin registry, builds a capability
820 summary by calling ``plugin.schema()`` once (if implemented), and emits
821 either a human-readable table or a JSON object.
822
823 Security: the domain name supplied on the command line is passed through
824 ``sanitize_display()`` before appearing in error messages. In text mode,
825 all plugin-sourced string fields (module path, schema version, merge mode,
826 dimension names, description) are sanitized before output so that a
827 malicious third-party plugin cannot inject terminal escape sequences via
828 its ``schema()`` return value.
829
830 JSON output fields (``--json`` / ``-j``)
831 -----------------------------------------
832 ``domain``
833 Registry key (the name passed on the command line).
834 ``module_path``
835 Relative filesystem path to the plugin's Python module.
836 ``capabilities``
837 Array of capability label strings present on this plugin, e.g.
838 ``["Typed Deltas", "Domain Schema", "OT Merge", "CRDT"]``.
839 ``active``
840 ``true`` when this domain matches the current repo's configured domain.
841 ``schema`` *(optional — absent when plugin does not implement* ``schema()`` *)*
842 Object with:
843
844 ``schema_version`` semver string declared by the plugin.
845 ``merge_mode`` merge strategy, e.g. ``"structured"``.
846 ``description`` human-readable summary of the domain.
847 ``dimensions`` array of ``{"name": str, "description": str}`` objects.
848
849 Exit codes
850 ----------
851 0 — domain found and info emitted
852 1 — domain name is not registered
853 """
854 name: str = args.info_name
855 as_json: bool = args.as_json
856
857 plugin = _REGISTRY.get(name)
858 if plugin is None:
859 known = ", ".join(sorted(_REGISTRY))
860 print(
861 f"❌ Domain {sanitize_display(name)!r} is not registered. "
862 f"Known domains: {sanitize_display(known)}",
863 file=sys.stderr,
864 )
865 raise SystemExit(1)
866
867 active_domain = _active_domain(_find_repo_root())
868 entry = _build_entry(name, plugin, active_domain)
869
870 if as_json:
871 print(json.dumps(entry))
872 return
873
874 safe_name = sanitize_display(name)
875 is_active = entry["active"]
876 active_suffix = " (active repo domain)" if is_active else ""
877 print(f"{'●' if is_active else '○'} {safe_name}{active_suffix}")
878 print(f" Module: {sanitize_display(entry['module_path'])}")
879 print(f" Capabilities: {' · '.join(sanitize_display(c) for c in entry['capabilities'])}")
880 if "schema" in entry:
881 s = entry["schema"]
882 dim_names = [sanitize_display(d["name"]) for d in s["dimensions"]]
883 print(
884 f" Schema: v{sanitize_display(str(s['schema_version']))} · "
885 f"merge_mode: {sanitize_display(s['merge_mode'])}"
886 )
887 print(f" Dimensions: {', '.join(dim_names)}")
888 print(f" Description: {sanitize_display(s['description'])}")
889 else:
890 print(" Schema: (not declared)")
891
892
893 # ---------------------------------------------------------------------------
894 # Use subcommand — switch the active domain for the current repo
895 # ---------------------------------------------------------------------------
896
897
898 def run_use(args: argparse.Namespace) -> None:
899 """Switch the current repository's active domain.
900
901 Writes the new domain name to ``.muse/repo.json`` atomically using
902 ``write_text_atomic`` (mkstemp → fsync → rename) so a crash during the
903 update cannot corrupt the file. All other fields in ``repo.json`` are
904 preserved verbatim.
905
906 The domain must be registered in the plugin registry — you cannot switch
907 to a domain that Muse does not know how to handle. The operation is
908 idempotent: switching to the already-active domain exits 0 without a write.
909
910 Security: the domain name supplied on the command line is validated against
911 the in-process plugin registry before it is written to ``repo.json``, so
912 only known registered names can be stored. ``write_text_atomic`` rejects
913 symlinked parent directories to prevent symlink-swap attacks. The domain
914 name and repo path are passed through ``sanitize_display()`` before
915 appearing in text output.
916
917 JSON output fields (``--json`` / ``-j``)
918 -----------------------------------------
919 ``domain``
920 The domain name that is now active (echoed from the argument).
921 ``repo``
922 Absolute path to the ``.muse/`` directory that was updated.
923 ``status``
924 Always ``"switched"`` on success.
925
926 Exit codes
927 ----------
928 0 — domain switched (or already active — idempotent)
929 1 — not inside a Muse repository, domain not registered, or repo.json
930 could not be read
931 """
932 name: str = args.use_name
933 as_json: bool = args.as_json
934
935 ctx = _find_repo_root()
936 if ctx is None:
937 print("❌ Not inside a Muse repository.", file=sys.stderr)
938 raise SystemExit(1)
939
940 if name not in _REGISTRY:
941 known = ", ".join(sorted(_REGISTRY))
942 print(
943 f"❌ Domain {sanitize_display(name)!r} is not registered. "
944 f"Known domains: {sanitize_display(known)}",
945 file=sys.stderr,
946 )
947 raise SystemExit(1)
948
949 muse_dir = ctx / ".muse"
950 repo_json_path = muse_dir / "repo.json"
951 try:
952 data = json.loads(repo_json_path.read_text(encoding="utf-8"))
953 except (OSError, json.JSONDecodeError) as exc:
954 print(f"❌ Could not read repo.json: {exc}", file=sys.stderr)
955 raise SystemExit(1) from exc
956
957 data["domain"] = name
958 write_text_atomic(repo_json_path, json.dumps(data, indent=2) + "\n")
959
960 muse_dir_str = sanitize_display(str(muse_dir))
961
962 if as_json:
963 result = _UseJson(domain=name, repo=str(muse_dir), status="switched")
964 print(json.dumps(result))
965 return
966
967 print(f"✅ Active domain switched to {sanitize_display(name)!r}")
968 print(f" Repo: {muse_dir_str}")
969
970
971 # ---------------------------------------------------------------------------
972 # Validate subcommand — protocol compliance checker
973 # ---------------------------------------------------------------------------
974
975
976 def _check_method(plugin: MuseDomainPlugin, method: str) -> _ValidateCheckJson:
977 """Return a validate check for whether *plugin* has a callable *method*."""
978 has_it = callable(getattr(plugin, method, None))
979 return _ValidateCheckJson(
980 name=f"has_method:{method}",
981 ok=has_it,
982 detail="present" if has_it else f"missing or not callable: {method}",
983 )
984
985
986 def _run_validate_plugin(name: str, plugin: MuseDomainPlugin, active_domain: str | None) -> _ValidateJson:
987 """Run all protocol compliance checks for *plugin* and return the result.
988
989 Checks performed:
990 1. Required ``MuseDomainPlugin`` methods exist and are callable.
991 2. ``schema()`` returns without raising (``NotImplementedError`` is noted
992 as a missing capability, not a hard failure).
993 3. ``StructuredMergePlugin.merge_structured`` exists when the plugin
994 advertises OT Merge capability.
995 4. ``CRDTPlugin.merge_crdt`` exists when the plugin advertises CRDT.
996
997 Args:
998 name: Registry key for the plugin.
999 plugin: Plugin instance to check.
1000 active_domain: Current repo's domain (for context display only).
1001
1002 Returns:
1003 A ``_ValidateJson`` with per-check details and an overall ``ok`` flag.
1004 """
1005 checks: list[_ValidateCheckJson] = []
1006
1007 # Required protocol methods (actual MuseDomainPlugin protocol method names)
1008 for method in ("snapshot", "diff", "merge", "drift", "apply"):
1009 checks.append(_check_method(plugin, method))
1010
1011 # schema() — optional but strongly recommended; catches AttributeError for
1012 # plugins that don't even define the attribute.
1013 try:
1014 plugin.schema()
1015 checks.append(_ValidateCheckJson(name="schema()", ok=True, detail="implemented"))
1016 except NotImplementedError:
1017 checks.append(_ValidateCheckJson(
1018 name="schema()",
1019 ok=False,
1020 detail="raises NotImplementedError — Domain Schema capability unavailable",
1021 ))
1022 except AttributeError:
1023 checks.append(_ValidateCheckJson(
1024 name="schema()",
1025 ok=False,
1026 detail="attribute missing — Domain Schema capability unavailable",
1027 ))
1028
1029 # Optional protocol extensions — only checked when the plugin claims them
1030 if isinstance(plugin, StructuredMergePlugin):
1031 checks.append(_check_method(plugin, "merge_ops"))
1032
1033 if isinstance(plugin, CRDTPlugin):
1034 checks.append(_check_method(plugin, "join"))
1035
1036 all_ok = all(c["ok"] for c in checks)
1037 return _ValidateJson(domain=name, ok=all_ok, checks=checks)
1038
1039
1040 def run_validate(args: argparse.Namespace) -> None:
1041 """Verify a domain plugin correctly implements the MuseDomainPlugin protocol.
1042
1043 Checks that required ``MuseDomainPlugin`` methods are callable
1044 (``snapshot``, ``diff``, ``merge``, ``drift``, ``apply``), that
1045 ``schema()`` does not raise unexpectedly, and that optional capability
1046 interfaces (``StructuredMergePlugin``, ``CRDTPlugin``) are correctly
1047 implemented when the plugin claims them.
1048
1049 When *name* is omitted the active repo's domain is validated; when not
1050 inside a repo, all registered domains are validated. With ``--json`` a
1051 single-domain result is emitted as an object; a multi-domain result is
1052 emitted as an array.
1053
1054 Security: the *name* argument is validated against the plugin registry
1055 before any checks are performed — unregistered names are rejected with
1056 exit code 1 and never reach the check logic. All text-mode output
1057 passes through ``sanitize_display()`` so domain names or check details
1058 containing ANSI escapes cannot corrupt the terminal.
1059
1060 JSON output fields (``--json`` / ``-j``)
1061 -----------------------------------------
1062 ``domain``
1063 Registry key of the validated plugin.
1064 ``ok``
1065 ``true`` when every check passed; ``false`` otherwise.
1066 ``checks``
1067 List of per-check objects, each with:
1068
1069 ``name`` Check identifier (e.g. ``"has_method:snapshot"``).
1070 ``ok`` ``true`` if the check passed.
1071 ``detail`` Human-readable result (e.g. ``"present"`` or reason).
1072
1073 Exit codes
1074 ----------
1075 0 — all checks passed (or all domains passed when validating all)
1076 1 — one or more checks failed, specified domain not registered,
1077 or no domains are registered
1078 """
1079 name: str | None = args.validate_name
1080 as_json: bool = args.as_json
1081
1082 active_domain = _active_domain(_find_repo_root())
1083
1084 # Resolve which domain(s) to validate
1085 if name is not None:
1086 if name not in _REGISTRY:
1087 known = ", ".join(sorted(_REGISTRY))
1088 print(
1089 f"❌ Domain {sanitize_display(name)!r} is not registered. "
1090 f"Known domains: {sanitize_display(known)}",
1091 file=sys.stderr,
1092 )
1093 raise SystemExit(1)
1094 targets: list[tuple[str, MuseDomainPlugin]] = [(name, _REGISTRY[name])]
1095 elif active_domain is not None and active_domain in _REGISTRY:
1096 targets = [(active_domain, _REGISTRY[active_domain])]
1097 else:
1098 targets = list(sorted(_REGISTRY.items()))
1099
1100 results = [_run_validate_plugin(n, p, active_domain) for n, p in targets]
1101 all_ok = all(r["ok"] for r in results)
1102
1103 if as_json:
1104 output: _ValidateJson | list[_ValidateJson] = results[0] if len(results) == 1 else results
1105 print(json.dumps(output))
1106 else:
1107 for r in results:
1108 icon = "✅" if r["ok"] else "❌"
1109 print(f"{icon} {sanitize_display(r['domain'])}")
1110 for c in r["checks"]:
1111 check_icon = " ✓" if c["ok"] else " ✗"
1112 print(f"{check_icon} {c['name']}: {sanitize_display(c['detail'])}")
1113 print("")
1114
1115 if not all_ok:
1116 raise SystemExit(1)
1117
1118
1119 # ---------------------------------------------------------------------------
1120 # CLI wiring
1121 # ---------------------------------------------------------------------------
1122
1123
1124 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
1125 """Register the ``domains`` command and its subcommands."""
1126 parser = subparsers.add_parser(
1127 "domains",
1128 help="Domain plugin dashboard — list registered domains and their capabilities.",
1129 description=__doc__,
1130 formatter_class=argparse.RawDescriptionHelpFormatter,
1131 )
1132 parser.add_argument(
1133 "--new", default=None, metavar="NAME",
1134 help="Scaffold a new domain plugin with the given name.",
1135 )
1136 parser.add_argument(
1137 "--json", action="store_true", dest="as_json",
1138 help="Emit domain registry (or scaffold result) as JSON.",
1139 )
1140 parser.set_defaults(func=run)
1141
1142 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
1143
1144 # ── info ───────────────────────────────────────────────────────────────────
1145 info_p = subs.add_parser(
1146 "info",
1147 help="Show full information for a single registered domain.",
1148 description=(
1149 "Look up NAME in the in-process plugin registry and print its\n"
1150 "capability labels, module path, and (when implemented) full schema\n"
1151 "including merge mode, dimensions, and description.\n\n"
1152 "Agent quickstart\n"
1153 "----------------\n"
1154 " muse domains info code --json\n"
1155 " muse domains info code -j\n"
1156 " muse domains info code -j | jq .capabilities\n"
1157 " muse domains info code -j | jq .schema.dimensions\n\n"
1158 "JSON output schema\n"
1159 "------------------\n"
1160 ' {"domain": "<str>", "module_path": "<str>",\n'
1161 ' "capabilities": ["Typed Deltas", ...], "active": <bool>,\n'
1162 ' "schema": {"schema_version": "<str>", "merge_mode": "<str>",\n'
1163 ' "description": "<str>",\n'
1164 ' "dimensions": [{"name": "<str>", "description": "<str>"}, ...]}}\n\n'
1165 " Note: 'schema' key is absent when the plugin does not implement schema().\n\n"
1166 "Exit codes\n"
1167 "----------\n"
1168 " 0 — domain found and info emitted\n"
1169 " 1 — domain name is not registered\n"
1170 ),
1171 formatter_class=argparse.RawDescriptionHelpFormatter,
1172 )
1173 info_p.add_argument("info_name", metavar="NAME", help="Domain name to inspect.")
1174 info_p.add_argument("--json", "-j", action="store_true", dest="as_json", help="Emit as JSON.")
1175 info_p.set_defaults(func=run_info)
1176
1177 # ── publish ────────────────────────────────────────────────────────────────
1178 publish_p = subs.add_parser(
1179 "publish",
1180 help="Publish a Muse domain plugin to the MuseHub marketplace.",
1181 description=(
1182 "Register ``@{author}/{slug}`` on MuseHub so agents and users can\n"
1183 "discover and install the domain via ``musehub_list_domains`` and\n"
1184 "``muse domains``.\n\n"
1185 "Capabilities are derived from the active repo's domain plugin\n"
1186 "``schema()`` when ``--capabilities`` is omitted. Pass an explicit\n"
1187 "JSON string to override or when not inside a repo.\n\n"
1188 "Agent quickstart\n"
1189 "----------------\n"
1190 " muse domains publish --author gabriel --slug genomics \\\n"
1191 " --name Genomics --description '...' --viewer-type genome\n"
1192 " muse domains publish ... --json\n"
1193 " muse domains publish ... -j | jq .scoped_id\n\n"
1194 "JSON output schema\n"
1195 "------------------\n"
1196 ' {"domain_id": "<str>", "scoped_id": "@<author>/<slug>",\n'
1197 ' "manifest_hash": "<sha256:...>"}\n\n'
1198 "Exit codes\n"
1199 "----------\n"
1200 " 0 — domain published successfully\n"
1201 " 1 — auth missing, bad URL scheme, bad --capabilities JSON,\n"
1202 " plugin schema unavailable, HTTP error, or connection failure\n"
1203 ),
1204 formatter_class=argparse.RawDescriptionHelpFormatter,
1205 )
1206 publish_p.add_argument(
1207 "--author", required=True, metavar="SLUG", dest="author_slug",
1208 help="Your MuseHub username (owner of the domain, e.g. 'gabriel').",
1209 )
1210 publish_p.add_argument(
1211 "--slug", required=True, metavar="SLUG",
1212 help="URL-safe domain name (e.g. 'genomics', 'spatial-3d').",
1213 )
1214 publish_p.add_argument(
1215 "--name", required=True, metavar="NAME", dest="display_name",
1216 help="Human-readable marketplace name (e.g. 'Genomics').",
1217 )
1218 publish_p.add_argument(
1219 "--description", required=True, metavar="TEXT",
1220 help="What this domain models and why it benefits from semantic VCS.",
1221 )
1222 publish_p.add_argument(
1223 "--viewer-type", required=True, metavar="TYPE", dest="viewer_type",
1224 help="Primary viewer identifier (e.g. 'midi', 'code', 'spatial', 'genome').",
1225 )
1226 publish_p.add_argument(
1227 "--version", default="0.1.0", metavar="SEMVER",
1228 help="Semver release string (default: 0.1.0).",
1229 )
1230 publish_p.add_argument(
1231 "--capabilities", default=None, metavar="JSON", dest="capabilities_json",
1232 help=(
1233 "Full capabilities manifest as a JSON string. "
1234 "Required keys: dimensions, artifact_types, merge_semantics, supported_commands. "
1235 "When omitted the active repo's domain plugin schema is used."
1236 ),
1237 )
1238 publish_p.add_argument(
1239 "--hub", default=None, metavar="URL", dest="hub_url",
1240 help="Override the MuseHub base URL (default: read from .muse/config.toml).",
1241 )
1242 publish_p.add_argument(
1243 "--json", "-j", action="store_true", dest="as_json",
1244 help="Emit result as JSON.",
1245 )
1246 publish_p.set_defaults(func=run_publish)
1247
1248 # ── use ────────────────────────────────────────────────────────────────────
1249 use_p = subs.add_parser(
1250 "use",
1251 help="Switch the current repository's active domain.",
1252 description=(
1253 "Write NAME as the active domain in ``.muse/repo.json`` using an\n"
1254 "atomic rename so a crash cannot corrupt the file. All other\n"
1255 "fields in repo.json are preserved. The operation is idempotent —\n"
1256 "switching to the already-active domain exits 0 without a write.\n\n"
1257 "NAME must be a domain registered in the Muse plugin registry.\n"
1258 "Run ``muse domains`` to list all registered domains.\n\n"
1259 "Agent quickstart\n"
1260 "----------------\n"
1261 " muse domains use code --json\n"
1262 " muse domains use code -j\n"
1263 " muse domains use code -j | jq .status\n\n"
1264 "JSON output schema\n"
1265 "------------------\n"
1266 ' {"domain": "<str>", "repo": "<abs-path/.muse>",\n'
1267 ' "status": "switched"}\n\n'
1268 "Exit codes\n"
1269 "----------\n"
1270 " 0 — domain switched (or already active)\n"
1271 " 1 — not inside a Muse repository, domain not registered,\n"
1272 " or repo.json could not be read\n"
1273 ),
1274 formatter_class=argparse.RawDescriptionHelpFormatter,
1275 )
1276 use_p.add_argument("use_name", metavar="NAME", help="Domain name to activate.")
1277 use_p.add_argument("--json", "-j", action="store_true", dest="as_json", help="Emit result as JSON.")
1278 use_p.set_defaults(func=run_use)
1279
1280 # ── validate ───────────────────────────────────────────────────────────────
1281 validate_p = subs.add_parser(
1282 "validate",
1283 help="Verify a domain plugin correctly implements the MuseDomainPlugin protocol.",
1284 description=(
1285 "Run protocol compliance checks on a registered domain plugin.\n"
1286 "Checks required methods (snapshot, diff, merge, drift, apply),\n"
1287 "schema() availability, and optional capability interfaces.\n\n"
1288 "When NAME is omitted, validates the active repo's domain; when\n"
1289 "not inside a repo, all registered domains are validated.\n\n"
1290 "Agent quickstart\n"
1291 "----------------\n"
1292 " muse domains validate code --json\n"
1293 " muse domains validate code -j\n"
1294 " muse domains validate -j # active repo domain\n"
1295 " muse domains validate -j | jq .ok\n"
1296 " muse domains validate -j | jq '.checks[] | select(.ok == false)'\n\n"
1297 "JSON output schema\n"
1298 "------------------\n"
1299 " Single domain:\n"
1300 ' {"domain": "<str>", "ok": <bool>,\n'
1301 ' "checks": [{"name": "<str>", "ok": <bool>, "detail": "<str>"}, ...]}\n\n'
1302 " Multiple domains (no NAME, not in a repo):\n"
1303 " [<domain-object>, ...]\n\n"
1304 "Exit codes\n"
1305 "----------\n"
1306 " 0 — all checks passed\n"
1307 " 1 — one or more checks failed or domain not registered\n"
1308 ),
1309 formatter_class=argparse.RawDescriptionHelpFormatter,
1310 )
1311 validate_p.add_argument(
1312 "validate_name", nargs="?", default=None, metavar="NAME",
1313 help="Domain to validate. Defaults to the active repo domain (or all domains if not in a repo).",
1314 )
1315 validate_p.add_argument("--json", "-j", action="store_true", dest="as_json", help="Emit result as JSON.")
1316 validate_p.set_defaults(func=run_validate)
1317
1318
1319 def run(args: argparse.Namespace) -> None:
1320 """Domain plugin dashboard — list registered domains and their capabilities.
1321
1322 Without flags: prints a human-readable table of all registered domains,
1323 their capability levels (Typed Deltas / Domain Schema / OT Merge / CRDT),
1324 and their declared schemas.
1325
1326 Flags::
1327
1328 --new <name> Scaffold a new domain plugin directory.
1329 --json Machine-readable JSON output (also works with --new).
1330
1331 Subcommands::
1332
1333 muse domains publish Push a plugin to the MuseHub marketplace.
1334 muse domains info Inspect a single domain (agent-optimised).
1335 muse domains use Switch the active domain for this repo.
1336 muse domains validate Check protocol compliance.
1337 """
1338 new: str | None = args.new
1339 as_json: bool = args.as_json
1340
1341 if new is not None:
1342 _validate_domain_name(new)
1343 _scaffold_new_domain(new, as_json)
1344 return
1345
1346 active_domain = _active_domain(_find_repo_root())
1347
1348 if as_json:
1349 _emit_json(active_domain)
1350 return
1351
1352 _print_dashboard(active_domain)
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago