gabriel / muse public
mist.py python
1,503 lines 54.9 KB
Raw
sha256:144495ae90498f9d51aeac4bf7f9ee9e9502d14474745a856b96921f7349c0ce next round of fixing tests Human 106 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 payload["content"] = pathlib.Path(args.content).read_text(encoding="utf-8")
971 except OSError as exc:
972 print(f"❌ Cannot read content file: {exc}", file=sys.stderr)
973 raise SystemExit(ExitCode.USER_ERROR)
974
975 if not payload:
976 print(
977 "❌ Nothing to update — provide at least one of: "
978 "--title, --description, --visibility, --tags, --content",
979 file=sys.stderr,
980 )
981 raise SystemExit(ExitCode.USER_ERROR)
982
983 hub_url, identity = _require_hub(hub_override)
984 parsed = urllib.parse.urlparse(hub_url)
985 server_root = f"{parsed.scheme}://{parsed.netloc}"
986
987 if "/" in mist_id:
988 id_part = urllib.parse.quote(mist_id.split("/", 1)[1].strip())
989 else:
990 id_part = urllib.parse.quote(mist_id)
991
992 data = _hub_api(server_root, identity, "PATCH", f"/api/mists/{id_part}", body=payload)
993
994 if json_output:
995 print(json.dumps(data))
996 return
997
998 clean_id = sanitize_display(str(data.get("mist_id", mist_id)))
999 version = data.get("version", "?")
1000 print(f"✅ Mist {clean_id} updated (v{version})")
1001 if "title" in data:
1002 print(f" Title: {sanitize_display(str(data['title']))}")
1003 if "visibility" in data:
1004 print(f" Visibility: {sanitize_display(str(data['visibility']))}")
1005
1006 def run_forks(args: argparse.Namespace) -> None:
1007 """List the direct (one-level) forks of a Mist.
1008
1009 Calls ``GET /api/mists/{mist_id}/forks`` and renders each fork as a
1010 compact summary row. With ``--json``, prints the raw API response.
1011
1012 JSON output (stdout) when ``--json``
1013 ------------------------------------
1014 ::
1015
1016 [
1017 {
1018 "mist_id": "Kx2mPq7bRnYt",
1019 "owner": "alice",
1020 "filename": "validate.py",
1021 "fork_depth": 1,
1022 "created_at": "2026-04-15T12:00:00+00:00"
1023 }
1024 ]
1025
1026 Exit codes
1027 ----------
1028 0 Success (empty list is also a success).
1029 4 Mist not found (HTTP 404).
1030 5 Remote error — unexpected HTTP status.
1031
1032 Args:
1033 args: Parsed argument namespace from the ``forks`` subparser.
1034 Relevant attributes: ``mist_id``, ``limit``, ``hub``,
1035 ``json_output``.
1036 """
1037 mist_id: str = args.mist_id.strip()
1038 limit: int = max(1, min(args.limit, 100))
1039 json_output: bool = args.json_output
1040 hub_override: str | None = getattr(args, "hub", None)
1041
1042 hub_url, identity = _require_hub(hub_override)
1043 parsed = urllib.parse.urlparse(hub_url)
1044 server_root = f"{parsed.scheme}://{parsed.netloc}"
1045
1046 if "/" in mist_id:
1047 id_part = urllib.parse.quote(mist_id.split("/", 1)[1].strip())
1048 else:
1049 id_part = urllib.parse.quote(mist_id)
1050
1051 api_path = f"/api/mists/{id_part}/forks?limit={limit}"
1052 data = _hub_api(server_root, identity, "GET", api_path)
1053
1054 if json_output:
1055 print(json.dumps(data))
1056 return
1057
1058 forks: list[dict] = data if isinstance(data, list) else data.get("forks", [])
1059 if not forks:
1060 print(f" No forks for mist {sanitize_display(mist_id)}.")
1061 return
1062
1063 print(f" Forks of {sanitize_display(mist_id)} ({len(forks)} shown):")
1064 for fork in forks:
1065 fid = sanitize_display(str(fork.get("mist_id", "")))
1066 owner = sanitize_display(str(fork.get("owner", "")))
1067 filename = sanitize_display(str(fork.get("filename", "")))
1068 depth = fork.get("fork_depth", "?")
1069 print(f" {fid} {owner}/{filename} depth={depth}")
1070
1071 def run_raw(args: argparse.Namespace) -> None:
1072 """Print or save the raw artifact bytes of a Mist.
1073
1074 Calls ``GET /api/mists/{mist_id}/raw`` and streams the response bytes to
1075 stdout, or writes them to ``--output FILE``. Useful for piping directly
1076 into tools::
1077
1078 muse mist raw aB3xKq9dPwNm > validate_handle.py
1079 muse mist raw aB3xKq9dPwNm | python3 -c "import sys; exec(sys.stdin.read())"
1080
1081 Exit codes
1082 ----------
1083 0 Success.
1084 4 Mist not found (HTTP 404).
1085 5 Permission denied — secret mist and not authenticated (HTTP 403), or
1086 other remote error.
1087
1088 Args:
1089 args: Parsed argument namespace from the ``raw`` subparser.
1090 Relevant attributes: ``mist_id``, ``output``, ``hub``.
1091 """
1092 import pathlib
1093
1094 mist_id: str = args.mist_id.strip()
1095 output: str | None = getattr(args, "output", None)
1096 hub_override: str | None = getattr(args, "hub", None)
1097
1098 hub_url, identity = _require_hub(hub_override)
1099 parsed = urllib.parse.urlparse(hub_url)
1100 server_root = f"{parsed.scheme}://{parsed.netloc}"
1101
1102 if "/" in mist_id:
1103 id_part = urllib.parse.quote(mist_id.split("/", 1)[1].strip())
1104 else:
1105 id_part = urllib.parse.quote(mist_id)
1106
1107 # Build a raw-bytes request — Accept: */* so the server sends the artifact MIME type.
1108 from muse.cli.config import get_signing_identity
1109 from muse.core.transport import HttpTransport, TransportError
1110
1111 url = f"{server_root}/api/mists/{id_part}/raw"
1112 signing = get_signing_identity(remote_url=server_root)
1113
1114 try:
1115 raw_bytes: bytes = HttpTransport().hub_bytes(url, signing)
1116 except TransportError as exc:
1117 if exc.status_code == 404:
1118 print(f"❌ Mist not found: {sanitize_display(mist_id)}", file=sys.stderr)
1119 raise SystemExit(ExitCode.NOT_FOUND)
1120 if exc.status_code == 403:
1121 print("❌ Permission denied — secret mist or not authenticated.", file=sys.stderr)
1122 raise SystemExit(ExitCode.REMOTE_ERROR)
1123 print(f"❌ HTTP {exc.status_code} from hub.", file=sys.stderr)
1124 raise SystemExit(ExitCode.REMOTE_ERROR)
1125
1126 if output:
1127 try:
1128 pathlib.Path(output).write_bytes(raw_bytes)
1129 print(f"✅ Saved {len(raw_bytes)} bytes to {sanitize_display(output)}")
1130 except OSError as exc:
1131 print(f"❌ Cannot write output file: {exc}", file=sys.stderr)
1132 raise SystemExit(ExitCode.USER_ERROR)
1133 else:
1134 sys.stdout.buffer.write(raw_bytes)
1135
1136 # ---------------------------------------------------------------------------
1137 # Subcommand registration
1138 # ---------------------------------------------------------------------------
1139
1140 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
1141 """Register the ``muse mist`` subcommand tree and all its flags.
1142
1143 Subcommands
1144 -----------
1145 create Create a new Mist from a local file.
1146 list List Mists for the authenticated user or a given handle.
1147 read Read a Mist's content and metadata.
1148 fork Fork a Mist into the caller's namespace.
1149 update Update a Mist's title, description, visibility, tags, or content.
1150 forks List direct forks of a Mist.
1151 raw Print or save the raw artifact bytes of a Mist.
1152 push Push a local Mist repo to MuseHub.
1153 embed Generate embed code for a Mist.
1154 delete Delete a Mist (owner only).
1155
1156 All subcommands accept ``--json`` for machine-readable output.
1157 ``create`` additionally accepts ``--sign`` to attach the caller's Ed25519
1158 signature, and ``--push`` to submit to MuseHub immediately after creation.
1159
1160 Exit codes
1161 ----------
1162 0 Success.
1163 1 User error — invalid arguments or bad input.
1164 2 Not inside a Muse repository (for ``push``).
1165 3 File not found or unreadable.
1166 4 Mist not found on MuseHub.
1167 5 Permission denied (for ``delete``).
1168
1169 Args:
1170 subparsers: The top-level argument parser's subparsers action.
1171 """
1172 parser = subparsers.add_parser(
1173 "mist",
1174 help="Create, share, and manage content-addressed Muse Mists.",
1175 description=__doc__,
1176 formatter_class=argparse.RawDescriptionHelpFormatter,
1177 )
1178 subs = parser.add_subparsers(dest="mist_subcommand", metavar="SUBCOMMAND")
1179 subs.required = True
1180
1181 # ── create ────────────────────────────────────────────────────────────────
1182 create_p = subs.add_parser(
1183 "create",
1184 help="Create a new Mist from a local file.",
1185 description=(
1186 "Read FILE and create a content-addressed Mist.\n\n"
1187 "The mist_id is the first 12 characters of the base-58 encoding of\n"
1188 "the file's SHA-256 digest — same bytes always yield the same ID.\n\n"
1189 "Without --push, only local metadata is computed (no network required).\n"
1190 "With --push, the Mist is submitted to MuseHub via POST /api/mists.\n\n"
1191 "Agent quickstart:\n"
1192 " muse mist create script.py --sign --push --json\n"
1193 " muse mist create track.mid --title 'My motif' --push --json"
1194 ),
1195 formatter_class=argparse.RawDescriptionHelpFormatter,
1196 )
1197 create_p.add_argument("file", metavar="FILE", help="Path to the artifact file.")
1198 create_p.add_argument(
1199 "--title", "-t", metavar="TEXT", default="",
1200 help="Optional human-readable title for the Mist.",
1201 )
1202 create_p.add_argument(
1203 "--description", "-d", metavar="TEXT", default="",
1204 help="Optional Markdown description.",
1205 )
1206 create_p.add_argument(
1207 "--visibility", metavar="public|secret", default="public",
1208 help="Visibility: 'public' (default) or 'secret' (direct-URL only).",
1209 )
1210 create_p.add_argument(
1211 "--tag", dest="tags", action="append", default=[], metavar="TAG",
1212 help="Add a tag (repeatable, max 10).",
1213 )
1214 create_p.add_argument(
1215 "--sign", action="store_true", default=False,
1216 help="Sign the Mist with the caller's Ed25519 key from identity.toml.",
1217 )
1218 create_p.add_argument(
1219 "--push", action="store_true", default=False,
1220 help="Publish the Mist to MuseHub immediately after creation.",
1221 )
1222 create_p.add_argument(
1223 "--agent-id", dest="agent_id", metavar="ID", default="",
1224 help="MSign agent identifier (set automatically in agent contexts).",
1225 )
1226 create_p.add_argument(
1227 "--model-id", dest="model_id", metavar="ID", default="",
1228 help="Model identifier for AI provenance (e.g. claude-sonnet-4-6).",
1229 )
1230 create_p.add_argument(
1231 "--hub", metavar="URL", default=None,
1232 help="Override the MuseHub URL (default: from .muse/config.toml).",
1233 )
1234 create_p.add_argument(
1235 "--json", "-j", action="store_true", dest="json_output", default=False,
1236 help="Emit a JSON object to stdout on success.",
1237 )
1238 create_p.set_defaults(func=run_create)
1239
1240 # ── list ──────────────────────────────────────────────────────────────────
1241 list_p = subs.add_parser(
1242 "list",
1243 help="List Mists for the authenticated user or a given handle.",
1244 description=(
1245 "List Mists on MuseHub. Defaults to the authenticated user's Mists.\n\n"
1246 "Agent quickstart:\n"
1247 " muse mist list --json\n"
1248 " muse mist list --handle gabriel --type code --json"
1249 ),
1250 formatter_class=argparse.RawDescriptionHelpFormatter,
1251 )
1252 list_p.add_argument(
1253 "--handle", "-u", metavar="HANDLE", default=None,
1254 help="MuseHub handle to list Mists for (default: authenticated user).",
1255 )
1256 list_p.add_argument(
1257 "--type", metavar="TYPE", default=None,
1258 help="Filter by artifact_type (code, midi, prose, schema, abi, unknown).",
1259 )
1260 list_p.add_argument(
1261 "--limit", "-n", type=int, default=20, metavar="N",
1262 help="Maximum number of Mists to return per page (default: 20, max: 100).",
1263 )
1264 list_p.add_argument(
1265 "--cursor", metavar="CURSOR", default=None,
1266 help="Pagination cursor from a previous list response.",
1267 )
1268 list_p.add_argument(
1269 "--hub", metavar="URL", default=None,
1270 help="Override the MuseHub URL.",
1271 )
1272 list_p.add_argument(
1273 "--json", "-j", action="store_true", dest="json_output", default=False,
1274 help="Emit a JSON object to stdout.",
1275 )
1276 list_p.set_defaults(func=run_list)
1277
1278 # ── read ──────────────────────────────────────────────────────────────────
1279 read_p = subs.add_parser(
1280 "read",
1281 help="Read a Mist's content and metadata from MuseHub.",
1282 description=(
1283 "Fetch full Mist content and metadata by ID.\n\n"
1284 "MIST_ID may be the 12-character mist ID or 'owner/ID' form.\n\n"
1285 "Agent quickstart:\n"
1286 " muse mist read aB3xKq9dPwNm --json\n"
1287 " muse mist read gabriel/aB3xKq9dPwNm --json"
1288 ),
1289 formatter_class=argparse.RawDescriptionHelpFormatter,
1290 )
1291 read_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID.")
1292 read_p.add_argument(
1293 "--hub", metavar="URL", default=None,
1294 help="Override the MuseHub URL.",
1295 )
1296 read_p.add_argument(
1297 "--json", "-j", action="store_true", dest="json_output", default=False,
1298 help="Emit a JSON object to stdout.",
1299 )
1300 read_p.set_defaults(func=run_read)
1301
1302 # ── fork ──────────────────────────────────────────────────────────────────
1303 fork_p = subs.add_parser(
1304 "fork",
1305 help="Fork a Mist into the caller's namespace.",
1306 description=(
1307 "Create a copy of MIST_ID in the authenticated user's namespace.\n"
1308 "The fork tracks its upstream; you can submit a proposal back.\n\n"
1309 "Agent quickstart:\n"
1310 " muse mist fork aB3xKq9dPwNm --json"
1311 ),
1312 formatter_class=argparse.RawDescriptionHelpFormatter,
1313 )
1314 fork_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID to fork.")
1315 fork_p.add_argument(
1316 "--hub", metavar="URL", default=None,
1317 help="Override the MuseHub URL.",
1318 )
1319 fork_p.add_argument(
1320 "--json", "-j", action="store_true", dest="json_output", default=False,
1321 help="Emit a JSON object to stdout.",
1322 )
1323 fork_p.set_defaults(func=run_fork)
1324
1325 # ── push ──────────────────────────────────────────────────────────────────
1326 push_p = subs.add_parser(
1327 "push",
1328 help="Push a local Mist repo to MuseHub.",
1329 description=(
1330 "Must be run from inside a Muse repo with domain='mist'.\n\n"
1331 "This is the manual workflow: init a mist repo, add your artifact,\n"
1332 "commit, then push. For one-shot creation use:\n"
1333 " muse mist create <file> --push\n\n"
1334 "Agent quickstart:\n"
1335 " muse mist push --remote local --branch main"
1336 ),
1337 formatter_class=argparse.RawDescriptionHelpFormatter,
1338 )
1339 push_p.add_argument(
1340 "--remote", "-r", metavar="REMOTE", default="local",
1341 help="Remote name to push to (default: local).",
1342 )
1343 push_p.add_argument(
1344 "--branch", "-b", metavar="BRANCH", default="main",
1345 help="Branch to push (default: main).",
1346 )
1347 push_p.add_argument(
1348 "--json", "-j", action="store_true", dest="json_output", default=False,
1349 help="Emit a JSON object to stdout.",
1350 )
1351 push_p.set_defaults(func=run_push)
1352
1353 # ── embed ─────────────────────────────────────────────────────────────────
1354 embed_p = subs.add_parser(
1355 "embed",
1356 help="Generate embed code (iframe, JS, Markdown badge) for a Mist.",
1357 description=(
1358 "Generate embeddable HTML, JS snippet, and Markdown badge for MIST_ID.\n\n"
1359 "Agent quickstart:\n"
1360 " muse mist embed aB3xKq9dPwNm --json\n"
1361 " muse mist embed gabriel/aB3xKq9dPwNm --width 800 --height 400"
1362 ),
1363 formatter_class=argparse.RawDescriptionHelpFormatter,
1364 )
1365 embed_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID.")
1366 embed_p.add_argument(
1367 "--width", type=int, default=600, metavar="N",
1368 help="Embed width in pixels (default: 600).",
1369 )
1370 embed_p.add_argument(
1371 "--height", type=int, default=300, metavar="N",
1372 help="Embed height in pixels (default: 300).",
1373 )
1374 embed_p.add_argument(
1375 "--hub", metavar="URL", default=None,
1376 help="Override the MuseHub URL.",
1377 )
1378 embed_p.add_argument(
1379 "--json", "-j", action="store_true", dest="json_output", default=False,
1380 help="Emit a JSON object to stdout.",
1381 )
1382 embed_p.set_defaults(func=run_embed)
1383
1384 # ── delete ────────────────────────────────────────────────────────────────
1385 delete_p = subs.add_parser(
1386 "delete",
1387 help="Delete a Mist from MuseHub (owner only).",
1388 description=(
1389 "Permanently delete MIST_ID and its underlying Muse repo.\n"
1390 "This cannot be undone. Only the owner can delete a Mist.\n\n"
1391 "Agent quickstart:\n"
1392 " muse mist delete aB3xKq9dPwNm --yes --json"
1393 ),
1394 formatter_class=argparse.RawDescriptionHelpFormatter,
1395 )
1396 delete_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID to delete.")
1397 delete_p.add_argument(
1398 "--yes", "-y", action="store_true", default=False,
1399 help="Skip the confirmation prompt.",
1400 )
1401 delete_p.add_argument(
1402 "--hub", metavar="URL", default=None,
1403 help="Override the MuseHub URL.",
1404 )
1405 delete_p.add_argument(
1406 "--json", "-j", action="store_true", dest="json_output", default=False,
1407 help="Emit a JSON object to stdout.",
1408 )
1409 delete_p.set_defaults(func=run_delete)
1410
1411 # ── update ────────────────────────────────────────────────────────────────
1412 update_p = subs.add_parser(
1413 "update",
1414 help="Update a Mist's title, description, visibility, tags, or content.",
1415 description=(
1416 "Partial update — only provided flags are changed; omitted flags are left\n"
1417 "unchanged. Updating --content increments the mist's version counter.\n\n"
1418 "Agent quickstart:\n"
1419 " muse mist update aB3xKq9dPwNm --title 'Better title' --json\n"
1420 " muse mist update aB3xKq9dPwNm --content new_version.py --json"
1421 ),
1422 formatter_class=argparse.RawDescriptionHelpFormatter,
1423 )
1424 update_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID to update.")
1425 update_p.add_argument(
1426 "--title", "-t", default=None, metavar="TEXT",
1427 help="New human-readable title.",
1428 )
1429 update_p.add_argument(
1430 "--description", "-d", default=None, metavar="TEXT",
1431 help="New Markdown description.",
1432 )
1433 update_p.add_argument(
1434 "--visibility", metavar="public|secret", default=None,
1435 help="New visibility ('public' or 'secret').",
1436 )
1437 update_p.add_argument(
1438 "--tags", metavar="TAG,...", default=None,
1439 help="Comma-separated tag list (replaces all current tags).",
1440 )
1441 update_p.add_argument(
1442 "--content", metavar="FILE", default=None,
1443 help="Path to a file; its UTF-8 contents replace the artifact. Increments version.",
1444 )
1445 update_p.add_argument(
1446 "--hub", metavar="URL", default=None,
1447 help="Override the MuseHub URL.",
1448 )
1449 update_p.add_argument(
1450 "--json", "-j", action="store_true", dest="json_output", default=False,
1451 help="Emit a JSON object to stdout on success.",
1452 )
1453 update_p.set_defaults(func=run_update)
1454
1455 # ── forks ─────────────────────────────────────────────────────────────────
1456 forks_p = subs.add_parser(
1457 "forks",
1458 help="List the direct forks of a Mist.",
1459 description=(
1460 "Fetch GET /api/mists/{mist_id}/forks and display each fork.\n\n"
1461 "Agent quickstart:\n"
1462 " muse mist forks aB3xKq9dPwNm --json"
1463 ),
1464 formatter_class=argparse.RawDescriptionHelpFormatter,
1465 )
1466 forks_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID.")
1467 forks_p.add_argument(
1468 "--limit", "-n", type=int, default=20, metavar="N",
1469 help="Maximum forks to return (1–100, default 20).",
1470 )
1471 forks_p.add_argument(
1472 "--hub", metavar="URL", default=None,
1473 help="Override the MuseHub URL.",
1474 )
1475 forks_p.add_argument(
1476 "--json", "-j", action="store_true", dest="json_output", default=False,
1477 help="Emit a JSON array to stdout.",
1478 )
1479 forks_p.set_defaults(func=run_forks)
1480
1481 # ── raw ───────────────────────────────────────────────────────────────────
1482 raw_p = subs.add_parser(
1483 "raw",
1484 help="Print or save the raw artifact bytes of a Mist.",
1485 description=(
1486 "Fetches GET /api/mists/{mist_id}/raw and writes to stdout\n"
1487 "or to --output FILE.\n\n"
1488 "Agent quickstart:\n"
1489 " muse mist raw aB3xKq9dPwNm > validate.py\n"
1490 " muse mist raw aB3xKq9dPwNm --output local_copy.py"
1491 ),
1492 formatter_class=argparse.RawDescriptionHelpFormatter,
1493 )
1494 raw_p.add_argument("mist_id", metavar="MIST_ID", help="Mist ID or owner/ID.")
1495 raw_p.add_argument(
1496 "--output", "-o", metavar="FILE", default=None,
1497 help="Write artifact bytes to FILE instead of stdout.",
1498 )
1499 raw_p.add_argument(
1500 "--hub", metavar="URL", default=None,
1501 help="Override the MuseHub URL.",
1502 )
1503 raw_p.set_defaults(func=run_raw)
File History 1 commit
sha256:144495ae90498f9d51aeac4bf7f9ee9e9502d14474745a856b96921f7349c0ce next round of fixing tests Human 106 days ago