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