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