gabriel / muse public
release.py python
850 lines 31.6 KB
Raw
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 160 days ago
1 """muse release — create and manage versioned releases on MuseHub.
2
3 A Muse release is richer than a Git tag:
4
5 - Semver parsed into queryable components (major/minor/patch/pre/build).
6 - Named distribution channel (stable | beta | alpha | nightly) rather than
7 a boolean ``is_prerelease`` flag.
8 - Changelog auto-generated from typed ``sem_ver_bump`` and
9 ``breaking_changes`` fields on commits since the previous release — no
10 conventional-commit parsing required.
11 - ``snapshot_id`` makes the release reproducible from the content-addressed
12 object store forever.
13 - ``agent_id`` / ``model_id`` surface AI provenance from the tip commit.
14
15 Usage::
16
17 muse release add <tag> — create a local release at HEAD
18 muse release list — list local releases
19 muse release show <tag> — inspect one release
20 muse release push <tag> — push a release to a remote
21 muse release push <tag> --dry-run — validate push without transmitting
22 muse release delete <tag> — delete a local release record
23 muse release delete <tag> --remote <remote> — retract a release from a remote
24 muse release delete <tag> --dry-run — show what would be deleted
25
26 All subcommands accept ``--json`` for machine-readable output::
27
28 muse release add v1.2.0 --title "Drop" --body "..." --json
29 muse release push v1.2.0 --remote origin --json
30 muse release delete v1.2.0 --yes --json
31
32 Deletion semantics::
33
34 Deleting a release removes the named label only. The underlying commit and
35 snapshot remain in the content-addressed object store forever — they are
36 still reachable by their SHA-256 and are fully reproducible. Only the
37 named pointer is removed, not the content it referenced.
38
39 Examples::
40
41 muse release add v1.2.0 --title "Summer drop" --body "Bug fixes"
42 muse release add v1.3.0-beta.1 --channel beta --draft
43 muse release push v1.2.0 --remote origin
44 muse release list --channel stable
45 muse release show v1.2.0 --json
46 muse release delete v1.2.0-beta.1
47 muse release delete v1.2.0 --remote origin
48 """
49
50 from __future__ import annotations
51
52 import argparse
53 import json
54 import logging
55 import pathlib
56 import sys
57 import uuid
58 from typing import TypedDict
59
60 from muse.cli.config import get_signing_identity, get_remote
61 from muse.core.errors import ExitCode
62 from muse.core.repo import read_repo_id, require_repo
63 from muse.core.semver import (
64 ReleaseChannel,
65 _CHANNEL_MAP,
66 parse_semver,
67 semver_channel,
68 semver_to_str,
69 )
70 from muse.core.store import (
71 ReleaseRecord,
72 build_changelog,
73 delete_release,
74 get_release_for_tag,
75 list_releases,
76 read_current_branch,
77 resolve_commit_ref,
78 write_release,
79 )
80 from muse.core.transport import TransportError, make_transport
81 from muse.core.validation import sanitize_display
82
83
84 logger = logging.getLogger(__name__)
85
86 _CHANNELS: frozenset[str] = frozenset({"stable", "beta", "alpha", "nightly"})
87
88
89 # ---------------------------------------------------------------------------
90 # JSON wire formats
91 # ---------------------------------------------------------------------------
92
93
94
95 class _ReleasePushJson(TypedDict):
96 """JSON output for ``muse release push``."""
97
98 status: str # "pushed" | "dry_run"
99 tag: str
100 remote: str
101 release_id: str
102 dry_run: bool
103
104
105 class _ReleaseDeleteJson(TypedDict):
106 """JSON output for ``muse release delete``."""
107
108 status: str # "deleted" | "aborted" | "dry_run"
109 tag: str
110 was_draft: bool
111 remote_retracted: bool
112 dry_run: bool
113
114
115 # ---------------------------------------------------------------------------
116 # Helpers
117 # ---------------------------------------------------------------------------
118
119
120 def _resolve_remote_url(root: pathlib.Path, remote: str) -> str:
121 """Resolve a named remote to its URL, exiting with USER_ERROR if not found."""
122 url = get_remote(remote, root)
123 if not url:
124 print(f"❌ Remote '{sanitize_display(remote)}' is not configured.", file=sys.stderr)
125 raise SystemExit(ExitCode.USER_ERROR)
126 return url
127
128
129 def _format_release(release: ReleaseRecord, output_json: bool) -> None:
130 """Print a release in text or JSON format.
131
132 All string fields are passed through ``sanitize_display`` before being
133 written to the terminal to prevent ANSI injection via crafted release
134 metadata (tag, channel, semver string, title, body, changelog messages).
135 """
136 if output_json:
137 print(json.dumps(release.to_dict(), default=str))
138 return
139 draft_label = " [DRAFT]" if release.is_draft else ""
140 print(f"Release {sanitize_display(release.tag)}{draft_label}")
141 print(f" Channel: {sanitize_display(release.channel)}")
142 print(f" Semver: {sanitize_display(semver_to_str(release.semver))}")
143 print(f" Commit: {release.commit_id[:8]}")
144 print(f" Created: {release.created_at.isoformat()}")
145 if release.title:
146 print(f" Title: {sanitize_display(release.title)}")
147 if release.body:
148 print(f" Body:\n{sanitize_display(release.body)}")
149 if release.changelog:
150 print(f" Changelog ({len(release.changelog)} commits):")
151 for entry in release.changelog[:20]:
152 bump = entry["sem_ver_bump"]
153 bump_label = {"major": "💥", "minor": "✨", "patch": "🔧"}.get(bump, " ")
154 print(
155 f" {bump_label} {entry['commit_id'][:8]} "
156 f"{sanitize_display(entry['message'][:72])}"
157 )
158 if len(release.changelog) > 20:
159 print(f" … and {len(release.changelog) - 20} more")
160
161
162 def _require_tty_or_yes(yes: bool, flag_name: str = "--yes") -> None:
163 """Exit with USER_ERROR if the process is non-interactive and --yes was not passed.
164
165 Agent pipelines run without a TTY. Any command that calls ``input()``
166 will block forever in that context. This guard makes the failure mode
167 explicit: the agent must pass ``--yes`` to skip interactive confirmation.
168 """
169 if not yes and not sys.stdin.isatty():
170 print(
171 f"❌ stdin is not a TTY — pass {flag_name} to skip interactive confirmation.",
172 file=sys.stderr,
173 )
174 raise SystemExit(ExitCode.USER_ERROR)
175
176
177 # ---------------------------------------------------------------------------
178 # Subcommand handlers
179 # ---------------------------------------------------------------------------
180
181
182 def run_add(args: argparse.Namespace) -> None:
183 """Create a local release at HEAD.
184
185 Parses ``<tag>`` as semver, auto-generates changelog from typed commit
186 metadata since the previous release, and writes the record to
187 ``.muse/releases/``.
188
189 The ``--ref`` flag lets you release any commit, not just the current HEAD.
190 Agents should pass ``--json`` to receive a stable, machine-readable payload
191 (full :class:`ReleaseRecord` serialised to JSON).
192
193 JSON output includes: ``tag``, ``channel``, ``commit_id``, ``snapshot_id``,
194 ``release_id``, ``is_draft``, ``changelog``, ``agent_id``, ``model_id``,
195 ``semver``, ``title``, ``body``, ``created_at``.
196
197 Exit codes:
198 0 — release created
199 1 — invalid semver; unknown channel; duplicate tag; ref not found
200 2 — not inside a Muse repository
201 """
202 tag: str = args.tag
203 title: str = args.title or ""
204 body: str = args.body or ""
205 channel_arg: str = args.channel or ""
206 is_draft: bool = args.draft
207 ref: str | None = args.ref
208 output_json: bool = args.output_json
209
210 try:
211 semver = parse_semver(tag)
212 except ValueError as exc:
213 print(f"❌ {exc}", file=sys.stderr)
214 raise SystemExit(ExitCode.USER_ERROR)
215
216 if channel_arg and channel_arg not in _CHANNELS:
217 print(
218 f"❌ Unknown channel '{sanitize_display(channel_arg)}'. "
219 f"Choose: {', '.join(sorted(_CHANNELS))}",
220 file=sys.stderr,
221 )
222 raise SystemExit(ExitCode.USER_ERROR)
223
224 channel: ReleaseChannel = _CHANNEL_MAP.get(channel_arg, semver_channel(semver))
225
226 root = require_repo()
227 repo_id = read_repo_id(root)
228 branch = read_current_branch(root)
229
230 commit = resolve_commit_ref(root, repo_id, branch, ref)
231 if commit is None:
232 ref_label = ref or "HEAD"
233 print(f"❌ Ref '{sanitize_display(ref_label)}' not found.", file=sys.stderr)
234 raise SystemExit(ExitCode.USER_ERROR)
235
236 if get_release_for_tag(root, repo_id, tag) is not None:
237 print(
238 f"❌ Release '{sanitize_display(tag)}' already exists. Delete it first.",
239 file=sys.stderr,
240 )
241 raise SystemExit(ExitCode.USER_ERROR)
242
243 existing = list_releases(root, repo_id, include_drafts=False)
244 prev_commit_id: str | None = existing[0].commit_id if existing else None
245
246 changelog = build_changelog(root, prev_commit_id, commit.commit_id)
247
248 release = ReleaseRecord(
249 release_id=str(uuid.uuid4()),
250 repo_id=repo_id,
251 tag=tag,
252 semver=semver,
253 channel=channel,
254 commit_id=commit.commit_id,
255 snapshot_id=commit.snapshot_id,
256 title=title,
257 body=body,
258 changelog=changelog,
259 agent_id=commit.agent_id,
260 model_id=commit.model_id,
261 is_draft=is_draft,
262 )
263 write_release(root, release)
264
265 if output_json:
266 # Emit the full record so agents get changelog, semver, provenance, etc.
267 print(json.dumps(release.to_dict(), default=str))
268 else:
269 draft_label = " (draft)" if is_draft else ""
270 print(
271 f"✅ Release {tag}{draft_label} — {len(changelog)} commits "
272 f"on branch {sanitize_display(branch)}"
273 )
274
275
276 def run_list(args: argparse.Namespace) -> None:
277 """List releases (local or remote).
278
279 With ``--json``, emits a JSON array of full ReleaseRecord objects.
280 Use ``--channel`` to filter by distribution channel, and ``--include-drafts``
281 to include unreleased drafts.
282
283 Exit codes:
284 0 — list returned (may be empty)
285 1 — remote not configured
286 2 — not inside a Muse repository
287 5 — remote communication error
288 """
289 channel_arg: str = args.channel or ""
290 include_drafts: bool = args.include_drafts
291 remote: str = args.remote or ""
292 output_json: bool = args.output_json
293
294 channel_filter: ReleaseChannel | None = _CHANNEL_MAP.get(channel_arg) if channel_arg else None
295
296 root = require_repo()
297
298 if remote:
299 url = _resolve_remote_url(root, remote)
300 token = get_signing_identity(root, url)
301 transport = make_transport(url)
302 try:
303 raw_releases = transport.list_releases_remote(
304 url, token,
305 channel=channel_filter,
306 include_drafts=include_drafts,
307 )
308 except TransportError as exc:
309 print(
310 f"❌ Could not fetch releases from remote: {sanitize_display(str(exc))}",
311 file=sys.stderr,
312 )
313 raise SystemExit(ExitCode.REMOTE_ERROR)
314
315 releases = [ReleaseRecord.from_dict(d) for d in raw_releases]
316 else:
317 repo_id = read_repo_id(root)
318 releases = list_releases(root, repo_id, channel=channel_filter, include_drafts=include_drafts)
319
320 if output_json:
321 print(json.dumps([r.to_dict() for r in releases], default=str))
322 return
323
324 if not releases:
325 print("No releases found.")
326 return
327
328 for r in releases:
329 draft_label = " [DRAFT]" if r.is_draft else ""
330 print(
331 f"{sanitize_display(r.tag):<20} {sanitize_display(r.channel):<8} "
332 f"{r.commit_id[:8]} "
333 f"{sanitize_display(r.title)[:40]}{draft_label}"
334 )
335
336
337 def run_show(args: argparse.Namespace) -> None:
338 """Show details of a single release.
339
340 With ``--json``, emits the full :class:`ReleaseRecord` as a JSON object
341 including the changelog, semver components, agent provenance, and
342 snapshot ID.
343
344 JSON output includes: ``tag``, ``channel``, ``commit_id``, ``snapshot_id``,
345 ``release_id``, ``is_draft``, ``changelog``, ``semver``, ``title``,
346 ``body``, ``agent_id``, ``model_id``, ``created_at``.
347
348 Exit codes:
349 0 — release shown
350 2 — not inside a Muse repository
351 4 — release tag not found
352 """
353 tag: str = args.tag
354 output_json: bool = args.output_json
355
356 root = require_repo()
357 repo_id = read_repo_id(root)
358
359 release = get_release_for_tag(root, repo_id, tag)
360 if release is None:
361 print(f"❌ Release '{sanitize_display(tag)}' not found.", file=sys.stderr)
362 raise SystemExit(ExitCode.NOT_FOUND)
363
364 _format_release(release, output_json)
365
366
367 def run_push(args: argparse.Namespace) -> None:
368 """Push a local release to a remote.
369
370 Transmits the lightweight ``ReleaseRecord`` payload to MuseHub, which then
371 runs the full semantic analysis (language breakdown, symbol inventory, API
372 surface diff, file hotspots, refactoring events, provenance) as a server-
373 side background task. The push completes immediately; the enriched release
374 detail page populates within seconds.
375
376 Use ``--dry-run`` to validate the release record and remote configuration
377 without actually transmitting anything.
378
379 JSON output fields (``--json``)
380 --------------------------------
381 ``status``
382 ``"pushed"`` on success; ``"dry_run"`` when ``--dry-run`` was passed.
383 ``tag``
384 The version tag that was pushed (e.g. ``"v1.2.0"``).
385 ``remote``
386 The named remote used (e.g. ``"origin"``).
387 ``release_id``
388 UUID assigned by the remote hub after a real push; local release ID
389 during dry-run.
390 ``dry_run``
391 ``true`` if ``--dry-run`` was passed, else ``false``.
392
393 Exit codes
394 ----------
395 0
396 Release pushed successfully (or dry-run validated).
397 1
398 Remote not configured.
399 2
400 Not inside a Muse repository.
401 4
402 Tag not found locally — run ``muse release add`` first.
403 5
404 Remote communication error (network failure, auth, server error).
405 """
406 tag: str = args.tag
407 remote: str = args.remote
408 dry_run: bool = args.dry_run
409 output_json: bool = args.output_json
410
411 root = require_repo()
412 repo_id = read_repo_id(root)
413
414 release = get_release_for_tag(root, repo_id, tag)
415 if release is None:
416 print(
417 f"❌ Release '{sanitize_display(tag)}' not found locally. "
418 "Run 'muse release add' first.",
419 file=sys.stderr,
420 )
421 raise SystemExit(ExitCode.NOT_FOUND)
422
423 if dry_run:
424 # Skip remote URL lookup — no network call is made in dry-run mode.
425 if output_json:
426 push_payload = _ReleasePushJson(
427 status="dry_run",
428 tag=tag,
429 remote=remote,
430 release_id=release.release_id,
431 dry_run=True,
432 )
433 print(json.dumps(push_payload))
434 else:
435 print(
436 f"Would push release {sanitize_display(tag)} to {sanitize_display(remote)} "
437 f"(id={release.release_id[:8]}, dry-run)"
438 )
439 return
440
441 url = _resolve_remote_url(root, remote)
442 token = get_signing_identity(root, url)
443 transport = make_transport(url)
444
445 try:
446 release_id = transport.create_release(url, token, release.to_dict())
447 except TransportError as exc:
448 print(f"❌ Push failed: {sanitize_display(str(exc))}", file=sys.stderr)
449 raise SystemExit(ExitCode.REMOTE_ERROR)
450
451 if output_json:
452 push_payload = _ReleasePushJson(
453 status="pushed",
454 tag=tag,
455 remote=remote,
456 release_id=release_id,
457 dry_run=False,
458 )
459 print(json.dumps(push_payload))
460 else:
461 print(f"✅ Release {sanitize_display(tag)} pushed to {sanitize_display(remote)} (id={release_id[:8]})")
462
463
464 def run_delete(args: argparse.Namespace) -> None:
465 """Delete a release label locally and optionally retract it from a remote.
466
467 Deletion removes only the named pointer — the underlying commit and
468 snapshot remain in the content-addressed object store forever. They
469 are still fully reproducible by their SHA-256; only the label is gone.
470
471 Published releases require explicit confirmation (type the tag name) so
472 accidental retractions of stable releases are hard to do silently.
473
474 Non-interactive contexts (no TTY, agent pipelines) must pass ``--yes``
475 to skip the confirmation prompt. Without it, the command exits with
476 USER_ERROR rather than blocking on ``input()``.
477
478 Use ``--dry-run`` to inspect what would be deleted without deleting it.
479
480 JSON output fields (``--json``)
481 --------------------------------
482 ``status``
483 ``"deleted"`` on success; ``"aborted"`` when the user declined
484 interactive confirmation; ``"dry_run"`` when ``--dry-run`` was passed.
485 ``tag``
486 The version tag that was (or would be) deleted.
487 ``was_draft``
488 ``true`` if the release was a draft at deletion time.
489 ``remote_retracted``
490 ``true`` if ``--remote`` was supplied and the remote retraction
491 succeeded. Always ``false`` in dry-run and aborted cases.
492 ``dry_run``
493 ``true`` if ``--dry-run`` was passed, else ``false``.
494
495 Exit codes
496 ----------
497 0
498 Release deleted (or dry-run validated, or user aborted interactively).
499 1
500 Non-TTY context without ``--yes``; or remote not configured.
501 2
502 Not inside a Muse repository.
503 4
504 Tag not found locally.
505 5
506 Remote retraction failed (network failure, auth, server error).
507 """
508 tag: str = args.tag
509 yes: bool = args.yes
510 remote: str = args.remote or ""
511 dry_run: bool = args.dry_run
512 output_json: bool = args.output_json
513
514 root = require_repo()
515 repo_id = read_repo_id(root)
516
517 release = get_release_for_tag(root, repo_id, tag)
518 if release is None:
519 print(f"❌ Release '{sanitize_display(tag)}' not found locally.", file=sys.stderr)
520 raise SystemExit(ExitCode.NOT_FOUND)
521
522 if dry_run:
523 if output_json:
524 del_payload = _ReleaseDeleteJson(
525 status="dry_run",
526 tag=tag,
527 was_draft=release.is_draft,
528 remote_retracted=False,
529 dry_run=True,
530 )
531 print(json.dumps(del_payload))
532 else:
533 remote_label = f" and retract from {sanitize_display(remote)}" if remote else ""
534 print(
535 f"Would delete release {sanitize_display(tag)}{remote_label} "
536 f"({'draft' if release.is_draft else 'published'}, dry-run)"
537 )
538 return
539
540 # Guard: non-TTY context must pass --yes.
541 _require_tty_or_yes(yes)
542
543 # Interactive confirmation.
544 if not release.is_draft and not yes:
545 print(
546 f"⚠️ '{sanitize_display(tag)}' is a published release. "
547 "Deleting it removes the label; the underlying commit is unaffected."
548 )
549 answer = input("Type the tag name to confirm deletion: ").strip()
550 if answer != tag:
551 if output_json:
552 del_payload = _ReleaseDeleteJson(
553 status="aborted",
554 tag=tag,
555 was_draft=False,
556 remote_retracted=False,
557 dry_run=False,
558 )
559 print(json.dumps(del_payload))
560 else:
561 print("Aborted.")
562 return
563 elif release.is_draft and not yes:
564 answer = input(f"Delete draft release '{sanitize_display(tag)}'? [y/N] ").strip().lower()
565 if answer not in ("y", "yes"):
566 if output_json:
567 del_payload = _ReleaseDeleteJson(
568 status="aborted",
569 tag=tag,
570 was_draft=True,
571 remote_retracted=False,
572 dry_run=False,
573 )
574 print(json.dumps(del_payload))
575 else:
576 print("Aborted.")
577 return
578
579 # Retract from remote first so a local-only failure doesn't leave the
580 # local record orphaned relative to the remote.
581 remote_retracted = False
582 if remote:
583 url = _resolve_remote_url(root, remote)
584 token = get_signing_identity(root, url)
585 transport = make_transport(url)
586 try:
587 transport.delete_release_remote(url, token, tag)
588 remote_retracted = True
589 except TransportError as exc:
590 print(
591 f"❌ Remote retraction failed: {sanitize_display(str(exc))}",
592 file=sys.stderr,
593 )
594 raise SystemExit(ExitCode.REMOTE_ERROR)
595 if not output_json:
596 print(f"✅ Release {sanitize_display(tag)} retracted from {sanitize_display(remote)}.")
597
598 deleted = delete_release(root, repo_id, release.release_id)
599 if deleted:
600 if output_json:
601 del_payload = _ReleaseDeleteJson(
602 status="deleted",
603 tag=tag,
604 was_draft=release.is_draft,
605 remote_retracted=remote_retracted,
606 dry_run=False,
607 )
608 print(json.dumps(del_payload))
609 else:
610 print(f"✅ Release {sanitize_display(tag)} deleted locally.")
611 else:
612 print(
613 f"❌ Local release '{sanitize_display(tag)}' could not be deleted.",
614 file=sys.stderr,
615 )
616 raise SystemExit(ExitCode.USER_ERROR)
617
618
619 # ---------------------------------------------------------------------------
620 # Command registration
621 # ---------------------------------------------------------------------------
622
623
624 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
625 """Register the ``muse release`` subcommand tree."""
626 parser = subparsers.add_parser(
627 "release",
628 help="Create and manage versioned releases.",
629 description=__doc__,
630 formatter_class=argparse.RawDescriptionHelpFormatter,
631 )
632 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
633 subs.required = True
634
635 # --- add ---
636 add_p = subs.add_parser(
637 "add",
638 help="Create a local release at HEAD.",
639 description=(
640 "Parse ``<tag>`` as semver, auto-generate a changelog from typed\n"
641 "commit metadata since the previous release, and write the record\n"
642 "to ``.muse/releases/``.\n\n"
643 "The channel is inferred from the semver pre-release label\n"
644 "(``-beta.*`` → beta, ``-alpha.*`` → alpha, ``-nightly.*`` → nightly,\n"
645 "no pre-release → stable) or overridden with ``--channel``.\n\n"
646 "Agent quickstart\n"
647 "----------------\n"
648 " muse release add v1.2.0 --json\n"
649 " muse release add v1.3.0-beta.1 --channel beta --draft --json\n\n"
650 "JSON output schema\n"
651 "------------------\n"
652 " Full ReleaseRecord — key fields:\n"
653 ' {"tag": "<str>", "channel": "<str>", "commit_id": "<hex64>",\n'
654 ' "snapshot_id": "<hex64>", "release_id": "<uuid>",\n'
655 ' "is_draft": <bool>, "changelog": [...]}\n\n'
656 "Exit codes\n"
657 "----------\n"
658 " 0 — release created\n"
659 " 1 — invalid semver; unknown channel; duplicate tag; ref not found\n"
660 " 2 — not inside a Muse repository\n"
661 ),
662 formatter_class=argparse.RawDescriptionHelpFormatter,
663 )
664 add_p.add_argument("tag", help="Version tag (semver, e.g. v1.2.0 or v1.3.0-beta.1).")
665 add_p.add_argument("--title", default="", help="Release title.")
666 add_p.add_argument("--body", default="", help="Release body / description.")
667 add_p.add_argument(
668 "--channel",
669 default="",
670 choices=sorted(_CHANNELS),
671 help="Distribution channel (default: inferred from semver pre-release label).",
672 )
673 add_p.add_argument("--draft", action="store_true", help="Mark release as a draft.")
674 add_p.add_argument(
675 "--ref", "--commit", default=None,
676 help="Commit ID or branch to release (default: HEAD).",
677 )
678 add_p.add_argument(
679 "--json", "-j", action="store_true", dest="output_json",
680 help="Emit machine-readable JSON on stdout.",
681 )
682 add_p.set_defaults(func=run_add)
683
684 # --- delete ---
685 del_p = subs.add_parser(
686 "delete",
687 help="Delete a release label locally and optionally retract it from a remote.",
688 description=(
689 "Remove a release label. The underlying commit and snapshot remain\n"
690 "in the content-addressed object store forever — only the named\n"
691 "pointer is deleted. Published releases require typed confirmation\n"
692 "of the tag name; drafts require y/N confirmation.\n\n"
693 "In non-interactive (agent) contexts pass ``--yes`` to skip all\n"
694 "prompts — without it the command exits with USER_ERROR rather\n"
695 "than blocking on stdin.\n\n"
696 "Use ``--remote`` to also retract the release from a named remote.\n"
697 "The remote retraction is attempted first; local deletion follows\n"
698 "only if the remote call succeeds.\n\n"
699 "Agent quickstart\n"
700 "----------------\n"
701 " muse release delete v1.2.0 --yes --json\n"
702 " muse release delete v1.2.0 --yes --remote origin --json\n"
703 " muse release delete v1.2.0 --dry-run --json\n\n"
704 "JSON output schema\n"
705 "------------------\n"
706 ' {"status": "deleted"|"aborted"|"dry_run",\n'
707 ' "tag": "<str>", "was_draft": <bool>,\n'
708 ' "remote_retracted": <bool>, "dry_run": <bool>}\n\n'
709 "Exit codes\n"
710 "----------\n"
711 " 0 — deleted (or dry-run validated, or user aborted interactively)\n"
712 " 1 — non-TTY without --yes; or remote not configured\n"
713 " 2 — not inside a Muse repository\n"
714 " 4 — tag not found locally\n"
715 " 5 — remote retraction failed\n"
716 ),
717 formatter_class=argparse.RawDescriptionHelpFormatter,
718 )
719 del_p.add_argument("tag", help="Version tag to delete (e.g. v1.2.0).")
720 del_p.add_argument("--remote", default="", help="Also retract from this remote (e.g. origin).")
721 del_p.add_argument(
722 "--yes", "-y", action="store_true",
723 help="Skip confirmation. Required in non-TTY (agent) contexts.",
724 )
725 del_p.add_argument(
726 "-n", "--dry-run", action="store_true", dest="dry_run",
727 help="Show what would be deleted without deleting.",
728 )
729 del_p.add_argument(
730 "--json", "-j", action="store_true", dest="output_json",
731 help="Emit machine-readable JSON on stdout.",
732 )
733 del_p.set_defaults(func=run_delete)
734
735 # --- list ---
736 list_p = subs.add_parser(
737 "list",
738 help="List releases.",
739 description=(
740 "List local releases, optionally filtered by channel or including\n"
741 "drafts. With ``--remote``, fetches from the named remote instead.\n\n"
742 "Agent quickstart\n"
743 "----------------\n"
744 " muse release list --json\n"
745 " muse release list --channel stable --json\n"
746 " muse release list --include-drafts --json\n\n"
747 "JSON output schema\n"
748 "------------------\n"
749 " Array of full ReleaseRecord objects — key fields per entry:\n"
750 ' [{"tag": "<str>", "channel": "<str>", "commit_id": "<hex64>",\n'
751 ' "snapshot_id": "<hex64>", "release_id": "<uuid>",\n'
752 ' "is_draft": <bool>, "changelog": [...]}, ...]\n\n'
753 "Exit codes\n"
754 "----------\n"
755 " 0 — list returned (may be empty)\n"
756 " 1 — remote not configured\n"
757 " 2 — not inside a Muse repository\n"
758 " 5 — remote communication error\n"
759 ),
760 formatter_class=argparse.RawDescriptionHelpFormatter,
761 )
762 list_p.add_argument(
763 "--channel",
764 default="",
765 choices=list(sorted(_CHANNELS)) + [""],
766 metavar="CHANNEL",
767 help=f"Filter by channel: {', '.join(sorted(_CHANNELS))}.",
768 )
769 list_p.add_argument("--include-drafts", action="store_true", help="Show draft releases.")
770 list_p.add_argument("--remote", default="", help="Fetch from this remote (e.g. origin).")
771 list_p.add_argument(
772 "--json", "-j", action="store_true", dest="output_json",
773 help="Emit machine-readable JSON on stdout.",
774 )
775 list_p.set_defaults(func=run_list)
776
777 # --- push ---
778 push_p = subs.add_parser(
779 "push",
780 help="Push a release to a remote.",
781 description=(
782 "Transmit a local release record to MuseHub. The remote runs full\n"
783 "semantic analysis (language breakdown, symbol inventory, API surface\n"
784 "diff, file hotspots) as a background task — the push completes\n"
785 "immediately and the enriched detail page populates within seconds.\n\n"
786 "Use ``--dry-run`` to validate without transmitting anything.\n\n"
787 "Agent quickstart\n"
788 "----------------\n"
789 " muse release push v1.2.0 --json\n"
790 " muse release push v1.2.0 --dry-run --json\n"
791 " muse release push v1.2.0 --remote staging --json\n\n"
792 "JSON output schema\n"
793 "------------------\n"
794 ' {"status": "pushed"|"dry_run", "tag": "<str>",\n'
795 ' "remote": "<str>", "release_id": "<uuid>", "dry_run": <bool>}\n\n'
796 "Exit codes\n"
797 "----------\n"
798 " 0 — pushed (or dry-run validated)\n"
799 " 1 — remote not configured\n"
800 " 2 — not inside a Muse repository\n"
801 " 4 — tag not found locally (run muse release add first)\n"
802 " 5 — remote communication error\n"
803 ),
804 formatter_class=argparse.RawDescriptionHelpFormatter,
805 )
806 push_p.add_argument("tag", help="Version tag to push (e.g. v1.2.0).")
807 push_p.add_argument("--remote", default="origin", help="Remote name (default: origin).")
808 push_p.add_argument(
809 "-n", "--dry-run", action="store_true", dest="dry_run",
810 help="Validate without transmitting.",
811 )
812 push_p.add_argument(
813 "--json", "-j", action="store_true", dest="output_json",
814 help="Emit machine-readable JSON on stdout.",
815 )
816 push_p.set_defaults(func=run_push)
817
818 # --- show ---
819 show_p = subs.add_parser(
820 "show",
821 help="Inspect a single release.",
822 description=(
823 "Display full details of one release: semver, channel, commit,\n"
824 "snapshot, changelog, agent provenance, and draft status.\n\n"
825 "Agent quickstart\n"
826 "----------------\n"
827 " muse release show v1.2.0 --json\n\n"
828 "JSON output schema\n"
829 "------------------\n"
830 " Full ReleaseRecord — key fields:\n"
831 ' {"tag": "<str>", "channel": "<str>", "commit_id": "<hex64>",\n'
832 ' "snapshot_id": "<hex64>", "release_id": "<uuid>",\n'
833 ' "is_draft": <bool>, "changelog": [...],\n'
834 ' "semver": {...}, "title": "<str>", "body": "<str>",\n'
835 ' "agent_id": "<str>", "model_id": "<str>",\n'
836 ' "created_at": "<iso8601>"}\n\n'
837 "Exit codes\n"
838 "----------\n"
839 " 0 — release shown\n"
840 " 2 — not inside a Muse repository\n"
841 " 4 — release tag not found\n"
842 ),
843 formatter_class=argparse.RawDescriptionHelpFormatter,
844 )
845 show_p.add_argument("tag", help="Version tag (e.g. v1.2.0).")
846 show_p.add_argument(
847 "--json", "-j", action="store_true", dest="output_json",
848 help="Emit machine-readable JSON on stdout.",
849 )
850 show_p.set_defaults(func=run_show)
File History 1 commit
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 160 days ago