mist.py
python
sha256:f1f585ee9ca4e1ada936668c1b14f42f961a1fa78a2c033b643595f9c1bf9ac7
fixes for proposal flow
Human
patch
5 days ago
| 1 | """``muse mist`` — create, share, and manage content-addressed Muse Mists. |
| 2 | |
| 3 | A *Mist* is the Muse answer to GitHub gists: a single artifact (code, MIDI, |
| 4 | prose, schema, ABI, or any binary blob) captured in the Muse object store, |
| 5 | content-addressed by its SHA-256 digest, signed with an Ed25519 key, and |
| 6 | version-controlled via a lightweight Muse repo with ``domain="mist"``. |
| 7 | |
| 8 | Unlike a gist, a Mist: |
| 9 | |
| 10 | - Has a globally unique, human-readable 12-character ID derived from content. |
| 11 | - Carries author provenance: Ed25519 signature + optional agent_id/model_id. |
| 12 | - Has full VCS lineage: branches, commits, proposals, diffs, releases. |
| 13 | - Is forkable with proposal-back-to-upstream support. |
| 14 | - Is embeddable via ``/embed`` with domain-appropriate rendering. |
| 15 | - Is MCP-accessible as ``muse:///handle/mists/ID``. |
| 16 | |
| 17 | Subcommands |
| 18 | ----------- |
| 19 | create Create a new Mist from a local file. |
| 20 | list List Mists for the authenticated user or a given handle. |
| 21 | read Read a Mist's content and metadata. |
| 22 | fork Fork a Mist into the caller's namespace. |
| 23 | update Update a Mist's title, description, visibility, tags, or content. |
| 24 | forks List direct forks of a Mist. |
| 25 | raw Print or save the raw artifact bytes of a Mist. |
| 26 | push Push a local Mist repo to MuseHub. |
| 27 | embed Generate embed code for a Mist. |
| 28 | delete Delete a Mist (owner only). |
| 29 | |
| 30 | All subcommands accept ``--json`` for machine-readable output. |
| 31 | ``create`` additionally accepts ``--sign`` to attach the caller's Ed25519 |
| 32 | signature, and ``--push`` to submit to MuseHub immediately after creation. |
| 33 | |
| 34 | Exit codes |
| 35 | ---------- |
| 36 | 0 Success. |
| 37 | 1 User error — invalid arguments or bad input. |
| 38 | 2 Not inside a Muse repository (for ``push`` subcommand). |
| 39 | 3 File not found or unreadable. |
| 40 | 4 Mist not found on MuseHub. |
| 41 | 5 Permission denied (for ``delete``). |
| 42 | |
| 43 | JSON output example (``create --json``):: |
| 44 | |
| 45 | { |
| 46 | "mist_id": "aB3xKq9dPwNm", |
| 47 | "url": "https://musehub.ai/gabriel/mists/aB3xKq9dPwNm", |
| 48 | "artifact_type": "code", |
| 49 | "language": "python", |
| 50 | "filename": "validate_assignee.py", |
| 51 | "size_bytes": 892, |
| 52 | "signed": true, |
| 53 | "agent_id": "", |
| 54 | "model_id": "" |
| 55 | } |
| 56 | """ |
| 57 | |
| 58 | import argparse |
| 59 | import json |
| 60 | import os |
| 61 | import sys |
| 62 | import urllib.parse |
| 63 | from collections.abc import Mapping |
| 64 | |
| 65 | from muse.core.envelope import JsonValue |
| 66 | from muse.core.errors import ExitCode |
| 67 | from muse.core.identity import IdentityEntry, load_identity |
| 68 | from muse.core.validation import sanitize_display |
| 69 | from muse.plugins.mist.plugin import ( |
| 70 | MIST_VISIBILITIES, |
| 71 | compute_mist_id, |
| 72 | detect_artifact_type, |
| 73 | extract_mist_symbol_anchors, |
| 74 | validate_mist_filename, |
| 75 | ) |
| 76 | |
| 77 | |
| 78 | # --------------------------------------------------------------------------- |
| 79 | # Constants |
| 80 | # --------------------------------------------------------------------------- |
| 81 | |
| 82 | _MAX_MIST_BYTES = 10 * 1024 * 1024 # 10 MiB hard limit |
| 83 | _MAX_TAG_LENGTH = 64 |
| 84 | _MAX_TAGS = 10 |
| 85 | |
| 86 | _ALLOWED_API_SCHEMES = frozenset({"http", "https"}) |
| 87 | |
| 88 | # --------------------------------------------------------------------------- |
| 89 | # Internal helpers |
| 90 | # --------------------------------------------------------------------------- |
| 91 | |
| 92 | def _get_hub_url() -> tuple[str, IdentityEntry] | None: |
| 93 | """Return (hub_url, identity) for the current context, or None. |
| 94 | |
| 95 | Tries the hub URL from ``.muse/config.toml``, then falls back to the |
| 96 | ``local`` remote. Returns ``None`` when neither is available — callers |
| 97 | that require MuseHub print their own error and exit. |
| 98 | |
| 99 | Returns: |
| 100 | A ``(hub_url, identity)`` tuple, or ``None`` if no hub is available. |
| 101 | """ |
| 102 | try: |
| 103 | from muse.cli.config import get_hub_url, get_remote, list_remotes |
| 104 | from muse.core.repo import find_repo_root |
| 105 | |
| 106 | root = find_repo_root() |
| 107 | hub_url: str | None = None |
| 108 | |
| 109 | if root is not None: |
| 110 | hub_url = get_hub_url(root) |
| 111 | if hub_url is None: |
| 112 | remote_url = get_remote("local", root) |
| 113 | if remote_url: |
| 114 | hub_url = remote_url.rstrip("/") |
| 115 | else: |
| 116 | remotes = list_remotes(root) |
| 117 | if remotes: |
| 118 | hub_url = remotes[0]["url"].rstrip("/") |
| 119 | |
| 120 | if hub_url: |
| 121 | identity = load_identity(hub_url) |
| 122 | if identity: |
| 123 | return hub_url, identity |
| 124 | return None |
| 125 | except Exception: |
| 126 | return None |
| 127 | |
| 128 | type _JsonObject = dict[str, JsonValue] |
| 129 | |
| 130 | def _hub_api( |
| 131 | hub_url: str, |
| 132 | identity: IdentityEntry, |
| 133 | method: str, |
| 134 | path: str, |
| 135 | body: Mapping[str, JsonValue] | None = None, |
| 136 | hub_override: str | None = None, |
| 137 | timeout: float = 15.0, |
| 138 | ) -> _JsonObject: |
| 139 | """Make an authenticated JSON request to the MuseHub API. |
| 140 | |
| 141 | Uses :class:`~muse.core.transport.HttpTransport` (httpx + mkcert) so that |
| 142 | self-signed localhost certificates are handled correctly. |
| 143 | |
| 144 | Args: |
| 145 | hub_url: Repository-level or server-root hub URL. |
| 146 | identity: Loaded identity entry for signing. |
| 147 | method: HTTP method (``GET``, ``POST``, ``PATCH``, ``DELETE``). |
| 148 | path: API path (e.g. ``/api/mists/{id}``). |
| 149 | body: Optional JSON body dict. |
| 150 | hub_override: Override the server root (from ``--hub`` flag). |
| 151 | timeout: Ignored — transport uses its own timeout configuration. |
| 152 | |
| 153 | Returns: |
| 154 | Parsed JSON response as a dict. |
| 155 | |
| 156 | Raises: |
| 157 | SystemExit: On scheme error, auth error, network error, or non-2xx response. |
| 158 | """ |
| 159 | from muse.cli.config import get_signing_identity |
| 160 | from muse.core.transport import HttpTransport, TransportError |
| 161 | |
| 162 | root_url = hub_override or hub_url |
| 163 | parsed = urllib.parse.urlparse(root_url) |
| 164 | scheme = parsed.scheme.lower() |
| 165 | if scheme not in _ALLOWED_API_SCHEMES: |
| 166 | print( |
| 167 | f"❌ Hub URL scheme {sanitize_display(scheme)!r} is not allowed. " |
| 168 | "Use http or https.", |
| 169 | file=sys.stderr, |
| 170 | ) |
| 171 | raise SystemExit(ExitCode.USER_ERROR) |
| 172 | |
| 173 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 174 | url = f"{server_root}{path}" |
| 175 | signing = get_signing_identity(remote_url=server_root) |
| 176 | |
| 177 | try: |
| 178 | return HttpTransport().hub_json(method, url, signing, body=dict(body) if body is not None else None) |
| 179 | except TransportError as exc: |
| 180 | status = exc.status_code |
| 181 | detail = sanitize_display(str(exc)) |
| 182 | if status == 401: |
| 183 | print("❌ Not authenticated. Run: muse auth register", file=sys.stderr) |
| 184 | elif status == 403: |
| 185 | print(f"❌ Permission denied: {detail or path}", file=sys.stderr) |
| 186 | raise SystemExit(ExitCode.REMOTE_ERROR) |
| 187 | elif status == 404: |
| 188 | print(f"❌ Not found: {detail or path}", file=sys.stderr) |
| 189 | raise SystemExit(ExitCode.NOT_FOUND) |
| 190 | elif status == 413: |
| 191 | print("❌ Content too large (limit: 10 MiB).", file=sys.stderr) |
| 192 | elif status == 422: |
| 193 | print(f"❌ Validation error: {detail}", file=sys.stderr) |
| 194 | else: |
| 195 | print(f"❌ Hub returned HTTP {status}: {detail}", file=sys.stderr) |
| 196 | raise SystemExit(ExitCode.REMOTE_ERROR) |
| 197 | |
| 198 | def _require_hub(hub_override: str | None = None) -> tuple[str, IdentityEntry]: |
| 199 | """Return (hub_url, identity) or exit with a clear error. |
| 200 | |
| 201 | Accepts an explicit ``--hub URL`` override; otherwise resolves from the |
| 202 | repo config and falls back to the ``local`` remote. |
| 203 | |
| 204 | Args: |
| 205 | hub_override: Optional ``--hub`` flag value. |
| 206 | |
| 207 | Returns: |
| 208 | A ``(hub_url, identity)`` tuple. |
| 209 | |
| 210 | Raises: |
| 211 | SystemExit: If no hub URL is available or the user is not authenticated. |
| 212 | """ |
| 213 | if hub_override: |
| 214 | identity = load_identity(hub_override) |
| 215 | if not identity: |
| 216 | print("❌ Not authenticated. Run: muse auth register", file=sys.stderr) |
| 217 | raise SystemExit(ExitCode.USER_ERROR) |
| 218 | return hub_override.rstrip("/"), identity |
| 219 | |
| 220 | ctx = _get_hub_url() |
| 221 | if ctx is None: |
| 222 | print( |
| 223 | "❌ No MuseHub configured. Run: muse hub connect <url>", |
| 224 | file=sys.stderr, |
| 225 | ) |
| 226 | raise SystemExit(ExitCode.USER_ERROR) |
| 227 | return ctx |
| 228 | |
| 229 | def _validate_tag(tag: str) -> None: |
| 230 | """Validate a single mist tag string. |
| 231 | |
| 232 | Tags must be non-empty, ≤ 64 characters, contain no control characters, |
| 233 | no HTML-special characters, and no null bytes. |
| 234 | |
| 235 | Args: |
| 236 | tag: The tag string to validate. |
| 237 | |
| 238 | Raises: |
| 239 | ValueError: With a description of the violation. |
| 240 | """ |
| 241 | if not tag or not tag.strip(): |
| 242 | raise ValueError("Tags must be non-empty strings.") |
| 243 | if len(tag) > _MAX_TAG_LENGTH: |
| 244 | raise ValueError(f"Tag exceeds {_MAX_TAG_LENGTH}-character limit: {tag!r}") |
| 245 | if "\x00" in tag: |
| 246 | raise ValueError(f"Tag must not contain null bytes: {tag!r}") |
| 247 | for ch in tag: |
| 248 | cp = ord(ch) |
| 249 | if 0x01 <= cp <= 0x1F or cp == 0x7F: |
| 250 | raise ValueError(f"Tag must not contain control characters: {tag!r}") |
| 251 | for bad in ("<", ">", '"', "'", "&"): |
| 252 | if bad in tag: |
| 253 | raise ValueError(f"Tag must not contain HTML special character {bad!r}: {tag!r}") |
| 254 | |
| 255 | # --------------------------------------------------------------------------- |
| 256 | # Subcommand handlers |
| 257 | # --------------------------------------------------------------------------- |
| 258 | |
| 259 | def run_create(args: argparse.Namespace) -> None: |
| 260 | """Create a new Mist from a local file. |
| 261 | |
| 262 | Reads the file at ``FILE``, validates the filename, computes a |
| 263 | content-addressed ``mist_id`` (12-character base-58 SHA-256 prefix), |
| 264 | detects the artifact type and language, and extracts symbol anchors for |
| 265 | code artifacts. |
| 266 | |
| 267 | With ``--push``, the mist is submitted to MuseHub via ``POST /api/mists``. |
| 268 | Without ``--push``, only local metadata is computed and printed — useful |
| 269 | for preview and scripting. |
| 270 | |
| 271 | Signing (``--sign``) attaches the caller's Ed25519 signature from |
| 272 | ``~/.muse/identity.toml``. AI agents set ``--agent-id`` and |
| 273 | ``--model-id`` for provenance tracking. |
| 274 | |
| 275 | JSON output (stdout) when ``--json`` |
| 276 | ------------------------------------ |
| 277 | :: |
| 278 | |
| 279 | { |
| 280 | "mist_id": "aB3xKq9dPwNm", |
| 281 | "url": "https://musehub.ai/gabriel/mists/aB3xKq9dPwNm", |
| 282 | "artifact_type": "code", |
| 283 | "language": "python", |
| 284 | "filename": "validate_assignee.py", |
| 285 | "size_bytes": 892, |
| 286 | "signed": true, |
| 287 | "agent_id": "cccode-v3", |
| 288 | "model_id": "claude-sonnet-4-6", |
| 289 | "symbol_anchors": ["validate_assignee.py::_validate_assignee"] |
| 290 | } |
| 291 | |
| 292 | Exit codes |
| 293 | ---------- |
| 294 | 0 Success. |
| 295 | 1 User error (invalid filename, tag, or visibility value). |
| 296 | 3 File not found or unreadable. |
| 297 | 5 MuseHub API error (when --push). |
| 298 | |
| 299 | Args: |
| 300 | args: Parsed argument namespace from the ``create`` subparser. |
| 301 | """ |
| 302 | file_path: str = args.file |
| 303 | json_output: bool = args.json_output |
| 304 | do_push: bool = args.push |
| 305 | do_sign: bool = args.sign |
| 306 | title: str = args.title or "" |
| 307 | description: str = args.description or "" |
| 308 | visibility: str = args.visibility or "public" |
| 309 | tag_strings: list[str] = args.tags or [] |
| 310 | agent_id: str = args.agent_id or "" |
| 311 | model_id: str = args.model_id or "" |
| 312 | hub_override: str | None = getattr(args, "hub", None) |
| 313 | |
| 314 | # Validate visibility |
| 315 | if visibility not in MIST_VISIBILITIES: |
| 316 | print( |
| 317 | f"❌ Invalid visibility {visibility!r}. Choose: public, secret", |
| 318 | file=sys.stderr, |
| 319 | ) |
| 320 | raise SystemExit(ExitCode.USER_ERROR) |
| 321 | |
| 322 | # Validate tags |
| 323 | if len(tag_strings) > _MAX_TAGS: |
| 324 | print(f"❌ Too many tags (max {_MAX_TAGS}): {len(tag_strings)} given.", file=sys.stderr) |
| 325 | raise SystemExit(ExitCode.USER_ERROR) |
| 326 | for tag in tag_strings: |
| 327 | try: |
| 328 | _validate_tag(tag) |
| 329 | except ValueError as exc: |
| 330 | print(f"❌ {exc}", file=sys.stderr) |
| 331 | raise SystemExit(ExitCode.USER_ERROR) |
| 332 | |
| 333 | # Read file |
| 334 | try: |
| 335 | with open(file_path, "rb") as fh: |
| 336 | content = fh.read() |
| 337 | except FileNotFoundError: |
| 338 | print(f"❌ File not found: {sanitize_display(file_path)}", file=sys.stderr) |
| 339 | raise SystemExit(ExitCode.NOT_FOUND) |
| 340 | except PermissionError: |
| 341 | print(f"❌ Permission denied: {sanitize_display(file_path)}", file=sys.stderr) |
| 342 | raise SystemExit(ExitCode.REMOTE_ERROR) |
| 343 | except OSError as exc: |
| 344 | print(f"❌ Cannot read file: {sanitize_display(str(exc))}", file=sys.stderr) |
| 345 | raise SystemExit(ExitCode.NOT_FOUND) |
| 346 | |
| 347 | if len(content) > _MAX_MIST_BYTES: |
| 348 | print( |
| 349 | f"❌ File exceeds 10 MiB limit: {len(content):,} bytes.", |
| 350 | file=sys.stderr, |
| 351 | ) |
| 352 | raise SystemExit(ExitCode.USER_ERROR) |
| 353 | |
| 354 | filename = os.path.basename(file_path) |
| 355 | try: |
| 356 | validate_mist_filename(filename) |
| 357 | except ValueError as exc: |
| 358 | print(f"❌ {exc}", file=sys.stderr) |
| 359 | raise SystemExit(ExitCode.USER_ERROR) |
| 360 | |
| 361 | # Compute mist properties |
| 362 | mist_id = compute_mist_id(content) |
| 363 | type_info = detect_artifact_type(filename, content) |
| 364 | artifact_type = type_info["artifact_type"] |
| 365 | language = type_info["language"] |
| 366 | size_bytes = len(content) |
| 367 | symbol_anchors = extract_mist_symbol_anchors(filename, content) |
| 368 | |
| 369 | # Sign if requested |
| 370 | gpg_signature: str | None = None |
| 371 | signed = False |
| 372 | if do_sign: |
| 373 | try: |
| 374 | from muse.cli.config import get_signing_identity |
| 375 | from muse.core.keypair import sign_bytes as _sign_bytes |
| 376 | _signing = get_signing_identity() |
| 377 | if _signing: |
| 378 | gpg_signature = _sign_bytes(_signing.private_key, content) |
| 379 | signed = True |
| 380 | except Exception as exc: |
| 381 | print( |
| 382 | f"⚠️ Could not sign mist: {sanitize_display(str(exc))}", |
| 383 | file=sys.stderr, |
| 384 | ) |
| 385 | |
| 386 | # Build content string (base64 for binary, utf-8 for text) |
| 387 | content_str: str |
| 388 | try: |
| 389 | content_str = content.decode("utf-8") |
| 390 | except UnicodeDecodeError: |
| 391 | import base64 |
| 392 | content_str = base64.b64encode(content).decode("ascii") |
| 393 | |
| 394 | url = "" |
| 395 | if do_push: |
| 396 | hub_url, identity = _require_hub(hub_override) |
| 397 | |
| 398 | # Derive server root from hub_url (strip repo path if present) |
| 399 | parsed = urllib.parse.urlparse(hub_url) |
| 400 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 401 | handle = identity.get("handle", "") |
| 402 | |
| 403 | payload = { |
| 404 | "filename": filename, |
| 405 | "content": content_str, |
| 406 | "artifact_type": artifact_type, |
| 407 | "language": language, |
| 408 | "title": title, |
| 409 | "description": description, |
| 410 | "visibility": visibility, |
| 411 | "tags": tag_strings, |
| 412 | "agent_id": agent_id, |
| 413 | "model_id": model_id, |
| 414 | } |
| 415 | if gpg_signature: |
| 416 | payload["gpg_signature"] = gpg_signature |
| 417 | |
| 418 | data = _hub_api(server_root, identity, "POST", "/api/mists", body=payload) |
| 419 | mist_id = str(data.get("mist_id", mist_id)) |
| 420 | handle = str(data.get("owner", handle)) |
| 421 | url = f"{server_root}/{handle}/mists/{mist_id}" |
| 422 | |
| 423 | result = { |
| 424 | "mist_id": mist_id, |
| 425 | "url": url, |
| 426 | "artifact_type": artifact_type, |
| 427 | "language": language, |
| 428 | "filename": filename, |
| 429 | "size_bytes": size_bytes, |
| 430 | "signed": signed, |
| 431 | "agent_id": agent_id, |
| 432 | "model_id": model_id, |
| 433 | "symbol_anchors": symbol_anchors, |
| 434 | } |
| 435 | |
| 436 | if json_output: |
| 437 | print(json.dumps(result)) |
| 438 | return |
| 439 | |
| 440 | type_badge = f"[{artifact_type}]" if artifact_type != "unknown" else "[unknown type]" |
| 441 | lang_badge = f" [{language}]" if language else "" |
| 442 | sign_badge = " [signed ✓]" if signed else "" |
| 443 | print(f"✅ Mist created") |
| 444 | print(f" ID: {mist_id}") |
| 445 | print(f" File: {sanitize_display(filename)}") |
| 446 | print(f" Type: {type_badge}{lang_badge}{sign_badge}") |
| 447 | print(f" Size: {size_bytes:,} bytes") |
| 448 | if symbol_anchors: |
| 449 | print(f" Symbols: {len(symbol_anchors)} ({', '.join(symbol_anchors[:3])}{'…' if len(symbol_anchors) > 3 else ''})") |
| 450 | if url: |
| 451 | print(f" URL: {url}") |
| 452 | else: |
| 453 | print(" (Use --push to publish to MuseHub)") |
| 454 | |
| 455 | def run_list(args: argparse.Namespace) -> None: |
| 456 | """List Mists for a MuseHub handle. |
| 457 | |
| 458 | Queries ``GET /api/{handle}/mists`` on MuseHub. When ``--handle`` is |
| 459 | omitted, uses the authenticated user's handle from |
| 460 | ``~/.muse/identity.toml``. |
| 461 | |
| 462 | Pagination is cursor-based: each response includes a ``next_cursor`` |
| 463 | field. Pass it with ``--cursor`` to retrieve the next page. |
| 464 | |
| 465 | JSON output (stdout) when ``--json`` |
| 466 | ------------------------------------ |
| 467 | :: |
| 468 | |
| 469 | { |
| 470 | "total": 47, |
| 471 | "next_cursor": "cursor_string_or_null", |
| 472 | "mists": [ |
| 473 | { |
| 474 | "mist_id": "aB3xKq9dPwNm", |
| 475 | "owner": "gabriel", |
| 476 | "artifact_type": "code", |
| 477 | "language": "python", |
| 478 | "filename": "validate_assignee.py", |
| 479 | "title": "...", |
| 480 | "size_bytes": 892, |
| 481 | "signed": true, |
| 482 | "fork_count": 3, |
| 483 | "view_count": 842, |
| 484 | "visibility": "public", |
| 485 | "tags": [], |
| 486 | "version": 3, |
| 487 | "created_at": "2026-04-14T00:00:00Z", |
| 488 | "updated_at": "2026-04-14T00:00:00Z" |
| 489 | } |
| 490 | ] |
| 491 | } |
| 492 | |
| 493 | Args: |
| 494 | args: Parsed argument namespace from the ``list`` subparser. |
| 495 | """ |
| 496 | handle: str | None = args.handle |
| 497 | json_output: bool = args.json_output |
| 498 | limit: int = max(1, min(args.limit, 100)) |
| 499 | cursor: str | None = args.cursor |
| 500 | artifact_type_filter: str | None = args.type |
| 501 | hub_override: str | None = getattr(args, "hub", None) |
| 502 | |
| 503 | hub_url, identity = _require_hub(hub_override) |
| 504 | parsed = urllib.parse.urlparse(hub_url) |
| 505 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 506 | |
| 507 | if not handle: |
| 508 | handle = identity.get("handle", "") |
| 509 | if not handle: |
| 510 | print("❌ No handle provided and no identity configured.", file=sys.stderr) |
| 511 | raise SystemExit(ExitCode.USER_ERROR) |
| 512 | |
| 513 | params: dict[str, str] = {"limit": str(limit)} |
| 514 | if cursor: |
| 515 | params["cursor"] = cursor |
| 516 | if artifact_type_filter: |
| 517 | params["artifact_type"] = artifact_type_filter |
| 518 | |
| 519 | query_string = "&".join(f"{k}={urllib.parse.quote(v)}" for k, v in params.items()) |
| 520 | api_path = f"/api/{urllib.parse.quote(handle)}/mists?{query_string}" |
| 521 | |
| 522 | data = _hub_api(server_root, identity, "GET", api_path) |
| 523 | |
| 524 | if json_output: |
| 525 | print(json.dumps(data)) |
| 526 | return |
| 527 | |
| 528 | mists: list[dict] = data.get("mists", []) |
| 529 | total: int = data.get("total", len(mists)) |
| 530 | next_cursor: str | None = data.get("next_cursor") |
| 531 | |
| 532 | if not mists: |
| 533 | print(f" {sanitize_display(handle)} has no mists.") |
| 534 | return |
| 535 | |
| 536 | print(f" {sanitize_display(handle)} / mists ({total} total)") |
| 537 | print() |
| 538 | for m in mists: |
| 539 | mid = sanitize_display(str(m.get("mist_id", ""))) |
| 540 | atype = m.get("artifact_type", "unknown") |
| 541 | lang = m.get("language", "") |
| 542 | fname = sanitize_display(str(m.get("filename", ""))) |
| 543 | ttl = sanitize_display(str(m.get("title", ""))) |
| 544 | forks = m.get("fork_count", 0) |
| 545 | views = m.get("view_count", 0) |
| 546 | vis = m.get("visibility", "public") |
| 547 | signed = m.get("signed", False) |
| 548 | |
| 549 | badges = f"[{atype}]" |
| 550 | if lang: |
| 551 | badges += f" [{lang}]" |
| 552 | if signed: |
| 553 | badges += " [signed]" |
| 554 | if vis == "secret": |
| 555 | badges += " [secret]" |
| 556 | |
| 557 | label = ttl or fname or mid |
| 558 | print(f" {mid} {badges}") |
| 559 | print(f" {label}") |
| 560 | print(f" {views} views · {forks} forks") |
| 561 | print() |
| 562 | |
| 563 | if next_cursor: |
| 564 | print(f" (More results — use --cursor {next_cursor!r} for next page)") |
| 565 | |
| 566 | def run_read(args: argparse.Namespace) -> None: |
| 567 | """Read a Mist's content and metadata from MuseHub. |
| 568 | |
| 569 | Resolves the mist by ``MIST_ID`` (12-character base-58 ID or |
| 570 | ``owner/ID`` form). Increments the view count on the server. |
| 571 | |
| 572 | JSON output (stdout) when ``--json`` |
| 573 | ------------------------------------ |
| 574 | :: |
| 575 | |
| 576 | { |
| 577 | "mist_id": "aB3xKq9dPwNm", |
| 578 | "url": "https://musehub.ai/gabriel/mists/aB3xKq9dPwNm", |
| 579 | "owner": "gabriel", |
| 580 | "artifact_type": "code", |
| 581 | "language": "python", |
| 582 | "filename": "validate_assignee.py", |
| 583 | "title": "...", |
| 584 | "description": "...", |
| 585 | "content": "def _validate_assignee...", |
| 586 | "size_bytes": 892, |
| 587 | "signed": true, |
| 588 | "agent_id": "", |
| 589 | "model_id": "", |
| 590 | "fork_count": 3, |
| 591 | "view_count": 843, |
| 592 | "visibility": "public", |
| 593 | "tags": [], |
| 594 | "version": 3, |
| 595 | "symbol_anchors": ["validate_assignee.py::_validate_assignee"], |
| 596 | "created_at": "2026-04-14T00:00:00Z", |
| 597 | "updated_at": "2026-04-14T00:00:00Z" |
| 598 | } |
| 599 | |
| 600 | Args: |
| 601 | args: Parsed argument namespace from the ``read`` subparser. |
| 602 | """ |
| 603 | mist_id: str = args.mist_id.strip() |
| 604 | json_output: bool = args.json_output |
| 605 | hub_override: str | None = getattr(args, "hub", None) |
| 606 | |
| 607 | hub_url, identity = _require_hub(hub_override) |
| 608 | parsed = urllib.parse.urlparse(hub_url) |
| 609 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 610 | |
| 611 | # Support owner/ID form |
| 612 | if "/" in mist_id: |
| 613 | parts = mist_id.split("/", 1) |
| 614 | owner_part = urllib.parse.quote(parts[0].strip()) |
| 615 | id_part = urllib.parse.quote(parts[1].strip()) |
| 616 | api_path = f"/api/{owner_part}/mists/{id_part}" |
| 617 | else: |
| 618 | api_path = f"/api/mists/{urllib.parse.quote(mist_id)}" |
| 619 | |
| 620 | data = _hub_api(server_root, identity, "GET", api_path) |
| 621 | |
| 622 | if json_output: |
| 623 | print(json.dumps(data)) |
| 624 | return |
| 625 | |
| 626 | mid = sanitize_display(str(data.get("mist_id", mist_id))) |
| 627 | owner = sanitize_display(str(data.get("owner", ""))) |
| 628 | atype = sanitize_display(str(data.get("artifact_type", "unknown"))) |
| 629 | lang = sanitize_display(str(data.get("language", ""))) |
| 630 | fname = sanitize_display(str(data.get("filename", ""))) |
| 631 | ttl = sanitize_display(str(data.get("title", ""))) |
| 632 | signed = data.get("signed", False) |
| 633 | agent_id = sanitize_display(str(data.get("agent_id", ""))) |
| 634 | model_id = sanitize_display(str(data.get("model_id", ""))) |
| 635 | version = data.get("version", 1) |
| 636 | forks = data.get("fork_count", 0) |
| 637 | views = data.get("view_count", 0) |
| 638 | content = data.get("content", "") |
| 639 | anchors: list[str] = data.get("symbol_anchors", []) |
| 640 | |
| 641 | print(f" {owner} / mists / {mid}") |
| 642 | if ttl: |
| 643 | print(f" \"{sanitize_display(ttl)}\"") |
| 644 | print(f" [{atype}]{' [' + lang + ']' if lang else ''}{' [signed ✓]' if signed else ''}") |
| 645 | if agent_id: |
| 646 | print(f" Agent: {agent_id} Model: {model_id}") |
| 647 | print(f" v{version} · {views} views · {forks} forks") |
| 648 | if anchors: |
| 649 | print(f" Symbols: {', '.join(anchors[:5])}{'…' if len(anchors) > 5 else ''}") |
| 650 | print() |
| 651 | # Print first 40 lines of content for human-readable preview |
| 652 | lines = content.splitlines() |
| 653 | preview_lines = lines[:40] |
| 654 | for line in preview_lines: |
| 655 | print(f" {sanitize_display(line)}") |
| 656 | if len(lines) > 40: |
| 657 | print(f" … ({len(lines) - 40} more lines — use --json for full content)") |
| 658 | |
| 659 | def run_fork(args: argparse.Namespace) -> None: |
| 660 | """Fork a Mist into the caller's namespace. |
| 661 | |
| 662 | Creates a new Mist in the caller's namespace rooted at the same commit |
| 663 | as the original. Sets ``fork_parent_id`` on the new mist to the |
| 664 | original's ``mist_id``. Increments ``fork_count`` on the original. |
| 665 | |
| 666 | JSON output (stdout) when ``--json`` |
| 667 | ------------------------------------ |
| 668 | :: |
| 669 | |
| 670 | { |
| 671 | "mist_id": "Kx2mPq7bRnYt", |
| 672 | "url": "https://musehub.ai/you/mists/Kx2mPq7bRnYt", |
| 673 | "fork_parent_id": "aB3xKq9dPwNm", |
| 674 | "owner": "you", |
| 675 | "artifact_type": "code", |
| 676 | "language": "python" |
| 677 | } |
| 678 | |
| 679 | Args: |
| 680 | args: Parsed argument namespace from the ``fork`` subparser. |
| 681 | """ |
| 682 | mist_id: str = args.mist_id.strip() |
| 683 | json_output: bool = args.json_output |
| 684 | hub_override: str | None = getattr(args, "hub", None) |
| 685 | |
| 686 | hub_url, identity = _require_hub(hub_override) |
| 687 | parsed = urllib.parse.urlparse(hub_url) |
| 688 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 689 | |
| 690 | if "/" in mist_id: |
| 691 | parts = mist_id.split("/", 1) |
| 692 | id_part = urllib.parse.quote(parts[1].strip()) |
| 693 | else: |
| 694 | id_part = urllib.parse.quote(mist_id) |
| 695 | |
| 696 | api_path = f"/api/mists/{id_part}/fork" |
| 697 | data = _hub_api(server_root, identity, "POST", api_path) |
| 698 | |
| 699 | if json_output: |
| 700 | print(json.dumps(data)) |
| 701 | return |
| 702 | |
| 703 | new_id = sanitize_display(str(data.get("mist_id", ""))) |
| 704 | owner = sanitize_display(str(data.get("owner", identity.get("handle", "")))) |
| 705 | url = data.get("url", f"{server_root}/{owner}/mists/{new_id}") |
| 706 | print(f"✅ Mist forked") |
| 707 | print(f" New ID: {new_id}") |
| 708 | print(f" Owner: {owner}") |
| 709 | print(f" URL: {sanitize_display(url)}") |
| 710 | print(f" Parent: {sanitize_display(mist_id)}") |
| 711 | |
| 712 | def run_push(args: argparse.Namespace) -> None: |
| 713 | """Push a local Mist repo to MuseHub. |
| 714 | |
| 715 | Must be run from inside a Muse repository with ``domain="mist"``. |
| 716 | Wraps the standard ``muse push`` infrastructure — the remote name |
| 717 | defaults to ``local`` but can be overridden with ``--remote``. |
| 718 | |
| 719 | This is the multi-step workflow alternative to ``muse mist create --push``: |
| 720 | |
| 721 | 1. ``muse init --domain mist`` |
| 722 | 2. Add your artifact file and ``muse commit`` |
| 723 | 3. ``muse mist push [--remote local]`` |
| 724 | |
| 725 | Exit codes |
| 726 | ---------- |
| 727 | 0 Success. |
| 728 | 2 Not inside a Muse repository or domain is not "mist". |
| 729 | |
| 730 | Args: |
| 731 | args: Parsed argument namespace from the ``push`` subparser. |
| 732 | """ |
| 733 | remote: str = args.remote or "local" |
| 734 | branch: str = args.branch or "main" |
| 735 | json_output: bool = args.json_output |
| 736 | |
| 737 | try: |
| 738 | from muse.core.repo import find_repo_root |
| 739 | except ImportError: |
| 740 | print("❌ Cannot import muse repo utilities.", file=sys.stderr) |
| 741 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 742 | |
| 743 | root = find_repo_root() |
| 744 | if root is None: |
| 745 | print("❌ Not inside a Muse repository.", file=sys.stderr) |
| 746 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 747 | |
| 748 | # Verify domain is "mist" |
| 749 | from muse.plugins.registry import read_domain |
| 750 | |
| 751 | domain = read_domain(root) |
| 752 | if domain != "mist": |
| 753 | print( |
| 754 | f"❌ This repo has domain={domain!r}, not 'mist'. " |
| 755 | "Run from inside a mist repo (muse init --domain mist).", |
| 756 | file=sys.stderr, |
| 757 | ) |
| 758 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 759 | |
| 760 | # Delegate to the push command's internals |
| 761 | from muse.cli.commands.push import run as push_run |
| 762 | import types |
| 763 | |
| 764 | push_args = types.SimpleNamespace( |
| 765 | remote=remote, |
| 766 | branch=branch, |
| 767 | force=False, |
| 768 | json_output=json_output, |
| 769 | ) |
| 770 | push_run(push_args) |
| 771 | |
| 772 | def run_embed(args: argparse.Namespace) -> None: |
| 773 | """Generate embed code for a Mist. |
| 774 | |
| 775 | Returns HTML iframe, JavaScript snippet, and Markdown badge code for |
| 776 | embedding a Mist in external pages, documentation, or dashboards. |
| 777 | |
| 778 | JSON output (stdout) when ``--json`` |
| 779 | ------------------------------------ |
| 780 | :: |
| 781 | |
| 782 | { |
| 783 | "mist_id": "aB3xKq9dPwNm", |
| 784 | "owner": "gabriel", |
| 785 | "iframe": "<iframe src=\"...\" width=\"600\" height=\"300\"></iframe>", |
| 786 | "js": "<script src=\"...\"></script>", |
| 787 | "badge": "[](/gabriel/mists/aB3xKq9dPwNm)" |
| 788 | } |
| 789 | |
| 790 | Args: |
| 791 | args: Parsed argument namespace from the ``embed`` subparser. |
| 792 | """ |
| 793 | mist_id: str = args.mist_id.strip() |
| 794 | json_output: bool = args.json_output |
| 795 | width: int = max(200, min(args.width, 1920)) |
| 796 | height: int = max(100, min(args.height, 1080)) |
| 797 | hub_override: str | None = getattr(args, "hub", None) |
| 798 | |
| 799 | hub_url, identity = _require_hub(hub_override) |
| 800 | parsed = urllib.parse.urlparse(hub_url) |
| 801 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 802 | |
| 803 | if "/" in mist_id: |
| 804 | parts = mist_id.split("/", 1) |
| 805 | owner_part = urllib.parse.quote(parts[0].strip()) |
| 806 | id_part = urllib.parse.quote(parts[1].strip()) |
| 807 | else: |
| 808 | owner_part = urllib.parse.quote(identity.get("handle", "")) |
| 809 | id_part = urllib.parse.quote(mist_id) |
| 810 | |
| 811 | api_path = f"/api/{owner_part}/mists/{id_part}/embed" |
| 812 | data = _hub_api(server_root, identity, "GET", api_path) |
| 813 | |
| 814 | owner = data.get("owner", owner_part) |
| 815 | clean_id = sanitize_display(str(data.get("mist_id", mist_id))) |
| 816 | embed_url = f"{server_root}/{sanitize_display(owner)}/mists/{clean_id}/embed" |
| 817 | iframe = ( |
| 818 | data.get("iframe") or |
| 819 | f'<iframe src="{embed_url}?width={width}&height={height}" ' |
| 820 | f'width="{width}" height="{height}" frameborder="0" ' |
| 821 | f'title="Mist {clean_id}"></iframe>' |
| 822 | ) |
| 823 | js = ( |
| 824 | data.get("js") or |
| 825 | f'<script src="{server_root}/static/mist-embed.js" ' |
| 826 | f'data-mist-id="{clean_id}" data-owner="{sanitize_display(owner)}"></script>' |
| 827 | ) |
| 828 | page_url = f"{server_root}/{sanitize_display(owner)}/mists/{clean_id}" |
| 829 | badge = ( |
| 830 | data.get("badge") or |
| 831 | f'[]({page_url})' |
| 832 | ) |
| 833 | |
| 834 | result = { |
| 835 | "mist_id": clean_id, |
| 836 | "owner": sanitize_display(str(owner)), |
| 837 | "iframe": iframe, |
| 838 | "js": js, |
| 839 | "badge": badge, |
| 840 | } |
| 841 | |
| 842 | if json_output: |
| 843 | print(json.dumps(result)) |
| 844 | return |
| 845 | |
| 846 | print(f" Embed code for mist {clean_id}") |
| 847 | print() |
| 848 | print(" iframe:") |
| 849 | print(f" {iframe}") |
| 850 | print() |
| 851 | print(" JS snippet:") |
| 852 | print(f" {js}") |
| 853 | print() |
| 854 | print(" Markdown badge:") |
| 855 | print(f" {badge}") |
| 856 | |
| 857 | def run_delete(args: argparse.Namespace) -> None: |
| 858 | """Delete a Mist from MuseHub (owner only). |
| 859 | |
| 860 | Sends ``DELETE /api/mists/{id}`` to MuseHub. Requires ownership — |
| 861 | returns HTTP 403 for non-owners. The ``--yes`` flag skips the |
| 862 | interactive confirmation prompt. |
| 863 | |
| 864 | This operation is irreversible. The underlying Muse repo is also |
| 865 | deleted. |
| 866 | |
| 867 | Exit codes |
| 868 | ---------- |
| 869 | 0 Success. |
| 870 | 4 Mist not found. |
| 871 | 5 Permission denied (not the owner). |
| 872 | |
| 873 | Args: |
| 874 | args: Parsed argument namespace from the ``delete`` subparser. |
| 875 | """ |
| 876 | mist_id: str = args.mist_id.strip() |
| 877 | yes: bool = args.yes |
| 878 | json_output: bool = args.json_output |
| 879 | hub_override: str | None = getattr(args, "hub", None) |
| 880 | |
| 881 | hub_url, identity = _require_hub(hub_override) |
| 882 | parsed = urllib.parse.urlparse(hub_url) |
| 883 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 884 | |
| 885 | if "/" in mist_id: |
| 886 | parts = mist_id.split("/", 1) |
| 887 | id_part = urllib.parse.quote(parts[1].strip()) |
| 888 | else: |
| 889 | id_part = urllib.parse.quote(mist_id) |
| 890 | |
| 891 | if not yes: |
| 892 | try: |
| 893 | answer = input( |
| 894 | f"Delete mist {sanitize_display(mist_id)}? This cannot be undone. [y/N] " |
| 895 | ).strip().lower() |
| 896 | except (EOFError, KeyboardInterrupt): |
| 897 | print("\nAborted.", file=sys.stderr) |
| 898 | raise SystemExit(0) |
| 899 | if answer not in ("y", "yes"): |
| 900 | print("Aborted.", file=sys.stderr) |
| 901 | raise SystemExit(0) |
| 902 | |
| 903 | api_path = f"/api/mists/{id_part}" |
| 904 | _hub_api(server_root, identity, "DELETE", api_path) |
| 905 | |
| 906 | result = {"mist_id": mist_id, "deleted": True} |
| 907 | if json_output: |
| 908 | print(json.dumps(result)) |
| 909 | else: |
| 910 | print(f"✅ Mist {sanitize_display(mist_id)} deleted.") |
| 911 | |
| 912 | def run_update(args: argparse.Namespace) -> None: |
| 913 | """Update a Mist's metadata or replace its artifact content. |
| 914 | |
| 915 | Sends ``PATCH /api/mists/{mist_id}`` with only the fields that were |
| 916 | explicitly supplied. Omitted flags are not sent — the server performs a |
| 917 | partial update so unspecified fields remain unchanged. |
| 918 | |
| 919 | When ``--content FILE`` is supplied, the file is read as UTF-8 and its |
| 920 | text replaces the current artifact. The server increments the mist's |
| 921 | version counter on every content change. |
| 922 | |
| 923 | JSON output (stdout) when ``--json`` |
| 924 | ------------------------------------ |
| 925 | :: |
| 926 | |
| 927 | { |
| 928 | "mist_id": "aB3xKq9dPwNm", |
| 929 | "version": 2, |
| 930 | "title": "Updated title", |
| 931 | "visibility": "public", |
| 932 | "updated_at": "2026-04-15T13:00:00+00:00" |
| 933 | } |
| 934 | |
| 935 | Exit codes |
| 936 | ---------- |
| 937 | 0 Success. |
| 938 | 1 User error — no fields supplied, or invalid visibility value. |
| 939 | 4 Mist not found or caller is not the owner (HTTP 404). |
| 940 | 5 Remote error — unexpected HTTP status. |
| 941 | |
| 942 | Args: |
| 943 | args: Parsed argument namespace from the ``update`` subparser. |
| 944 | Relevant attributes: ``mist_id``, ``title``, ``description``, |
| 945 | ``visibility``, ``tags``, ``content``, ``hub``, ``json_output``. |
| 946 | """ |
| 947 | import pathlib |
| 948 | |
| 949 | mist_id: str = args.mist_id.strip() |
| 950 | json_output: bool = args.json_output |
| 951 | hub_override: str | None = getattr(args, "hub", None) |
| 952 | |
| 953 | payload = {} |
| 954 | if args.title is not None: |
| 955 | payload["title"] = args.title |
| 956 | if args.description is not None: |
| 957 | payload["description"] = args.description |
| 958 | if args.visibility is not None: |
| 959 | if args.visibility not in MIST_VISIBILITIES: |
| 960 | print( |
| 961 | f"❌ Invalid visibility {args.visibility!r}. Choose: public, secret", |
| 962 | file=sys.stderr, |
| 963 | ) |
| 964 | raise SystemExit(ExitCode.USER_ERROR) |
| 965 | payload["visibility"] = args.visibility |
| 966 | if args.tags is not None: |
| 967 | payload["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()] |
| 968 | if args.content is not None: |
| 969 | try: |
| 970 | content_path = pathlib.Path(args.content) |
| 971 | payload["content"] = content_path.read_text(encoding="utf-8") |
| 972 | payload["filename"] = content_path.name |
| 973 | except OSError as exc: |
| 974 | print(f"❌ Cannot read content file: {exc}", file=sys.stderr) |
| 975 | raise SystemExit(ExitCode.USER_ERROR) |
| 976 | |
| 977 | if not payload: |
| 978 | print( |
| 979 | "❌ Nothing to update — provide at least one of: " |
| 980 | "--title, --description, --visibility, --tags, --content", |
| 981 | file=sys.stderr, |
| 982 | ) |
| 983 | raise SystemExit(ExitCode.USER_ERROR) |
| 984 | |
| 985 | hub_url, identity = _require_hub(hub_override) |
| 986 | parsed = urllib.parse.urlparse(hub_url) |
| 987 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 988 | |
| 989 | if "/" in mist_id: |
| 990 | id_part = urllib.parse.quote(mist_id.split("/", 1)[1].strip()) |
| 991 | else: |
| 992 | id_part = urllib.parse.quote(mist_id) |
| 993 | |
| 994 | data = _hub_api(server_root, identity, "PATCH", f"/api/mists/{id_part}", body=payload) |
| 995 | |
| 996 | if json_output: |
| 997 | print(json.dumps(data)) |
| 998 | return |
| 999 | |
| 1000 | clean_id = sanitize_display(str(data.get("mist_id", mist_id))) |
| 1001 | version = data.get("version", "?") |
| 1002 | print(f"✅ Mist {clean_id} updated (v{version})") |
| 1003 | if "title" in data: |
| 1004 | print(f" Title: {sanitize_display(str(data['title']))}") |
| 1005 | if "visibility" in data: |
| 1006 | print(f" Visibility: {sanitize_display(str(data['visibility']))}") |
| 1007 | |
| 1008 | def run_forks(args: argparse.Namespace) -> None: |
| 1009 | """List the direct (one-level) forks of a Mist. |
| 1010 | |
| 1011 | Calls ``GET /api/mists/{mist_id}/forks`` and renders each fork as a |
| 1012 | compact summary row. With ``--json``, prints the raw API response. |
| 1013 | |
| 1014 | JSON output (stdout) when ``--json`` |
| 1015 | ------------------------------------ |
| 1016 | :: |
| 1017 | |
| 1018 | [ |
| 1019 | { |
| 1020 | "mist_id": "Kx2mPq7bRnYt", |
| 1021 | "owner": "alice", |
| 1022 | "filename": "validate.py", |
| 1023 | "fork_depth": 1, |
| 1024 | "created_at": "2026-04-15T12:00:00+00:00" |
| 1025 | } |
| 1026 | ] |
| 1027 | |
| 1028 | Exit codes |
| 1029 | ---------- |
| 1030 | 0 Success (empty list is also a success). |
| 1031 | 4 Mist not found (HTTP 404). |
| 1032 | 5 Remote error — unexpected HTTP status. |
| 1033 | |
| 1034 | Args: |
| 1035 | args: Parsed argument namespace from the ``forks`` subparser. |
| 1036 | Relevant attributes: ``mist_id``, ``limit``, ``hub``, |
| 1037 | ``json_output``. |
| 1038 | """ |
| 1039 | mist_id: str = args.mist_id.strip() |
| 1040 | limit: int = max(1, min(args.limit, 100)) |
| 1041 | json_output: bool = args.json_output |
| 1042 | hub_override: str | None = getattr(args, "hub", None) |
| 1043 | |
| 1044 | hub_url, identity = _require_hub(hub_override) |
| 1045 | parsed = urllib.parse.urlparse(hub_url) |
| 1046 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 1047 | |
| 1048 | if "/" in mist_id: |
| 1049 | id_part = urllib.parse.quote(mist_id.split("/", 1)[1].strip()) |
| 1050 | else: |
| 1051 | id_part = urllib.parse.quote(mist_id) |
| 1052 | |
| 1053 | api_path = f"/api/mists/{id_part}/forks?limit={limit}" |
| 1054 | data = _hub_api(server_root, identity, "GET", api_path) |
| 1055 | |
| 1056 | if json_output: |
| 1057 | print(json.dumps(data)) |
| 1058 | return |
| 1059 | |
| 1060 | forks: list[dict] = data if isinstance(data, list) else data.get("forks", []) |
| 1061 | if not forks: |
| 1062 | print(f" No forks for mist {sanitize_display(mist_id)}.") |
| 1063 | return |
| 1064 | |
| 1065 | print(f" Forks of {sanitize_display(mist_id)} ({len(forks)} shown):") |
| 1066 | for fork in forks: |
| 1067 | fid = sanitize_display(str(fork.get("mist_id", ""))) |
| 1068 | owner = sanitize_display(str(fork.get("owner", ""))) |
| 1069 | filename = sanitize_display(str(fork.get("filename", ""))) |
| 1070 | depth = fork.get("fork_depth", "?") |
| 1071 | print(f" {fid} {owner}/{filename} depth={depth}") |
| 1072 | |
| 1073 | def run_raw(args: argparse.Namespace) -> None: |
| 1074 | """Print or save the raw artifact bytes of a Mist. |
| 1075 | |
| 1076 | Calls ``GET /api/mists/{mist_id}/raw`` and streams the response bytes to |
| 1077 | stdout, or writes them to ``--output FILE``. Useful for piping directly |
| 1078 | into tools:: |
| 1079 | |
| 1080 | muse mist raw aB3xKq9dPwNm > validate_handle.py |
| 1081 | muse mist raw aB3xKq9dPwNm | python3 -c "import sys; exec(sys.stdin.read())" |
| 1082 | |
| 1083 | Exit codes |
| 1084 | ---------- |
| 1085 | 0 Success. |
| 1086 | 4 Mist not found (HTTP 404). |
| 1087 | 5 Permission denied — secret mist and not authenticated (HTTP 403), or |
| 1088 | other remote error. |
| 1089 | |
| 1090 | Args: |
| 1091 | args: Parsed argument namespace from the ``raw`` subparser. |
| 1092 | Relevant attributes: ``mist_id``, ``output``, ``hub``. |
| 1093 | """ |
| 1094 | import pathlib |
| 1095 | |
| 1096 | mist_id: str = args.mist_id.strip() |
| 1097 | output: str | None = getattr(args, "output", None) |
| 1098 | hub_override: str | None = getattr(args, "hub", None) |
| 1099 | |
| 1100 | hub_url, identity = _require_hub(hub_override) |
| 1101 | parsed = urllib.parse.urlparse(hub_url) |
| 1102 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 1103 | |
| 1104 | if "/" in mist_id: |
| 1105 | id_part = urllib.parse.quote(mist_id.split("/", 1)[1].strip()) |
| 1106 | else: |
| 1107 | id_part = urllib.parse.quote(mist_id) |
| 1108 | |
| 1109 | # Build a raw-bytes request — Accept: */* so the server sends the artifact MIME type. |
| 1110 | from muse.cli.config import get_signing_identity |
| 1111 | from muse.core.transport import HttpTransport, TransportError |
| 1112 | |
| 1113 | url = f"{server_root}/api/mists/{id_part}/raw" |
| 1114 | signing = get_signing_identity(remote_url=server_root) |
| 1115 | |
| 1116 | try: |
| 1117 | raw_bytes: bytes = HttpTransport().hub_bytes(url, signing) |
| 1118 | except TransportError as exc: |
| 1119 | if exc.status_code == 404: |
| 1120 | print(f"❌ Mist not found: {sanitize_display(mist_id)}", file=sys.stderr) |
| 1121 | raise SystemExit(ExitCode.NOT_FOUND) |
| 1122 | if exc.status_code == 403: |
| 1123 | print("❌ Permission denied — secret mist or not authenticated.", file=sys.stderr) |
| 1124 | raise SystemExit(ExitCode.REMOTE_ERROR) |
| 1125 | print(f"❌ HTTP {exc.status_code} from hub.", file=sys.stderr) |
| 1126 | raise SystemExit(ExitCode.REMOTE_ERROR) |
| 1127 | |
| 1128 | if output: |
| 1129 | try: |
| 1130 | pathlib.Path(output).write_bytes(raw_bytes) |
| 1131 | print(f"✅ Saved {len(raw_bytes)} bytes to {sanitize_display(output)}") |
| 1132 | except OSError as exc: |
| 1133 | print(f"❌ Cannot write output file: {exc}", file=sys.stderr) |
| 1134 | raise SystemExit(ExitCode.USER_ERROR) |
| 1135 | else: |
| 1136 | sys.stdout.buffer.write(raw_bytes) |
| 1137 | |
| 1138 | # --------------------------------------------------------------------------- |
| 1139 | # Subcommand registration |
| 1140 | # --------------------------------------------------------------------------- |
| 1141 | |
| 1142 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 1143 | """Register the ``muse mist`` subcommand tree and all its flags. |
| 1144 | |
| 1145 | Subcommands |
| 1146 | ----------- |
| 1147 | create Create a new Mist from a local file. |
| 1148 | list List Mists for the authenticated user or a given handle. |
| 1149 | read Read a Mist's content and metadata. |
| 1150 | fork Fork a Mist into the caller's namespace. |
| 1151 | update Update a Mist's title, description, visibility, tags, or content. |
| 1152 | forks List direct forks of a Mist. |
| 1153 | raw Print or save the raw artifact bytes of a Mist. |
| 1154 | push Push a local Mist repo to MuseHub. |
| 1155 | embed Generate embed code for a Mist. |
| 1156 | delete Delete a Mist (owner only). |
| 1157 | |
| 1158 | All subcommands accept ``--json`` for machine-readable output. |
| 1159 | ``create`` additionally accepts ``--sign`` to attach the caller's Ed25519 |
| 1160 | signature, and ``--push`` to submit to MuseHub immediately after creation. |
| 1161 | |
| 1162 | Exit codes |
| 1163 | ---------- |
| 1164 | 0 Success. |
| 1165 | 1 User error — invalid arguments or bad input. |
| 1166 | 2 Not inside a Muse repository (for ``push``). |
| 1167 | 3 File not found or unreadable. |
| 1168 | 4 Mist not found on MuseHub. |
| 1169 | 5 Permission denied (for ``delete``). |
| 1170 | |
| 1171 | Args: |
| 1172 | subparsers: The top-level argument parser's subparsers action. |
| 1173 | """ |
| 1174 | parser = subparsers.add_parser( |
| 1175 | "mist", |
| 1176 | help="Create, share, and manage content-addressed Muse Mists.", |
| 1177 | description=__doc__, |
| 1178 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1179 | ) |
| 1180 | subs = parser.add_subparsers(dest="mist_subcommand", metavar="SUBCOMMAND") |
| 1181 | subs.required = True |
| 1182 | |
| 1183 | # ── create ──────────────────────────────────────────────────────────────── |
| 1184 | create_p = subs.add_parser( |
| 1185 | "create", |
| 1186 | help="Create a new Mist from a local file.", |
| 1187 | description=( |
| 1188 | "Read FILE and create a content-addressed Mist.\n\n" |
| 1189 | "The mist_id is the first 12 characters of the base-58 encoding of\n" |
| 1190 | "the file's SHA-256 digest — same bytes always yield the same ID.\n\n" |
| 1191 | "Without --push, only local metadata is computed (no network required).\n" |
| 1192 | "With --push, the Mist is submitted to MuseHub via POST /api/mists.\n\n" |
| 1193 | "Agent quickstart:\n" |
| 1194 | " muse mist create script.py --sign --push --json\n" |
| 1195 | " muse mist create track.mid --title 'My motif' --push --json" |
| 1196 | ), |
| 1197 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1198 | ) |
| 1199 | create_p.add_argument("file", metavar="FILE", help="Path to the artifact file.") |
| 1200 | create_p.add_argument( |
| 1201 | "--title", "-t", metavar="TEXT", default="", |
| 1202 | help="Optional human-readable title for the Mist.", |
| 1203 | ) |
| 1204 | create_p.add_argument( |
| 1205 | "--description", "-d", metavar="TEXT", default="", |
| 1206 | help="Optional Markdown description.", |
| 1207 | ) |
| 1208 | create_p.add_argument( |
| 1209 | "--visibility", metavar="public|secret", default="public", |
| 1210 | help="Visibility: 'public' (default) or 'secret' (direct-URL only).", |
| 1211 | ) |
| 1212 | create_p.add_argument( |
| 1213 | "--tag", dest="tags", action="append", default=[], metavar="TAG", |
| 1214 | help="Add a tag (repeatable, max 10).", |
| 1215 | ) |
| 1216 | create_p.add_argument( |
| 1217 | "--sign", action="store_true", default=False, |
| 1218 | help="Sign the Mist with the caller's Ed25519 key from identity.toml.", |
| 1219 | ) |
| 1220 | create_p.add_argument( |
| 1221 | "--push", action="store_true", default=False, |
| 1222 | help="Publish the Mist to MuseHub immediately after creation.", |
| 1223 | ) |
| 1224 | create_p.add_argument( |
| 1225 | "--agent-id", dest="agent_id", metavar="ID", default="", |
| 1226 | help="MSign agent identifier (set automatically in agent contexts).", |
| 1227 | ) |
| 1228 | create_p.add_argument( |
| 1229 | "--model-id", dest="model_id", metavar="ID", default="", |
| 1230 | help="Model identifier for AI provenance (e.g. claude-sonnet-4-6).", |
| 1231 | ) |
| 1232 | create_p.add_argument( |
| 1233 | "--hub", metavar="URL", default=None, |
| 1234 | help="Override the MuseHub URL (default: from .muse/config.toml).", |
| 1235 | ) |
| 1236 | create_p.add_argument( |
| 1237 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1238 | help="Emit a JSON object to stdout on success.", |
| 1239 | ) |
| 1240 | create_p.set_defaults(func=run_create) |
| 1241 | |
| 1242 | # ── list ────────────────────────────────────────────────────────────────── |
| 1243 | list_p = subs.add_parser( |
| 1244 | "list", |
| 1245 | help="List Mists for the authenticated user or a given handle.", |
| 1246 | description=( |
| 1247 | "List Mists on MuseHub. Defaults to the authenticated user's Mists.\n\n" |
| 1248 | "Agent quickstart:\n" |
| 1249 | " muse mist list --json\n" |
| 1250 | " muse mist list --handle gabriel --type code --json" |
| 1251 | ), |
| 1252 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1253 | ) |
| 1254 | list_p.add_argument( |
| 1255 | "--handle", "-u", metavar="HANDLE", default=None, |
| 1256 | help="MuseHub handle to list Mists for (default: authenticated user).", |
| 1257 | ) |
| 1258 | list_p.add_argument( |
| 1259 | "--type", metavar="TYPE", default=None, |
| 1260 | help="Filter by artifact_type (code, midi, prose, schema, abi, unknown).", |
| 1261 | ) |
| 1262 | list_p.add_argument( |
| 1263 | "--limit", "-n", type=int, default=20, metavar="N", |
| 1264 | help="Maximum number of Mists to return per page (default: 20, max: 100).", |
| 1265 | ) |
| 1266 | list_p.add_argument( |
| 1267 | "--cursor", metavar="CURSOR", default=None, |
| 1268 | help="Pagination cursor from a previous list response.", |
| 1269 | ) |
| 1270 | list_p.add_argument( |
| 1271 | "--hub", metavar="URL", default=None, |
| 1272 | help="Override the MuseHub URL.", |
| 1273 | ) |
| 1274 | list_p.add_argument( |
| 1275 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1276 | help="Emit a JSON object to stdout.", |
| 1277 | ) |
| 1278 | list_p.set_defaults(func=run_list) |
| 1279 | |
| 1280 | # ── read ────────────────────────────────────────────────────────────────── |
| 1281 | read_p = subs.add_parser( |
| 1282 | "read", |
| 1283 | help="Read a Mist's content and metadata from MuseHub.", |
| 1284 | description=( |
| 1285 | "Fetch full Mist content and metadata by ID.\n\n" |
| 1286 | "MIST_ID may be the 12-character mist ID or 'owner/ID' form.\n\n" |
| 1287 | "Agent quickstart:\n" |
| 1288 | " muse mist read aB3xKq9dPwNm --json\n" |
| 1289 | " muse mist read gabriel/aB3xKq9dPwNm --json" |
| 1290 | ), |
| 1291 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1292 | ) |
| 1293 | read_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID.") |
| 1294 | read_p.add_argument( |
| 1295 | "--hub", metavar="URL", default=None, |
| 1296 | help="Override the MuseHub URL.", |
| 1297 | ) |
| 1298 | read_p.add_argument( |
| 1299 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1300 | help="Emit a JSON object to stdout.", |
| 1301 | ) |
| 1302 | read_p.set_defaults(func=run_read) |
| 1303 | |
| 1304 | # ── fork ────────────────────────────────────────────────────────────────── |
| 1305 | fork_p = subs.add_parser( |
| 1306 | "fork", |
| 1307 | help="Fork a Mist into the caller's namespace.", |
| 1308 | description=( |
| 1309 | "Create a copy of MIST_ID in the authenticated user's namespace.\n" |
| 1310 | "The fork tracks its upstream; you can submit a proposal back.\n\n" |
| 1311 | "Agent quickstart:\n" |
| 1312 | " muse mist fork aB3xKq9dPwNm --json" |
| 1313 | ), |
| 1314 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1315 | ) |
| 1316 | fork_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID to fork.") |
| 1317 | fork_p.add_argument( |
| 1318 | "--hub", metavar="URL", default=None, |
| 1319 | help="Override the MuseHub URL.", |
| 1320 | ) |
| 1321 | fork_p.add_argument( |
| 1322 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1323 | help="Emit a JSON object to stdout.", |
| 1324 | ) |
| 1325 | fork_p.set_defaults(func=run_fork) |
| 1326 | |
| 1327 | # ── push ────────────────────────────────────────────────────────────────── |
| 1328 | push_p = subs.add_parser( |
| 1329 | "push", |
| 1330 | help="Push a local Mist repo to MuseHub.", |
| 1331 | description=( |
| 1332 | "Must be run from inside a Muse repo with domain='mist'.\n\n" |
| 1333 | "This is the manual workflow: init a mist repo, add your artifact,\n" |
| 1334 | "commit, then push. For one-shot creation use:\n" |
| 1335 | " muse mist create <file> --push\n\n" |
| 1336 | "Agent quickstart:\n" |
| 1337 | " muse mist push --remote local --branch main" |
| 1338 | ), |
| 1339 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1340 | ) |
| 1341 | push_p.add_argument( |
| 1342 | "--remote", "-r", metavar="REMOTE", default="local", |
| 1343 | help="Remote name to push to (default: local).", |
| 1344 | ) |
| 1345 | push_p.add_argument( |
| 1346 | "--branch", "-b", metavar="BRANCH", default="main", |
| 1347 | help="Branch to push (default: main).", |
| 1348 | ) |
| 1349 | push_p.add_argument( |
| 1350 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1351 | help="Emit a JSON object to stdout.", |
| 1352 | ) |
| 1353 | push_p.set_defaults(func=run_push) |
| 1354 | |
| 1355 | # ── embed ───────────────────────────────────────────────────────────────── |
| 1356 | embed_p = subs.add_parser( |
| 1357 | "embed", |
| 1358 | help="Generate embed code (iframe, JS, Markdown badge) for a Mist.", |
| 1359 | description=( |
| 1360 | "Generate embeddable HTML, JS snippet, and Markdown badge for MIST_ID.\n\n" |
| 1361 | "Agent quickstart:\n" |
| 1362 | " muse mist embed aB3xKq9dPwNm --json\n" |
| 1363 | " muse mist embed gabriel/aB3xKq9dPwNm --width 800 --height 400" |
| 1364 | ), |
| 1365 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1366 | ) |
| 1367 | embed_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID.") |
| 1368 | embed_p.add_argument( |
| 1369 | "--width", type=int, default=600, metavar="N", |
| 1370 | help="Embed width in pixels (default: 600).", |
| 1371 | ) |
| 1372 | embed_p.add_argument( |
| 1373 | "--height", type=int, default=300, metavar="N", |
| 1374 | help="Embed height in pixels (default: 300).", |
| 1375 | ) |
| 1376 | embed_p.add_argument( |
| 1377 | "--hub", metavar="URL", default=None, |
| 1378 | help="Override the MuseHub URL.", |
| 1379 | ) |
| 1380 | embed_p.add_argument( |
| 1381 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1382 | help="Emit a JSON object to stdout.", |
| 1383 | ) |
| 1384 | embed_p.set_defaults(func=run_embed) |
| 1385 | |
| 1386 | # ── delete ──────────────────────────────────────────────────────────────── |
| 1387 | delete_p = subs.add_parser( |
| 1388 | "delete", |
| 1389 | help="Delete a Mist from MuseHub (owner only).", |
| 1390 | description=( |
| 1391 | "Permanently delete MIST_ID and its underlying Muse repo.\n" |
| 1392 | "This cannot be undone. Only the owner can delete a Mist.\n\n" |
| 1393 | "Agent quickstart:\n" |
| 1394 | " muse mist delete aB3xKq9dPwNm --yes --json" |
| 1395 | ), |
| 1396 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1397 | ) |
| 1398 | delete_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID to delete.") |
| 1399 | delete_p.add_argument( |
| 1400 | "--yes", "-y", action="store_true", default=False, |
| 1401 | help="Skip the confirmation prompt.", |
| 1402 | ) |
| 1403 | delete_p.add_argument( |
| 1404 | "--hub", metavar="URL", default=None, |
| 1405 | help="Override the MuseHub URL.", |
| 1406 | ) |
| 1407 | delete_p.add_argument( |
| 1408 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1409 | help="Emit a JSON object to stdout.", |
| 1410 | ) |
| 1411 | delete_p.set_defaults(func=run_delete) |
| 1412 | |
| 1413 | # ── update ──────────────────────────────────────────────────────────────── |
| 1414 | update_p = subs.add_parser( |
| 1415 | "update", |
| 1416 | help="Update a Mist's title, description, visibility, tags, or content.", |
| 1417 | description=( |
| 1418 | "Partial update — only provided flags are changed; omitted flags are left\n" |
| 1419 | "unchanged. Updating --content increments the mist's version counter.\n\n" |
| 1420 | "Agent quickstart:\n" |
| 1421 | " muse mist update aB3xKq9dPwNm --title 'Better title' --json\n" |
| 1422 | " muse mist update aB3xKq9dPwNm --content new_version.py --json" |
| 1423 | ), |
| 1424 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1425 | ) |
| 1426 | update_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID to update.") |
| 1427 | update_p.add_argument( |
| 1428 | "--title", "-t", default=None, metavar="TEXT", |
| 1429 | help="New human-readable title.", |
| 1430 | ) |
| 1431 | update_p.add_argument( |
| 1432 | "--description", "-d", default=None, metavar="TEXT", |
| 1433 | help="New Markdown description.", |
| 1434 | ) |
| 1435 | update_p.add_argument( |
| 1436 | "--visibility", metavar="public|secret", default=None, |
| 1437 | help="New visibility ('public' or 'secret').", |
| 1438 | ) |
| 1439 | update_p.add_argument( |
| 1440 | "--tags", metavar="TAG,...", default=None, |
| 1441 | help="Comma-separated tag list (replaces all current tags).", |
| 1442 | ) |
| 1443 | update_p.add_argument( |
| 1444 | "--content", metavar="FILE", default=None, |
| 1445 | help="Path to a file; its UTF-8 contents replace the artifact. Increments version.", |
| 1446 | ) |
| 1447 | update_p.add_argument( |
| 1448 | "--hub", metavar="URL", default=None, |
| 1449 | help="Override the MuseHub URL.", |
| 1450 | ) |
| 1451 | update_p.add_argument( |
| 1452 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1453 | help="Emit a JSON object to stdout on success.", |
| 1454 | ) |
| 1455 | update_p.set_defaults(func=run_update) |
| 1456 | |
| 1457 | # ── forks ───────────────────────────────────────────────────────────────── |
| 1458 | forks_p = subs.add_parser( |
| 1459 | "forks", |
| 1460 | help="List the direct forks of a Mist.", |
| 1461 | description=( |
| 1462 | "Fetch GET /api/mists/{mist_id}/forks and display each fork.\n\n" |
| 1463 | "Agent quickstart:\n" |
| 1464 | " muse mist forks aB3xKq9dPwNm --json" |
| 1465 | ), |
| 1466 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1467 | ) |
| 1468 | forks_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID.") |
| 1469 | forks_p.add_argument( |
| 1470 | "--limit", "-n", type=int, default=20, metavar="N", |
| 1471 | help="Maximum forks to return (1–100, default 20).", |
| 1472 | ) |
| 1473 | forks_p.add_argument( |
| 1474 | "--hub", metavar="URL", default=None, |
| 1475 | help="Override the MuseHub URL.", |
| 1476 | ) |
| 1477 | forks_p.add_argument( |
| 1478 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1479 | help="Emit a JSON array to stdout.", |
| 1480 | ) |
| 1481 | forks_p.set_defaults(func=run_forks) |
| 1482 | |
| 1483 | # ── raw ─────────────────────────────────────────────────────────────────── |
| 1484 | raw_p = subs.add_parser( |
| 1485 | "raw", |
| 1486 | help="Print or save the raw artifact bytes of a Mist.", |
| 1487 | description=( |
| 1488 | "Fetches GET /api/mists/{mist_id}/raw and writes to stdout\n" |
| 1489 | "or to --output FILE.\n\n" |
| 1490 | "Agent quickstart:\n" |
| 1491 | " muse mist raw aB3xKq9dPwNm > validate.py\n" |
| 1492 | " muse mist raw aB3xKq9dPwNm --output local_copy.py" |
| 1493 | ), |
| 1494 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1495 | ) |
| 1496 | raw_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID.") |
| 1497 | raw_p.add_argument( |
| 1498 | "--output", "-o", metavar="FILE", default=None, |
| 1499 | help="Write artifact bytes to FILE instead of stdout.", |
| 1500 | ) |
| 1501 | raw_p.add_argument( |
| 1502 | "--hub", metavar="URL", default=None, |
| 1503 | help="Override the MuseHub URL.", |
| 1504 | ) |
| 1505 | raw_p.set_defaults(func=run_raw) |
File History
1 commit
sha256:f1f585ee9ca4e1ada936668c1b14f42f961a1fa78a2c033b643595f9c1bf9ac7
fixes for proposal flow
Human
patch
5 days ago