repos.py
python
sha256:371209e571fc5fc0010114aaa8f272b15179a2245abd7f62320fa700fbe2a60b
feat: add url and base64 id to hub issue/proposal/repo crea…
Sonnet 4.6
patch
8 days ago
| 1 | import argparse |
| 2 | import base64 |
| 3 | from ._core import * |
| 4 | |
| 5 | def run_repo_create(args: argparse.Namespace) -> None: # noqa: C901 |
| 6 | """Create a new repository on MuseHub. |
| 7 | |
| 8 | All local validation (name format, length) runs before any network I/O so |
| 9 | errors are reported immediately without contacting the hub. |
| 10 | |
| 11 | The owner defaults to the authenticated identity's handle. Pass |
| 12 | ``--owner`` to create under a different handle (requires the caller to |
| 13 | have permission on the server). |
| 14 | |
| 15 | The repo is created with ``initialize=True`` by default so the default |
| 16 | branch exists and the repo is immediately browsable and pushable. Pass |
| 17 | ``--no-init`` to skip the initial commit (useful when you are about to |
| 18 | push an existing history). |
| 19 | |
| 20 | JSON output (``--json``, stdout) |
| 21 | -------------------------------- |
| 22 | The standard 6-field envelope plus:: |
| 23 | |
| 24 | { |
| 25 | "repo_id": "<sha256:...>", |
| 26 | "name": "<name>", |
| 27 | "owner": "<owner>", |
| 28 | "owner_user_id": "<sha256:...>", |
| 29 | "slug": "<url-safe-slug>", |
| 30 | "visibility": "public" | "private", |
| 31 | "description": "<desc>", |
| 32 | "domain_id": "<label>" | null, |
| 33 | "domain": "code" | "midi" | "mist" | "identity" | ..., |
| 34 | "default_branch": "<branch>", |
| 35 | "clone_url": "<url>", |
| 36 | "tags": ["<tag>", ...], |
| 37 | "created_at": "<iso8601>", |
| 38 | "updated_at": "<iso8601>", |
| 39 | "pushed_at": "<iso8601>" |
| 40 | } |
| 41 | |
| 42 | Agent quickstart |
| 43 | ---------------- |
| 44 | :: |
| 45 | |
| 46 | muse hub repo create --name my-repo --json |
| 47 | # → {"repo_id": "...", "slug": "my-repo", "clone_url": "...", ...} |
| 48 | |
| 49 | # Create private repo, push immediately: |
| 50 | muse hub repo create --name my-repo --private --no-init --json |
| 51 | muse push <remote> main |
| 52 | |
| 53 | Exit codes |
| 54 | ---------- |
| 55 | 0 Repo created. |
| 56 | 1 Validation error, name conflict (409), or not authenticated. |
| 57 | 2 Not inside a Muse repository (when hub URL is inferred from config). |
| 58 | 3 API / network error. |
| 59 | """ |
| 60 | elapsed = start_timer() |
| 61 | from muse.cli.commands.hub import _hub_api, _get_hub_and_identity, _resolve_repo_id # noqa: PLC0415 |
| 62 | name: str = args.name.strip() |
| 63 | owner_override: str = getattr(args, "owner", "") or "" |
| 64 | description: str = getattr(args, "description", "") or "" |
| 65 | # --visibility public|private is an alias for --private (GitHub CLI muscle memory). |
| 66 | # Detect contradictory use of both flags before resolving. |
| 67 | _visibility_flag: str | None = getattr(args, "visibility_alias", None) or None |
| 68 | _private_flag: bool = getattr(args, "private", False) |
| 69 | if _visibility_flag is not None and _private_flag: |
| 70 | vis_implies_private = _visibility_flag == "private" |
| 71 | if not vis_implies_private: |
| 72 | print( |
| 73 | "❌ Contradictory flags: --visibility public and --private cannot both be set.", |
| 74 | file=sys.stderr, |
| 75 | ) |
| 76 | raise SystemExit(ExitCode.USER_ERROR) |
| 77 | if _visibility_flag is not None: |
| 78 | visibility: str = _visibility_flag |
| 79 | else: |
| 80 | visibility = "private" if _private_flag else "public" |
| 81 | tags: list[str] = getattr(args, "tags", []) or [] |
| 82 | initialize: bool = not getattr(args, "no_init", False) |
| 83 | default_branch: str = getattr(args, "default_branch", "main") or "main" |
| 84 | json_output: bool = args.json_output |
| 85 | |
| 86 | # ── Local validation — fail fast before any network I/O ────────────────── |
| 87 | |
| 88 | if not name: |
| 89 | print("❌ Repo name must not be empty.", file=sys.stderr) |
| 90 | raise SystemExit(ExitCode.USER_ERROR) |
| 91 | if len(name) > _MAX_REPO_NAME_LEN: |
| 92 | print( |
| 93 | f"❌ Repo name is too long ({len(name)} chars); " |
| 94 | f"maximum is {_MAX_REPO_NAME_LEN}.", |
| 95 | file=sys.stderr, |
| 96 | ) |
| 97 | raise SystemExit(ExitCode.USER_ERROR) |
| 98 | if len(description) > _MAX_REPO_DESC_LEN: |
| 99 | print( |
| 100 | f"❌ Description is too long ({len(description)} chars); " |
| 101 | f"maximum is {_MAX_REPO_DESC_LEN}.", |
| 102 | file=sys.stderr, |
| 103 | ) |
| 104 | raise SystemExit(ExitCode.USER_ERROR) |
| 105 | if visibility not in ("public", "private"): |
| 106 | print( |
| 107 | f"❌ Invalid visibility '{sanitize_display(visibility)}'. " |
| 108 | "Use 'public' or 'private'.", |
| 109 | file=sys.stderr, |
| 110 | ) |
| 111 | raise SystemExit(ExitCode.USER_ERROR) |
| 112 | if not default_branch.strip(): |
| 113 | print("❌ Default branch name must not be empty.", file=sys.stderr) |
| 114 | raise SystemExit(ExitCode.USER_ERROR) |
| 115 | |
| 116 | # ── Network calls ───────────────────────────────────────────────────────── |
| 117 | |
| 118 | hub_url, identity = _get_hub_and_identity(hub_url_override=getattr(args, "hub", None)) |
| 119 | |
| 120 | # Resolve owner: use --owner if given, else fall back to authenticated handle. |
| 121 | owner = owner_override.strip() or str(identity.get("handle", "")) |
| 122 | if not owner: |
| 123 | print( |
| 124 | "❌ Could not determine owner. Pass --owner or authenticate with " |
| 125 | "`muse auth register`.", |
| 126 | file=sys.stderr, |
| 127 | ) |
| 128 | raise SystemExit(ExitCode.USER_ERROR) |
| 129 | |
| 130 | payload: _HubPayload = { |
| 131 | "name": name, |
| 132 | "owner": owner, |
| 133 | "visibility": visibility, |
| 134 | "description": description, |
| 135 | "tags": tags, |
| 136 | "initialize": initialize, |
| 137 | "defaultBranch": default_branch, |
| 138 | } |
| 139 | |
| 140 | parsed_hub = urllib.parse.urlparse(hub_url) |
| 141 | server_root = f"{parsed_hub.scheme}://{parsed_hub.netloc}" |
| 142 | api_path = "/api/repos" |
| 143 | |
| 144 | try: |
| 145 | data = _hub_api(hub_url, identity, "POST", api_path, body=payload) |
| 146 | except SystemExit as exc: |
| 147 | raise exc |
| 148 | |
| 149 | slug = str(data.get("slug", name)) |
| 150 | repo_id = str(data.get("repoId", data.get("repo_id", ""))) |
| 151 | clone_url = str(data.get("cloneUrl", data.get("clone_url", ""))) |
| 152 | created_at = str(data.get("createdAt", data.get("created_at", ""))) |
| 153 | resp_tags: list[str] = [str(t) for t in data.get("tags", [])] if isinstance(data.get("tags"), list) else [] |
| 154 | repo_url = f"{server_root}/{sanitize_display(owner)}/{sanitize_display(slug)}" |
| 155 | |
| 156 | if repo_id.startswith("sha256:"): |
| 157 | repo_id_b64 = base64.urlsafe_b64encode(bytes.fromhex(repo_id[7:])).rstrip(b"=").decode() |
| 158 | else: |
| 159 | repo_id_b64 = "" |
| 160 | |
| 161 | if json_output: |
| 162 | print(json.dumps({**make_envelope(elapsed), **{ |
| 163 | "repo_id": repo_id, |
| 164 | "name": name, |
| 165 | "owner": owner, |
| 166 | "owner_user_id": str(data.get("ownerUserId", data.get("owner_user_id", ""))), |
| 167 | "slug": slug, |
| 168 | "visibility": visibility, |
| 169 | "description": description, |
| 170 | "domain_id": data.get("domainId") or data.get("domain_id"), |
| 171 | "domain": str(data.get("domain", "generic")), |
| 172 | "default_branch": str(data.get("defaultBranch", data.get("default_branch", "main"))), |
| 173 | "clone_url": clone_url, |
| 174 | "tags": resp_tags, |
| 175 | "created_at": created_at, |
| 176 | "updated_at": str(data.get("updatedAt", data.get("updated_at", ""))), |
| 177 | "pushed_at": str(data.get("pushedAt", data.get("pushed_at", ""))), |
| 178 | "url": repo_url, |
| 179 | "repoIdB64": repo_id_b64, |
| 180 | }})) |
| 181 | return |
| 182 | |
| 183 | print( |
| 184 | f"✅ Repository created: {sanitize_display(owner)}/{sanitize_display(slug)}", |
| 185 | file=sys.stderr, |
| 186 | ) |
| 187 | print(f" URL: {sanitize_display(repo_url)}", file=sys.stderr) |
| 188 | print( |
| 189 | f" Visibility: {sanitize_display(visibility)} " |
| 190 | f"Branch: {sanitize_display(default_branch)} " |
| 191 | f"Init: {'yes' if initialize else 'no'}", |
| 192 | file=sys.stderr, |
| 193 | ) |
| 194 | print( |
| 195 | f"\n To push an existing repo:\n" |
| 196 | f" muse remote add origin {sanitize_display(repo_url)}\n" |
| 197 | f" muse push origin {sanitize_display(default_branch)}", |
| 198 | file=sys.stderr, |
| 199 | ) |
| 200 | |
| 201 | def run_repo_delete(args: argparse.Namespace) -> None: |
| 202 | """Delete a repository on MuseHub. |
| 203 | |
| 204 | Only the repository owner may delete. All data (commits, snapshots, objects, |
| 205 | issues, proposals) is permanently removed via cascade delete. |
| 206 | Requires explicit confirmation via ``--yes``. |
| 207 | |
| 208 | When ``target`` is provided it may be ``OWNER/SLUG`` or a repo ID, |
| 209 | allowing bulk deletion without being inside the target repo's directory. |
| 210 | When omitted the repo is resolved from the current directory's remote |
| 211 | config (original behaviour):: |
| 212 | |
| 213 | muse hub repo delete --yes |
| 214 | muse hub repo delete gabriel/my-repo --yes |
| 215 | muse hub repo delete a3f2c9d1-... --yes --json |
| 216 | |
| 217 | JSON output (``--json``, stdout) |
| 218 | -------------------------------- |
| 219 | The standard 6-field envelope plus:: |
| 220 | |
| 221 | { |
| 222 | "deleted": true, |
| 223 | "repo_id": "<sha256:...>" |
| 224 | } |
| 225 | |
| 226 | Exit codes |
| 227 | ---------- |
| 228 | 0 Deleted successfully. |
| 229 | 1 Auth error, not authorized, or ``--yes`` not passed. |
| 230 | 2 Not inside a Muse repository (and no target given). |
| 231 | 3 API error. |
| 232 | """ |
| 233 | elapsed = start_timer() |
| 234 | from muse.cli.commands.hub import _hub_api, _get_hub_and_identity, _resolve_repo_id # noqa: PLC0415 |
| 235 | yes: bool = args.yes |
| 236 | json_output: bool = args.json_output |
| 237 | target: str | None = getattr(args, "target", None) |
| 238 | |
| 239 | if not yes: |
| 240 | print( |
| 241 | "❌ Pass --yes to confirm deletion. This action cannot be undone.", |
| 242 | file=sys.stderr, |
| 243 | ) |
| 244 | raise SystemExit(ExitCode.USER_ERROR) |
| 245 | |
| 246 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 247 | |
| 248 | if target is not None: |
| 249 | if target.startswith("sha256:"): |
| 250 | # Already a content-addressed repo ID — delete directly. |
| 251 | repo_id = target |
| 252 | elif "/" in target: |
| 253 | owner, slug = target.split("/", 1) |
| 254 | resp = _hub_api(hub_url, identity, "GET", f"/api/{owner}/{slug}") |
| 255 | repo_id = resp.get("repoId") or resp.get("repo_id", "") |
| 256 | else: |
| 257 | # Bare slug — implies the authenticated user's repo. |
| 258 | owner = str(identity.get("handle", "")) |
| 259 | slug = target |
| 260 | resp = _hub_api(hub_url, identity, "GET", f"/api/{owner}/{slug}") |
| 261 | repo_id = resp.get("repoId") or resp.get("repo_id", "") |
| 262 | else: |
| 263 | repo_id = _resolve_repo_id(hub_url, identity) |
| 264 | |
| 265 | _hub_api(hub_url, identity, "DELETE", f"/api/repos/{repo_id}") |
| 266 | |
| 267 | if json_output: |
| 268 | print(json.dumps({**make_envelope(elapsed), **{ |
| 269 | "deleted": True, |
| 270 | "repo_id": repo_id, |
| 271 | }})) |
| 272 | return |
| 273 | |
| 274 | print(f"✅ Repository {sanitize_display(repo_id)} deleted.", file=sys.stderr) |
| 275 | |
| 276 | def run_repo_update(args: argparse.Namespace) -> None: |
| 277 | """Show or update repository settings on MuseHub. |
| 278 | |
| 279 | Without flags, prints the current settings. Pass update flags to patch |
| 280 | specific fields — only provided flags are written:: |
| 281 | |
| 282 | muse hub repo update |
| 283 | muse hub repo update --visibility private |
| 284 | muse hub repo update --description "My project" --json |
| 285 | |
| 286 | JSON output (``--json``, stdout) |
| 287 | -------------------------------- |
| 288 | The standard 6-field envelope plus the raw settings object from the server. |
| 289 | Field names follow MuseHub's camelCase convention (``defaultBranch``, |
| 290 | ``hasIssues``, etc.). |
| 291 | |
| 292 | Exit codes |
| 293 | ---------- |
| 294 | 0 Success. |
| 295 | 1 Auth error or not authorized. |
| 296 | 2 Not inside a Muse repository. |
| 297 | 3 API error. |
| 298 | """ |
| 299 | elapsed = start_timer() |
| 300 | from muse.cli.commands.hub import _hub_api, _get_hub_and_identity, _resolve_repo_id # noqa: PLC0415 |
| 301 | json_output: bool = args.json_output |
| 302 | |
| 303 | patch = {} |
| 304 | for field in ("name", "description", "visibility", "default_branch", "homepage_url"): |
| 305 | val = getattr(args, field, None) |
| 306 | if val is not None: |
| 307 | patch[field] = val |
| 308 | for bool_field in ("has_issues", "has_wiki", "allow_merge_commit", |
| 309 | "allow_squash_merge", "allow_rebase_merge", "delete_branch_on_merge"): |
| 310 | val = getattr(args, bool_field, None) |
| 311 | if val is not None: |
| 312 | patch[bool_field] = val |
| 313 | |
| 314 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 315 | repo_id = _resolve_repo_id(hub_url, identity) |
| 316 | |
| 317 | if patch: |
| 318 | data = _hub_api(hub_url, identity, "PATCH", f"/api/repos/{repo_id}/settings", body=patch) |
| 319 | else: |
| 320 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/settings") |
| 321 | |
| 322 | if json_output: |
| 323 | print(json.dumps({**make_envelope(elapsed), **data})) |
| 324 | return |
| 325 | |
| 326 | print(f"Name: {sanitize_display(str(data.get('name', '')))}", file=sys.stderr) |
| 327 | print(f"Description: {sanitize_display(str(data.get('description', '')))}", file=sys.stderr) |
| 328 | print(f"Visibility: {sanitize_display(str(data.get('visibility', '')))}", file=sys.stderr) |
| 329 | print(f"Branch: {sanitize_display(str(data.get('defaultBranch', '')))}", file=sys.stderr) |
| 330 | print(f"Issues: {data.get('hasIssues', True)}", file=sys.stderr) |
| 331 | print(f"Wiki: {data.get('hasWiki', False)}", file=sys.stderr) |
| 332 | |
| 333 | def run_repo_list(args: argparse.Namespace) -> None: |
| 334 | """List repositories owned by or collaborated on by the authenticated user. |
| 335 | |
| 336 | Results are ordered newest-first. Pass ``--limit`` to control page size and |
| 337 | ``--cursor`` to page through results using the ``next_cursor`` value from a |
| 338 | previous call. |
| 339 | |
| 340 | JSON output (``--json``, stdout) |
| 341 | -------------------------------- |
| 342 | The standard 6-field envelope plus:: |
| 343 | |
| 344 | { |
| 345 | "total": 42, |
| 346 | "next_cursor": "<opaque-string>" | null, |
| 347 | "repos": [ |
| 348 | { |
| 349 | "repo_id": "<sha256:...>", |
| 350 | "name": "<name>", |
| 351 | "owner": "<handle>", |
| 352 | "owner_user_id": "<sha256:...>", |
| 353 | "slug": "<slug>", |
| 354 | "visibility": "public" | "private", |
| 355 | "description": "<desc>", |
| 356 | "domain_id": "<label>" | null, |
| 357 | "domain": "code" | "midi" | "mist" | "identity" | ..., |
| 358 | "tags": ["<tag>", ...], |
| 359 | "default_branch": "<branch>", |
| 360 | "created_at": "<iso8601>", |
| 361 | "updated_at": "<iso8601>", |
| 362 | "pushed_at": "<iso8601>" |
| 363 | }, |
| 364 | ... |
| 365 | ] |
| 366 | } |
| 367 | |
| 368 | Agent quickstart |
| 369 | ---------------- |
| 370 | :: |
| 371 | |
| 372 | muse hub repo list --json |
| 373 | muse hub repo list --limit 50 --json |
| 374 | muse hub repo list --cursor "<cursor>" --json |
| 375 | |
| 376 | Exit codes |
| 377 | ---------- |
| 378 | 0 Success. |
| 379 | 1 Not authenticated. |
| 380 | 2 Not inside a Muse repository (when hub URL is inferred from config). |
| 381 | 3 API / network error. |
| 382 | """ |
| 383 | elapsed = start_timer() |
| 384 | from muse.cli.commands.hub import _hub_api, _get_hub_and_identity, _resolve_repo_id # noqa: PLC0415 |
| 385 | |
| 386 | # --owner is not a server-side filter — hub repo list returns repos for the |
| 387 | # authenticated user only. Give an actionable error rather than silently |
| 388 | # ignoring the flag (which looks like success but returns wrong results). |
| 389 | owner_filter: str | None = getattr(args, "owner_filter", None) or None |
| 390 | if owner_filter is not None: |
| 391 | print( |
| 392 | f"❌ --owner is not supported by 'hub repo list' — the server only returns " |
| 393 | f"repos for the authenticated user.\n" |
| 394 | f"\n" |
| 395 | f" To filter by owner '{sanitize_display(owner_filter)}', fetch all repos and filter in Python:\n" |
| 396 | f"\n" |
| 397 | f" muse hub repo list --json \\\n" |
| 398 | f" | python3 -c \"import sys, json; " |
| 399 | f"[print(r['slug']) for r in json.load(sys.stdin)['repos'] " |
| 400 | f"if r['owner'] == '{sanitize_display(owner_filter)}']\"\n", |
| 401 | file=sys.stderr, |
| 402 | ) |
| 403 | raise SystemExit(ExitCode.USER_ERROR) |
| 404 | |
| 405 | limit: int = getattr(args, "limit", 20) or 20 |
| 406 | cursor: str | None = getattr(args, "cursor", None) or None |
| 407 | json_output: bool = args.json_output |
| 408 | |
| 409 | hub_url, identity = _get_hub_and_identity(hub_url_override=getattr(args, "hub", None)) |
| 410 | |
| 411 | params: list[str] = [f"limit={min(max(1, limit), 100)}"] |
| 412 | if cursor: |
| 413 | params.append(f"cursor={cursor}") |
| 414 | api_path = f"/api/repos?{'&'.join(params)}" |
| 415 | |
| 416 | data = _hub_api(hub_url, identity, "GET", api_path) |
| 417 | |
| 418 | repos = data.get("repos", []) # type: ignore[assignment] |
| 419 | total: int = int(data.get("total", len(repos))) |
| 420 | next_cursor: str | None = data.get("nextCursor") or data.get("next_cursor") # type: ignore[assignment] |
| 421 | |
| 422 | if json_output: |
| 423 | print(json.dumps({**make_envelope(elapsed), **{ |
| 424 | "total": total, |
| 425 | "next_cursor": next_cursor, |
| 426 | "repos": [ |
| 427 | { |
| 428 | "repo_id": str(r.get("repoId", r.get("repo_id", ""))), |
| 429 | "name": str(r.get("name", "")), |
| 430 | "owner": str(r.get("owner", "")), |
| 431 | "owner_user_id": str(r.get("ownerUserId", r.get("owner_user_id", ""))), |
| 432 | "slug": str(r.get("slug", "")), |
| 433 | "visibility": str(r.get("visibility", "public")), |
| 434 | "description": str(r.get("description", "")), |
| 435 | "domain_id": r.get("domainId") or r.get("domain_id"), |
| 436 | "domain": str(r.get("domain", "generic")), |
| 437 | "tags": r.get("tags", []), |
| 438 | "default_branch": str(r.get("defaultBranch", r.get("default_branch", "main"))), |
| 439 | "created_at": str(r.get("createdAt", r.get("created_at", ""))), |
| 440 | "updated_at": str(r.get("updatedAt", r.get("updated_at", ""))), |
| 441 | "pushed_at": str(r.get("pushedAt", r.get("pushed_at", ""))), |
| 442 | } |
| 443 | for r in repos |
| 444 | if isinstance(r, dict) |
| 445 | ], |
| 446 | }})) |
| 447 | return |
| 448 | |
| 449 | if not repos: |
| 450 | print("No repositories found.", file=sys.stderr) |
| 451 | return |
| 452 | |
| 453 | print(f"Repositories ({total} total):", file=sys.stderr) |
| 454 | for r in repos: |
| 455 | if not isinstance(r, dict): |
| 456 | continue |
| 457 | owner = r.get("owner", "") |
| 458 | slug = r.get("slug", r.get("name", "")) |
| 459 | visibility = r.get("visibility", "public") |
| 460 | desc = r.get("description", "") |
| 461 | marker = "🔒 " if visibility == "private" else " " |
| 462 | print(f" {marker}{sanitize_display(str(owner))}/{sanitize_display(str(slug))}", file=sys.stderr) |
| 463 | if desc: |
| 464 | print(f" {sanitize_display(str(desc))[:72]}", file=sys.stderr) |
| 465 | if next_cursor: |
| 466 | print(f"\n (more results — pass --cursor {sanitize_display(str(next_cursor))} to continue)", file=sys.stderr) |
| 467 | |
| 468 | def run_repo_read(args: argparse.Namespace) -> None: |
| 469 | """Read metadata for a single MuseHub repository. |
| 470 | |
| 471 | Resolves by ``owner/slug`` (positional ``OWNER/SLUG`` argument) or by the |
| 472 | hub remote config of the current directory when no argument is given. |
| 473 | |
| 474 | JSON output (``--json``, stdout) |
| 475 | -------------------------------- |
| 476 | The standard 6-field envelope plus:: |
| 477 | |
| 478 | { |
| 479 | "repo_id": "<sha256:...>", |
| 480 | "name": "<name>", |
| 481 | "owner": "<handle>", |
| 482 | "owner_user_id": "<sha256:...>", |
| 483 | "slug": "<slug>", |
| 484 | "visibility": "public" | "private", |
| 485 | "description": "<desc>", |
| 486 | "domain_id": "<label>" | null, |
| 487 | "domain": "code" | "midi" | "mist" | "identity" | ..., |
| 488 | "tags": ["<tag>", ...], |
| 489 | "default_branch": "<branch>", |
| 490 | "clone_url": "<url>", |
| 491 | "created_at": "<iso8601>", |
| 492 | "updated_at": "<iso8601>", |
| 493 | "pushed_at": "<iso8601>" |
| 494 | } |
| 495 | |
| 496 | Agent quickstart |
| 497 | ---------------- |
| 498 | :: |
| 499 | |
| 500 | muse hub repo read gabriel/my-repo --json |
| 501 | muse hub repo read --json # resolves from current repo's hub config |
| 502 | |
| 503 | Exit codes |
| 504 | ---------- |
| 505 | 0 Success. |
| 506 | 1 Not authenticated or not found (404). |
| 507 | 2 Not inside a Muse repository (when hub URL is inferred from config). |
| 508 | 3 API / network error. |
| 509 | """ |
| 510 | elapsed = start_timer() |
| 511 | from muse.cli.commands.hub import _hub_api, _get_hub_and_identity, _resolve_repo_id # noqa: PLC0415 |
| 512 | target: str | None = getattr(args, "target", None) |
| 513 | json_output: bool = args.json_output |
| 514 | |
| 515 | hub_url, identity = _get_hub_and_identity(hub_url_override=getattr(args, "hub", None)) |
| 516 | |
| 517 | if target and "/" in target: |
| 518 | parts = target.split("/", 1) |
| 519 | owner_part = parts[0].strip() |
| 520 | slug_part = parts[1].strip() |
| 521 | api_path = f"/api/{owner_part}/{slug_part}" |
| 522 | else: |
| 523 | repo_id = _resolve_repo_id(hub_url, identity) |
| 524 | api_path = f"/api/repos/{repo_id}" |
| 525 | |
| 526 | data = _hub_api(hub_url, identity, "GET", api_path) |
| 527 | |
| 528 | repo_id_val = str(data.get("repoId", data.get("repo_id", ""))) |
| 529 | name = str(data.get("name", "")) |
| 530 | owner = str(data.get("owner", "")) |
| 531 | slug = str(data.get("slug", "")) |
| 532 | visibility = str(data.get("visibility", "public")) |
| 533 | description = str(data.get("description", "")) |
| 534 | tags: list[str] = [str(t) for t in data.get("tags", [])] if isinstance(data.get("tags"), list) else [] |
| 535 | default_branch = str(data.get("defaultBranch", data.get("default_branch", "main"))) |
| 536 | clone_url = str(data.get("cloneUrl", data.get("clone_url", ""))) |
| 537 | created_at = str(data.get("createdAt", data.get("created_at", ""))) |
| 538 | updated_at = str(data.get("updatedAt", data.get("updated_at", ""))) |
| 539 | pushed_at = str(data.get("pushedAt", data.get("pushed_at", ""))) |
| 540 | |
| 541 | if json_output: |
| 542 | print(json.dumps({**make_envelope(elapsed), **{ |
| 543 | "repo_id": repo_id_val, |
| 544 | "name": name, |
| 545 | "owner": owner, |
| 546 | "owner_user_id": str(data.get("ownerUserId", data.get("owner_user_id", ""))), |
| 547 | "slug": slug, |
| 548 | "visibility": visibility, |
| 549 | "description": description, |
| 550 | "domain_id": data.get("domainId") or data.get("domain_id"), |
| 551 | "domain": str(data.get("domain", "generic")), |
| 552 | "tags": tags, |
| 553 | "default_branch": default_branch, |
| 554 | "clone_url": clone_url, |
| 555 | "created_at": created_at, |
| 556 | "updated_at": updated_at, |
| 557 | "pushed_at": pushed_at, |
| 558 | }})) |
| 559 | return |
| 560 | |
| 561 | visibility_icon = "🔒 private" if visibility == "private" else "public" |
| 562 | print(f" {sanitize_display(owner)}/{sanitize_display(slug)} [{visibility_icon}]", file=sys.stderr) |
| 563 | if description: |
| 564 | print(f" {sanitize_display(description)}", file=sys.stderr) |
| 565 | if tags: |
| 566 | print(f" Tags: {', '.join(sanitize_display(t) for t in tags)}", file=sys.stderr) |
| 567 | print(f" Branch: {sanitize_display(default_branch)}", file=sys.stderr) |
| 568 | if clone_url: |
| 569 | print(f" Clone: {sanitize_display(clone_url)}", file=sys.stderr) |
| 570 | if pushed_at: |
| 571 | print(f" Last push: {sanitize_display(pushed_at)}", file=sys.stderr) |
| 572 | elif created_at: |
| 573 | print(f" Created: {sanitize_display(created_at)}", file=sys.stderr) |
| 574 | |
| 575 | def run_repo_transfer_ownership(args: argparse.Namespace) -> None: |
| 576 | """Transfer ownership of a repository to another user. |
| 577 | |
| 578 | Only the current owner may initiate. After transfer the calling user loses |
| 579 | owner privileges immediately. Requires ``--new-owner``:: |
| 580 | |
| 581 | muse hub repo transfer --new-owner alice |
| 582 | muse hub repo transfer --new-owner alice --json |
| 583 | |
| 584 | JSON output (``--json``, stdout) |
| 585 | -------------------------------- |
| 586 | The standard 6-field envelope plus the raw transfer response from the |
| 587 | server. Typically includes ``ownerUserId`` (new owner's ID) and |
| 588 | ``slug`` of the transferred repo. |
| 589 | |
| 590 | Exit codes |
| 591 | ---------- |
| 592 | 0 Transfer succeeded. |
| 593 | 1 Auth error, not authorized, or ``--new-owner`` missing. |
| 594 | 2 Not inside a Muse repository. |
| 595 | 3 API error. |
| 596 | """ |
| 597 | elapsed = start_timer() |
| 598 | from muse.cli.commands.hub import _hub_api, _get_hub_and_identity, _resolve_repo_id # noqa: PLC0415 |
| 599 | new_owner: str = args.new_owner |
| 600 | json_output: bool = args.json_output |
| 601 | |
| 602 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 603 | repo_id = _resolve_repo_id(hub_url, identity) |
| 604 | |
| 605 | data = _hub_api( |
| 606 | hub_url, identity, "POST", |
| 607 | f"/api/repos/{repo_id}/transfer", |
| 608 | body={"newOwner": new_owner}, |
| 609 | ) |
| 610 | |
| 611 | if json_output: |
| 612 | print(json.dumps({**make_envelope(elapsed), **data})) |
| 613 | return |
| 614 | |
| 615 | new_owner_out = sanitize_display(str(data.get("ownerUserId", new_owner))) |
| 616 | print(f"✅ Repository transferred to {new_owner_out}.", file=sys.stderr) |
| 617 | |
| 618 | def register(subs: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 619 | """Register repos subcommands.""" |
| 620 | # ── repo ────────────────────────────────────────────────────────────────── |
| 621 | repo_p = subs.add_parser( |
| 622 | "repo", |
| 623 | help="Manage repositories on MuseHub.", |
| 624 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 625 | ) |
| 626 | repo_subs = repo_p.add_subparsers(dest="repo_subcommand", metavar="REPO_COMMAND") |
| 627 | repo_subs.required = True |
| 628 | |
| 629 | repo_create_p = repo_subs.add_parser( |
| 630 | "create", |
| 631 | help="Create a new repository on MuseHub.", |
| 632 | description=( |
| 633 | "Create a new remote Muse repository on MuseHub.\n\n" |
| 634 | "Name must be non-empty and ≤ 255 characters.\n" |
| 635 | "Owner defaults to the authenticated identity's handle.\n" |
| 636 | "The repo is initialized with an empty commit by default so it\n" |
| 637 | "is immediately pushable. Pass --no-init to skip initialization\n" |
| 638 | "(useful when you are about to push existing history).\n\n" |
| 639 | "Agent quickstart:\n" |
| 640 | " muse hub repo create --name my-repo --json\n" |
| 641 | " muse hub repo create --name my-repo --private --no-init --json\n\n" |
| 642 | "JSON output keys: repo_id, name, owner, slug, visibility,\n" |
| 643 | " description, clone_url, tags, created_at\n\n" |
| 644 | "Exit codes: 0 created, 1 validation/conflict/auth error,\n" |
| 645 | " 2 not in repo, 3 API/network error." |
| 646 | ), |
| 647 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 648 | ) |
| 649 | repo_create_p.add_argument( |
| 650 | "--hub", dest="hub", default=None, metavar="URL", |
| 651 | help="Override the hub URL from config (e.g. https://localhost:1337/owner/repo).", |
| 652 | ) |
| 653 | repo_create_p.add_argument( |
| 654 | "--name", "-n", required=True, |
| 655 | help="Repository name (used to generate the URL slug).", |
| 656 | ) |
| 657 | repo_create_p.add_argument( |
| 658 | "--owner", dest="owner", default="", metavar="OWNER", |
| 659 | help="Owner username. Defaults to the authenticated identity's handle.", |
| 660 | ) |
| 661 | repo_create_p.add_argument( |
| 662 | "--description", "-d", default="", |
| 663 | help="Short description shown on the explore page.", |
| 664 | ) |
| 665 | repo_create_p.add_argument( |
| 666 | "--private", action="store_true", default=False, |
| 667 | help="Create as a private repository (default: public).", |
| 668 | ) |
| 669 | repo_create_p.add_argument( |
| 670 | "--visibility", dest="visibility_alias", default=None, |
| 671 | choices=["public", "private"], metavar="public|private", |
| 672 | help=( |
| 673 | "Alias for --private: 'public' (default) or 'private'. " |
| 674 | "Cannot be combined with --private." |
| 675 | ), |
| 676 | ) |
| 677 | repo_create_p.add_argument( |
| 678 | "--tag", dest="tags", action="append", default=[], metavar="TAG", |
| 679 | help="Tag to apply (repeatable, e.g. --tag jazz --tag piano).", |
| 680 | ) |
| 681 | repo_create_p.add_argument( |
| 682 | "--no-init", dest="no_init", action="store_true", default=False, |
| 683 | help=( |
| 684 | "Skip the initial empty commit. Use this when you are about to " |
| 685 | "push existing history." |
| 686 | ), |
| 687 | ) |
| 688 | repo_create_p.add_argument( |
| 689 | "--default-branch", dest="default_branch", default="main", metavar="BRANCH", |
| 690 | help="Name of the default branch created on initialization (default: main).", |
| 691 | ) |
| 692 | repo_create_p.add_argument( |
| 693 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 694 | help="Emit a JSON object to stdout on success.", |
| 695 | ) |
| 696 | repo_create_p.set_defaults(func=run_repo_create) |
| 697 | |
| 698 | # ── repo delete ─────────────────────────────────────────────────────────── |
| 699 | repo_delete_p = repo_subs.add_parser( |
| 700 | "delete", |
| 701 | help="Delete a repository (owner only). Permanent — all data removed.", |
| 702 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 703 | description=textwrap.dedent( |
| 704 | """\ |
| 705 | Permanently delete a MuseHub repository. Only the repository owner may |
| 706 | delete. All data (commits, snapshots, objects, issues, proposals) is |
| 707 | removed via cascade delete. This cannot be undone. |
| 708 | |
| 709 | TARGET may be OWNER/SLUG or a repo ID. When omitted the repo is |
| 710 | resolved from the current directory's hub remote config. |
| 711 | |
| 712 | Examples: |
| 713 | muse hub repo delete --yes |
| 714 | muse hub repo delete gabriel/my-repo --yes |
| 715 | muse hub repo delete a3f2c9d1-... --yes --json |
| 716 | """ |
| 717 | ), |
| 718 | ) |
| 719 | repo_delete_p.add_argument("target", nargs="?", default=None, |
| 720 | metavar="OWNER/SLUG|REPO_ID", |
| 721 | help="Repo to delete: OWNER/SLUG or repo ID (default: current dir).") |
| 722 | repo_delete_p.add_argument("--yes", "-y", action="store_true", dest="yes", default=False, |
| 723 | help="Confirm deletion (required).") |
| 724 | repo_delete_p.add_argument("--hub", dest="hub", default=None, metavar="URL", |
| 725 | help="MuseHub base URL (overrides config).") |
| 726 | repo_delete_p.add_argument("--json", "-j", action="store_true", dest="json_output", |
| 727 | default=False, help="Emit JSON on success.") |
| 728 | repo_delete_p.set_defaults(func=run_repo_delete) |
| 729 | |
| 730 | # ── repo settings ───────────────────────────────────────────────────────── |
| 731 | repo_update_p = repo_subs.add_parser( |
| 732 | "update", |
| 733 | help="View or update repository settings (owner/admin).", |
| 734 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 735 | description=textwrap.dedent( |
| 736 | """\ |
| 737 | View or patch mutable settings for a MuseHub repository. |
| 738 | Omit all patch flags to read current settings. |
| 739 | The repository is resolved from the current directory's hub remote config. |
| 740 | |
| 741 | Examples: |
| 742 | muse hub repo update |
| 743 | muse hub repo update --description "New description" --visibility private |
| 744 | muse hub repo update --json |
| 745 | """ |
| 746 | ), |
| 747 | ) |
| 748 | repo_update_p.add_argument("--name", dest="name", default=None, metavar="NAME", |
| 749 | help="New repository name.") |
| 750 | repo_update_p.add_argument("--description", dest="description", default=None, |
| 751 | metavar="TEXT", help="New markdown description.") |
| 752 | repo_update_p.add_argument("--visibility", dest="visibility", default=None, |
| 753 | choices=["public", "private"], |
| 754 | help="New visibility: public or private.") |
| 755 | repo_update_p.add_argument("--default-branch", dest="default_branch", default=None, |
| 756 | metavar="BRANCH", help="New default branch name.") |
| 757 | repo_update_p.add_argument("--homepage-url", dest="homepage_url", default=None, |
| 758 | metavar="URL", help="Project homepage URL.") |
| 759 | repo_update_p.add_argument("--hub", dest="hub", default=None, metavar="URL", |
| 760 | help="MuseHub base URL (overrides config).") |
| 761 | repo_update_p.add_argument("--json", "-j", action="store_true", dest="json_output", |
| 762 | default=False, help="Emit JSON on success.") |
| 763 | repo_update_p.set_defaults(func=run_repo_update) |
| 764 | |
| 765 | # ── repo transfer ───────────────────────────────────────────────────────── |
| 766 | repo_transfer_ownership_p = repo_subs.add_parser( |
| 767 | "transfer-ownership", |
| 768 | help="Transfer repository ownership to another user (owner only).", |
| 769 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 770 | description=textwrap.dedent( |
| 771 | """\ |
| 772 | Transfer ownership of a MuseHub repository to another user. |
| 773 | Only the current owner may initiate a transfer. |
| 774 | The repository is resolved from the current directory's hub remote config. |
| 775 | |
| 776 | Examples: |
| 777 | muse hub repo transfer-ownership --new-owner bob |
| 778 | muse hub repo transfer-ownership --new-owner bob --json |
| 779 | """ |
| 780 | ), |
| 781 | ) |
| 782 | repo_transfer_ownership_p.add_argument("--new-owner", dest="new_owner", required=True, |
| 783 | metavar="HANDLE", help="MSign handle of the new owner.") |
| 784 | repo_transfer_ownership_p.add_argument("--hub", dest="hub", default=None, metavar="URL", |
| 785 | help="MuseHub base URL (overrides config).") |
| 786 | repo_transfer_ownership_p.add_argument("--json", "-j", action="store_true", dest="json_output", |
| 787 | default=False, help="Emit JSON on success.") |
| 788 | repo_transfer_ownership_p.set_defaults(func=run_repo_transfer_ownership) |
| 789 | |
| 790 | # ── repo list ───────────────────────────────────────────────────────────── |
| 791 | repo_list_p = repo_subs.add_parser( |
| 792 | "list", |
| 793 | help="List repositories owned by or collaborated on by the authenticated user.", |
| 794 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 795 | description=textwrap.dedent( |
| 796 | """\ |
| 797 | List MuseHub repositories owned by or collaborated on by the |
| 798 | authenticated user. Results are ordered newest-first. |
| 799 | |
| 800 | Examples: |
| 801 | muse hub repo list --json |
| 802 | muse hub repo list --limit 50 --json |
| 803 | muse hub repo list --cursor "<cursor>" --json |
| 804 | """ |
| 805 | ), |
| 806 | ) |
| 807 | repo_list_p.add_argument( |
| 808 | "--limit", dest="limit", type=int, default=20, metavar="N", |
| 809 | help="Maximum repos per page (default 20, max 100).", |
| 810 | ) |
| 811 | repo_list_p.add_argument( |
| 812 | "--cursor", dest="cursor", default=None, metavar="CURSOR", |
| 813 | help="Pagination cursor from a previous next_cursor field.", |
| 814 | ) |
| 815 | repo_list_p.add_argument( |
| 816 | "--hub", dest="hub", default=None, metavar="URL", |
| 817 | help="MuseHub base URL (overrides config).", |
| 818 | ) |
| 819 | repo_list_p.add_argument( |
| 820 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 821 | help="Emit JSON to stdout.", |
| 822 | ) |
| 823 | repo_list_p.add_argument( |
| 824 | "--owner", dest="owner_filter", default=None, metavar="HANDLE", |
| 825 | help=( |
| 826 | "No server-side owner filter exists. Passing this flag exits with " |
| 827 | "a message showing how to filter client-side via --json | python3." |
| 828 | ), |
| 829 | ) |
| 830 | repo_list_p.set_defaults(func=run_repo_list) |
| 831 | |
| 832 | # ── repo read ───────────────────────────────────────────────────────────── |
| 833 | repo_read_p = repo_subs.add_parser( |
| 834 | "read", |
| 835 | help="Read metadata for a single MuseHub repository.", |
| 836 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 837 | description=textwrap.dedent( |
| 838 | """\ |
| 839 | Read metadata for a MuseHub repository. Pass OWNER/SLUG to target |
| 840 | a specific repo, or omit to resolve from the current directory's |
| 841 | hub remote config. |
| 842 | |
| 843 | Examples: |
| 844 | muse hub repo read gabriel/jazz-standards --json |
| 845 | muse hub repo read --json |
| 846 | """ |
| 847 | ), |
| 848 | ) |
| 849 | repo_read_p.add_argument( |
| 850 | "target", nargs="?", default=None, metavar="OWNER/SLUG", |
| 851 | help="Repository to read (e.g. gabriel/my-repo). Omit to use current repo.", |
| 852 | ) |
| 853 | repo_read_p.add_argument( |
| 854 | "--hub", dest="hub", default=None, metavar="URL", |
| 855 | help="MuseHub base URL (overrides config).", |
| 856 | ) |
| 857 | repo_read_p.add_argument( |
| 858 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 859 | help="Emit JSON to stdout.", |
| 860 | ) |
| 861 | repo_read_p.set_defaults(func=run_repo_read) |
| 862 | |
| 863 | repo_p.set_defaults(func=lambda a: repo_p.print_help()) |
| 864 |
File History
4 commits
sha256:371209e571fc5fc0010114aaa8f272b15179a2245abd7f62320fa700fbe2a60b
feat: add url and base64 id to hub issue/proposal/repo crea…
Sonnet 4.6
patch
8 days ago
sha256:99f8eb388d9a9c353e68b9a4e5bebe1b4240a8f511e6f0928e58c0e95153e103
feat: branch --prune-config, fix hub repo delete docstrings…
Sonnet 4.6
minor
⚠
17 days ago
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4
fix: carry dev changes harmony dropped in merge — detached …
Sonnet 4.6
minor
⚠
17 days ago
sha256:7355a363c85a9bb4e89ab76048dc895e528c2c9a72060b5e97701aac20ddebeb
clean up `muse -C ~/ecosystem/muse hub repo create --name m…
Human
patch
19 days ago