"""muse release — create and manage versioned releases on MuseHub. A Muse release is richer than a Git tag: - Semver parsed into queryable components (major/minor/patch/pre/build). - Named distribution channel (stable | beta | alpha | nightly) rather than a boolean ``is_prerelease`` flag. - Changelog auto-generated from typed ``sem_ver_bump`` and ``breaking_changes`` fields on commits since the previous release — no conventional-commit parsing required. - ``snapshot_id`` makes the release reproducible from the content-addressed object store forever. - ``agent_id`` / ``model_id`` surface AI provenance from the tip commit. Usage:: muse release — list local releases (default) muse release add — create a local release at HEAD muse release list — list local releases muse release suggest — infer next version from commit graph muse release show — inspect one release muse release push — push a release to a remote muse release push --dry-run — validate push without transmitting muse release delete — delete a local release record muse release delete --remote — retract a release from a remote muse release delete --dry-run — show what would be deleted All subcommands accept ``--json`` for machine-readable output:: muse release add v1.2.0 --title "Drop" --body "..." --json muse release push v1.2.0 --remote origin --json muse release delete v1.2.0 --yes --json muse release suggest --json Deletion semantics:: Deleting a release removes the named label only. The underlying commit and snapshot remain in the content-addressed object store forever — they are still reachable by their SHA-256 and are fully reproducible. Only the named pointer is removed, not the content it referenced. suggest semantics:: ``muse release suggest`` derives the next version entirely from the commit graph — no human input required. For each unreleased commit, Muse recorded a ``sem_ver_bump`` (none/patch/minor/major) and ``breaking_changes`` at commit time by diffing the public symbol graph. ``suggest`` aggregates these to the highest bump seen since the last release and applies semver arithmetic. Pre-1.0 adjustment (major = 0): a major structural break bumps the minor component (0.x+1.0) rather than crossing the 1.0 boundary, matching the semver spec for unstable APIs. The result is a machine-verifiable claim: ``suggested_tag`` is exactly the version implied by the code, not a number someone typed. Examples:: muse release add v1.2.0 --title "Summer drop" --body "Bug fixes" muse release add v1.3.0-beta.1 --channel beta --draft muse release push v1.2.0 --remote origin muse release list --channel stable muse release suggest muse release suggest --base v1.1.0 muse release show v1.2.0 --json muse release delete v1.2.0-beta.1 muse release delete v1.2.0 --remote origin """ from __future__ import annotations import argparse import json import logging import pathlib import sys import uuid from typing import TypedDict from muse.cli.config import get_signing_identity, get_remote from muse.core.errors import ExitCode from muse.core.repo import read_repo_id, require_repo from muse.core.semver import ( ReleaseChannel, _CHANNEL_MAP, parse_semver, semver_channel, semver_to_str, ) from muse.core.store import ( ReleaseRecord, build_changelog, delete_release, get_head_commit_id, get_release_for_tag, list_releases, read_current_branch, resolve_commit_ref, walk_commits_between, write_release, ) from muse.domain import SemVerBump from muse.core.transport import TransportError, make_transport from muse.core.validation import sanitize_display logger = logging.getLogger(__name__) _CHANNELS: frozenset[str] = frozenset({"stable", "beta", "alpha", "nightly"}) # --------------------------------------------------------------------------- # JSON wire formats # --------------------------------------------------------------------------- class _ReleasePushJson(TypedDict): """JSON output for ``muse release push``.""" status: str # "pushed" | "dry_run" tag: str remote: str release_id: str dry_run: bool class _ReleaseDeleteJson(TypedDict): """JSON output for ``muse release delete``.""" status: str # "deleted" | "aborted" | "dry_run" tag: str was_draft: bool remote_retracted: bool dry_run: bool # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _resolve_remote_url(root: pathlib.Path, remote: str) -> str: """Resolve a named remote to its URL, exiting with USER_ERROR if not found.""" url = get_remote(remote, root) if not url: print(f"❌ Remote '{sanitize_display(remote)}' is not configured.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) return url def _format_release(release: ReleaseRecord, output_json: bool) -> None: """Print a release in text or JSON format. All string fields are passed through ``sanitize_display`` before being written to the terminal to prevent ANSI injection via crafted release metadata (tag, channel, semver string, title, body, changelog messages). """ if output_json: print(json.dumps(release.to_dict(), default=str)) return draft_label = " [DRAFT]" if release.is_draft else "" print(f"Release {sanitize_display(release.tag)}{draft_label}") print(f" Channel: {sanitize_display(release.channel)}") print(f" Semver: {sanitize_display(semver_to_str(release.semver))}") print(f" Commit: {release.commit_id[:8]}") print(f" Created: {release.created_at.isoformat()}") if release.title: print(f" Title: {sanitize_display(release.title)}") if release.body: print(f" Body:\n{sanitize_display(release.body)}") if release.changelog: print(f" Changelog ({len(release.changelog)} commits):") for entry in release.changelog[:20]: bump = entry["sem_ver_bump"] bump_label = {"major": "💥", "minor": "✨", "patch": "🔧"}.get(bump, " ") print( f" {bump_label} {entry['commit_id'][:8]} " f"{sanitize_display(entry['message'][:72])}" ) if len(release.changelog) > 20: print(f" … and {len(release.changelog) - 20} more") def _require_tty_or_yes(yes: bool, flag_name: str = "--yes") -> None: """Exit with USER_ERROR if the process is non-interactive and --yes was not passed. Agent pipelines run without a TTY. Any command that calls ``input()`` will block forever in that context. This guard makes the failure mode explicit: the agent must pass ``--yes`` to skip interactive confirmation. """ if not yes and not sys.stdin.isatty(): print( f"❌ stdin is not a TTY — pass {flag_name} to skip interactive confirmation.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # --------------------------------------------------------------------------- # Subcommand handlers # --------------------------------------------------------------------------- def run_add(args: argparse.Namespace) -> None: """Create a local release at HEAD. Parses ```` as semver, auto-generates changelog from typed commit metadata since the previous release, and writes the record to ``.muse/releases/``. The ``--ref`` flag lets you release any commit, not just the current HEAD. Agents should pass ``--json`` to receive a stable, machine-readable payload (full :class:`ReleaseRecord` serialised to JSON). JSON output includes: ``tag``, ``channel``, ``commit_id``, ``snapshot_id``, ``release_id``, ``is_draft``, ``changelog``, ``agent_id``, ``model_id``, ``semver``, ``title``, ``body``, ``created_at``. Exit codes: 0 — release created 1 — invalid semver; unknown channel; duplicate tag; ref not found 2 — not inside a Muse repository """ tag: str = args.tag title: str = args.title or "" body: str = args.body or "" channel_arg: str = args.channel or "" is_draft: bool = args.draft ref: str | None = args.ref output_json: bool = args.output_json try: semver = parse_semver(tag) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if channel_arg and channel_arg not in _CHANNELS: print( f"❌ Unknown channel '{sanitize_display(channel_arg)}'. " f"Choose: {', '.join(sorted(_CHANNELS))}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) channel: ReleaseChannel = _CHANNEL_MAP.get(channel_arg, semver_channel(semver)) root = require_repo() repo_id = read_repo_id(root) branch = read_current_branch(root) commit = resolve_commit_ref(root, repo_id, branch, ref) if commit is None: ref_label = ref or "HEAD" print(f"❌ Ref '{sanitize_display(ref_label)}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if get_release_for_tag(root, repo_id, tag) is not None: print( f"❌ Release '{sanitize_display(tag)}' already exists. Delete it first.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) existing = list_releases(root, repo_id, include_drafts=False) prev_commit_id: str | None = existing[0].commit_id if existing else None changelog = build_changelog(root, prev_commit_id, commit.commit_id) release = ReleaseRecord( release_id=str(uuid.uuid4()), repo_id=repo_id, tag=tag, semver=semver, channel=channel, commit_id=commit.commit_id, snapshot_id=commit.snapshot_id, title=title, body=body, changelog=changelog, agent_id=commit.agent_id, model_id=commit.model_id, is_draft=is_draft, ) write_release(root, release) if output_json: # Emit the full record so agents get changelog, semver, provenance, etc. print(json.dumps(release.to_dict(), default=str)) else: draft_label = " (draft)" if is_draft else "" print( f"✅ Release {tag}{draft_label} — {len(changelog)} commits " f"on branch {sanitize_display(branch)}" ) def run_list(args: argparse.Namespace) -> None: """List releases (local or remote). With ``--json``, emits a JSON array of full ReleaseRecord objects. Use ``--channel`` to filter by distribution channel, and ``--include-drafts`` to include unreleased drafts. Exit codes: 0 — list returned (may be empty) 1 — remote not configured 2 — not inside a Muse repository 5 — remote communication error """ channel_arg: str = args.channel or "" include_drafts: bool = args.include_drafts remote: str = args.remote or "" output_json: bool = args.output_json channel_filter: ReleaseChannel | None = _CHANNEL_MAP.get(channel_arg) if channel_arg else None root = require_repo() if remote: url = _resolve_remote_url(root, remote) token = get_signing_identity(root, url) transport = make_transport(url) try: raw_releases = transport.list_releases_remote( url, token, channel=channel_filter, include_drafts=include_drafts, ) except TransportError as exc: print( f"❌ Could not fetch releases from remote: {sanitize_display(str(exc))}", file=sys.stderr, ) raise SystemExit(ExitCode.REMOTE_ERROR) releases = [ReleaseRecord.from_dict(d) for d in raw_releases] else: repo_id = read_repo_id(root) releases = list_releases(root, repo_id, channel=channel_filter, include_drafts=include_drafts) if output_json: print(json.dumps([r.to_dict() for r in releases], default=str)) return if not releases: print("No releases found.") return for r in releases: draft_label = " [DRAFT]" if r.is_draft else "" print( f"{sanitize_display(r.tag):<20} {sanitize_display(r.channel):<8} " f"{r.commit_id[:8]} " f"{sanitize_display(r.title)[:40]}{draft_label}" ) def run_show(args: argparse.Namespace) -> None: """Show details of a single release. With ``--json``, emits the full :class:`ReleaseRecord` as a JSON object including the changelog, semver components, agent provenance, and snapshot ID. JSON output includes: ``tag``, ``channel``, ``commit_id``, ``snapshot_id``, ``release_id``, ``is_draft``, ``changelog``, ``semver``, ``title``, ``body``, ``agent_id``, ``model_id``, ``created_at``. Exit codes: 0 — release shown 2 — not inside a Muse repository 4 — release tag not found """ tag: str = args.tag output_json: bool = args.output_json root = require_repo() repo_id = read_repo_id(root) release = get_release_for_tag(root, repo_id, tag) if release is None: print(f"❌ Release '{sanitize_display(tag)}' not found.", file=sys.stderr) raise SystemExit(ExitCode.NOT_FOUND) _format_release(release, output_json) def run_push(args: argparse.Namespace) -> None: """Push a local release to a remote. Transmits the lightweight ``ReleaseRecord`` payload to MuseHub, which then runs the full semantic analysis (language breakdown, symbol inventory, API surface diff, file hotspots, refactoring events, provenance) as a server- side background task. The push completes immediately; the enriched release detail page populates within seconds. Use ``--dry-run`` to validate the release record and remote configuration without actually transmitting anything. JSON output fields (``--json``) -------------------------------- ``status`` ``"pushed"`` on success; ``"dry_run"`` when ``--dry-run`` was passed. ``tag`` The version tag that was pushed (e.g. ``"v1.2.0"``). ``remote`` The named remote used (e.g. ``"origin"``). ``release_id`` UUID assigned by the remote hub after a real push; local release ID during dry-run. ``dry_run`` ``true`` if ``--dry-run`` was passed, else ``false``. Exit codes ---------- 0 Release pushed successfully (or dry-run validated). 1 Remote not configured. 2 Not inside a Muse repository. 4 Tag not found locally — run ``muse release add`` first. 5 Remote communication error (network failure, auth, server error). """ tag: str = args.tag remote: str = args.remote dry_run: bool = args.dry_run output_json: bool = args.output_json root = require_repo() repo_id = read_repo_id(root) release = get_release_for_tag(root, repo_id, tag) if release is None: print( f"❌ Release '{sanitize_display(tag)}' not found locally. " "Run 'muse release add' first.", file=sys.stderr, ) raise SystemExit(ExitCode.NOT_FOUND) if dry_run: # Skip remote URL lookup — no network call is made in dry-run mode. if output_json: push_payload = _ReleasePushJson( status="dry_run", tag=tag, remote=remote, release_id=release.release_id, dry_run=True, ) print(json.dumps(push_payload)) else: print( f"Would push release {sanitize_display(tag)} to {sanitize_display(remote)} " f"(id={release.release_id[:8]}, dry-run)" ) return url = _resolve_remote_url(root, remote) token = get_signing_identity(root, url) transport = make_transport(url) try: release_id = transport.create_release(url, token, release.to_dict()) except TransportError as exc: print(f"❌ Push failed: {sanitize_display(str(exc))}", file=sys.stderr) raise SystemExit(ExitCode.REMOTE_ERROR) if output_json: push_payload = _ReleasePushJson( status="pushed", tag=tag, remote=remote, release_id=release_id, dry_run=False, ) print(json.dumps(push_payload)) else: print(f"✅ Release {sanitize_display(tag)} pushed to {sanitize_display(remote)} (id={release_id[:8]})") def run_delete(args: argparse.Namespace) -> None: """Delete a release label locally and optionally retract it from a remote. Deletion removes only the named pointer — the underlying commit and snapshot remain in the content-addressed object store forever. They are still fully reproducible by their SHA-256; only the label is gone. Published releases require explicit confirmation (type the tag name) so accidental retractions of stable releases are hard to do silently. Non-interactive contexts (no TTY, agent pipelines) must pass ``--yes`` to skip the confirmation prompt. Without it, the command exits with USER_ERROR rather than blocking on ``input()``. Use ``--dry-run`` to inspect what would be deleted without deleting it. JSON output fields (``--json``) -------------------------------- ``status`` ``"deleted"`` on success; ``"aborted"`` when the user declined interactive confirmation; ``"dry_run"`` when ``--dry-run`` was passed. ``tag`` The version tag that was (or would be) deleted. ``was_draft`` ``true`` if the release was a draft at deletion time. ``remote_retracted`` ``true`` if ``--remote`` was supplied and the remote retraction succeeded. Always ``false`` in dry-run and aborted cases. ``dry_run`` ``true`` if ``--dry-run`` was passed, else ``false``. Exit codes ---------- 0 Release deleted (or dry-run validated, or user aborted interactively). 1 Non-TTY context without ``--yes``; or remote not configured. 2 Not inside a Muse repository. 4 Tag not found locally. 5 Remote retraction failed (network failure, auth, server error). """ tag: str = args.tag yes: bool = args.yes remote: str = args.remote or "" dry_run: bool = args.dry_run output_json: bool = args.output_json root = require_repo() repo_id = read_repo_id(root) release = get_release_for_tag(root, repo_id, tag) if release is None: print(f"❌ Release '{sanitize_display(tag)}' not found locally.", file=sys.stderr) raise SystemExit(ExitCode.NOT_FOUND) if dry_run: if output_json: del_payload = _ReleaseDeleteJson( status="dry_run", tag=tag, was_draft=release.is_draft, remote_retracted=False, dry_run=True, ) print(json.dumps(del_payload)) else: remote_label = f" and retract from {sanitize_display(remote)}" if remote else "" print( f"Would delete release {sanitize_display(tag)}{remote_label} " f"({'draft' if release.is_draft else 'published'}, dry-run)" ) return # Guard: non-TTY context must pass --yes. _require_tty_or_yes(yes) # Interactive confirmation. if not release.is_draft and not yes: print( f"⚠️ '{sanitize_display(tag)}' is a published release. " "Deleting it removes the label; the underlying commit is unaffected." ) answer = input("Type the tag name to confirm deletion: ").strip() if answer != tag: if output_json: del_payload = _ReleaseDeleteJson( status="aborted", tag=tag, was_draft=False, remote_retracted=False, dry_run=False, ) print(json.dumps(del_payload)) else: print("Aborted.") return elif release.is_draft and not yes: answer = input(f"Delete draft release '{sanitize_display(tag)}'? [y/N] ").strip().lower() if answer not in ("y", "yes"): if output_json: del_payload = _ReleaseDeleteJson( status="aborted", tag=tag, was_draft=True, remote_retracted=False, dry_run=False, ) print(json.dumps(del_payload)) else: print("Aborted.") return # Retract from remote first so a local-only failure doesn't leave the # local record orphaned relative to the remote. remote_retracted = False if remote: url = _resolve_remote_url(root, remote) token = get_signing_identity(root, url) transport = make_transport(url) try: transport.delete_release_remote(url, token, tag) remote_retracted = True except TransportError as exc: print( f"❌ Remote retraction failed: {sanitize_display(str(exc))}", file=sys.stderr, ) raise SystemExit(ExitCode.REMOTE_ERROR) if not output_json: print(f"✅ Release {sanitize_display(tag)} retracted from {sanitize_display(remote)}.") deleted = delete_release(root, repo_id, release.release_id) if deleted: if output_json: del_payload = _ReleaseDeleteJson( status="deleted", tag=tag, was_draft=release.is_draft, remote_retracted=remote_retracted, dry_run=False, ) print(json.dumps(del_payload)) else: print(f"✅ Release {sanitize_display(tag)} deleted locally.") else: print( f"❌ Local release '{sanitize_display(tag)}' could not be deleted.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) _BUMP_ORDER: list[SemVerBump] = ["none", "patch", "minor", "major"] def _bump_rank(bump: SemVerBump) -> int: try: return _BUMP_ORDER.index(bump) except ValueError: return 0 def run_suggest(args: argparse.Namespace) -> None: """Infer the next version tag from the commit graph. Walks every commit since the last release (or since the initial commit if no releases exist) and aggregates the ``sem_ver_bump`` values that Muse recorded at commit time — derived from structural diffs of the public symbol graph, not from commit message conventions. The highest bump seen across all unreleased commits drives the version arithmetic: * **Pre-1.0 repos** (``major == 0``): a ``major`` structural break bumps the *minor* component (``0.x+1.0``) rather than crossing the 1.0 boundary. A ``minor`` or ``patch`` bump increments patch (``0.x.y+1``). This matches the semver spec for unstable APIs. * **1.0+ repos**: standard semver — major/minor/patch bumps their respective components. If no commits carry a meaningful bump (all are ``"none"``), no suggestion is made and the command exits 0 with an explicit message. Options: --base Start from this release tag instead of the latest. --ref Treat this commit as HEAD instead of the branch tip. --json Machine-readable output. JSON output schema:: { "suggested_tag": "v0.3.0", // null when bump is "none" "inferred_bump": "major", // max sem_ver_bump across commits "pre_1_0_adjusted": true, // true when 0.x rules applied "base_tag": "v0.2.0", // null when no prior release "base_commit_id": "", // null when no prior release "head_commit_id": "", "unreleased_count": 7, "drivers": [ // commits that raised the bump { "commit_id": "", "message": "feat: ...", "sem_ver_bump": "major", "breaking_changes": ["path/file.py::Symbol"] } ] } Exit codes: 0 — suggestion produced (or no bump needed) 2 — not inside a Muse repository 4 — --base tag not found """ output_json: bool = args.output_json base_tag: str = args.base or "" ref: str | None = args.ref root = require_repo() repo_id = read_repo_id(root) branch = read_current_branch(root) # Resolve base release. if base_tag: base_release = get_release_for_tag(root, repo_id, base_tag) if base_release is None: print(f"❌ Base release '{sanitize_display(base_tag)}' not found.", file=sys.stderr) raise SystemExit(ExitCode.NOT_FOUND) else: all_releases = list_releases(root, repo_id, include_drafts=False) base_release = all_releases[0] if all_releases else None # newest first # Resolve HEAD. if ref: head_commit = resolve_commit_ref(root, repo_id, branch, ref) if head_commit is None: print(f"❌ Ref '{sanitize_display(ref)}' not found.", file=sys.stderr) raise SystemExit(ExitCode.NOT_FOUND) head_id = head_commit.commit_id else: head_id = get_head_commit_id(root, branch) or "" if not head_id: print("❌ No commits on this branch yet.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) from_commit_id: str | None = base_release.commit_id if base_release else None # Walk unreleased commits (newest first). commits = walk_commits_between(root, head_id, from_commit_id) # Aggregate highest bump and collect driver commits. agg_bump: SemVerBump = "none" drivers: list[dict[str, object]] = [] for commit in commits: cb: SemVerBump = commit.sem_ver_bump # type: ignore[assignment] if _bump_rank(cb) > _bump_rank(agg_bump): agg_bump = cb if cb != "none": drivers.append({ "commit_id": commit.commit_id, "message": commit.message, "sem_ver_bump": cb, "breaking_changes": list(commit.breaking_changes), }) # Parse base semver. if base_release and base_release.semver: sv = base_release.semver major = int(sv.get("major") or 0) minor = int(sv.get("minor") or 0) patch = int(sv.get("patch") or 0) else: major, minor, patch = 0, 0, 0 # Compute next version. suggested_tag: str | None = None pre_1_0_adjusted = False if agg_bump == "none": suggested_tag = None elif major == 0: pre_1_0_adjusted = True if agg_bump == "major": suggested_tag = f"v0.{minor + 1}.0" else: # minor or patch → bump patch in 0.x.y suggested_tag = f"v0.{minor}.{patch + 1}" else: if agg_bump == "major": suggested_tag = f"v{major + 1}.0.0" elif agg_bump == "minor": suggested_tag = f"v{major}.{minor + 1}.0" else: suggested_tag = f"v{major}.{minor}.{patch + 1}" payload = { "suggested_tag": suggested_tag, "inferred_bump": agg_bump, "pre_1_0_adjusted": pre_1_0_adjusted, "base_tag": base_release.tag if base_release else None, "base_commit_id": base_release.commit_id if base_release else None, "head_commit_id": head_id, "unreleased_count": len(commits), "drivers": drivers, } if output_json: print(json.dumps(payload, default=str)) return base_label = base_release.tag if base_release else "(no prior release)" if suggested_tag is None: print( f"No version bump required since {sanitize_display(base_label)}. " f"All {len(commits)} unreleased commit(s) carry bump=\"none\"." ) return adj_note = " (pre-1.0 adjusted)" if pre_1_0_adjusted else "" print( f"Suggested next release: {suggested_tag}{adj_note}\n" f" Inferred bump : {agg_bump}\n" f" Base : {sanitize_display(base_label)}\n" f" Unreleased : {len(commits)} commit(s)\n" f" Drivers : {len(drivers)} commit(s) with meaningful bump" ) for d in drivers: bump_label = {"major": "💥", "minor": "✨", "patch": "🔧"}.get(str(d["sem_ver_bump"]), " ") bc = d["breaking_changes"] bc_str = f" — breaks: {', '.join(str(s) for s in bc[:3])}" if bc else "" # type: ignore[arg-type] print( f" {bump_label} {str(d['commit_id'])[:8]} " f"{sanitize_display(str(d['message'])[:60])}{bc_str}" ) # --------------------------------------------------------------------------- # Command registration # --------------------------------------------------------------------------- def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the ``muse release`` subcommand tree.""" parser = subparsers.add_parser( "release", help="Create and manage versioned releases.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) # Top-level flags for the default (no subcommand → list) behaviour. # These mirror the `list` subparser flags so `muse release --json` works # identically to `muse release list --json`. parser.add_argument( "--json", "-j", action="store_true", dest="output_json", help="Emit machine-readable JSON on stdout (default action: list).", ) parser.add_argument( "--channel", default="", metavar="CHANNEL", help="Filter by channel when listing (stable, beta, alpha, nightly).", ) parser.add_argument( "--include-drafts", action="store_true", help="Include draft releases when listing.", ) parser.add_argument( "--remote", default="", help="Fetch from this remote when listing (e.g. origin).", ) subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") subs.required = False # Default: bare `muse release` (no subcommand) lists releases, matching # the ergonomics of `muse branch`, `muse tag`, etc. parser.set_defaults(func=run_list) # --- add --- add_p = subs.add_parser( "add", help="Create a local release at HEAD.", description=( "Parse ```` as semver, auto-generate a changelog from typed\n" "commit metadata since the previous release, and write the record\n" "to ``.muse/releases/``.\n\n" "The channel is inferred from the semver pre-release label\n" "(``-beta.*`` → beta, ``-alpha.*`` → alpha, ``-nightly.*`` → nightly,\n" "no pre-release → stable) or overridden with ``--channel``.\n\n" "Agent quickstart\n" "----------------\n" " muse release add v1.2.0 --json\n" " muse release add v1.3.0-beta.1 --channel beta --draft --json\n\n" "JSON output schema\n" "------------------\n" " Full ReleaseRecord — key fields:\n" ' {"tag": "", "channel": "", "commit_id": "",\n' ' "snapshot_id": "", "release_id": "",\n' ' "is_draft": , "changelog": [...]}\n\n' "Exit codes\n" "----------\n" " 0 — release created\n" " 1 — invalid semver; unknown channel; duplicate tag; ref not found\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) add_p.add_argument("tag", help="Version tag (semver, e.g. v1.2.0 or v1.3.0-beta.1).") add_p.add_argument("--title", default="", help="Release title.") add_p.add_argument("--body", default="", help="Release body / description.") add_p.add_argument( "--channel", default="", choices=sorted(_CHANNELS), help="Distribution channel (default: inferred from semver pre-release label).", ) add_p.add_argument("--draft", action="store_true", help="Mark release as a draft.") add_p.add_argument( "--ref", "--commit", default=None, help="Commit ID or branch to release (default: HEAD).", ) add_p.add_argument( "--json", "-j", action="store_true", dest="output_json", help="Emit machine-readable JSON on stdout.", ) add_p.set_defaults(func=run_add) # --- delete --- del_p = subs.add_parser( "delete", help="Delete a release label locally and optionally retract it from a remote.", description=( "Remove a release label. The underlying commit and snapshot remain\n" "in the content-addressed object store forever — only the named\n" "pointer is deleted. Published releases require typed confirmation\n" "of the tag name; drafts require y/N confirmation.\n\n" "In non-interactive (agent) contexts pass ``--yes`` to skip all\n" "prompts — without it the command exits with USER_ERROR rather\n" "than blocking on stdin.\n\n" "Use ``--remote`` to also retract the release from a named remote.\n" "The remote retraction is attempted first; local deletion follows\n" "only if the remote call succeeds.\n\n" "Agent quickstart\n" "----------------\n" " muse release delete v1.2.0 --yes --json\n" " muse release delete v1.2.0 --yes --remote origin --json\n" " muse release delete v1.2.0 --dry-run --json\n\n" "JSON output schema\n" "------------------\n" ' {"status": "deleted"|"aborted"|"dry_run",\n' ' "tag": "", "was_draft": ,\n' ' "remote_retracted": , "dry_run": }\n\n' "Exit codes\n" "----------\n" " 0 — deleted (or dry-run validated, or user aborted interactively)\n" " 1 — non-TTY without --yes; or remote not configured\n" " 2 — not inside a Muse repository\n" " 4 — tag not found locally\n" " 5 — remote retraction failed\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) del_p.add_argument("tag", help="Version tag to delete (e.g. v1.2.0).") del_p.add_argument("--remote", default="", help="Also retract from this remote (e.g. origin).") del_p.add_argument( "--yes", "-y", action="store_true", help="Skip confirmation. Required in non-TTY (agent) contexts.", ) del_p.add_argument( "-n", "--dry-run", action="store_true", dest="dry_run", help="Show what would be deleted without deleting.", ) del_p.add_argument( "--json", "-j", action="store_true", dest="output_json", help="Emit machine-readable JSON on stdout.", ) del_p.set_defaults(func=run_delete) # --- list --- list_p = subs.add_parser( "list", help="List releases.", description=( "List local releases, optionally filtered by channel or including\n" "drafts. With ``--remote``, fetches from the named remote instead.\n\n" "Agent quickstart\n" "----------------\n" " muse release list --json\n" " muse release list --channel stable --json\n" " muse release list --include-drafts --json\n\n" "JSON output schema\n" "------------------\n" " Array of full ReleaseRecord objects — key fields per entry:\n" ' [{"tag": "", "channel": "", "commit_id": "",\n' ' "snapshot_id": "", "release_id": "",\n' ' "is_draft": , "changelog": [...]}, ...]\n\n' "Exit codes\n" "----------\n" " 0 — list returned (may be empty)\n" " 1 — remote not configured\n" " 2 — not inside a Muse repository\n" " 5 — remote communication error\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) list_p.add_argument( "--channel", default="", choices=list(sorted(_CHANNELS)) + [""], metavar="CHANNEL", help=f"Filter by channel: {', '.join(sorted(_CHANNELS))}.", ) list_p.add_argument("--include-drafts", action="store_true", help="Show draft releases.") list_p.add_argument("--remote", default="", help="Fetch from this remote (e.g. origin).") list_p.add_argument( "--json", "-j", action="store_true", dest="output_json", help="Emit machine-readable JSON on stdout.", ) list_p.set_defaults(func=run_list) # --- push --- push_p = subs.add_parser( "push", help="Push a release to a remote.", description=( "Transmit a local release record to MuseHub. The remote runs full\n" "semantic analysis (language breakdown, symbol inventory, API surface\n" "diff, file hotspots) as a background task — the push completes\n" "immediately and the enriched detail page populates within seconds.\n\n" "Use ``--dry-run`` to validate without transmitting anything.\n\n" "Agent quickstart\n" "----------------\n" " muse release push v1.2.0 --json\n" " muse release push v1.2.0 --dry-run --json\n" " muse release push v1.2.0 --remote staging --json\n\n" "JSON output schema\n" "------------------\n" ' {"status": "pushed"|"dry_run", "tag": "",\n' ' "remote": "", "release_id": "", "dry_run": }\n\n' "Exit codes\n" "----------\n" " 0 — pushed (or dry-run validated)\n" " 1 — remote not configured\n" " 2 — not inside a Muse repository\n" " 4 — tag not found locally (run muse release add first)\n" " 5 — remote communication error\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) push_p.add_argument("tag", help="Version tag to push (e.g. v1.2.0).") push_p.add_argument("--remote", default="origin", help="Remote name (default: origin).") push_p.add_argument( "-n", "--dry-run", action="store_true", dest="dry_run", help="Validate without transmitting.", ) push_p.add_argument( "--json", "-j", action="store_true", dest="output_json", help="Emit machine-readable JSON on stdout.", ) push_p.set_defaults(func=run_push) # --- suggest --- suggest_p = subs.add_parser( "suggest", help="Infer the next version tag from the commit graph.", description=( "Derive the next semantic version by aggregating the ``sem_ver_bump``\n" "values Muse recorded on each commit since the last release. Those\n" "values are structural — they come from diffing the public symbol\n" "graph at commit time, not from parsing commit messages.\n\n" "The highest bump seen across unreleased commits drives version\n" "arithmetic. Pre-1.0 repos (major == 0) apply semver unstable-API\n" "rules: a major structural break bumps the minor component instead\n" "of crossing the 1.0 boundary.\n\n" "Agent quickstart\n" "----------------\n" " muse release suggest --json\n" " muse release suggest --base v1.1.0 --json\n\n" "JSON output schema\n" "------------------\n" ' {"suggested_tag": "v0.3.0", // null when bump is "none"\n' ' "inferred_bump": "major", // max sem_ver_bump across commits\n' ' "pre_1_0_adjusted": true, // pre-1.0 minor-bump rule applied\n' ' "base_tag": "v0.2.0", // null when no prior release\n' ' "base_commit_id": "", // null when no prior release\n' ' "head_commit_id": "",\n' ' "unreleased_count": 7,\n' ' "drivers": [{"commit_id": ..., "message": ...,\n' ' "sem_ver_bump": ..., "breaking_changes": [...]}]}\n\n' "Exit codes\n" "----------\n" " 0 — suggestion produced (or no bump needed)\n" " 2 — not inside a Muse repository\n" " 4 — --base tag not found\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) suggest_p.add_argument( "--base", default="", metavar="TAG", help="Start from this release tag instead of the latest.", ) suggest_p.add_argument( "--ref", "--commit", default=None, help="Treat this commit as HEAD instead of the branch tip.", ) suggest_p.add_argument( "--json", "-j", action="store_true", dest="output_json", help="Emit machine-readable JSON on stdout.", ) suggest_p.set_defaults(func=run_suggest) # --- show --- show_p = subs.add_parser( "show", help="Inspect a single release.", description=( "Display full details of one release: semver, channel, commit,\n" "snapshot, changelog, agent provenance, and draft status.\n\n" "Agent quickstart\n" "----------------\n" " muse release show v1.2.0 --json\n\n" "JSON output schema\n" "------------------\n" " Full ReleaseRecord — key fields:\n" ' {"tag": "", "channel": "", "commit_id": "",\n' ' "snapshot_id": "", "release_id": "",\n' ' "is_draft": , "changelog": [...],\n' ' "semver": {...}, "title": "", "body": "",\n' ' "agent_id": "", "model_id": "",\n' ' "created_at": ""}\n\n' "Exit codes\n" "----------\n" " 0 — release shown\n" " 2 — not inside a Muse repository\n" " 4 — release tag not found\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) show_p.add_argument("tag", help="Version tag (e.g. v1.2.0).") show_p.add_argument( "--json", "-j", action="store_true", dest="output_json", help="Emit machine-readable JSON on stdout.", ) show_p.set_defaults(func=run_show)