gabriel / muse public
mist.py python
1,505 lines 55.0 KB
Raw
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": "[![Mist](...)](/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'[![Mist {clean_id}]({server_root}/static/badge.svg)]({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