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