gabriel / muse public
domains.py python
2,101 lines 85.8 KB
Raw
sha256:ac29b8ba9021514a03ab2782d92bf67671f0efa5b3b70d46f7598c5d4e923378 docs: record muse#74 Phase 4 live verification (SCR_13, SCR_14) Sonnet 5 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 · 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, cast
63
64 from muse.cli.config import get_signing_identity, get_hub_url
65 from muse.cli.domain_command_registry import get_supported_commands
66 from muse.core.types import load_json_file
67 from muse.core.paths import muse_dir as _muse_dir, repo_json_path as _repo_json_path
68 from muse.core.envelope import EnvelopeJson, make_envelope
69 from muse.core.repo import find_repo_root
70 from muse.core.io import write_text_atomic
71 from muse.core.timing import start_timer
72 from muse.domain import AddressedMergePlugin, CRDTPlugin, MuseDomainPlugin
73 from muse.plugins.registry import _REGISTRY
74 from muse.core.validation import sanitize_display
75
76 if TYPE_CHECKING:
77 from muse.core.transport import SigningIdentity
78
79 logger = logging.getLogger(__name__)
80
81 # ---------------------------------------------------------------------------
82 # Constants
83 # ---------------------------------------------------------------------------
84
85 _DEFAULT_DOMAIN = "code"
86 _ALLOWED_PUBLISH_SCHEMES = frozenset({"http", "https"})
87 _MAX_RESPONSE_BYTES = 4 * 1024 * 1024 # 4 MiB — prevent OOM from malicious servers
88 _PUBLISH_TIMEOUT = 15 # seconds
89 _WIDTH = 62
90
91 # Domain names must be lowercase alphanumeric with hyphens or underscores.
92 # The anchor prevents path traversal via "../traversal".
93 _DOMAIN_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
94
95 # ---------------------------------------------------------------------------
96 # Internal types — capabilities / JSON output
97 # ---------------------------------------------------------------------------
98
99 _CapabilityLabel = Literal["Typed Deltas", "Domain Schema", "Addressed Merge", "CRDT"]
100
101 class _DimensionDef(TypedDict):
102 """One semantic dimension exported by a domain plugin.
103
104 Fields
105 ------
106 name Short identifier for the dimension (e.g. ``"pitch"``, ``"tempo"``).
107 description Human-readable explanation of what this dimension encodes.
108 """
109
110 name: str
111 description: str
112
113 class _SchemaInfoJson(TypedDict):
114 """Schema block embedded in a domain's JSON entry.
115
116 Absent when the plugin does not implement ``schema()`` — agents should
117 check ``"schema" in entry`` before accessing.
118
119 Fields
120 ------
121 schema_version Semantic version of the domain schema (e.g. ``"1.0.0"``).
122 merge_mode Merge strategy: ``"structured"``, ``"crdt"``, or ``"binary"``.
123 description Human-readable description of what the domain models.
124 dimensions Ordered list of semantic dimensions the plugin exposes.
125 """
126
127 schema_version: str
128 merge_mode: str
129 description: str
130 dimensions: list[_DimensionDef]
131
132 class _DomainEntryJsonBase(TypedDict):
133 """Required keys present on every domain JSON entry in the list output.
134
135 Fields
136 ------
137 domain Domain identifier string (e.g. ``"midi"``, ``"code"``).
138 module_path Fully-qualified Python module path of the plugin class.
139 capabilities List of capability label strings the plugin declares.
140 active True when this domain is the active one for the current repo.
141 """
142
143 domain: str
144 module_path: str
145 capabilities: list[str]
146 active: bool
147
148 class _DomainEntryJson(_DomainEntryJsonBase, total=False):
149 """Full per-domain JSON entry — ``schema`` key present only when the plugin
150 implements ``schema()``.
151
152 Fields
153 ------
154 schema Optional schema block; see :class:`_SchemaInfoJson`.
155 Absent (not null) when the plugin does not implement ``schema()``.
156 """
157
158 schema: _SchemaInfoJson
159
160 class _DomainsListJson(EnvelopeJson):
161 """JSON output for ``muse domains --json`` (full list).
162
163 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
164
165 Fields
166 ------
167 domains Sorted list of all registered domain entries; see :class:`_DomainEntryJson`.
168 """
169
170 domains: list[_DomainEntryJson]
171
172 class _ScaffoldJson(EnvelopeJson):
173 """JSON output for ``muse domains --new <name> --json``.
174
175 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
176
177 Fields
178 ------
179 name The domain identifier as given (e.g. ``"midi"``).
180 class_name PascalCase class name derived from the domain name.
181 path Repo-relative path where the plugin skeleton was written.
182 status ``"created"`` on success, ``"exists"`` when the file already existed.
183 """
184
185 name: str
186 class_name: str
187 path: str
188 status: str
189
190 class _DomainInfoOutputJsonBase(EnvelopeJson):
191 """Required fields for ``muse domains info <name> --json``."""
192
193 domain: str
194 module_path: str
195 capabilities: list[str]
196 active: bool
197
198 class _DomainInfoOutputJson(_DomainInfoOutputJsonBase, total=False):
199 """JSON output for ``muse domains info <name> --json``.
200
201 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
202
203 Fields
204 ------
205 domain Domain identifier queried.
206 module_path Fully-qualified Python module path of the plugin class.
207 capabilities List of capability label strings the plugin declares.
208 active True when this domain is the active one for the current repo.
209 schema Optional schema block; absent when the plugin does not implement schema().
210 """
211
212 schema: _SchemaInfoJson
213
214 class _UseJson(EnvelopeJson):
215 """JSON output for ``muse domains use <name> --json``.
216
217 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
218
219 Fields
220 ------
221 domain The domain identifier that was activated.
222 repo Absolute path to the repository whose active domain was updated.
223 status ``"activated"`` on success.
224 """
225
226 domain: str
227 repo: str
228 status: str
229
230 class _PublishResultJson(EnvelopeJson):
231 """JSON output for ``muse domains publish --json``.
232
233 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
234
235 Fields
236 ------
237 domain_id MuseHub domain identifier assigned by the server (e.g. ``"midi"``).
238 scoped_id Fully-scoped identifier including the author slug
239 (e.g. ``"gabriel/midi"``).
240 manifest_hash SHA-256 hash of the published capability manifest, used for
241 integrity verification on later downloads.
242 """
243
244 domain_id: str
245 scoped_id: str
246 manifest_hash: str
247
248 class _UpdateResultJson(EnvelopeJson):
249 """JSON output for ``muse domains update --json`` (musehub#117 DOM_18).
250
251 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
252
253 Fields
254 ------
255 scoped_id Fully-scoped identifier of the updated domain.
256 display_name The domain's current display name after the update.
257 manifest_hash SHA-256 hash of the current capability manifest — changed
258 if ``--capabilities`` was part of this update.
259 """
260
261 scoped_id: str
262 display_name: str
263 manifest_hash: str
264
265 class _DeleteResultJson(EnvelopeJson):
266 """JSON output for ``muse domains delete --json`` (musehub#117 DOM_18).
267
268 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
269
270 Fields
271 ------
272 scoped_id Fully-scoped identifier of the deprecated domain.
273 status Always ``"deprecated"`` on success — deletion is a soft flag
274 flip, not a row delete; see ``run_delete``'s docstring.
275 """
276
277 scoped_id: str
278 status: str
279
280 class _ValidateCheckJson(TypedDict):
281 """One protocol compliance check — nested inside validate output.
282
283 Fields
284 ------
285 name Short identifier for the check (e.g. ``"has_schema"``,
286 ``"addressed_merge_callable"``).
287 ok True when the check passed.
288 detail Explanatory message — empty on pass, failure reason on failure.
289 """
290
291 name: str
292 ok: bool
293 detail: str
294
295 class _ValidateJson(TypedDict):
296 """Per-domain validate result — assembled by ``_run_validate_plugin``.
297
298 Fields
299 ------
300 domain Domain identifier that was validated.
301 ok True when all checks passed for this domain.
302 checks Ordered list of individual compliance checks; see :class:`_ValidateCheckJson`.
303 """
304
305 domain: str
306 ok: bool
307 checks: list[_ValidateCheckJson]
308
309 class _ValidateOutputJson(EnvelopeJson):
310 """JSON output for ``muse domains validate <name> --json`` (single domain).
311
312 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
313
314 Fields
315 ------
316 domain Domain identifier that was validated.
317 ok True when all protocol checks passed.
318 checks Ordered list of compliance check results; see :class:`_ValidateCheckJson`.
319 """
320
321 domain: str
322 ok: bool
323 checks: list[_ValidateCheckJson]
324
325 class _ValidateListOutputJson(EnvelopeJson):
326 """JSON output for ``muse domains validate --json`` (all domains).
327
328 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
329
330 Fields
331 ------
332 results One validate result per registered domain; see :class:`_ValidateJson`.
333 all_ok True when every domain passed all protocol checks.
334 """
335
336 results: list[_ValidateJson]
337 all_ok: bool
338
339 # ---------------------------------------------------------------------------
340 # Publish-specific types (MuseHub wire format)
341 # ---------------------------------------------------------------------------
342
343 class _Capabilities(TypedDict, total=False):
344 """Capability manifest sent to MuseHub on domain publish.
345
346 ``total=False`` because MuseHub accepts partial manifests and fills
347 defaults. When derived from a plugin ``schema()``, ``dimensions`` and
348 ``merge_semantics`` are always populated.
349
350 Fields
351 ------
352 dimensions Semantic dimensions the plugin models (e.g. pitch, tempo).
353 Max 50 entries; server rejects more with a 422.
354 artifact_types File extensions / MIME types the plugin handles.
355 merge_semantics Merge strategy identifier: ``"ot"``, ``"crdt"``, or
356 ``"three_way"`` — any other value is rejected with a 422.
357 supported_commands CLI subcommands exposed by this plugin.
358
359 The serialized manifest is capped at 16 KB server-side (422 if exceeded).
360 """
361
362 dimensions: list[_DimensionDef]
363 artifact_types: list[str]
364 merge_semantics: str
365 supported_commands: list[str]
366
367 class _PublishPayload(TypedDict):
368 """Wire payload for ``POST /api/domains`` on MuseHub.
369
370 Fields
371 ------
372 author_slug MuseHub handle of the publishing user (e.g. ``"gabriel"``).
373 slug URL-safe domain identifier (e.g. ``"midi"``).
374 display_name Human-readable domain name shown in the MuseHub UI.
375 description Markdown description of what this domain models.
376 capabilities Structured capability manifest; see :class:`_Capabilities`.
377 viewer_type Built-in viewer for rendering domain files — one of
378 ``"generic"``, ``"symbol_graph"``, ``"piano_roll"``; any
379 other value is rejected with a 422 (there is no way to
380 register a custom viewer).
381 version Semantic version string of the domain plugin being published.
382 """
383
384 author_slug: str
385 slug: str
386 display_name: str
387 description: str
388 capabilities: _Capabilities
389 viewer_type: str
390 version: str
391
392 class _PublishResponse(TypedDict):
393 """Parsed response body from ``POST /api/domains``.
394
395 All keys are guaranteed present — ``_post_json`` normalises missing keys
396 to empty strings so callers never hit ``KeyError``.
397
398 Fields
399 ------
400 domain_id MuseHub-assigned domain identifier.
401 scoped_id Author-scoped identifier, e.g. ``"gabriel/midi"``.
402 manifest_hash SHA-256 of the capability manifest accepted by the server.
403 """
404
405 domain_id: str
406 scoped_id: str
407 manifest_hash: str
408
409 def _plugin_module_path(name: str) -> str:
410 """Return the display-friendly module path for a plugin.
411
412 Args:
413 name: Domain name string (key in the registry).
414
415 Returns:
416 Path string like ``plugins/code/plugin.py``.
417 """
418 return f"plugins/{name}/plugin.py"
419
420 # ---------------------------------------------------------------------------
421 # Helpers — repository context
422 # ---------------------------------------------------------------------------
423
424 def _active_domain(ctx: pathlib.Path | None) -> str | None:
425 """Return the domain name of the repository at *ctx*, or ``None``.
426
427 Returns ``None`` — not a default domain name — when no repo is found so
428 callers can distinguish "inside a repo with an explicit domain" from "not
429 inside any repo at all".
430
431 Args:
432 ctx: Repo root path or ``None`` when not inside a repo.
433
434 Returns:
435 Domain name string or ``None``.
436 """
437 if ctx is None:
438 return None
439 repo_json = _repo_json_path(ctx)
440 if not repo_json.exists():
441 return None
442 data = load_json_file(repo_json)
443 if data is None:
444 return None
445 domain = data.get("domain")
446 return str(domain) if domain else _DEFAULT_DOMAIN
447
448 def _find_repo_root() -> pathlib.Path | None:
449 """Find the current repository root.
450
451 Returns:
452 The repo root :class:`pathlib.Path`, or ``None`` when not inside a repo.
453 """
454 return find_repo_root()
455
456 # ---------------------------------------------------------------------------
457 # Helpers — validation
458 # ---------------------------------------------------------------------------
459
460 def _validate_domain_name(name: str) -> None:
461 """Raise ``SystemExit(1)`` if *name* is not a safe domain name.
462
463 Rules:
464 - Must match ``^[a-z][a-z0-9_-]{0,63}$`` — blocks path traversal
465 sequences such as ``../traversal`` and shell-special characters.
466 - Must not be ``"scaffold"`` — that is the built-in template directory.
467
468 Args:
469 name: The raw domain name supplied by the user.
470 """
471 if not _DOMAIN_NAME_RE.match(name):
472 print(
473 f"❌ Invalid domain name {sanitize_display(name)!r}.\n"
474 " Names must start with a lowercase letter, contain only "
475 "lowercase letters, digits, hyphens, or underscores, and be "
476 "at most 64 characters.",
477 file=sys.stderr,
478 )
479 raise SystemExit(1)
480 if name == "scaffold":
481 print(
482 "❌ 'scaffold' is the built-in template — choose a different name.",
483 file=sys.stderr,
484 )
485 raise SystemExit(1)
486
487 def _validate_publish_url(url: str) -> None:
488 """Raise ``SystemExit(1)`` if *url* has a non-HTTP/HTTPS scheme.
489
490 Prevents SSRF via ``file://``, ``ftp://``, ``javascript:``, etc.
491
492 Args:
493 url: The resolved hub URL to validate.
494 """
495 parsed = urllib.parse.urlparse(url)
496 if parsed.scheme not in _ALLOWED_PUBLISH_SCHEMES:
497 print(
498 f"❌ Blocked hub URL scheme {sanitize_display(parsed.scheme)!r} — "
499 "only http:// and https:// are allowed.",
500 file=sys.stderr,
501 )
502 raise SystemExit(1)
503
504 # ---------------------------------------------------------------------------
505 # JSON emitter
506 # ---------------------------------------------------------------------------
507
508 def _build_entry(domain_name: str, plugin: MuseDomainPlugin, active_domain: str | None) -> _DomainEntryJson:
509 """Build a single ``_DomainEntryJson`` for *domain_name*.
510
511 Calls ``plugin.schema()`` once and reuses the result for both the
512 capabilities check and the schema block — avoids the double-call that
513 was present in earlier code.
514
515 Args:
516 domain_name: Registry key.
517 plugin: Registered plugin instance.
518 active_domain: The current repo's domain (for the ``active`` flag).
519
520 Returns:
521 A fully populated ``_DomainEntryJson`` with ``schema`` omitted when
522 the plugin raises ``NotImplementedError``.
523 """
524 caps: list[_CapabilityLabel] = ["Typed Deltas"]
525 schema_result: _SchemaInfoJson | None = None
526
527 try:
528 s = plugin.schema()
529 caps.append("Domain Schema")
530 if isinstance(plugin, AddressedMergePlugin):
531 caps.append("Addressed Merge")
532 if isinstance(plugin, CRDTPlugin):
533 caps.append("CRDT")
534 schema_result = _SchemaInfoJson(
535 schema_version=str(s["schema_version"]),
536 merge_mode=s["merge_mode"],
537 description=s["description"],
538 dimensions=[
539 _DimensionDef(name=d["name"], description=d["description"])
540 for d in s["dimensions"]
541 ],
542 )
543 except NotImplementedError:
544 pass
545
546 entry = _DomainEntryJson(
547 domain=domain_name,
548 module_path=_plugin_module_path(domain_name),
549 capabilities=list(caps),
550 active=domain_name == active_domain,
551 )
552 if schema_result is not None:
553 entry["schema"] = schema_result
554 return entry
555
556 def _emit_json(active_domain: str | None, elapsed: Callable[[], float]) -> None:
557 """Print all registered domains and their capabilities as JSON to stdout."""
558 domains: list[_DomainEntryJson] = [
559 _build_entry(name, plugin, active_domain)
560 for name, plugin in sorted(_REGISTRY.items())
561 ]
562 print(json.dumps(_DomainsListJson(**make_envelope(elapsed), domains=domains)))
563
564 # ---------------------------------------------------------------------------
565 # Human-readable dashboard
566 # ---------------------------------------------------------------------------
567
568 def _box_line(text: str) -> str:
569 """Center *text* inside a box line of width ``_WIDTH``."""
570 inner = _WIDTH - 2
571 padded = text.center(inner)
572 return f"║{padded}║"
573
574 def _hr() -> str:
575 """Return a horizontal rule of width ``_WIDTH``."""
576 return "─" * _WIDTH
577
578 def _print_dashboard(active_domain: str | None) -> None:
579 """Print the human-readable domain dashboard to stdout.
580
581 Args:
582 active_domain: Domain of the current repo (highlighted with ●), or ``None``.
583 """
584 print(f"╔{'═' * (_WIDTH - 2)}╗")
585 print(_box_line("Muse Domain Plugin Dashboard"))
586 print(f"╚{'═' * (_WIDTH - 2)}╝")
587 print("")
588
589 count = len(_REGISTRY)
590 print(f"Registered domains: {count}")
591 print(_hr())
592
593 for domain_name, plugin in sorted(_REGISTRY.items()):
594 is_active = domain_name == active_domain
595 bullet = "●" if is_active else "○"
596 safe_name = sanitize_display(domain_name)
597
598 print("")
599 active_suffix = " (active repo domain)" if is_active else ""
600 print(f" {bullet} {safe_name}{active_suffix}")
601 print(f" Module: {_plugin_module_path(domain_name)}")
602
603 # Call schema() once; reuse for capabilities and schema display.
604 caps: list[_CapabilityLabel] = ["Typed Deltas"]
605 try:
606 s = plugin.schema()
607 caps.append("Domain Schema")
608 if isinstance(plugin, AddressedMergePlugin):
609 caps.append("Addressed Merge")
610 if isinstance(plugin, CRDTPlugin):
611 caps.append("CRDT")
612
613 print(f" Capabilities: {' · '.join(caps)}")
614
615 dim_names = [d["name"] for d in s["dimensions"]]
616 top_kind = s["top_level"]["kind"]
617 print(
618 f" Schema: v{s['schema_version']} · "
619 f"top_level: {top_kind} · merge_mode: {s['merge_mode']}"
620 )
621 print(f" Dimensions: {', '.join(dim_names)}")
622 print(f" Description: {sanitize_display(s['description'][:55])}")
623 except NotImplementedError:
624 print(f" Capabilities: {' · '.join(caps)}")
625 print(" Schema: (not declared)")
626
627 print("")
628 print(_hr())
629 print("To scaffold a new domain:")
630 print(" muse domains --new <name>")
631 print("To inspect a specific domain:")
632 print(" muse domains info <name>")
633 print("To switch the current repo's domain:")
634 print(" muse domains use <name>")
635 print("To validate protocol compliance:")
636 print(" muse domains validate [<name>]")
637 print("To see machine-readable output:")
638 print(" muse domains --json")
639 print("See docs/guide/plugin-authoring-guide.md for the full walkthrough.")
640 print(_hr())
641
642 # ---------------------------------------------------------------------------
643 # Scaffold wizard
644 # ---------------------------------------------------------------------------
645
646 def _scaffold_new_domain(name: str, json_out: bool, elapsed: Callable[[], float]) -> None:
647 """Create a new plugin directory by copying the scaffold template.
648
649 Copies ``muse/plugins/scaffold/`` to ``muse/plugins/<name>/``, then
650 renames ``ScaffoldPlugin`` to ``<Name>Plugin`` in the source files.
651 ``__pycache__`` and bytecode are excluded from the copy.
652
653 Security: *name* must pass ``_validate_domain_name()`` before this
654 function is called — the function asserts the constraint and exits early
655 if it detects a path traversal attempt or reserved name.
656
657 Args:
658 name: The new domain name (validated before this call).
659 json_out: When ``True`` emit a ``_ScaffoldJson`` dict to stdout.
660 """
661 scaffold_src = pathlib.Path(__file__).parents[2] / "plugins" / "scaffold"
662 dest = pathlib.Path(__file__).parents[2] / "plugins" / name
663
664 if dest.exists():
665 print(
666 f"❌ Plugin directory already exists: {sanitize_display(str(dest))}",
667 file=sys.stderr,
668 )
669 raise SystemExit(1)
670
671 if not scaffold_src.exists():
672 print(
673 "❌ Scaffold source not found. Make sure muse/plugins/scaffold/ exists.",
674 file=sys.stderr,
675 )
676 raise SystemExit(1)
677
678 shutil.copytree(
679 str(scaffold_src),
680 str(dest),
681 ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"),
682 )
683
684 class_name = f"{''.join(part.capitalize() for part in name.split('_'))}Plugin"
685
686 for py_file in dest.glob("*.py"):
687 text = py_file.read_text(encoding="utf-8")
688 text = text.replace("ScaffoldPlugin", class_name)
689 text = text.replace('_DOMAIN_NAME = "scaffold"', f'_DOMAIN_NAME = "{name}"')
690 text = text.replace(
691 "Scaffold domain plugin — copy-paste template for a new Muse domain.",
692 f"{class_name} — Muse domain plugin for the {name!r} domain.",
693 )
694 py_file.write_text(text, encoding="utf-8")
695
696 if json_out:
697 print(json.dumps(_ScaffoldJson(
698 **make_envelope(elapsed),
699 name=name,
700 class_name=class_name,
701 path=f"muse/plugins/{name}/",
702 status="ok",
703 )))
704 return
705
706 print(f"✅ Scaffolded new domain plugin: muse/plugins/{sanitize_display(name)}/")
707 print(f" Class name: {sanitize_display(class_name)}")
708 print("")
709 print("Next steps:")
710 print(f" 1. Implement every NotImplementedError in muse/plugins/{sanitize_display(name)}/plugin.py")
711 print(" 2. Register the plugin in muse/plugins/registry.py:")
712 print(f' from muse.plugins.{sanitize_display(name)}.plugin import {sanitize_display(class_name)}')
713 print(f' _REGISTRY["{sanitize_display(name)}"] = {sanitize_display(class_name)}()')
714 print(f' 3. muse init --domain {sanitize_display(name)}')
715 print(" 4. See docs/guide/plugin-authoring-guide.md for the full walkthrough")
716
717 # ---------------------------------------------------------------------------
718 # Publish subcommand
719 # ---------------------------------------------------------------------------
720
721 def _post_json(url: str, payload: _PublishPayload, signing: "SigningIdentity") -> _PublishResponse:
722 """HTTP POST *payload* as JSON to *url* authenticated with MSign.
723
724 Uses :mod:`urllib.request` (no third-party dependencies) with a
725 ``Content-Type: application/json`` body and ``Authorization: MSign``
726 header. The timeout is :data:`_PUBLISH_TIMEOUT` seconds and the response
727 body is capped at :data:`_MAX_RESPONSE_BYTES` to prevent OOM.
728
729 The caller is responsible for validating *url*'s scheme via
730 ``_validate_publish_url`` before invoking this function.
731
732 Args:
733 url: Full endpoint URL with a validated http/https scheme.
734 payload: Typed publish payload — serialised verbatim to JSON.
735 signing: :class:`~muse.core.transport.SigningIdentity` from identity.toml.
736
737 Returns:
738 Parsed ``_PublishResponse`` with ``domain_id``, ``scoped_id``, and
739 ``manifest_hash`` from the server. Missing keys are normalised to
740 empty strings.
741
742 Raises:
743 urllib.error.HTTPError: on non-2xx HTTP responses.
744 urllib.error.URLError: on DNS/connection failure.
745 ValueError: when the response body is not a JSON object.
746 """
747 from muse.core.transport import SigningIdentity, _make_ssl_ctx
748 from muse.core.msign import build_msign_header
749
750 body = json.dumps(payload).encode()
751 req = urllib.request.Request(
752 url,
753 data=body,
754 headers={
755 "Content-Type": "application/json",
756 "Accept": "application/json",
757 "Authorization": build_msign_header(signing, "POST", url, body),
758 },
759 method="POST",
760 )
761 with urllib.request.urlopen(req, timeout=_PUBLISH_TIMEOUT, context=_make_ssl_ctx(url)) as resp: # noqa: S310
762 raw = resp.read(_MAX_RESPONSE_BYTES).decode(errors="replace")
763 parsed = json.loads(raw)
764 if not isinstance(parsed, dict):
765 raise ValueError(f"Expected JSON object from server, got: {type(parsed).__name__}")
766 return _PublishResponse(
767 domain_id=str(parsed.get("domain_id") or ""),
768 scoped_id=str(parsed.get("scoped_id") or ""),
769 manifest_hash=str(parsed.get("manifest_hash") or ""),
770 )
771
772
773 def _patch_json(url: str, payload: dict[str, object], signing: "SigningIdentity") -> dict[str, object]:
774 """HTTP PATCH *payload* as JSON to *url*, authenticated with MSign.
775
776 Mirrors :func:`_post_json` for the update path (musehub#117 DOM_18) —
777 see that function's docstring for the shared security/behavior notes
778 (SSRF-guarded caller, capped response, MSign auth header).
779 """
780 from muse.core.transport import SigningIdentity, _make_ssl_ctx
781 from muse.core.msign import build_msign_header
782
783 body = json.dumps(payload).encode()
784 req = urllib.request.Request(
785 url,
786 data=body,
787 headers={
788 "Content-Type": "application/json",
789 "Accept": "application/json",
790 "Authorization": build_msign_header(signing, "PATCH", url, body),
791 },
792 method="PATCH",
793 )
794 with urllib.request.urlopen(req, timeout=_PUBLISH_TIMEOUT, context=_make_ssl_ctx(url)) as resp: # noqa: S310
795 raw = resp.read(_MAX_RESPONSE_BYTES).decode(errors="replace")
796 parsed = json.loads(raw)
797 if not isinstance(parsed, dict):
798 raise ValueError(f"Expected JSON object from server, got: {type(parsed).__name__}")
799 return parsed
800
801
802 def _delete_request(url: str, signing: "SigningIdentity") -> None:
803 """HTTP DELETE *url*, authenticated with MSign (musehub#117 DOM_18).
804
805 The server responds 204 No Content on success — the body is read (to
806 drain the connection) and discarded; there is nothing to parse.
807 """
808 from muse.core.transport import SigningIdentity, _make_ssl_ctx
809 from muse.core.msign import build_msign_header
810
811 req = urllib.request.Request(
812 url,
813 headers={
814 "Accept": "application/json",
815 "Authorization": build_msign_header(signing, "DELETE", url, b""),
816 },
817 method="DELETE",
818 )
819 with urllib.request.urlopen(req, timeout=_PUBLISH_TIMEOUT, context=_make_ssl_ctx(url)) as resp: # noqa: S310
820 resp.read(_MAX_RESPONSE_BYTES)
821
822 # musehub#117 DOM_15: mirrors the server-side Pydantic schema
823 # (musehub/api/routes/musehub/domains.py's RegisterDomainRequest/
824 # DomainCapabilities) so --dry-run can catch a malformed manifest locally,
825 # before a round trip. Deliberately duplicated rather than imported — muse
826 # and musehub are separate deployable packages, and muse is a client for
827 # any musehub-compatible server, not just this exact version. Keep in sync
828 # by hand if the server schema changes.
829 _VALID_VIEWER_TYPES = ("generic", "symbol_graph", "piano_roll")
830 _VALID_MERGE_SEMANTICS = ("ot", "crdt", "three_way")
831 _MAX_DIMENSIONS = 50
832 _MAX_CAPABILITIES_BYTES = 16 * 1024
833
834
835 def _validate_manifest_locally(viewer_type: str, capabilities: _Capabilities) -> list[str]:
836 """Return a list of validation errors; empty means the manifest would pass
837 the server's schema checks (Phase 1 of musehub#117) as of this writing.
838 """
839 errors: list[str] = []
840
841 if viewer_type not in _VALID_VIEWER_TYPES:
842 errors.append(
843 f"viewer_type must be one of {_VALID_VIEWER_TYPES}, got {viewer_type!r}"
844 )
845
846 dimensions = capabilities.get("dimensions", [])
847 if len(dimensions) > _MAX_DIMENSIONS:
848 errors.append(
849 f"dimensions has {len(dimensions)} entries; max is {_MAX_DIMENSIONS}"
850 )
851 for i, dim in enumerate(dimensions):
852 if not isinstance(dim, dict) or not dim.get("name"):
853 errors.append(f"dimensions[{i}] must be an object with a non-empty 'name'")
854
855 merge_semantics = capabilities.get("merge_semantics", "three_way")
856 if merge_semantics not in _VALID_MERGE_SEMANTICS:
857 errors.append(
858 f"merge_semantics must be one of {_VALID_MERGE_SEMANTICS}, got {merge_semantics!r}"
859 )
860
861 size = len(json.dumps(capabilities).encode("utf-8"))
862 if size > _MAX_CAPABILITIES_BYTES:
863 errors.append(
864 f"capabilities manifest is {size} bytes; max is {_MAX_CAPABILITIES_BYTES} bytes"
865 )
866
867 return errors
868
869
870 def run_publish(args: argparse.Namespace) -> None:
871 """Publish a Muse domain plugin to the MuseHub marketplace.
872
873 Registers ``@{author}/{slug}`` so agents and users can discover and install
874 the domain via ``musehub_list_domains`` and ``muse domains``.
875
876 Capabilities are read from the active domain plugin's ``schema()`` when
877 ``--capabilities`` is omitted — so you can run this command from inside a
878 repo that uses the domain you want to publish.
879
880 Security:
881
882 - The hub URL scheme is validated before any network request — ``file://``,
883 ``ftp://``, and similar non-HTTP/HTTPS schemes are rejected (SSRF guard).
884 - The response body is capped at 4 MiB to prevent OOM from malicious servers.
885 - All server-returned values are passed through ``sanitize_display()`` before
886 appearing in human-readable output.
887 - The signing identity is never logged or printed.
888
889 JSON output fields (``--json`` / ``-j``)
890 -----------------------------------------
891 ``domain_id``
892 Hub-assigned opaque identifier for the registered domain.
893 ``scoped_id``
894 Canonical scoped name in ``@{author}/{slug}`` form, e.g.
895 ``"@gabriel/genomics"``. Use this value in subsequent
896 ``musehub_get_domain`` or ``musehub_create_repo`` calls.
897 ``manifest_hash``
898 Content hash of the published capability manifest, e.g.
899 ``"sha256:abc123"``. Useful for auditing and reproducibility.
900
901 Exit codes
902 ----------
903 0 — domain published successfully (or ``--dry-run``: manifest valid)
904 1 — auth token missing, invalid hub URL scheme, bad ``--capabilities``
905 JSON, plugin schema unavailable, HTTP error, connection failure, or
906 (``--dry-run``) manifest fails local schema validation
907 2 — not inside a Muse repository (only when ``--hub`` is not provided
908 and no config.toml hub URL is set)
909
910 Example::
911
912 muse domains publish \\
913 --author gabriel --slug genomics \\
914 --name "Genomics" \\
915 --description "Version DNA sequences as multidimensional state" \\
916 --viewer-type generic
917
918 muse domains publish --author gabriel --slug my-lang \\
919 --name "My Language" \\
920 --description "Version source in a custom language as structured commits" \\
921 --viewer-type symbol_graph \\
922 --capabilities '{"dimensions":[{"name":"function","description":"Function bodies"}],"merge_semantics":"three_way"}'
923
924 ``--viewer-type`` must be one of the built-in palette values: ``generic``,
925 ``symbol_graph``, or ``piano_roll`` — the server rejects anything else
926 with a 422. There is no way to register a custom viewer; see
927 `muse domains publish --help` for the full list.
928 """
929 elapsed = start_timer()
930 author_slug: str = args.author_slug
931 slug: str = args.slug
932 display_name: str = args.display_name
933 description: str = args.description
934 viewer_type: str = args.viewer_type
935 version: str = args.version
936 capabilities_json: str | None = args.capabilities_json
937 hub_url: str | None = args.hub_url
938 json_out: bool = args.json_out
939 dry_run: bool = getattr(args, "dry_run", False)
940
941 # ── Resolve hub URL and validate scheme ────────────────────────────────────
942 repo_root = find_repo_root()
943 resolved_hub = hub_url or get_hub_url(repo_root) or "https://musehub.ai"
944 resolved_hub = resolved_hub.rstrip("/")
945 _validate_publish_url(resolved_hub)
946
947 # --dry-run needs neither a signing identity nor a network call — it only
948 # validates --viewer-type/--capabilities locally (musehub#117 DOM_15).
949 token: "SigningIdentity | None" = None
950 if not dry_run:
951 token = get_signing_identity(repo_root)
952 if not token:
953 print(
954 "❌ No signing identity found. Run:\n"
955 " muse auth keygen --hub <url>\n"
956 " muse auth register --hub <url> --handle <your-handle>",
957 file=sys.stderr,
958 )
959 raise SystemExit(1)
960
961 # ── Resolve the active domain name — used to auto-derive both dimensions
962 # (existing behavior) and supported_commands (muse#74) from the real,
963 # live CLI subcommand tree registered by domain_command_registry.
964 active_domain_name: str | None = None
965 if repo_root is not None:
966 repo_json = _repo_json_path(repo_root)
967 _repo_data = load_json_file(repo_json)
968 if _repo_data is not None:
969 active_domain_name = _repo_data.get("domain")
970
971 # ── Build capabilities manifest ────────────────────────────────────────────
972 capabilities: _Capabilities
973 if capabilities_json is not None:
974 try:
975 raw_caps = json.loads(capabilities_json)
976 if not isinstance(raw_caps, dict):
977 raise ValueError("capabilities JSON must be an object")
978 if "supported_commands" in raw_caps:
979 supported_commands = [
980 str(c) for c in raw_caps.get("supported_commands", []) if isinstance(c, str)
981 ]
982 else:
983 # publish (unlike update) has a trustworthy active-domain
984 # context — dimensions/merge_semantics already assume it.
985 supported_commands = (
986 get_supported_commands(active_domain_name) if active_domain_name else []
987 )
988 capabilities = _Capabilities(
989 dimensions=[
990 _DimensionDef(name=str(d.get("name", "")), description=str(d.get("description", "")))
991 for d in raw_caps.get("dimensions", [])
992 if isinstance(d, dict)
993 ],
994 artifact_types=[str(a) for a in raw_caps.get("artifact_types", []) if isinstance(a, str)],
995 merge_semantics=str(raw_caps.get("merge_semantics", "three_way")),
996 supported_commands=supported_commands,
997 )
998 except (json.JSONDecodeError, ValueError) as exc:
999 print(f"❌ --capabilities is not valid JSON: {exc}", file=sys.stderr)
1000 raise SystemExit(1) from exc
1001 else:
1002 # Derive from the active domain plugin schema when available.
1003 plugin = _REGISTRY.get(active_domain_name or "") if active_domain_name else None
1004 capabilities_ok = False
1005 if plugin is not None:
1006 try:
1007 schema = plugin.schema()
1008 capabilities = _Capabilities(
1009 dimensions=[
1010 _DimensionDef(name=d["name"], description=d["description"])
1011 for d in schema["dimensions"]
1012 ],
1013 artifact_types=[],
1014 merge_semantics=schema["merge_mode"],
1015 supported_commands=get_supported_commands(active_domain_name) if active_domain_name else [],
1016 )
1017 capabilities_ok = True
1018 except NotImplementedError:
1019 capabilities = _Capabilities()
1020 else:
1021 capabilities = _Capabilities()
1022
1023 if not capabilities_ok:
1024 print(
1025 "⚠️ Could not derive capabilities from active plugin. "
1026 "Provide --capabilities '<json>' to set them explicitly.",
1027 file=sys.stderr,
1028 )
1029 print(
1030 " Recognized keys (all optional, default to empty/safe values): "
1031 "dimensions, artifact_types, merge_semantics, supported_commands",
1032 file=sys.stderr,
1033 )
1034 raise SystemExit(1)
1035
1036 # ── --dry-run: validate locally, no network call, no auth (DOM_15) ─────────
1037 if dry_run:
1038 errors = _validate_manifest_locally(viewer_type, capabilities)
1039 if errors:
1040 if json_out:
1041 print(json.dumps({**make_envelope(elapsed), "valid": False, "errors": errors}))
1042 else:
1043 print("❌ Manifest would be rejected by the server:", file=sys.stderr)
1044 for err in errors:
1045 print(f" - {err}", file=sys.stderr)
1046 raise SystemExit(1)
1047 if json_out:
1048 print(json.dumps({**make_envelope(elapsed), "valid": True, "errors": []}))
1049 else:
1050 print(f"✅ Manifest is valid — would publish as @{sanitize_display(author_slug)}/{sanitize_display(slug)}")
1051 return
1052
1053 # ── POST to MuseHub ────────────────────────────────────────────────────────
1054 # musehub#117 DOM_01: /api/v1/domains never existed on the server — no
1055 # /api/v1/* namespace is mounted at all. The real route is /api/domains.
1056 endpoint = f"{resolved_hub}/api/domains"
1057 payload = _PublishPayload(
1058 author_slug=author_slug,
1059 slug=slug,
1060 display_name=display_name,
1061 description=description,
1062 capabilities=capabilities,
1063 viewer_type=viewer_type,
1064 version=version,
1065 )
1066
1067 try:
1068 result = _post_json(endpoint, payload, token)
1069 except urllib.error.HTTPError as exc:
1070 body = exc.read().decode(errors="replace")
1071 if exc.code == 409:
1072 print(
1073 f"❌ Domain '@{sanitize_display(author_slug)}/{sanitize_display(slug)}' "
1074 "is already registered. Use a different slug or bump the version.",
1075 file=sys.stderr,
1076 )
1077 elif exc.code == 401:
1078 print("❌ Authentication failed — is your MuseHub token valid?", file=sys.stderr)
1079 else:
1080 print(f"❌ MuseHub returned HTTP {exc.code}: {sanitize_display(body[:200])}", file=sys.stderr)
1081 raise SystemExit(1) from exc
1082 except urllib.error.URLError as exc:
1083 print(
1084 f"❌ Could not reach MuseHub at {sanitize_display(resolved_hub)}: "
1085 f"{sanitize_display(str(exc.reason))}",
1086 file=sys.stderr,
1087 )
1088 raise SystemExit(1) from exc
1089 except ValueError as exc:
1090 print(f"❌ Unexpected response from MuseHub: {sanitize_display(str(exc))}", file=sys.stderr)
1091 raise SystemExit(1) from exc
1092
1093 # ── Emit result ────────────────────────────────────────────────────────────
1094 if json_out:
1095 print(json.dumps(_PublishResultJson(
1096 **make_envelope(elapsed),
1097 domain_id=result.get("domain_id", ""),
1098 scoped_id=result.get("scoped_id", ""),
1099 manifest_hash=result.get("manifest_hash", ""),
1100 )))
1101 return
1102
1103 scoped_id = sanitize_display(result.get("scoped_id") or f"@{author_slug}/{slug}")
1104 manifest_hash = sanitize_display(result.get("manifest_hash") or "")
1105 safe_hub = sanitize_display(resolved_hub)
1106 safe_author = sanitize_display(author_slug)
1107 safe_slug = sanitize_display(slug)
1108 print(f"✅ Domain published: {scoped_id}")
1109 print(f" manifest_hash: {manifest_hash}")
1110 print(f" Discoverable at: {safe_hub}/domains/@{safe_author}/{safe_slug}")
1111 print("")
1112 print("Agents can now use it:")
1113 print(f' musehub_read_domain(scoped_id="{scoped_id}")')
1114 print(f' musehub_create_repo(domain="{scoped_id}", ...)')
1115
1116 # ---------------------------------------------------------------------------
1117 # Update subcommand (musehub#117 DOM_16/DOM_18)
1118 # ---------------------------------------------------------------------------
1119
1120 def run_update(args: argparse.Namespace) -> None:
1121 """Update a published Muse domain plugin on the MuseHub marketplace.
1122
1123 Partially updates ``@{author}/{slug}`` — only the flags you pass are
1124 changed; omitted fields keep their current server-side value. Only the
1125 domain's owner may update it (the server enforces this; there is no
1126 local ownership check here, matching ``publish``'s reliance on the
1127 server's identity check).
1128
1129 Security: same SSRF URL-scheme guard, 4 MiB response cap, and
1130 ``sanitize_display()``-wrapped output as ``publish`` — see that
1131 docstring for details.
1132
1133 JSON output fields (``--json`` / ``-j``)
1134 -----------------------------------------
1135 ``scoped_id``
1136 Canonical scoped name in ``@{author}/{slug}`` form.
1137 ``display_name``
1138 The domain's current display name after the update.
1139 ``manifest_hash``
1140 Current capability manifest hash — changed if ``--capabilities``
1141 was part of this update.
1142
1143 Exit codes
1144 ----------
1145 0 — domain updated successfully (or ``--dry-run``: manifest valid)
1146 1 — no fields to update, auth missing, bad URL scheme, bad
1147 ``--capabilities`` JSON, not the domain's owner (403), domain not
1148 found (404), HTTP error, connection failure, or (``--dry-run``)
1149 manifest fails local schema validation
1150
1151 Example::
1152
1153 muse domains update --author gabriel --slug genomics \\
1154 --description "Now with protein folding support"
1155
1156 muse domains update --author gabriel --slug genomics \\
1157 --viewer-type piano_roll --dry-run
1158 """
1159 elapsed = start_timer()
1160 author_slug: str = args.author_slug
1161 slug: str = args.slug
1162 display_name: str | None = args.display_name
1163 description: str | None = args.description
1164 viewer_type: str | None = args.viewer_type
1165 version: str | None = args.version
1166 capabilities_json: str | None = args.capabilities_json
1167 hub_url: str | None = args.hub_url
1168 json_out: bool = args.json_out
1169 dry_run: bool = getattr(args, "dry_run", False)
1170
1171 if all(v is None for v in (display_name, description, viewer_type, version, capabilities_json)):
1172 print(
1173 "❌ Provide at least one of --name, --description, --viewer-type, "
1174 "--version, --capabilities to update.",
1175 file=sys.stderr,
1176 )
1177 raise SystemExit(1)
1178
1179 repo_root = find_repo_root()
1180 resolved_hub = hub_url or get_hub_url(repo_root) or "https://musehub.ai"
1181 resolved_hub = resolved_hub.rstrip("/")
1182 _validate_publish_url(resolved_hub)
1183
1184 token: "SigningIdentity | None" = None
1185 if not dry_run:
1186 raw_token = get_signing_identity(repo_root)
1187 if not raw_token:
1188 print(
1189 "❌ No signing identity found. Run:\n"
1190 " muse auth keygen --hub <url>\n"
1191 " muse auth register --hub <url> --handle <your-handle>",
1192 file=sys.stderr,
1193 )
1194 raise SystemExit(1)
1195 token = cast("SigningIdentity", raw_token)
1196
1197 capabilities: _Capabilities | None = None
1198 if capabilities_json is not None:
1199 try:
1200 raw_caps = json.loads(capabilities_json)
1201 if not isinstance(raw_caps, dict):
1202 raise ValueError("capabilities JSON must be an object")
1203 capabilities = _Capabilities(
1204 dimensions=[
1205 _DimensionDef(name=str(d.get("name", "")), description=str(d.get("description", "")))
1206 for d in raw_caps.get("dimensions", [])
1207 if isinstance(d, dict)
1208 ],
1209 artifact_types=[str(a) for a in raw_caps.get("artifact_types", []) if isinstance(a, str)],
1210 merge_semantics=str(raw_caps.get("merge_semantics", "three_way")),
1211 supported_commands=[str(c) for c in raw_caps.get("supported_commands", []) if isinstance(c, str)],
1212 )
1213 except (json.JSONDecodeError, ValueError) as exc:
1214 print(f"❌ --capabilities is not valid JSON: {exc}", file=sys.stderr)
1215 raise SystemExit(1) from exc
1216
1217 # ── --dry-run: validate locally, no network call, no auth ──────────────────
1218 # "generic"/{} are always-valid placeholders for fields not part of this
1219 # update — _validate_manifest_locally only flags genuinely bad input.
1220 if dry_run:
1221 errors = _validate_manifest_locally(viewer_type or "generic", capabilities or _Capabilities())
1222 if errors:
1223 if json_out:
1224 print(json.dumps({**make_envelope(elapsed), "valid": False, "errors": errors}))
1225 else:
1226 print("❌ Update would be rejected by the server:", file=sys.stderr)
1227 for err in errors:
1228 print(f" - {err}", file=sys.stderr)
1229 raise SystemExit(1)
1230 if json_out:
1231 print(json.dumps({**make_envelope(elapsed), "valid": True, "errors": []}))
1232 else:
1233 print(f"✅ Update is valid for @{sanitize_display(author_slug)}/{sanitize_display(slug)}")
1234 return
1235
1236 # ── PATCH to MuseHub ─────────────────────────────────────────────────────
1237 # dry_run is False here (the branch above always returns), so token was
1238 # populated above — re-narrow past the shared Optional annotation.
1239 token = cast("SigningIdentity", token)
1240 endpoint = f"{resolved_hub}/api/domains/@{author_slug}/{slug}"
1241 payload: dict[str, object] = {}
1242 if display_name is not None:
1243 payload["display_name"] = display_name
1244 if description is not None:
1245 payload["description"] = description
1246 if capabilities is not None:
1247 payload["capabilities"] = capabilities
1248 if viewer_type is not None:
1249 payload["viewer_type"] = viewer_type
1250 if version is not None:
1251 payload["version"] = version
1252
1253 try:
1254 result = _patch_json(endpoint, payload, token)
1255 except urllib.error.HTTPError as exc:
1256 body = exc.read().decode(errors="replace")
1257 if exc.code == 403:
1258 print(
1259 f"❌ You do not own '@{sanitize_display(author_slug)}/{sanitize_display(slug)}' "
1260 "— only the domain's owner may update it.",
1261 file=sys.stderr,
1262 )
1263 elif exc.code == 404:
1264 print(f"❌ Domain '@{sanitize_display(author_slug)}/{sanitize_display(slug)}' not found.", file=sys.stderr)
1265 elif exc.code == 401:
1266 print("❌ Authentication failed — is your MuseHub token valid?", file=sys.stderr)
1267 elif exc.code == 422:
1268 print(f"❌ MuseHub rejected the update: {sanitize_display(body[:200])}", file=sys.stderr)
1269 else:
1270 print(f"❌ MuseHub returned HTTP {exc.code}: {sanitize_display(body[:200])}", file=sys.stderr)
1271 raise SystemExit(1) from exc
1272 except urllib.error.URLError as exc:
1273 print(
1274 f"❌ Could not reach MuseHub at {sanitize_display(resolved_hub)}: "
1275 f"{sanitize_display(str(exc.reason))}",
1276 file=sys.stderr,
1277 )
1278 raise SystemExit(1) from exc
1279 except ValueError as exc:
1280 print(f"❌ Unexpected response from MuseHub: {sanitize_display(str(exc))}", file=sys.stderr)
1281 raise SystemExit(1) from exc
1282
1283 # ── Emit result ────────────────────────────────────────────────────────────
1284 scoped_id = sanitize_display(str(result.get("scoped_id") or f"@{author_slug}/{slug}"))
1285 if json_out:
1286 print(json.dumps(_UpdateResultJson(
1287 **make_envelope(elapsed),
1288 scoped_id=scoped_id,
1289 display_name=sanitize_display(str(result.get("display_name") or "")),
1290 manifest_hash=sanitize_display(str(result.get("manifest_hash") or "")),
1291 )))
1292 return
1293
1294 print(f"✅ Domain updated: {scoped_id}")
1295
1296 # ---------------------------------------------------------------------------
1297 # Delete subcommand (musehub#117 DOM_17/DOM_18)
1298 # ---------------------------------------------------------------------------
1299
1300 def run_delete(args: argparse.Namespace) -> None:
1301 """Deprecate a published Muse domain plugin on the MuseHub marketplace.
1302
1303 Sets the domain's ``is_deprecated`` flag — this is a soft flag flip, not
1304 a row delete (a hard delete would orphan repos already linked to this
1305 domain). The domain immediately disappears from ``muse domains`` /
1306 ``GET /api/domains`` browsing but remains individually fetchable by
1307 scoped ID, matching npm's "deprecated package" semantics. Only the
1308 domain's owner may delete it.
1309
1310 Security: same SSRF URL-scheme guard as ``publish``/``update``.
1311
1312 JSON output fields (``--json`` / ``-j``)
1313 -----------------------------------------
1314 ``scoped_id``
1315 Canonical scoped name in ``@{author}/{slug}`` form.
1316 ``status``
1317 Always ``"deprecated"`` on success.
1318
1319 Exit codes
1320 ----------
1321 0 — domain deprecated successfully
1322 1 — auth missing, bad URL scheme, not the domain's owner (403),
1323 domain not found or already deprecated (404), HTTP error, or
1324 connection failure
1325
1326 Example::
1327
1328 muse domains delete --author gabriel --slug old-experiment
1329 """
1330 elapsed = start_timer()
1331 author_slug: str = args.author_slug
1332 slug: str = args.slug
1333 hub_url: str | None = args.hub_url
1334 json_out: bool = args.json_out
1335
1336 repo_root = find_repo_root()
1337 resolved_hub = hub_url or get_hub_url(repo_root) or "https://musehub.ai"
1338 resolved_hub = resolved_hub.rstrip("/")
1339 _validate_publish_url(resolved_hub)
1340
1341 raw_token = get_signing_identity(repo_root)
1342 if not raw_token:
1343 print(
1344 "❌ No signing identity found. Run:\n"
1345 " muse auth keygen --hub <url>\n"
1346 " muse auth register --hub <url> --handle <your-handle>",
1347 file=sys.stderr,
1348 )
1349 raise SystemExit(1)
1350 token = cast("SigningIdentity", raw_token)
1351
1352 endpoint = f"{resolved_hub}/api/domains/@{author_slug}/{slug}"
1353 try:
1354 _delete_request(endpoint, token)
1355 except urllib.error.HTTPError as exc:
1356 if exc.code == 403:
1357 print(
1358 f"❌ You do not own '@{sanitize_display(author_slug)}/{sanitize_display(slug)}' "
1359 "— only the domain's owner may delete it.",
1360 file=sys.stderr,
1361 )
1362 elif exc.code == 404:
1363 print(
1364 f"❌ Domain '@{sanitize_display(author_slug)}/{sanitize_display(slug)}' "
1365 "not found (or already deprecated).",
1366 file=sys.stderr,
1367 )
1368 elif exc.code == 401:
1369 print("❌ Authentication failed — is your MuseHub token valid?", file=sys.stderr)
1370 else:
1371 body = exc.read().decode(errors="replace")
1372 print(f"❌ MuseHub returned HTTP {exc.code}: {sanitize_display(body[:200])}", file=sys.stderr)
1373 raise SystemExit(1) from exc
1374 except urllib.error.URLError as exc:
1375 print(
1376 f"❌ Could not reach MuseHub at {sanitize_display(resolved_hub)}: "
1377 f"{sanitize_display(str(exc.reason))}",
1378 file=sys.stderr,
1379 )
1380 raise SystemExit(1) from exc
1381
1382 scoped_id = f"@{sanitize_display(author_slug)}/{sanitize_display(slug)}"
1383 if json_out:
1384 print(json.dumps(_DeleteResultJson(
1385 **make_envelope(elapsed),
1386 scoped_id=scoped_id,
1387 status="deprecated",
1388 )))
1389 return
1390
1391 print(f"✅ Domain deprecated: {scoped_id}")
1392 print(" It no longer appears in `muse domains` marketplace browsing")
1393 print(" but remains individually fetchable by scoped ID.")
1394
1395 # ---------------------------------------------------------------------------
1396 # Info subcommand — targeted per-domain query (agent-native)
1397 # ---------------------------------------------------------------------------
1398
1399 def run_info(args: argparse.Namespace) -> None:
1400 """Show full information for a single registered domain plugin.
1401
1402 Looks up *name* in the in-process plugin registry, builds a capability
1403 summary by calling ``plugin.schema()`` once (if implemented), and emits
1404 either a human-readable table or a JSON object.
1405
1406 Security: the domain name supplied on the command line is passed through
1407 ``sanitize_display()`` before appearing in error messages. In text mode,
1408 all plugin-sourced string fields (module path, schema version, merge mode,
1409 dimension names, description) are sanitized before output so that a
1410 malicious third-party plugin cannot inject terminal escape sequences via
1411 its ``schema()`` return value.
1412
1413 JSON output fields (``--json`` / ``-j``)
1414 -----------------------------------------
1415 ``domain``
1416 Registry key (the name passed on the command line).
1417 ``module_path``
1418 Relative filesystem path to the plugin's Python module.
1419 ``capabilities``
1420 Array of capability label strings present on this plugin, e.g.
1421 ``["Typed Deltas", "Domain Schema", "Addressed Merge", "CRDT"]``.
1422 ``active``
1423 ``true`` when this domain matches the current repo's configured domain.
1424 ``schema`` *(optional — absent when plugin does not implement* ``schema()`` *)*
1425 Object with:
1426
1427 ``schema_version`` semver string declared by the plugin.
1428 ``merge_mode`` merge strategy, e.g. ``"structured"``.
1429 ``description`` human-readable summary of the domain.
1430 ``dimensions`` array of ``{"name": str, "description": str}`` objects.
1431
1432 Exit codes
1433 ----------
1434 0 — domain found and info emitted
1435 1 — domain name is not registered
1436 """
1437 elapsed = start_timer()
1438 name: str = args.info_name
1439 json_out: bool = args.json_out
1440
1441 plugin = _REGISTRY.get(name)
1442 if plugin is None:
1443 known = ", ".join(sorted(_REGISTRY))
1444 print(
1445 f"❌ Domain {sanitize_display(name)!r} is not registered. "
1446 f"Known domains: {sanitize_display(known)}",
1447 file=sys.stderr,
1448 )
1449 raise SystemExit(1)
1450
1451 active_domain = _active_domain(_find_repo_root())
1452 entry = _build_entry(name, plugin, active_domain)
1453
1454 if json_out:
1455 out = _DomainInfoOutputJson(
1456 **make_envelope(elapsed),
1457 domain=entry["domain"],
1458 module_path=entry["module_path"],
1459 capabilities=entry["capabilities"],
1460 active=entry["active"],
1461 )
1462 if "schema" in entry:
1463 out["schema"] = entry["schema"]
1464 print(json.dumps(out))
1465 return
1466
1467 safe_name = sanitize_display(name)
1468 is_active = entry["active"]
1469 active_suffix = " (active repo domain)" if is_active else ""
1470 print(f"{'●' if is_active else '○'} {safe_name}{active_suffix}")
1471 print(f" Module: {sanitize_display(entry['module_path'])}")
1472 print(f" Capabilities: {' · '.join(sanitize_display(c) for c in entry['capabilities'])}")
1473 if "schema" in entry:
1474 s = entry["schema"]
1475 dim_names = [sanitize_display(d["name"]) for d in s["dimensions"]]
1476 print(
1477 f" Schema: v{sanitize_display(str(s['schema_version']))} · "
1478 f"merge_mode: {sanitize_display(s['merge_mode'])}"
1479 )
1480 print(f" Dimensions: {', '.join(dim_names)}")
1481 print(f" Description: {sanitize_display(s['description'])}")
1482 else:
1483 print(" Schema: (not declared)")
1484
1485 # ---------------------------------------------------------------------------
1486 # Use subcommand — switch the active domain for the current repo
1487 # ---------------------------------------------------------------------------
1488
1489 def run_use(args: argparse.Namespace) -> None:
1490 """Switch the current repository's active domain.
1491
1492 Writes the new domain name to ``.muse/repo.json`` atomically using
1493 ``write_text_atomic`` (mkstemp → fsync → rename) so a crash during the
1494 update cannot corrupt the file. All other fields in ``repo.json`` are
1495 preserved verbatim.
1496
1497 The domain must be registered in the plugin registry — you cannot switch
1498 to a domain that Muse does not know how to handle. The operation is
1499 idempotent: switching to the already-active domain exits 0 without a write.
1500
1501 Security: the domain name supplied on the command line is validated against
1502 the in-process plugin registry before it is written to ``repo.json``, so
1503 only known registered names can be stored. ``write_text_atomic`` rejects
1504 symlinked parent directories to prevent symlink-swap attacks. The domain
1505 name and repo path are passed through ``sanitize_display()`` before
1506 appearing in text output.
1507
1508 JSON output fields (``--json`` / ``-j``)
1509 -----------------------------------------
1510 ``domain``
1511 The domain name that is now active (echoed from the argument).
1512 ``repo``
1513 Absolute path to the ``.muse/`` directory that was updated.
1514 ``status``
1515 Always ``"switched"`` on success.
1516
1517 Exit codes
1518 ----------
1519 0 — domain switched (or already active — idempotent)
1520 1 — not inside a Muse repository, domain not registered, or repo.json
1521 could not be read
1522 """
1523 elapsed = start_timer()
1524 name: str = args.use_name
1525 json_out: bool = args.json_out
1526
1527 ctx = _find_repo_root()
1528 if ctx is None:
1529 print("❌ Not inside a Muse repository.", file=sys.stderr)
1530 raise SystemExit(1)
1531
1532 if name not in _REGISTRY:
1533 known = ", ".join(sorted(_REGISTRY))
1534 print(
1535 f"❌ Domain {sanitize_display(name)!r} is not registered. "
1536 f"Known domains: {sanitize_display(known)}",
1537 file=sys.stderr,
1538 )
1539 raise SystemExit(1)
1540
1541 _rjp = _repo_json_path(ctx)
1542 data = load_json_file(_rjp)
1543 if not isinstance(data, dict):
1544 print("❌ Could not read repo.json", file=sys.stderr)
1545 raise SystemExit(1)
1546
1547 data["domain"] = name
1548 write_text_atomic(_rjp, f"{json.dumps(data)}\n")
1549
1550 muse_dir_str = sanitize_display(str(_muse_dir(ctx)))
1551
1552 if json_out:
1553 print(json.dumps(_UseJson(
1554 **make_envelope(elapsed),
1555 domain=name,
1556 repo=str(_muse_dir(ctx)),
1557 status="switched",
1558 )))
1559 return
1560
1561 print(f"✅ Active domain switched to {sanitize_display(name)!r}")
1562 print(f" Repo: {muse_dir_str}")
1563
1564 # ---------------------------------------------------------------------------
1565 # Validate subcommand — protocol compliance checker
1566 # ---------------------------------------------------------------------------
1567
1568 def _check_method(plugin: MuseDomainPlugin, method: str) -> _ValidateCheckJson:
1569 """Return a validate check for whether *plugin* has a callable *method*."""
1570 has_it = callable(getattr(plugin, method, None))
1571 return _ValidateCheckJson(
1572 name=f"has_method:{method}",
1573 ok=has_it,
1574 detail="present" if has_it else f"missing or not callable: {method}",
1575 )
1576
1577 def _run_validate_plugin(name: str, plugin: MuseDomainPlugin, active_domain: str | None) -> _ValidateJson:
1578 """Run all protocol compliance checks for *plugin* and return the result.
1579
1580 Checks performed:
1581 1. Required ``MuseDomainPlugin`` methods exist and are callable.
1582 2. ``schema()`` returns without raising (``NotImplementedError`` is noted
1583 as a missing capability, not a hard failure).
1584 3. ``AddressedMergePlugin.merge_structured`` exists when the plugin
1585 advertises Addressed Merge capability.
1586 4. ``CRDTPlugin.merge_crdt`` exists when the plugin advertises CRDT.
1587
1588 Args:
1589 name: Registry key for the plugin.
1590 plugin: Plugin instance to check.
1591 active_domain: Current repo's domain (for context display only).
1592
1593 Returns:
1594 A ``_ValidateJson`` with per-check details and an overall ``ok`` flag.
1595 """
1596 checks: list[_ValidateCheckJson] = []
1597
1598 # Required protocol methods (actual MuseDomainPlugin protocol method names)
1599 for method in ("snapshot", "diff", "merge", "drift", "apply"):
1600 checks.append(_check_method(plugin, method))
1601
1602 # schema() — optional but strongly recommended; catches AttributeError for
1603 # plugins that don't even define the attribute.
1604 try:
1605 plugin.schema()
1606 checks.append(_ValidateCheckJson(name="schema()", ok=True, detail="implemented"))
1607 except NotImplementedError:
1608 checks.append(_ValidateCheckJson(
1609 name="schema()",
1610 ok=False,
1611 detail="raises NotImplementedError — Domain Schema capability unavailable",
1612 ))
1613 except AttributeError:
1614 checks.append(_ValidateCheckJson(
1615 name="schema()",
1616 ok=False,
1617 detail="attribute missing — Domain Schema capability unavailable",
1618 ))
1619
1620 # Optional protocol extensions — only checked when the plugin claims them
1621 if isinstance(plugin, AddressedMergePlugin):
1622 checks.append(_check_method(plugin, "merge_ops"))
1623
1624 if isinstance(plugin, CRDTPlugin):
1625 checks.append(_check_method(plugin, "join"))
1626
1627 all_ok = all(c["ok"] for c in checks)
1628 return _ValidateJson(domain=name, ok=all_ok, checks=checks)
1629
1630 def run_validate(args: argparse.Namespace) -> None:
1631 """Verify a domain plugin correctly implements the MuseDomainPlugin protocol.
1632
1633 Checks that required ``MuseDomainPlugin`` methods are callable
1634 (``snapshot``, ``diff``, ``merge``, ``drift``, ``apply``), that
1635 ``schema()`` does not raise unexpectedly, and that optional capability
1636 interfaces (``AddressedMergePlugin``, ``CRDTPlugin``) are correctly
1637 implemented when the plugin claims them.
1638
1639 When *name* is omitted the active repo's domain is validated; when not
1640 inside a repo, all registered domains are validated. With ``--json`` a
1641 single-domain result is emitted as an object; a multi-domain result is
1642 emitted as an array.
1643
1644 Security: the *name* argument is validated against the plugin registry
1645 before any checks are performed — unregistered names are rejected with
1646 exit code 1 and never reach the check logic. All text-mode output
1647 passes through ``sanitize_display()`` so domain names or check details
1648 containing ANSI escapes cannot corrupt the terminal.
1649
1650 JSON output fields (``--json`` / ``-j``)
1651 -----------------------------------------
1652 ``domain``
1653 Registry key of the validated plugin.
1654 ``ok``
1655 ``true`` when every check passed; ``false`` otherwise.
1656 ``checks``
1657 List of per-check objects, each with:
1658
1659 ``name`` Check identifier (e.g. ``"has_method:snapshot"``).
1660 ``ok`` ``true`` if the check passed.
1661 ``detail`` Human-readable result (e.g. ``"present"`` or reason).
1662
1663 Exit codes
1664 ----------
1665 0 — all checks passed (or all domains passed when validating all)
1666 1 — one or more checks failed, specified domain not registered,
1667 or no domains are registered
1668 """
1669 elapsed = start_timer()
1670 name: str | None = args.validate_name
1671 json_out: bool = args.json_out
1672
1673 active_domain = _active_domain(_find_repo_root())
1674
1675 # Resolve which domain(s) to validate
1676 if name is not None:
1677 if name not in _REGISTRY:
1678 known = ", ".join(sorted(_REGISTRY))
1679 print(
1680 f"❌ Domain {sanitize_display(name)!r} is not registered. "
1681 f"Known domains: {sanitize_display(known)}",
1682 file=sys.stderr,
1683 )
1684 raise SystemExit(1)
1685 targets: list[tuple[str, MuseDomainPlugin]] = [(name, _REGISTRY[name])]
1686 elif active_domain is not None and active_domain in _REGISTRY:
1687 targets = [(active_domain, _REGISTRY[active_domain])]
1688 else:
1689 targets = list(sorted(_REGISTRY.items()))
1690
1691 results = [_run_validate_plugin(n, p, active_domain) for n, p in targets]
1692 all_ok = all(r["ok"] for r in results)
1693
1694 if json_out:
1695 if len(results) == 1:
1696 r = results[0]
1697 print(json.dumps(_ValidateOutputJson(
1698 **make_envelope(elapsed),
1699 domain=r["domain"],
1700 ok=r["ok"],
1701 checks=r["checks"],
1702 )))
1703 else:
1704 print(json.dumps(_ValidateListOutputJson(
1705 **make_envelope(elapsed),
1706 results=results,
1707 all_ok=all_ok,
1708 )))
1709 else:
1710 for r in results:
1711 icon = "✅" if r["ok"] else "❌"
1712 print(f"{icon} {sanitize_display(r['domain'])}")
1713 for c in r["checks"]:
1714 check_icon = " ✓" if c["ok"] else " ✗"
1715 print(f"{check_icon} {c['name']}: {sanitize_display(c['detail'])}")
1716 print("")
1717
1718 if not all_ok:
1719 raise SystemExit(1)
1720
1721 # ---------------------------------------------------------------------------
1722 # CLI wiring
1723 # ---------------------------------------------------------------------------
1724
1725 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
1726 """Register the ``domains`` command and its subcommands."""
1727 parser = subparsers.add_parser(
1728 "domains",
1729 help="Domain plugin dashboard — list registered domains and their capabilities.",
1730 description=__doc__,
1731 formatter_class=argparse.RawDescriptionHelpFormatter,
1732 )
1733 parser.add_argument(
1734 "--new", default=None, metavar="NAME",
1735 help="Scaffold a new domain plugin with the given name.",
1736 )
1737 parser.add_argument(
1738 "--json", "-j", action="store_true", dest="json_out",
1739 help="Emit domain registry (or scaffold result) as JSON.",
1740 )
1741 parser.set_defaults(func=run)
1742
1743 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
1744
1745 # ── info ───────────────────────────────────────────────────────────────────
1746 info_p = subs.add_parser(
1747 "info",
1748 help="Show full information for a single registered domain.",
1749 description=(
1750 "Look up NAME in the in-process plugin registry and print its\n"
1751 "capability labels, module path, and (when implemented) full schema\n"
1752 "including merge mode, dimensions, and description.\n\n"
1753 "Agent quickstart\n"
1754 "----------------\n"
1755 " muse domains info code --json\n"
1756 " muse domains info code -j\n"
1757 " muse domains info code -j | jq .capabilities\n"
1758 " muse domains info code -j | jq .schema.dimensions\n\n"
1759 "JSON output schema\n"
1760 "------------------\n"
1761 ' {"domain": "<str>", "module_path": "<str>",\n'
1762 ' "capabilities": ["Typed Deltas", ...], "active": <bool>,\n'
1763 ' "schema": {"schema_version": "<str>", "merge_mode": "<str>",\n'
1764 ' "description": "<str>",\n'
1765 ' "dimensions": [{"name": "<str>", "description": "<str>"}, ...]}}\n\n'
1766 " Note: 'schema' key is absent when the plugin does not implement schema().\n\n"
1767 "Exit codes\n"
1768 "----------\n"
1769 " 0 — domain found and info emitted\n"
1770 " 1 — domain name is not registered\n"
1771 ),
1772 formatter_class=argparse.RawDescriptionHelpFormatter,
1773 )
1774 info_p.add_argument("info_name", metavar="NAME", help="Domain name to inspect.")
1775 info_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit as JSON.")
1776 info_p.set_defaults(func=run_info)
1777
1778 # ── publish ────────────────────────────────────────────────────────────────
1779 publish_p = subs.add_parser(
1780 "publish",
1781 help="Publish a Muse domain plugin to the MuseHub marketplace.",
1782 description=(
1783 "Register ``@{author}/{slug}`` on MuseHub so agents and users can\n"
1784 "discover and install the domain via ``musehub_list_domains`` and\n"
1785 "``muse domains``.\n\n"
1786 "Capabilities are derived from the active repo's domain plugin\n"
1787 "``schema()`` when ``--capabilities`` is omitted. Pass an explicit\n"
1788 "JSON string to override or when not inside a repo.\n\n"
1789 "Agent quickstart\n"
1790 "----------------\n"
1791 " muse domains publish --author gabriel --slug genomics \\\n"
1792 " --name Genomics --description '...' --viewer-type generic\n"
1793 " muse domains publish ... --dry-run # validate locally, no network call\n"
1794 " muse domains publish ... --json\n"
1795 " muse domains publish ... -j | jq .scoped_id\n\n"
1796 "JSON output schema\n"
1797 "------------------\n"
1798 ' {"domain_id": "<str>", "scoped_id": "@<author>/<slug>",\n'
1799 ' "manifest_hash": "<sha256:...>"}\n\n'
1800 "Exit codes\n"
1801 "----------\n"
1802 " 0 — domain published successfully (or --dry-run: manifest valid)\n"
1803 " 1 — auth missing, bad URL scheme, bad --capabilities JSON,\n"
1804 " plugin schema unavailable, HTTP error, connection failure,\n"
1805 " or (--dry-run) manifest fails local schema validation\n"
1806 ),
1807 formatter_class=argparse.RawDescriptionHelpFormatter,
1808 )
1809 publish_p.add_argument(
1810 "--author", required=True, metavar="SLUG", dest="author_slug",
1811 help="Your MuseHub username (owner of the domain, e.g. 'gabriel').",
1812 )
1813 publish_p.add_argument(
1814 "--slug", required=True, metavar="SLUG",
1815 help="URL-safe domain name (e.g. 'genomics', 'spatial-3d').",
1816 )
1817 publish_p.add_argument(
1818 "--name", required=True, metavar="NAME", dest="display_name",
1819 help="Human-readable marketplace name (e.g. 'Genomics').",
1820 )
1821 publish_p.add_argument(
1822 "--description", required=True, metavar="TEXT",
1823 help="What this domain models and why it benefits from semantic VCS.",
1824 )
1825 publish_p.add_argument(
1826 "--viewer-type", required=True, metavar="TYPE", dest="viewer_type",
1827 help="Built-in viewer to render this domain's files with. Must be one "
1828 "of: generic, symbol_graph, piano_roll (the server rejects any "
1829 "other value with a 422 — there is no custom-viewer option). "
1830 "Use --dry-run to check locally before publishing.",
1831 )
1832 publish_p.add_argument(
1833 "--version", default="0.1.0", metavar="SEMVER",
1834 help="Semver release string (default: 0.1.0).",
1835 )
1836 publish_p.add_argument(
1837 "--capabilities", default=None, metavar="JSON", dest="capabilities_json",
1838 help=(
1839 "Full capabilities manifest as a JSON string. Recognized keys, all "
1840 "optional (default to empty/safe values): dimensions "
1841 "(list of {name, description}, max 50), artifact_types (list of "
1842 "str), merge_semantics (one of: ot, crdt, three_way), "
1843 "supported_commands (list of str). Serialized manifest is capped "
1844 "at 16 KB. When omitted, the active repo's domain plugin schema "
1845 "is used."
1846 ),
1847 )
1848 publish_p.add_argument(
1849 "--hub", default=None, metavar="URL", dest="hub_url",
1850 help="Override the MuseHub base URL (default: read from .muse/config.toml).",
1851 )
1852 publish_p.add_argument(
1853 "--dry-run", action="store_true", dest="dry_run",
1854 help="Validate --viewer-type and --capabilities locally against the "
1855 "same schema MuseHub enforces server-side, then exit — no "
1856 "network call, no auth required. Catches a malformed manifest "
1857 "before a round trip.",
1858 )
1859 publish_p.add_argument(
1860 "--json", "-j", action="store_true", dest="json_out",
1861 help="Emit result as JSON.",
1862 )
1863 publish_p.set_defaults(func=run_publish)
1864
1865 # ── update ─────────────────────────────────────────────────────────────────
1866 update_p = subs.add_parser(
1867 "update",
1868 help="Update a published Muse domain plugin on the MuseHub marketplace.",
1869 description=(
1870 "Partially update ``@{author}/{slug}``'s display name, description,\n"
1871 "capabilities, viewer type, or version. Only the flags you pass are\n"
1872 "changed. Only the domain's owner may update it.\n\n"
1873 "Agent quickstart\n"
1874 "----------------\n"
1875 " muse domains update --author gabriel --slug genomics \\\n"
1876 " --description 'Now with protein folding support'\n"
1877 " muse domains update ... --dry-run # validate locally, no network call\n"
1878 " muse domains update ... --json\n\n"
1879 "JSON output schema\n"
1880 "------------------\n"
1881 ' {"scoped_id": "@<author>/<slug>", "display_name": "<str>",\n'
1882 ' "manifest_hash": "<sha256:...>"}\n\n'
1883 "Exit codes\n"
1884 "----------\n"
1885 " 0 — domain updated successfully (or --dry-run: valid)\n"
1886 " 1 — no fields to update, auth missing, bad URL scheme, bad\n"
1887 " --capabilities JSON, not the owner (403), not found (404),\n"
1888 " HTTP error, connection failure, or (--dry-run) invalid input\n"
1889 ),
1890 formatter_class=argparse.RawDescriptionHelpFormatter,
1891 )
1892 update_p.add_argument(
1893 "--author", required=True, metavar="SLUG", dest="author_slug",
1894 help="The domain owner's MuseHub username (must match the domain being updated).",
1895 )
1896 update_p.add_argument(
1897 "--slug", required=True, metavar="SLUG",
1898 help="URL-safe domain name of the domain to update.",
1899 )
1900 update_p.add_argument(
1901 "--name", default=None, metavar="NAME", dest="display_name",
1902 help="New human-readable marketplace name.",
1903 )
1904 update_p.add_argument(
1905 "--description", default=None, metavar="TEXT",
1906 help="New description of what this domain models.",
1907 )
1908 update_p.add_argument(
1909 "--viewer-type", default=None, metavar="TYPE", dest="viewer_type",
1910 help="New viewer type. Must be one of: generic, symbol_graph, "
1911 "piano_roll (the server rejects any other value with a 422).",
1912 )
1913 update_p.add_argument(
1914 "--version", default=None, metavar="SEMVER",
1915 help="New semver release string.",
1916 )
1917 update_p.add_argument(
1918 "--capabilities", default=None, metavar="JSON", dest="capabilities_json",
1919 help=(
1920 "New full capabilities manifest as a JSON string, replacing the "
1921 "existing one. Recognized keys, all optional: dimensions (list of "
1922 "{name, description}, max 50), artifact_types (list of str), "
1923 "merge_semantics (one of: ot, crdt, three_way), supported_commands "
1924 "(list of str). Serialized manifest is capped at 16 KB."
1925 ),
1926 )
1927 update_p.add_argument(
1928 "--hub", default=None, metavar="URL", dest="hub_url",
1929 help="Override the MuseHub base URL (default: read from .muse/config.toml).",
1930 )
1931 update_p.add_argument(
1932 "--dry-run", action="store_true", dest="dry_run",
1933 help="Validate --viewer-type and --capabilities locally against the "
1934 "same schema MuseHub enforces server-side, then exit — no "
1935 "network call, no auth required.",
1936 )
1937 update_p.add_argument(
1938 "--json", "-j", action="store_true", dest="json_out",
1939 help="Emit result as JSON.",
1940 )
1941 update_p.set_defaults(func=run_update)
1942
1943 # ── delete ─────────────────────────────────────────────────────────────────
1944 delete_p = subs.add_parser(
1945 "delete",
1946 help="Deprecate a published Muse domain plugin on the MuseHub marketplace.",
1947 description=(
1948 "Deprecate ``@{author}/{slug}`` — sets is_deprecated rather than\n"
1949 "deleting the row (a hard delete would orphan repos already linked\n"
1950 "to this domain). The domain immediately disappears from ``muse\n"
1951 "domains`` / ``GET /api/domains`` browsing but remains individually\n"
1952 "fetchable by scoped ID. Only the domain's owner may delete it.\n\n"
1953 "Agent quickstart\n"
1954 "----------------\n"
1955 " muse domains delete --author gabriel --slug old-experiment\n"
1956 " muse domains delete ... --json\n\n"
1957 "JSON output schema\n"
1958 "------------------\n"
1959 ' {"scoped_id": "@<author>/<slug>", "status": "deprecated"}\n\n'
1960 "Exit codes\n"
1961 "----------\n"
1962 " 0 — domain deprecated successfully\n"
1963 " 1 — auth missing, bad URL scheme, not the owner (403), not\n"
1964 " found or already deprecated (404), HTTP error, or\n"
1965 " connection failure\n"
1966 ),
1967 formatter_class=argparse.RawDescriptionHelpFormatter,
1968 )
1969 delete_p.add_argument(
1970 "--author", required=True, metavar="SLUG", dest="author_slug",
1971 help="The domain owner's MuseHub username (must match the domain being deleted).",
1972 )
1973 delete_p.add_argument(
1974 "--slug", required=True, metavar="SLUG",
1975 help="URL-safe domain name of the domain to delete.",
1976 )
1977 delete_p.add_argument(
1978 "--hub", default=None, metavar="URL", dest="hub_url",
1979 help="Override the MuseHub base URL (default: read from .muse/config.toml).",
1980 )
1981 delete_p.add_argument(
1982 "--json", "-j", action="store_true", dest="json_out",
1983 help="Emit result as JSON.",
1984 )
1985 delete_p.set_defaults(func=run_delete)
1986
1987 # ── use ────────────────────────────────────────────────────────────────────
1988 use_p = subs.add_parser(
1989 "use",
1990 help="Switch the current repository's active domain.",
1991 description=(
1992 "Write NAME as the active domain in ``.muse/repo.json`` using an\n"
1993 "atomic rename so a crash cannot corrupt the file. All other\n"
1994 "fields in repo.json are preserved. The operation is idempotent —\n"
1995 "switching to the already-active domain exits 0 without a write.\n\n"
1996 "NAME must be a domain registered in the Muse plugin registry.\n"
1997 "Run ``muse domains`` to list all registered domains.\n\n"
1998 "Agent quickstart\n"
1999 "----------------\n"
2000 " muse domains use code --json\n"
2001 " muse domains use code -j\n"
2002 " muse domains use code -j | jq .status\n\n"
2003 "JSON output schema\n"
2004 "------------------\n"
2005 ' {"domain": "<str>", "repo": "<abs-path/.muse>",\n'
2006 ' "status": "switched"}\n\n'
2007 "Exit codes\n"
2008 "----------\n"
2009 " 0 — domain switched (or already active)\n"
2010 " 1 — not inside a Muse repository, domain not registered,\n"
2011 " or repo.json could not be read\n"
2012 ),
2013 formatter_class=argparse.RawDescriptionHelpFormatter,
2014 )
2015 use_p.add_argument("use_name", metavar="NAME", help="Domain name to activate.")
2016 use_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit result as JSON.")
2017 use_p.set_defaults(func=run_use)
2018
2019 # ── validate ───────────────────────────────────────────────────────────────
2020 validate_p = subs.add_parser(
2021 "validate",
2022 help="Verify a domain plugin correctly implements the MuseDomainPlugin protocol.",
2023 description=(
2024 "Run protocol compliance checks on a registered domain plugin.\n"
2025 "Checks required methods (snapshot, diff, merge, drift, apply),\n"
2026 "schema() availability, and optional capability interfaces.\n\n"
2027 "When NAME is omitted, validates the active repo's domain; when\n"
2028 "not inside a repo, all registered domains are validated.\n\n"
2029 "Agent quickstart\n"
2030 "----------------\n"
2031 " muse domains validate code --json\n"
2032 " muse domains validate code -j\n"
2033 " muse domains validate -j # active repo domain\n"
2034 " muse domains validate -j | jq .ok\n"
2035 " muse domains validate -j | jq '.checks[] | select(.ok == false)'\n\n"
2036 "JSON output schema\n"
2037 "------------------\n"
2038 " Single domain:\n"
2039 ' {"domain": "<str>", "ok": <bool>,\n'
2040 ' "checks": [{"name": "<str>", "ok": <bool>, "detail": "<str>"}, ...]}\n\n'
2041 " Multiple domains (no NAME, not in a repo):\n"
2042 " [<domain-object>, ...]\n\n"
2043 "Exit codes\n"
2044 "----------\n"
2045 " 0 — all checks passed\n"
2046 " 1 — one or more checks failed or domain not registered\n"
2047 ),
2048 formatter_class=argparse.RawDescriptionHelpFormatter,
2049 )
2050 validate_p.add_argument(
2051 "validate_name", nargs="?", default=None, metavar="NAME",
2052 help="Domain to validate. Defaults to the active repo domain (or all domains if not in a repo).",
2053 )
2054 validate_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit result as JSON.")
2055 validate_p.set_defaults(func=run_validate)
2056
2057 def run(args: argparse.Namespace) -> None:
2058 """Domain plugin dashboard — list registered domains and their capabilities.
2059
2060 Without flags, prints a human-readable table of all registered domains,
2061 their capability levels (Typed Deltas / Domain Schema / Addressed Merge / CRDT),
2062 and their declared schemas. Use ``--new <name>`` to scaffold a new domain
2063 plugin directory from the standard template.
2064
2065 Agent quickstart
2066 ----------------
2067 ::
2068
2069 muse domains --json
2070 muse domains --new mymusic --json
2071 muse domains info code --json
2072 muse domains validate --json
2073
2074 JSON fields
2075 -----------
2076 domains List of domain objects: ``name``, ``active``, ``capabilities``
2077 (``typed_deltas``, ``domain_schema``, ``ot_merge``, ``crdt``),
2078 ``plugin_class``.
2079
2080 Exit codes
2081 ----------
2082 0 Success.
2083 1 Invalid domain name or scaffold failure.
2084 2 Not inside a Muse repository.
2085 """
2086 elapsed = start_timer()
2087 new: str | None = args.new
2088 json_out: bool = args.json_out
2089
2090 if new is not None:
2091 _validate_domain_name(new)
2092 _scaffold_new_domain(new, json_out, elapsed)
2093 return
2094
2095 active_domain = _active_domain(_find_repo_root())
2096
2097 if json_out:
2098 _emit_json(active_domain, elapsed)
2099 return
2100
2101 _print_dashboard(active_domain)
File History 2 commits
sha256:ac29b8ba9021514a03ab2782d92bf67671f0efa5b3b70d46f7598c5d4e923378 docs: record muse#74 Phase 4 live verification (SCR_13, SCR_14) Sonnet 5 13 days ago
sha256:2562dffa0a0822ac1bdea854f9b267843c6ce95b497a9dc5c55837c80a3ebd0a feat: domain_command_registry — Phase 1 of muse#74 (SCR_01-03) Sonnet 4.6 patch 13 days ago