releases.py
python
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f
fix: show full cryptographic IDs in all human-readable CLI output
Sonnet 4.6
patch
8 days ago
| 1 | import argparse |
| 2 | from ._core import * |
| 3 | |
| 4 | def run_release_list(args: argparse.Namespace) -> None: |
| 5 | """List releases for the repo on MuseHub, newest first. |
| 6 | |
| 7 | Supports cursor pagination. |
| 8 | |
| 9 | JSON output is the raw API response merged with the standard envelope. |
| 10 | Human-readable text goes to stderr. |
| 11 | |
| 12 | Agent quickstart |
| 13 | ---------------- |
| 14 | :: |
| 15 | |
| 16 | muse hub release list --json |
| 17 | muse hub release list --limit 10 --json | jq '.releases[].tag' |
| 18 | muse hub release list --cursor <token> --json |
| 19 | |
| 20 | JSON output keys (from hub): ``releases`` (list), ``nextCursor`` |
| 21 | (str|null), ``total`` (int). Each release: ``tag``, ``title``, |
| 22 | ``channel``, ``isDraft``, ``createdAt``, ``assetCount``. |
| 23 | |
| 24 | Exit codes |
| 25 | ---------- |
| 26 | 0 Success (including empty list). |
| 27 | 1 Validation or auth error. |
| 28 | 2 Not inside a Muse repository. |
| 29 | 3 API error. |
| 30 | """ |
| 31 | json_output: bool = args.json_output |
| 32 | elapsed = start_timer() |
| 33 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 34 | repo_id = _resolve_repo_id(hub_url, identity) |
| 35 | |
| 36 | params: dict[str, str | int] = {"limit": args.limit} |
| 37 | if args.cursor: |
| 38 | params["cursor"] = args.cursor |
| 39 | |
| 40 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/releases", params=params) |
| 41 | |
| 42 | if json_output: |
| 43 | print(json.dumps({**make_envelope(elapsed), **data})) |
| 44 | return |
| 45 | |
| 46 | releases = data.get("releases", []) |
| 47 | if not releases: |
| 48 | print(" No releases found.", file=sys.stderr) |
| 49 | return |
| 50 | |
| 51 | print(f"\n Releases ({len(releases)} shown)", file=sys.stderr) |
| 52 | print(f" {'─' * 50}", file=sys.stderr) |
| 53 | for r in releases: |
| 54 | draft = " [draft]" if r.get("isDraft") else "" |
| 55 | channel = r.get("channel", "stable") |
| 56 | print(f" {r.get('tag')} {r.get('title', '')} ({channel}){draft}", file=sys.stderr) |
| 57 | next_c = data.get("nextCursor") |
| 58 | if next_c: |
| 59 | print(f"\n (more — pass --cursor {next_c})", file=sys.stderr) |
| 60 | |
| 61 | def run_release_create(args: argparse.Namespace) -> None: |
| 62 | """Create a new release for the repo on MuseHub. |
| 63 | |
| 64 | Tag must be a valid semver string (e.g. ``v1.0.0``, ``v2.0.0-beta.1``). |
| 65 | |
| 66 | JSON output is the raw API response merged with the standard envelope. |
| 67 | Human-readable text goes to stderr. |
| 68 | |
| 69 | Agent quickstart |
| 70 | ---------------- |
| 71 | :: |
| 72 | |
| 73 | muse hub release create --tag v1.0.0 --title 'First release' --json |
| 74 | muse hub release create --tag v1.0.0 --body 'Notes' --channel beta --json |
| 75 | # → {"muse_version": "...", ..., "releaseId": "...", "tag": "v1.0.0"} |
| 76 | |
| 77 | JSON output keys (from hub): ``releaseId``, ``tag``, ``title``, |
| 78 | ``channel``, ``isDraft``, ``createdAt``. |
| 79 | |
| 80 | Exit codes |
| 81 | ---------- |
| 82 | 0 Release created. |
| 83 | 1 Validation or auth error. |
| 84 | 2 Not inside a Muse repository. |
| 85 | 3 API error (409 if tag already exists). |
| 86 | """ |
| 87 | if not args.tag.strip(): |
| 88 | print("❌ --tag must not be empty.", file=sys.stderr) |
| 89 | raise SystemExit(ExitCode.USER_ERROR) |
| 90 | |
| 91 | elapsed = start_timer() |
| 92 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 93 | repo_id = _resolve_repo_id(hub_url, identity) |
| 94 | |
| 95 | payload = { |
| 96 | "tag": args.tag, |
| 97 | "title": args.title, |
| 98 | "body": _resolve_body(args), |
| 99 | "channel": args.channel, |
| 100 | "isDraft": args.is_draft, |
| 101 | } |
| 102 | if args.commit_id: |
| 103 | payload["commitId"] = args.commit_id |
| 104 | |
| 105 | data = _hub_api(hub_url, identity, "POST", f"/api/repos/{repo_id}/releases", body=payload) |
| 106 | |
| 107 | if args.json_output: |
| 108 | print(json.dumps({**make_envelope(elapsed), **data})) |
| 109 | return |
| 110 | |
| 111 | print(f"✅ Release {data.get('tag')} created (id={str(data.get('releaseId',''))}).", file=sys.stderr) |
| 112 | |
| 113 | def run_release_read(args: argparse.Namespace) -> None: |
| 114 | """Fetch a single release by tag from MuseHub. |
| 115 | |
| 116 | JSON output is the raw API response merged with the standard envelope. |
| 117 | Human-readable text goes to stderr. |
| 118 | |
| 119 | Agent quickstart |
| 120 | ---------------- |
| 121 | :: |
| 122 | |
| 123 | muse hub release read v1.0.0 --json |
| 124 | muse hub release read v1.0.0 --json | jq '{tag,title,channel,isDraft}' |
| 125 | |
| 126 | JSON output keys (from hub): ``releaseId``, ``tag``, ``title``, ``body``, |
| 127 | ``channel``, ``isDraft``, ``commitId``, ``assetCount``, ``createdAt``, |
| 128 | ``downloadStats``. |
| 129 | |
| 130 | Exit codes |
| 131 | ---------- |
| 132 | 0 Success. |
| 133 | 1 Auth error. |
| 134 | 2 Not inside a Muse repository. |
| 135 | 3 API error (404 if tag not found). |
| 136 | """ |
| 137 | elapsed = start_timer() |
| 138 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 139 | repo_id = _resolve_repo_id(hub_url, identity) |
| 140 | |
| 141 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/releases/{args.tag}") |
| 142 | |
| 143 | if args.json_output: |
| 144 | print(json.dumps({**make_envelope(elapsed), **data})) |
| 145 | return |
| 146 | |
| 147 | print(f"\n Release: {data.get('tag')} {data.get('title', '')}", file=sys.stderr) |
| 148 | print(f" Channel: {data.get('channel', 'stable')} Draft: {data.get('isDraft', False)}", file=sys.stderr) |
| 149 | if data.get("body"): |
| 150 | print(f"\n {data['body'][:200]}", file=sys.stderr) |
| 151 | |
| 152 | def run_release_delete(args: argparse.Namespace) -> None: |
| 153 | """Delete a release and its assets from MuseHub. |
| 154 | |
| 155 | JSON output includes envelope fields plus ``deleted`` (bool) and |
| 156 | ``tag`` (str). |
| 157 | |
| 158 | Agent quickstart |
| 159 | ---------------- |
| 160 | :: |
| 161 | |
| 162 | muse hub release delete v1.0.0 --json |
| 163 | # → {"muse_version": "...", ..., "deleted": true, "tag": "v1.0.0"} |
| 164 | |
| 165 | Exit codes |
| 166 | ---------- |
| 167 | 0 Deleted. |
| 168 | 1 Auth error. |
| 169 | 2 Not inside a Muse repository. |
| 170 | 3 API error (404 if tag not found). |
| 171 | """ |
| 172 | elapsed = start_timer() |
| 173 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 174 | repo_id = _resolve_repo_id(hub_url, identity) |
| 175 | |
| 176 | _hub_api(hub_url, identity, "DELETE", f"/api/repos/{repo_id}/releases/{args.tag}") |
| 177 | |
| 178 | if args.json_output: |
| 179 | print(json.dumps({**make_envelope(elapsed), **{"deleted": True, "tag": args.tag}})) |
| 180 | return |
| 181 | |
| 182 | print(f"✅ Release {args.tag} deleted.", file=sys.stderr) |
| 183 | |
| 184 | def run_release_asset_list(args: argparse.Namespace) -> None: |
| 185 | """List assets attached to a release. |
| 186 | |
| 187 | JSON output is the raw API response merged with the standard envelope. |
| 188 | Human-readable text goes to stderr. |
| 189 | |
| 190 | Agent quickstart |
| 191 | ---------------- |
| 192 | :: |
| 193 | |
| 194 | muse hub release asset-list v1.0.0 --json |
| 195 | muse hub release asset-list v1.0.0 --json | jq '.assets[].downloadUrl' |
| 196 | |
| 197 | JSON output keys (from hub): ``assets`` (list), ``nextCursor``, ``total``. |
| 198 | Each asset: ``assetId``, ``name``, ``label``, ``contentType``, ``size``, |
| 199 | ``downloadUrl``, ``downloadCount``. |
| 200 | |
| 201 | Exit codes |
| 202 | ---------- |
| 203 | 0 Success. |
| 204 | 1 Auth error. |
| 205 | 2 Not inside a Muse repository. |
| 206 | 3 API error. |
| 207 | """ |
| 208 | elapsed = start_timer() |
| 209 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 210 | repo_id = _resolve_repo_id(hub_url, identity) |
| 211 | |
| 212 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/releases/{args.tag}/assets") |
| 213 | |
| 214 | if args.json_output: |
| 215 | print(json.dumps({**make_envelope(elapsed), **data})) |
| 216 | return |
| 217 | |
| 218 | assets = data.get("assets", []) |
| 219 | if not assets: |
| 220 | print(f" No assets for release {args.tag}.", file=sys.stderr) |
| 221 | return |
| 222 | |
| 223 | print(f"\n Assets for {args.tag} ({len(assets)} total)", file=sys.stderr) |
| 224 | print(f" {'─' * 50}", file=sys.stderr) |
| 225 | for a in assets: |
| 226 | size_kb = int(a.get("size", 0)) // 1024 |
| 227 | dl = a.get("downloadCount", 0) |
| 228 | print(f" {a.get('name')} {size_kb}KB {dl} downloads {a.get('downloadUrl', '')[:60]}", file=sys.stderr) |
| 229 | |
| 230 | def run_release_asset_attach(args: argparse.Namespace) -> None: |
| 231 | """Attach a downloadable asset to a release on MuseHub. |
| 232 | |
| 233 | JSON output is the raw API response merged with the standard envelope. |
| 234 | Human-readable text goes to stderr. |
| 235 | |
| 236 | Agent quickstart |
| 237 | ---------------- |
| 238 | :: |
| 239 | |
| 240 | muse hub release asset-attach v1.0.0 --name track.mid --url https://cdn.example.com/track.mid --json |
| 241 | muse hub release asset-attach v1.0.0 --name mpack.zip --url <url> --content-type application/zip --json |
| 242 | # → {"muse_version": "...", ..., "assetId": "...", "name": "track.mid"} |
| 243 | |
| 244 | JSON output keys (from hub): ``assetId``, ``name``, ``label``, |
| 245 | ``contentType``, ``size``, ``downloadUrl``. |
| 246 | |
| 247 | Exit codes |
| 248 | ---------- |
| 249 | 0 Asset attached. |
| 250 | 1 Auth error. |
| 251 | 2 Not inside a Muse repository. |
| 252 | 3 API error. |
| 253 | """ |
| 254 | if not args.name.strip(): |
| 255 | print("❌ --name must not be empty.", file=sys.stderr) |
| 256 | raise SystemExit(ExitCode.USER_ERROR) |
| 257 | if not args.download_url.strip(): |
| 258 | print("❌ --url must not be empty.", file=sys.stderr) |
| 259 | raise SystemExit(ExitCode.USER_ERROR) |
| 260 | |
| 261 | elapsed = start_timer() |
| 262 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 263 | repo_id = _resolve_repo_id(hub_url, identity) |
| 264 | |
| 265 | payload = { |
| 266 | "name": args.name, |
| 267 | "label": args.label, |
| 268 | "contentType": args.content_type, |
| 269 | "size": args.size, |
| 270 | "downloadUrl": args.download_url, |
| 271 | } |
| 272 | |
| 273 | data = _hub_api(hub_url, identity, "POST", |
| 274 | f"/api/repos/{repo_id}/releases/{args.tag}/assets", body=payload) |
| 275 | |
| 276 | if args.json_output: |
| 277 | print(json.dumps({**make_envelope(elapsed), **data})) |
| 278 | return |
| 279 | |
| 280 | print(f"✅ Asset '{data.get('name')}' attached to release {args.tag}.", file=sys.stderr) |
| 281 | |
| 282 | def run_release_asset_delete(args: argparse.Namespace) -> None: |
| 283 | """Remove an asset from a release on MuseHub. |
| 284 | |
| 285 | JSON output includes envelope fields plus ``deleted`` (bool) and |
| 286 | ``assetId`` (str). |
| 287 | |
| 288 | Agent quickstart |
| 289 | ---------------- |
| 290 | :: |
| 291 | |
| 292 | muse hub release asset-delete v1.0.0 --asset-id <id> --json |
| 293 | # → {"muse_version": "...", ..., "deleted": true, "assetId": "..."} |
| 294 | |
| 295 | Exit codes |
| 296 | ---------- |
| 297 | 0 Deleted. |
| 298 | 1 Auth error. |
| 299 | 2 Not inside a Muse repository. |
| 300 | 3 API error (404 if asset not found). |
| 301 | """ |
| 302 | if not args.asset_id.strip(): |
| 303 | print("❌ --asset-id must not be empty.", file=sys.stderr) |
| 304 | raise SystemExit(ExitCode.USER_ERROR) |
| 305 | |
| 306 | elapsed = start_timer() |
| 307 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 308 | repo_id = _resolve_repo_id(hub_url, identity) |
| 309 | |
| 310 | _hub_api(hub_url, identity, "DELETE", |
| 311 | f"/api/repos/{repo_id}/releases/{args.tag}/assets/{args.asset_id}") |
| 312 | |
| 313 | if args.json_output: |
| 314 | print(json.dumps({**make_envelope(elapsed), **{"deleted": True, "assetId": args.asset_id}})) |
| 315 | return |
| 316 | |
| 317 | print(f"✅ Asset {args.asset_id} deleted from release {args.tag}.", file=sys.stderr) |
| 318 | |
| 319 | def register(subs: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 320 | """Register releases subcommands.""" |
| 321 | # ── release ─────────────────────────────────────────────────────────────── |
| 322 | release_p = subs.add_parser( |
| 323 | "release", |
| 324 | help="Manage releases on MuseHub.", |
| 325 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 326 | ) |
| 327 | release_subs = release_p.add_subparsers(dest="release_subcommand", metavar="RELEASE_COMMAND") |
| 328 | release_subs.required = True |
| 329 | |
| 330 | def _release_repo_args(p: argparse.ArgumentParser) -> None: |
| 331 | p.add_argument("--hub", dest="hub", default=None, metavar="URL", |
| 332 | help="Override the hub URL from config.") |
| 333 | p.add_argument("--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 334 | help="Specify repo as owner/repo.") |
| 335 | p.add_argument("--json", "-j", action="store_true", dest="json_output", |
| 336 | help="Emit JSON output.") |
| 337 | |
| 338 | # release list |
| 339 | release_list_p = release_subs.add_parser( |
| 340 | "list", help="List releases.", |
| 341 | description=( |
| 342 | "List all releases for the repo, newest first.\n\n" |
| 343 | " muse hub release list\n" |
| 344 | " muse hub release list --limit 10 --json\n\n" |
| 345 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 346 | ), |
| 347 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 348 | ) |
| 349 | _release_repo_args(release_list_p) |
| 350 | release_list_p.add_argument("--limit", type=int, default=20, metavar="N", |
| 351 | help="Max releases to return (default 20).") |
| 352 | release_list_p.add_argument("--cursor", default=None, metavar="CURSOR", |
| 353 | help="Pagination cursor.") |
| 354 | release_list_p.set_defaults(func=run_release_list) |
| 355 | |
| 356 | # release create |
| 357 | release_create_p = release_subs.add_parser( |
| 358 | "create", help="Create a new release.", |
| 359 | description=( |
| 360 | "Create a new release for the repo.\n\n" |
| 361 | " muse hub release create --tag v1.0.0 --title 'First release'\n" |
| 362 | " muse hub release create --tag v1.0.0 --body 'Notes' --channel stable --json\n\n" |
| 363 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 364 | ), |
| 365 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 366 | ) |
| 367 | _release_repo_args(release_create_p) |
| 368 | release_create_p.add_argument("--tag", required=True, help="Semver tag, e.g. v1.0.0.") |
| 369 | release_create_p.add_argument("--title", default="", help="Release title.") |
| 370 | release_create_p.add_argument("--body", default="", help="Release notes (Markdown, inline text).") |
| 371 | release_create_p.add_argument( |
| 372 | "--body-file", default=None, metavar="PATH", dest="body_file", |
| 373 | help="Read release notes from PATH. Pass '-' for stdin. Takes precedence over --body.", |
| 374 | ) |
| 375 | release_create_p.add_argument("--commit-id", dest="commit_id", default=None, |
| 376 | help="Pin release to a specific commit ID.") |
| 377 | release_create_p.add_argument( |
| 378 | "--channel", default="stable", |
| 379 | choices=["stable", "beta", "alpha", "nightly"], |
| 380 | help="Distribution channel (default: stable).", |
| 381 | ) |
| 382 | release_create_p.add_argument("--draft", action="store_true", dest="is_draft", |
| 383 | help="Save as draft — not yet publicly visible.") |
| 384 | release_create_p.set_defaults(func=run_release_create) |
| 385 | |
| 386 | # release read |
| 387 | release_read_p = release_subs.add_parser( |
| 388 | "read", help="Read a release by tag.", |
| 389 | description=( |
| 390 | "Fetch a single release by its tag.\n\n" |
| 391 | " muse hub release read v1.0.0\n" |
| 392 | " muse hub release read v1.0.0 --json\n\n" |
| 393 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 394 | ), |
| 395 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 396 | ) |
| 397 | _release_repo_args(release_read_p) |
| 398 | release_read_p.add_argument("tag", help="Release tag, e.g. v1.0.0.") |
| 399 | release_read_p.set_defaults(func=run_release_read) |
| 400 | |
| 401 | # release delete |
| 402 | release_delete_p = release_subs.add_parser( |
| 403 | "delete", help="Delete a release by tag.", |
| 404 | description=( |
| 405 | "Delete a release. Assets attached to the release are also removed.\n\n" |
| 406 | " muse hub release delete v1.0.0\n" |
| 407 | " muse hub release delete v1.0.0 --json\n\n" |
| 408 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 409 | ), |
| 410 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 411 | ) |
| 412 | _release_repo_args(release_delete_p) |
| 413 | release_delete_p.add_argument("tag", help="Release tag to delete.") |
| 414 | release_delete_p.set_defaults(func=run_release_delete) |
| 415 | |
| 416 | # release asset-list |
| 417 | release_asset_list_p = release_subs.add_parser( |
| 418 | "asset-list", help="List assets attached to a release.", |
| 419 | description=( |
| 420 | "List all assets for a release.\n\n" |
| 421 | " muse hub release asset-list v1.0.0\n" |
| 422 | " muse hub release asset-list v1.0.0 --json\n\n" |
| 423 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 424 | ), |
| 425 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 426 | ) |
| 427 | _release_repo_args(release_asset_list_p) |
| 428 | release_asset_list_p.add_argument("tag", help="Release tag.") |
| 429 | release_asset_list_p.set_defaults(func=run_release_asset_list) |
| 430 | |
| 431 | # release asset-attach |
| 432 | release_asset_attach_p = release_subs.add_parser( |
| 433 | "asset-attach", help="Attach an asset to a release.", |
| 434 | description=( |
| 435 | "Attach a downloadable asset to a release.\n\n" |
| 436 | " muse hub release asset-attach v1.0.0 --name track.mid --url https://cdn.example.com/track.mid\n" |
| 437 | " muse hub release asset-attach v1.0.0 --name mpack.zip --url <url> --content-type application/zip --json\n\n" |
| 438 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 439 | ), |
| 440 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 441 | ) |
| 442 | _release_repo_args(release_asset_attach_p) |
| 443 | release_asset_attach_p.add_argument("tag", help="Release tag.") |
| 444 | release_asset_attach_p.add_argument("--name", required=True, help="Filename shown in the UI.") |
| 445 | release_asset_attach_p.add_argument("--url", required=True, dest="download_url", |
| 446 | help="Direct download URL for the asset.") |
| 447 | release_asset_attach_p.add_argument("--label", default="", help="Human-readable label.") |
| 448 | release_asset_attach_p.add_argument("--content-type", dest="content_type", default="", |
| 449 | help="MIME type, e.g. audio/midi.") |
| 450 | release_asset_attach_p.add_argument("--size", type=int, default=0, |
| 451 | help="File size in bytes (0 if unknown).") |
| 452 | release_asset_attach_p.set_defaults(func=run_release_asset_attach) |
| 453 | |
| 454 | # release asset-delete |
| 455 | release_asset_delete_p = release_subs.add_parser( |
| 456 | "asset-delete", help="Delete an asset from a release.", |
| 457 | description=( |
| 458 | "Remove an asset from a release by asset ID.\n\n" |
| 459 | " muse hub release asset-delete v1.0.0 --asset-id <id>\n" |
| 460 | " muse hub release asset-delete v1.0.0 --asset-id <id> --json\n\n" |
| 461 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 462 | ), |
| 463 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 464 | ) |
| 465 | _release_repo_args(release_asset_delete_p) |
| 466 | release_asset_delete_p.add_argument("tag", help="Release tag.") |
| 467 | release_asset_delete_p.add_argument("--asset-id", dest="asset_id", required=True, |
| 468 | help="ID of the asset to delete.") |
| 469 | release_asset_delete_p.set_defaults(func=run_release_asset_delete) |
| 470 | |
| 471 | release_p.set_defaults(func=lambda a: release_p.print_help()) |
| 472 |
File History
1 commit
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f
fix: show full cryptographic IDs in all human-readable CLI output
Sonnet 4.6
patch
8 days ago