hub.py
python
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
| 1 | """muse hub — MuseHub fabric connection management. |
| 2 | |
| 3 | The hub is not just a remote. It is the shared fabric where versioned |
| 4 | multidimensional state flows across agents, humans, and repositories. |
| 5 | Connecting a repo to a hub anchors it to the synchronisation layer that |
| 6 | enables push/pull, plugin discovery, and multi-agent coordination. |
| 7 | |
| 8 | Separation of concerns |
| 9 | ----------------------- |
| 10 | - ``muse remote`` manages generic push/pull endpoints (any Muse server). |
| 11 | - ``muse hub`` manages the *primary identity fabric* — the one hub this |
| 12 | repo belongs to for authentication, discovery, and coordination. |
| 13 | |
| 14 | A repo has at most **one** hub. It may have many remotes. |
| 15 | |
| 16 | Security model |
| 17 | -------------- |
| 18 | - ``_normalise_url`` enforces HTTPS for non-loopback hosts at connect time. |
| 19 | - ``_hub_api`` validates the URL scheme before every network request, |
| 20 | preventing SSRF from a tampered ``config.toml``. |
| 21 | - ``_hub_api`` caps the response body at ``_MAX_API_RESPONSE_BYTES`` to |
| 22 | prevent OOM from a hostile hub. |
| 23 | - All user-controlled values (proposal titles, branch names, hub URLs) are passed |
| 24 | through ``sanitize_display()`` before being printed. |
| 25 | - All diagnostic messages go to **stderr**; **stdout** is reserved for JSON |
| 26 | and machine-readable data. |
| 27 | |
| 28 | Subcommands |
| 29 | ----------- |
| 30 | :: |
| 31 | |
| 32 | muse hub connect <url> Attach this repo to a MuseHub instance. |
| 33 | muse hub status [--json] Show connection and identity information. |
| 34 | muse hub disconnect [--json] Remove the hub association from this repo. |
| 35 | muse hub ping [--json] Test HTTP connectivity to the hub. |
| 36 | |
| 37 | muse hub proposal list [--json] List proposals on MuseHub. |
| 38 | muse hub proposal read <proposal-id> [--json] Show a single proposal. |
| 39 | muse hub proposal create [--json] Open a new proposal. |
| 40 | muse hub proposal merge <proposal-id> [--json] Merge a proposal. |
| 41 | |
| 42 | JSON schemas |
| 43 | ------------ |
| 44 | ``muse hub connect --json``:: |
| 45 | |
| 46 | {"status": "ok", "hub_url": "<url>", "hostname": "<host>", |
| 47 | "authenticated": true|false, "identity_name": "<name>", |
| 48 | "identity_type": "<type>"} |
| 49 | |
| 50 | ``muse hub status --json``:: |
| 51 | |
| 52 | {"hub_url": "<url>", "hostname": "<host>", |
| 53 | "authenticated": true|false, "identity_type": "<type>", |
| 54 | "identity_name": "<name>", "identity_id": "<id>"} |
| 55 | |
| 56 | ``muse hub disconnect --json``:: |
| 57 | |
| 58 | {"status": "ok"|"nothing_to_do", "hostname": "<host>"} |
| 59 | |
| 60 | ``muse hub ping --json``:: |
| 61 | |
| 62 | {"status": "ok"|"error", "hub_url": "<url>", |
| 63 | "hostname": "<host>", "reachable": true|false, "message": "<msg>"} |
| 64 | |
| 65 | Agent workflow examples |
| 66 | ----------------------- |
| 67 | :: |
| 68 | |
| 69 | # Verify hub auth before starting work |
| 70 | muse hub status --json | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d['authenticated'] else 1)" |
| 71 | |
| 72 | # Create a proposal and capture the ID |
| 73 | muse hub proposal create --title "feat: x" --json | python3 -c "import json,sys; print(json.load(sys.stdin)['proposalId'])" |
| 74 | |
| 75 | # List open proposals (machine-readable) |
| 76 | muse hub proposal list --json |
| 77 | |
| 78 | # Merge with squash strategy |
| 79 | muse hub proposal merge af54753d --strategy squash --json |
| 80 | """ |
| 81 | |
| 82 | from __future__ import annotations |
| 83 | |
| 84 | import argparse |
| 85 | import http.client |
| 86 | import json |
| 87 | import logging |
| 88 | import sys |
| 89 | import textwrap |
| 90 | import urllib.error |
| 91 | import urllib.parse |
| 92 | import urllib.request |
| 93 | from collections.abc import Mapping |
| 94 | from typing import IO, TypedDict |
| 95 | |
| 96 | from muse.cli.config import ( |
| 97 | clear_hub_url, |
| 98 | get_hub_url, |
| 99 | get_remote, |
| 100 | list_remotes, |
| 101 | set_hub_url, |
| 102 | ) |
| 103 | from muse.core.errors import ExitCode |
| 104 | from muse.core.identity import IdentityEntry, load_identity |
| 105 | from muse.core.repo import find_repo_root |
| 106 | from muse.core.store import read_current_branch |
| 107 | from muse.core._types import Metadata |
| 108 | from muse.core.validation import clamp_int, sanitize_display |
| 109 | |
| 110 | logger = logging.getLogger(__name__) |
| 111 | |
| 112 | type _HubPayload = dict[str, str | bool | list[str]] |
| 113 | type _ProposalPayload = dict[str, str | list[str]] |
| 114 | |
| 115 | _CONNECT_TIMEOUT = 8 # seconds for ping/status health check |
| 116 | _MAX_API_RESPONSE_BYTES = 4 * 1024 * 1024 # 4 MiB cap on API responses |
| 117 | |
| 118 | # Only allow http and https — no file://, ftp://, data://, etc. |
| 119 | _ALLOWED_API_SCHEMES = frozenset({"http", "https"}) |
| 120 | |
| 121 | # Maximum proposals fetched when resolving an 8-char prefix — repos with more proposals |
| 122 | # than this will not find older ones by prefix. Intentionally conservative; |
| 123 | # raise if real-world repos hit the limit. |
| 124 | _PROPOSAL_PREFIX_RESOLVE_LIMIT = 200 |
| 125 | _MAX_PROPOSAL_BODY_LINES = 20 # body lines shown in text mode before truncation hint |
| 126 | _MAX_PROPOSAL_TITLE_LEN = 512 # client-side guard: titles longer than this are rejected |
| 127 | _MAX_ISSUE_TITLE_LEN = 512 # client-side guard: issue titles longer than this are rejected |
| 128 | _MAX_REPO_NAME_LEN = 255 # mirrors CreateRepoRequest.name max_length on the server |
| 129 | _MAX_REPO_DESC_LEN = 1024 # reasonable cap on description before sending to server |
| 130 | _MAX_ISSUE_LABEL_LEN = 255 # mirrors label name max_length on the server |
| 131 | _MAX_ISSUE_COMMENT_LEN = 50_000 # mirrors IssueCommentCreate.body max_length on the server |
| 132 | _MAX_LABEL_NAME_LEN = 50 # mirrors LabelCreate.name max_length on the server |
| 133 | _MAX_LABEL_DESC_LEN = 200 # mirrors LabelCreate.description max_length on the server |
| 134 | |
| 135 | |
| 136 | # ── TypedDicts ──────────────────────────────────────────────────────────────── |
| 137 | |
| 138 | |
| 139 | class _ConnectJson(TypedDict): |
| 140 | """JSON schema for ``muse hub connect --json``.""" |
| 141 | |
| 142 | status: str # "ok" |
| 143 | hub_url: str |
| 144 | hostname: str |
| 145 | authenticated: bool |
| 146 | identity_name: str # display name or "" |
| 147 | identity_type: str # "human" | "agent" | "" |
| 148 | |
| 149 | |
| 150 | class _StatusJson(TypedDict): |
| 151 | """JSON schema for ``muse hub status --json``. |
| 152 | |
| 153 | All keys are always present so agents can access them without guard checks. |
| 154 | """ |
| 155 | |
| 156 | hub_url: str |
| 157 | hostname: str |
| 158 | authenticated: bool |
| 159 | identity_type: str # "" when not authenticated |
| 160 | identity_name: str # "" when not authenticated |
| 161 | identity_id: str # "" when not authenticated |
| 162 | capabilities: list[str] # [] when not authenticated or not an agent |
| 163 | |
| 164 | |
| 165 | class _DisconnectJson(TypedDict): |
| 166 | """JSON schema for ``muse hub disconnect --json``.""" |
| 167 | |
| 168 | status: str # "ok" | "nothing_to_do" |
| 169 | hub_url: str # full normalised URL removed, or "" when nothing was connected |
| 170 | hostname: str # host[:port] display form, or "" when nothing was connected |
| 171 | |
| 172 | |
| 173 | class _PingJson(TypedDict): |
| 174 | """JSON schema for ``muse hub ping --json``.""" |
| 175 | |
| 176 | status: str # "ok" | "error" |
| 177 | hub_url: str |
| 178 | hostname: str |
| 179 | reachable: bool |
| 180 | message: str |
| 181 | |
| 182 | |
| 183 | class _RepoEntry(TypedDict, total=False): |
| 184 | """A single repo entry returned by the MuseHub search/list API.""" |
| 185 | |
| 186 | owner: str |
| 187 | slug: str |
| 188 | repoId: str |
| 189 | |
| 190 | |
| 191 | class _ProposalEntry(TypedDict, total=False): |
| 192 | """A single proposal entry returned by the MuseHub proposals API.""" |
| 193 | |
| 194 | proposalId: str |
| 195 | title: str |
| 196 | state: str # "open" | "merged" | "closed" |
| 197 | fromBranch: str |
| 198 | toBranch: str |
| 199 | author: str |
| 200 | createdAt: str |
| 201 | |
| 202 | |
| 203 | class _RepoJson(TypedDict): |
| 204 | """JSON schema for ``muse hub repo create --json``.""" |
| 205 | |
| 206 | repo_id: str |
| 207 | name: str |
| 208 | owner: str |
| 209 | slug: str |
| 210 | visibility: str # "public" | "private" |
| 211 | description: str |
| 212 | clone_url: str |
| 213 | tags: list[str] |
| 214 | created_at: str # ISO-8601 UTC |
| 215 | |
| 216 | |
| 217 | class _HubApiResponse(TypedDict, total=False): |
| 218 | """Generic MuseHub API response envelope (all keys optional). |
| 219 | |
| 220 | Different endpoints populate different subsets of these keys. |
| 221 | Callers must validate presence before accessing. |
| 222 | """ |
| 223 | |
| 224 | repo_id: str |
| 225 | repos: list[_RepoEntry] |
| 226 | proposals: list[_ProposalEntry] |
| 227 | |
| 228 | |
| 229 | class _LabelEntry(TypedDict, total=False): |
| 230 | """One label row returned by the MuseHub labels API.""" |
| 231 | |
| 232 | label_id: str |
| 233 | repo_id: str |
| 234 | name: str |
| 235 | color: str |
| 236 | description: str |
| 237 | |
| 238 | |
| 239 | # ── Security — redirect refusal for ping ────────────────────────────────────── |
| 240 | |
| 241 | |
| 242 | class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): |
| 243 | """Refuse all HTTP redirects for the ping health check. |
| 244 | |
| 245 | No credentials travel on the ping request, but silently following |
| 246 | redirects (potentially cross-scheme or cross-host) is misleading about |
| 247 | what was actually reached and normalises insecure redirect behaviour. |
| 248 | """ |
| 249 | |
| 250 | def redirect_request( |
| 251 | self, |
| 252 | req: urllib.request.Request, |
| 253 | fp: IO[bytes], |
| 254 | code: int, |
| 255 | msg: str, |
| 256 | headers: http.client.HTTPMessage, |
| 257 | newurl: str, |
| 258 | ) -> urllib.request.Request | None: |
| 259 | raise urllib.error.HTTPError( |
| 260 | req.full_url, |
| 261 | code, |
| 262 | f"Redirect refused ({code}): hub redirected to {newurl!r}. Update the hub URL.", |
| 263 | headers, |
| 264 | fp, |
| 265 | ) |
| 266 | |
| 267 | |
| 268 | _PING_OPENER = urllib.request.build_opener(_NoRedirectHandler()) |
| 269 | |
| 270 | |
| 271 | # ── URL / hostname helpers ──────────────────────────────────────────────────── |
| 272 | |
| 273 | |
| 274 | def _normalise_url(url: str) -> str: |
| 275 | """Normalise *url* to an https:// URL (or http:// for loopback addresses). |
| 276 | |
| 277 | Adds ``https://`` when no scheme is present. Raises ``ValueError`` when |
| 278 | an explicit ``http://`` scheme is given for a non-loopback host — sending |
| 279 | sending credentials over cleartext HTTP to a remote server is never acceptable. |
| 280 | |
| 281 | Exception: ``http://localhost``, ``http://127.0.0.1``, and |
| 282 | ``http://[::1]`` (with or without a port) are allowed because loopback |
| 283 | traffic never leaves the machine and cannot be intercepted in transit. |
| 284 | |
| 285 | Args: |
| 286 | url: Raw user-supplied URL. |
| 287 | |
| 288 | Returns: |
| 289 | Normalised URL without a trailing slash. |
| 290 | |
| 291 | Raises: |
| 292 | ValueError: If the URL uses ``http://`` for a non-loopback host, or |
| 293 | uses a disallowed scheme (``file://``, ``ftp://``, etc.). |
| 294 | """ |
| 295 | stripped = url.strip().rstrip("/") |
| 296 | |
| 297 | # Check for an explicit URL scheme BEFORE adding https://, catching |
| 298 | # file:///etc/passwd, ftp://, data:text/plain, javascript:, etc. |
| 299 | # |
| 300 | # We can't unconditionally rely on urlparse's scheme detection because |
| 301 | # bare host:port inputs like "musehub.ai:8443" are misidentified: |
| 302 | # urlparse("musehub.ai:8443").scheme == "musehub.ai". |
| 303 | # |
| 304 | # Heuristic: if the URL contains "://" OR if the part after the first ":" |
| 305 | # is NOT a pure port number (digits), treat the prefix as a scheme. |
| 306 | # Examples: |
| 307 | # "musehub.ai:8443" → after ":" is "8443" (digits) → host:port, skip |
| 308 | # "localhost:8080" → after ":" is "8080" (digits) → host:port, skip |
| 309 | # "data:text/plain,x" → after ":" is "text/plain,x" → scheme, check |
| 310 | # "javascript:alert()" → after ":" is "alert()" → scheme, check |
| 311 | # "https://musehub.ai" → "://" present → scheme, check |
| 312 | colon_idx = stripped.find(":") |
| 313 | if colon_idx > 0: |
| 314 | after_colon = stripped[colon_idx + 1:].lstrip("/") |
| 315 | is_port = after_colon.isdigit() # bare port: "8443", "10003", etc. |
| 316 | if not is_port: |
| 317 | pre_scheme = urllib.parse.urlparse(stripped).scheme.lower() |
| 318 | if pre_scheme and pre_scheme not in _ALLOWED_API_SCHEMES: |
| 319 | raise ValueError( |
| 320 | f"URL scheme '{pre_scheme}' is not allowed. " |
| 321 | "Use https:// (or http:// for localhost)." |
| 322 | ) |
| 323 | |
| 324 | if not stripped.startswith(("http://", "https://")): |
| 325 | stripped = f"https://{stripped}" |
| 326 | |
| 327 | if stripped.startswith("http://"): |
| 328 | # Use urlparse to correctly extract the hostname — handles IPv6 |
| 329 | # bracket notation (e.g. http://[::1]:8080) where manual split(":")[0] |
| 330 | # would yield "[" instead of "::1". |
| 331 | _loopback = {"localhost", "127.0.0.1", "::1"} |
| 332 | parsed_host = urllib.parse.urlparse(stripped).hostname or "" |
| 333 | if parsed_host not in _loopback: |
| 334 | host = stripped[len("http://"):] |
| 335 | raise ValueError( |
| 336 | f"Insecure URL rejected: {stripped!r}\n" |
| 337 | f"MuseHub requires HTTPS for non-loopback hosts. Did you mean: https://{host}" |
| 338 | ) |
| 339 | return stripped |
| 340 | |
| 341 | |
| 342 | def _hub_hostname(url: str) -> str: |
| 343 | """Extract the display hostname (host:port) from a hub URL.""" |
| 344 | stripped = url.strip().rstrip("/") |
| 345 | if "://" in stripped: |
| 346 | stripped = stripped.split("://", 1)[1] |
| 347 | return stripped.split("/")[0] |
| 348 | |
| 349 | |
| 350 | def _ping_hub(url: str) -> tuple[bool, str]: |
| 351 | """Attempt an HTTP GET to ``<url>/health``. |
| 352 | |
| 353 | Returns a ``(reachable, message)`` tuple. Never raises — all errors are |
| 354 | captured and surfaced as human-readable strings. |
| 355 | |
| 356 | Security note |
| 357 | ------------- |
| 358 | Only ``http`` and ``https`` schemes are attempted. Any other scheme |
| 359 | (``file://``, ``ftp://``, etc.) returns ``(False, "scheme not allowed")`` |
| 360 | without opening a socket — prevents callers from accidentally probing the |
| 361 | local filesystem via a ``--hub`` override. |
| 362 | |
| 363 | Exception coverage |
| 364 | ------------------ |
| 365 | - ``urllib.error.HTTPError`` — non-2xx from a reachable server |
| 366 | - ``urllib.error.URLError`` — DNS failure, connection refused |
| 367 | - ``TimeoutError`` — connect/read timeout |
| 368 | - ``OSError`` — network-layer errors (SSL, connection reset, etc.) |
| 369 | - ``http.client.HTTPException`` — malformed HTTP response |
| 370 | (``BadStatusLine``, ``InvalidURL``) from a misbehaving server; |
| 371 | NOT a subclass of OSError so must be caught separately |
| 372 | """ |
| 373 | # Scheme guard — never probe non-HTTP(S) targets. |
| 374 | scheme = urllib.parse.urlparse(url).scheme.lower() |
| 375 | if scheme not in _ALLOWED_API_SCHEMES: |
| 376 | return False, f"URL scheme '{scheme}' is not allowed (use http or https)" |
| 377 | |
| 378 | health_url = f"{url.rstrip('/')}/health" |
| 379 | try: |
| 380 | req = urllib.request.Request(health_url, method="GET") |
| 381 | with _PING_OPENER.open(req, timeout=_CONNECT_TIMEOUT) as resp: |
| 382 | status = resp.status |
| 383 | if 200 <= status < 300: |
| 384 | return True, f"HTTP {status} OK" |
| 385 | return False, f"HTTP {status}" |
| 386 | except urllib.error.HTTPError as exc: |
| 387 | return False, f"HTTP {exc.code} {exc.reason}" |
| 388 | except urllib.error.URLError as exc: |
| 389 | return False, str(exc.reason) |
| 390 | except TimeoutError: |
| 391 | return False, "timed out" |
| 392 | except OSError as exc: |
| 393 | return False, str(exc) |
| 394 | except http.client.HTTPException as exc: |
| 395 | # Malformed HTTP response (BadStatusLine, InvalidURL, etc.). |
| 396 | # Not an OSError subclass — must be caught explicitly. |
| 397 | return False, f"malformed response: {type(exc).__name__}" |
| 398 | |
| 399 | |
| 400 | # ── MuseHub HTTP helper ─────────────────────────────────────────────────────── |
| 401 | |
| 402 | |
| 403 | def _hub_api( |
| 404 | hub_url: str, |
| 405 | identity: IdentityEntry, |
| 406 | method: str, |
| 407 | path: str, |
| 408 | body: Mapping[str, str | bool | list[str] | None] | None = None, |
| 409 | timeout: float = 10.0, |
| 410 | ) -> _HubApiResponse: |
| 411 | """Make an authenticated JSON request to the MuseHub API. |
| 412 | |
| 413 | Security: |
| 414 | - Validates the hub URL scheme (``http``/``https`` only) before opening |
| 415 | any socket — prevents SSRF if ``config.toml`` is tampered with. |
| 416 | - Caps the response body at ``_MAX_API_RESPONSE_BYTES`` to prevent OOM |
| 417 | from a hostile or misbehaving hub. |
| 418 | - Sanitizes error detail from HTTP responses before printing. |
| 419 | |
| 420 | Args: |
| 421 | hub_url: Repository-level hub URL (e.g. ``http://localhost:10003/gabriel/muse``). |
| 422 | identity: Identity entry from ``~/.muse/identity.toml``. |
| 423 | method: HTTP method (``GET``, ``POST``, ``DELETE``). |
| 424 | path: API path appended to the server root (e.g. ``/api/v1/repos/{id}/proposals``). |
| 425 | body: Optional JSON body for POST/PUT requests. |
| 426 | timeout: Connect+read timeout in seconds. |
| 427 | |
| 428 | Returns: |
| 429 | Parsed JSON response body as a dict. |
| 430 | |
| 431 | Raises: |
| 432 | SystemExit: On invalid URL scheme, missing token, network errors, or non-2xx responses. |
| 433 | """ |
| 434 | parsed = urllib.parse.urlparse(hub_url) |
| 435 | scheme = parsed.scheme.lower() |
| 436 | if scheme not in _ALLOWED_API_SCHEMES: |
| 437 | print( |
| 438 | f"❌ Hub URL scheme '{sanitize_display(scheme)}' is not allowed. " |
| 439 | "Use http or https.", |
| 440 | file=sys.stderr, |
| 441 | ) |
| 442 | raise SystemExit(ExitCode.USER_ERROR) |
| 443 | |
| 444 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 445 | url = f"{server_root}{path}" |
| 446 | |
| 447 | # Resolve signing identity — try the passed identity first, then fall back to config. |
| 448 | # This fixes the bug where the identity parameter was silently ignored. |
| 449 | from muse.core.transport import SigningIdentity |
| 450 | signing: SigningIdentity | None = None |
| 451 | _handle = identity.get("handle", "") |
| 452 | _key_path_str = identity.get("key_path", "") |
| 453 | if _handle and _key_path_str: |
| 454 | import pathlib as _pl |
| 455 | _key_path = _pl.Path(_key_path_str) |
| 456 | if _key_path.is_file(): |
| 457 | try: |
| 458 | from cryptography.hazmat.primitives.serialization import load_pem_private_key |
| 459 | _pem = _key_path.read_bytes() |
| 460 | _private_key = load_pem_private_key(_pem, password=None) |
| 461 | signing = SigningIdentity(handle=_handle, private_key=_private_key) |
| 462 | except Exception: |
| 463 | pass |
| 464 | if signing is None: |
| 465 | from muse.cli.config import get_signing_identity |
| 466 | signing = get_signing_identity(remote_url=hub_url) |
| 467 | if not signing: |
| 468 | print( |
| 469 | "❌ No signing identity for this hub. Run:\n" |
| 470 | " muse auth keygen --hub <url>\n" |
| 471 | " muse auth register --hub <url> --handle <your-handle>", |
| 472 | file=sys.stderr, |
| 473 | ) |
| 474 | raise SystemExit(ExitCode.USER_ERROR) |
| 475 | |
| 476 | from muse.core.transport import build_msign_header |
| 477 | data: bytes | None = None |
| 478 | body_bytes: bytes | None = None |
| 479 | if body is not None: |
| 480 | body_bytes = json.dumps(body).encode() |
| 481 | data = body_bytes |
| 482 | headers: Metadata = { |
| 483 | "Authorization": build_msign_header(signing, method, url, body_bytes), |
| 484 | "Accept": "application/json", |
| 485 | } |
| 486 | if body_bytes is not None: |
| 487 | headers["Content-Type"] = "application/json" |
| 488 | |
| 489 | req = urllib.request.Request(url, data=data, headers=headers, method=method) |
| 490 | try: |
| 491 | with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 |
| 492 | raw = resp.read(_MAX_API_RESPONSE_BYTES + 1) |
| 493 | if len(raw) > _MAX_API_RESPONSE_BYTES: |
| 494 | print( |
| 495 | f"❌ Response from {sanitize_display(url)} exceeds size limit.", |
| 496 | file=sys.stderr, |
| 497 | ) |
| 498 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 499 | text = raw.decode("utf-8", errors="replace") |
| 500 | parsed = json.loads(text) if text.strip() else {} |
| 501 | return parsed if isinstance(parsed, dict) else {} |
| 502 | except urllib.error.HTTPError as exc: |
| 503 | raw_body = exc.read().decode(errors="replace") |
| 504 | try: |
| 505 | detail = json.loads(raw_body).get("detail", raw_body) |
| 506 | except (json.JSONDecodeError, AttributeError): |
| 507 | detail = raw_body[:200] |
| 508 | print( |
| 509 | f"❌ MuseHub API error {exc.code}: {sanitize_display(str(detail))}", |
| 510 | file=sys.stderr, |
| 511 | ) |
| 512 | raise SystemExit(ExitCode.INTERNAL_ERROR) from exc |
| 513 | except urllib.error.URLError as exc: |
| 514 | print( |
| 515 | f"❌ Cannot reach MuseHub: {sanitize_display(str(exc.reason))}", |
| 516 | file=sys.stderr, |
| 517 | ) |
| 518 | raise SystemExit(ExitCode.INTERNAL_ERROR) from exc |
| 519 | |
| 520 | |
| 521 | # ── Repo / identity resolution helpers ─────────────────────────────────────── |
| 522 | |
| 523 | |
| 524 | def _enrich_hub_url_from_remote(hub_base_url: str) -> str: |
| 525 | """Append owner/slug to a bare hub URL by cross-referencing configured remotes. |
| 526 | |
| 527 | When the hub URL stored by ``muse hub connect`` is a bare base URL |
| 528 | (e.g. ``http://localhost:10003``) with no owner/slug path component, |
| 529 | this function searches the configured remotes for one whose netloc matches |
| 530 | the hub netloc and extracts the owner/slug from its path. |
| 531 | |
| 532 | Returns the enriched URL on a match; returns *hub_base_url* unchanged when |
| 533 | no matching remote is found or when the caller is not inside a repo. |
| 534 | """ |
| 535 | base_parsed = urllib.parse.urlparse(hub_base_url) |
| 536 | if len(base_parsed.path.strip("/").split("/")) >= 2 and base_parsed.path.strip("/"): |
| 537 | # Already has owner/slug — nothing to do. |
| 538 | return hub_base_url |
| 539 | root = find_repo_root() |
| 540 | if root is None: |
| 541 | return hub_base_url |
| 542 | for remote in list_remotes(root): |
| 543 | r = urllib.parse.urlparse(remote["url"]) |
| 544 | if r.netloc == base_parsed.netloc: |
| 545 | parts = r.path.strip("/").split("/") |
| 546 | if len(parts) >= 2 and parts[0]: |
| 547 | return f"{base_parsed.scheme}://{base_parsed.netloc}/{parts[0]}/{parts[1]}" |
| 548 | return hub_base_url |
| 549 | |
| 550 | |
| 551 | def _resolve_repo_id(hub_url: str, identity: IdentityEntry) -> str: |
| 552 | """Look up the MuseHub repo UUID from the hub URL's owner/slug path. |
| 553 | |
| 554 | The hub URL is ``http://host/owner/slug``. This calls |
| 555 | ``GET /{owner}/{slug}/refs`` to obtain the UUID, falling back to the |
| 556 | search endpoint if the refs response does not include ``repo_id``. |
| 557 | |
| 558 | When the hub URL is a bare base URL (no owner/slug), the function first |
| 559 | attempts to enrich it from the configured remotes via |
| 560 | :func:`_enrich_hub_url_from_remote`. |
| 561 | |
| 562 | Raises: |
| 563 | SystemExit: If the URL has no owner/slug or the repo ID cannot be resolved. |
| 564 | """ |
| 565 | hub_url = _enrich_hub_url_from_remote(hub_url) |
| 566 | parsed = urllib.parse.urlparse(hub_url) |
| 567 | parts = parsed.path.strip("/").split("/") |
| 568 | if len(parts) < 2: |
| 569 | print( |
| 570 | f"❌ Hub URL '{sanitize_display(hub_url)}' does not contain " |
| 571 | "owner/slug — cannot resolve repo ID.", |
| 572 | file=sys.stderr, |
| 573 | ) |
| 574 | raise SystemExit(ExitCode.USER_ERROR) |
| 575 | owner, slug = parts[0], parts[1] |
| 576 | |
| 577 | data = _hub_api(hub_url, identity, "GET", f"/{owner}/{slug}/refs") |
| 578 | repo_id: str = str(data.get("repo_id", "")) |
| 579 | if not repo_id: |
| 580 | # Fallback: search endpoint |
| 581 | search = _hub_api( |
| 582 | hub_url, identity, "GET", |
| 583 | f"/api/search?q={urllib.parse.quote(slug)}&limit=5", |
| 584 | ) |
| 585 | repos_val = search.get("repos", []) |
| 586 | repos_list: list[_RepoEntry] = ( |
| 587 | [r for r in repos_val if isinstance(r, dict)] |
| 588 | if isinstance(repos_val, list) else [] |
| 589 | ) |
| 590 | for repo in repos_list: |
| 591 | if repo.get("owner") == owner and repo.get("slug") == slug: |
| 592 | repo_id = str(repo.get("repoId", "")) |
| 593 | break |
| 594 | if not repo_id: |
| 595 | print( |
| 596 | f"❌ Could not resolve repo ID for {sanitize_display(owner)}/{sanitize_display(slug)}.", |
| 597 | file=sys.stderr, |
| 598 | ) |
| 599 | raise SystemExit(ExitCode.USER_ERROR) |
| 600 | return repo_id |
| 601 | |
| 602 | |
| 603 | def _get_hub_and_identity( |
| 604 | remote: str | None = None, |
| 605 | hub_url_override: str | None = None, |
| 606 | ) -> tuple[str, IdentityEntry]: |
| 607 | """Load hub URL and identity for the current repo, or exit with a helpful error. |
| 608 | |
| 609 | Falls back to a named remote's URL when no hub is configured — useful for |
| 610 | local development where the remote IS the hub. |
| 611 | |
| 612 | When *hub_url_override* is provided it takes precedence over the hub URL |
| 613 | stored in ``.muse/config.toml``. This is the value supplied via the |
| 614 | ``--hub`` CLI flag and lets callers (e.g. containerised agents) target a |
| 615 | hub at a different address than the one recorded in the repo config without |
| 616 | having to modify ``config.toml``. |
| 617 | |
| 618 | Raises: |
| 619 | SystemExit: If not inside a repo, no hub/remote is configured, or not authenticated. |
| 620 | """ |
| 621 | root = find_repo_root() |
| 622 | if root is None: |
| 623 | print("❌ Not inside a Muse repository.", file=sys.stderr) |
| 624 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 625 | |
| 626 | hub_url = hub_url_override or get_hub_url(root) |
| 627 | |
| 628 | if hub_url is None: |
| 629 | # No hub configured — try the specified remote, then 'local', then any remote. |
| 630 | candidate_name = remote or "local" |
| 631 | candidate_url = get_remote(candidate_name, root) |
| 632 | if candidate_url is None and remote is None: |
| 633 | remotes = list_remotes(root) |
| 634 | candidate_url = remotes[0]["url"] if remotes else None |
| 635 | if candidate_url: |
| 636 | candidate_name = remotes[0]["name"] |
| 637 | if candidate_url: |
| 638 | hub_url = candidate_url |
| 639 | logger.debug( |
| 640 | "No hub configured — using remote '%s' as hub URL: %s", |
| 641 | candidate_name, hub_url, |
| 642 | ) |
| 643 | else: |
| 644 | print("❌ No hub connected and no remotes configured.", file=sys.stderr) |
| 645 | print( |
| 646 | " Run `muse hub connect <url>` or `muse remote add local <url>`.", |
| 647 | file=sys.stderr, |
| 648 | ) |
| 649 | raise SystemExit(ExitCode.USER_ERROR) |
| 650 | |
| 651 | identity = load_identity(hub_url) |
| 652 | if identity is None: |
| 653 | print( |
| 654 | f"❌ Not authenticated for {sanitize_display(hub_url)}.", |
| 655 | file=sys.stderr, |
| 656 | ) |
| 657 | print(f" Run: muse auth register --hub {hub_url}", file=sys.stderr) |
| 658 | raise SystemExit(ExitCode.USER_ERROR) |
| 659 | |
| 660 | return hub_url, identity |
| 661 | |
| 662 | |
| 663 | def _resolve_proposal_id( |
| 664 | hub_url: str, |
| 665 | identity: IdentityEntry, |
| 666 | repo_id: str, |
| 667 | proposal_id_or_prefix: str, |
| 668 | ) -> str: |
| 669 | """Resolve a proposal ID prefix to a full UUID. |
| 670 | |
| 671 | If *proposal_id_or_prefix* looks like a full UUID (≥32 chars with hyphens), it |
| 672 | is returned as-is. Otherwise the most recent ``_PROPOSAL_PREFIX_RESOLVE_LIMIT`` |
| 673 | proposals are fetched and the first match whose ``proposalId`` starts with the prefix |
| 674 | is returned. |
| 675 | |
| 676 | All user-supplied and API-sourced strings are sanitized before printing. |
| 677 | |
| 678 | Note |
| 679 | ---- |
| 680 | Prefix resolution is capped at :data:`_PROPOSAL_PREFIX_RESOLVE_LIMIT` proposals. |
| 681 | On repositories with many more open proposals, very old proposals may not be findable |
| 682 | by prefix — pass the full UUID in that case. |
| 683 | |
| 684 | Raises: |
| 685 | SystemExit: When no match is found or multiple proposals match the prefix. |
| 686 | """ |
| 687 | if "-" in proposal_id_or_prefix and len(proposal_id_or_prefix) >= 32: |
| 688 | return proposal_id_or_prefix # looks like a full UUID |
| 689 | |
| 690 | data = _hub_api( |
| 691 | hub_url, identity, "GET", |
| 692 | f"/api/repos/{repo_id}/proposals?limit={_PROPOSAL_PREFIX_RESOLVE_LIMIT}", |
| 693 | ) |
| 694 | proposals_val = data.get("proposals", []) |
| 695 | proposals: list[_ProposalEntry] = ( |
| 696 | [r for r in proposals_val if isinstance(r, dict)] |
| 697 | if isinstance(proposals_val, list) else [] |
| 698 | ) |
| 699 | matches = [p for p in proposals if str(p.get("proposalId", "")).startswith(proposal_id_or_prefix)] |
| 700 | |
| 701 | if not matches: |
| 702 | print( |
| 703 | f"❌ No proposal found with ID prefix '{sanitize_display(proposal_id_or_prefix)}'.", |
| 704 | file=sys.stderr, |
| 705 | ) |
| 706 | raise SystemExit(ExitCode.USER_ERROR) |
| 707 | if len(matches) > 1: |
| 708 | print( |
| 709 | f"❌ Ambiguous prefix '{sanitize_display(proposal_id_or_prefix)}' " |
| 710 | f"— matches {len(matches)} proposals:", |
| 711 | file=sys.stderr, |
| 712 | ) |
| 713 | for m in matches: |
| 714 | print( |
| 715 | f" {sanitize_display(str(m.get('proposalId', '')))} " |
| 716 | f"{sanitize_display(str(m.get('title', '')))}", |
| 717 | file=sys.stderr, |
| 718 | ) |
| 719 | raise SystemExit(ExitCode.USER_ERROR) |
| 720 | return str(matches[0].get("proposalId", proposal_id_or_prefix)) |
| 721 | |
| 722 | |
| 723 | def _format_proposal(entry: _ProposalEntry, *, verbose: bool = False) -> str: |
| 724 | """Format a single proposal for human-readable display. |
| 725 | |
| 726 | All fields from the API response are sanitized via :func:`sanitize_display` |
| 727 | before inclusion in the returned string — including ``author`` and |
| 728 | ``createdAt`` in verbose mode. |
| 729 | |
| 730 | Args: |
| 731 | entry: Raw proposal dict from the MuseHub API. |
| 732 | verbose: When ``True``, append author name and creation date (YYYY-MM-DD). |
| 733 | """ |
| 734 | proposal_id = sanitize_display(str(entry.get("proposalId", ""))[:8]) |
| 735 | title = sanitize_display(str(entry.get("title", "(no title)"))) |
| 736 | state = str(entry.get("state", "?")) |
| 737 | from_b = sanitize_display(str(entry.get("fromBranch", "?"))) |
| 738 | to_b = sanitize_display(str(entry.get("toBranch", "?"))) |
| 739 | state_icon = {"open": "🟢", "merged": "🟣", "closed": "⛔"}.get(state, "❓") |
| 740 | line = f" {state_icon} {proposal_id} {from_b} → {to_b} {title}" |
| 741 | if verbose: |
| 742 | author = sanitize_display(str(entry.get("author", ""))) |
| 743 | created = sanitize_display(str(entry.get("createdAt", ""))[:10]) |
| 744 | line += f"\n by {author or '?'} {created}" |
| 745 | return line |
| 746 | |
| 747 | |
| 748 | # ── register ────────────────────────────────────────────────────────────────── |
| 749 | |
| 750 | |
| 751 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 752 | """Register the ``muse hub`` subcommand tree and all its flags. |
| 753 | |
| 754 | Every subcommand that produces output accepts ``--json`` for |
| 755 | machine-readable output on stdout. All progress and diagnostic messages |
| 756 | go to stderr. |
| 757 | """ |
| 758 | parser = subparsers.add_parser( |
| 759 | "hub", |
| 760 | help="MuseHub fabric connection management.", |
| 761 | description=__doc__, |
| 762 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 763 | ) |
| 764 | subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") |
| 765 | subs.required = True |
| 766 | |
| 767 | # ── connect ─────────────────────────────────────────────────────────────── |
| 768 | connect_p = subs.add_parser( |
| 769 | "connect", |
| 770 | help="Attach this repository to a MuseHub instance.", |
| 771 | description=( |
| 772 | "Write [hub] url to .muse/config.toml and confirm auth status.\n" |
| 773 | "Does not touch credentials — authenticate with 'muse auth register'.\n\n" |
| 774 | "URL is normalised: bare hostnames gain https://, trailing slashes\n" |
| 775 | "are stripped, http:// is rejected for non-loopback hosts.\n\n" |
| 776 | "Agent quickstart:\n" |
| 777 | " muse hub connect https://musehub.ai --json && muse auth register --agent --json\n\n" |
| 778 | "JSON output keys: status, hub_url, hostname, authenticated,\n" |
| 779 | " identity_name, identity_type" |
| 780 | ), |
| 781 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 782 | ) |
| 783 | connect_p.add_argument( |
| 784 | "url", metavar="URL", |
| 785 | help="MuseHub URL (e.g. https://musehub.ai or just musehub.ai).", |
| 786 | ) |
| 787 | connect_p.add_argument( |
| 788 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 789 | help="Emit a JSON object to stdout on success.", |
| 790 | ) |
| 791 | connect_p.set_defaults(func=run_connect) |
| 792 | |
| 793 | # ── disconnect ──────────────────────────────────────────────────────────── |
| 794 | disconnect_p = subs.add_parser( |
| 795 | "disconnect", |
| 796 | help="Remove the hub association from this repository.", |
| 797 | description=( |
| 798 | "Remove [hub] url from .muse/config.toml. Credentials in\n" |
| 799 | "~/.muse/identity.toml are preserved — use 'muse auth logout'\n" |
| 800 | "to remove them too. Makes no network calls.\n\n" |
| 801 | "Operation is idempotent: exits 0 with status 'nothing_to_do'\n" |
| 802 | "when no hub is configured.\n\n" |
| 803 | "Agent quickstart:\n" |
| 804 | " muse hub disconnect --json # get hub_url for cleanup\n\n" |
| 805 | "JSON keys: status, hub_url, hostname" |
| 806 | ), |
| 807 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 808 | ) |
| 809 | disconnect_p.add_argument( |
| 810 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 811 | help="Emit a JSON object to stdout on completion.", |
| 812 | ) |
| 813 | disconnect_p.set_defaults(func=run_disconnect) |
| 814 | |
| 815 | # ── repo ────────────────────────────────────────────────────────────────── |
| 816 | repo_p = subs.add_parser( |
| 817 | "repo", |
| 818 | help="Manage repositories on MuseHub.", |
| 819 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 820 | ) |
| 821 | repo_subs = repo_p.add_subparsers(dest="repo_subcommand", metavar="REPO_COMMAND") |
| 822 | repo_subs.required = True |
| 823 | |
| 824 | repo_create_p = repo_subs.add_parser( |
| 825 | "create", |
| 826 | help="Create a new repository on MuseHub.", |
| 827 | description=( |
| 828 | "Create a new remote Muse repository on MuseHub.\n\n" |
| 829 | "Name must be non-empty and ≤ 255 characters.\n" |
| 830 | "Owner defaults to the authenticated identity's handle.\n" |
| 831 | "The repo is initialized with an empty commit by default so it\n" |
| 832 | "is immediately pushable. Pass --no-init to skip initialization\n" |
| 833 | "(useful when you are about to push existing history).\n\n" |
| 834 | "Agent quickstart:\n" |
| 835 | " muse hub repo create --name my-repo --json\n" |
| 836 | " muse hub repo create --name my-repo --private --no-init --json\n\n" |
| 837 | "JSON output keys: repo_id, name, owner, slug, visibility,\n" |
| 838 | " description, clone_url, tags, created_at\n\n" |
| 839 | "Exit codes: 0 created, 1 validation/conflict/auth error,\n" |
| 840 | " 2 not in repo, 3 API/network error." |
| 841 | ), |
| 842 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 843 | ) |
| 844 | repo_create_p.add_argument( |
| 845 | "--hub", dest="hub", default=None, metavar="URL", |
| 846 | help="Override the hub URL from config (e.g. http://localhost:10003/owner/repo).", |
| 847 | ) |
| 848 | repo_create_p.add_argument( |
| 849 | "--name", "-n", required=True, |
| 850 | help="Repository name (used to generate the URL slug).", |
| 851 | ) |
| 852 | repo_create_p.add_argument( |
| 853 | "--owner", dest="owner", default="", metavar="OWNER", |
| 854 | help="Owner username. Defaults to the authenticated identity's handle.", |
| 855 | ) |
| 856 | repo_create_p.add_argument( |
| 857 | "--description", "-d", default="", |
| 858 | help="Short description shown on the explore page.", |
| 859 | ) |
| 860 | repo_create_p.add_argument( |
| 861 | "--private", action="store_true", default=False, |
| 862 | help="Create as a private repository (default: public).", |
| 863 | ) |
| 864 | repo_create_p.add_argument( |
| 865 | "--tag", dest="tags", action="append", default=[], metavar="TAG", |
| 866 | help="Tag to apply (repeatable, e.g. --tag jazz --tag piano).", |
| 867 | ) |
| 868 | repo_create_p.add_argument( |
| 869 | "--no-init", dest="no_init", action="store_true", default=False, |
| 870 | help=( |
| 871 | "Skip the initial empty commit. Use this when you are about to " |
| 872 | "push existing history." |
| 873 | ), |
| 874 | ) |
| 875 | repo_create_p.add_argument( |
| 876 | "--default-branch", dest="default_branch", default="main", metavar="BRANCH", |
| 877 | help="Name of the default branch created on initialization (default: main).", |
| 878 | ) |
| 879 | repo_create_p.add_argument( |
| 880 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 881 | help="Emit a JSON object to stdout on success.", |
| 882 | ) |
| 883 | repo_create_p.set_defaults(func=run_repo_create) |
| 884 | |
| 885 | # ── repo delete ─────────────────────────────────────────────────────────── |
| 886 | repo_delete_p = repo_subs.add_parser( |
| 887 | "delete", |
| 888 | help="Soft-delete a repository (owner only).", |
| 889 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 890 | description=textwrap.dedent( |
| 891 | """\ |
| 892 | Soft-delete a MuseHub repository. Only the repository owner may delete. |
| 893 | All data is retained for audit purposes; subsequent reads return 404. |
| 894 | |
| 895 | TARGET may be OWNER/SLUG or a repo UUID. When omitted the repo is |
| 896 | resolved from the current directory's hub remote config. |
| 897 | |
| 898 | Examples: |
| 899 | muse hub repo delete --yes |
| 900 | muse hub repo delete gabriel/my-repo --yes |
| 901 | muse hub repo delete a3f2c9d1-... --yes --json |
| 902 | """ |
| 903 | ), |
| 904 | ) |
| 905 | repo_delete_p.add_argument("target", nargs="?", default=None, |
| 906 | metavar="OWNER/SLUG|REPO_ID", |
| 907 | help="Repo to delete: OWNER/SLUG or UUID (default: current dir).") |
| 908 | repo_delete_p.add_argument("--yes", "-y", action="store_true", dest="yes", default=False, |
| 909 | help="Confirm deletion (required).") |
| 910 | repo_delete_p.add_argument("--hub", dest="hub", default=None, metavar="URL", |
| 911 | help="MuseHub base URL (overrides config).") |
| 912 | repo_delete_p.add_argument("--json", "-j", action="store_true", dest="json_output", |
| 913 | default=False, help="Emit JSON on success.") |
| 914 | repo_delete_p.set_defaults(func=run_repo_delete) |
| 915 | |
| 916 | # ── repo settings ───────────────────────────────────────────────────────── |
| 917 | repo_update_p = repo_subs.add_parser( |
| 918 | "update", |
| 919 | help="View or update repository settings (owner/admin).", |
| 920 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 921 | description=textwrap.dedent( |
| 922 | """\ |
| 923 | View or patch mutable settings for a MuseHub repository. |
| 924 | Omit all patch flags to read current settings. |
| 925 | The repository is resolved from the current directory's hub remote config. |
| 926 | |
| 927 | Examples: |
| 928 | muse hub repo update |
| 929 | muse hub repo update --description "New description" --visibility private |
| 930 | muse hub repo update --json |
| 931 | """ |
| 932 | ), |
| 933 | ) |
| 934 | repo_update_p.add_argument("--name", dest="name", default=None, metavar="NAME", |
| 935 | help="New repository name.") |
| 936 | repo_update_p.add_argument("--description", dest="description", default=None, |
| 937 | metavar="TEXT", help="New markdown description.") |
| 938 | repo_update_p.add_argument("--visibility", dest="visibility", default=None, |
| 939 | choices=["public", "private"], |
| 940 | help="New visibility: public or private.") |
| 941 | repo_update_p.add_argument("--default-branch", dest="default_branch", default=None, |
| 942 | metavar="BRANCH", help="New default branch name.") |
| 943 | repo_update_p.add_argument("--homepage-url", dest="homepage_url", default=None, |
| 944 | metavar="URL", help="Project homepage URL.") |
| 945 | repo_update_p.add_argument("--hub", dest="hub", default=None, metavar="URL", |
| 946 | help="MuseHub base URL (overrides config).") |
| 947 | repo_update_p.add_argument("--json", "-j", action="store_true", dest="json_output", |
| 948 | default=False, help="Emit JSON on success.") |
| 949 | repo_update_p.set_defaults(func=run_repo_update) |
| 950 | |
| 951 | # ── repo transfer ───────────────────────────────────────────────────────── |
| 952 | repo_transfer_ownership_p = repo_subs.add_parser( |
| 953 | "transfer-ownership", |
| 954 | help="Transfer repository ownership to another user (owner only).", |
| 955 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 956 | description=textwrap.dedent( |
| 957 | """\ |
| 958 | Transfer ownership of a MuseHub repository to another user. |
| 959 | Only the current owner may initiate a transfer. |
| 960 | The repository is resolved from the current directory's hub remote config. |
| 961 | |
| 962 | Examples: |
| 963 | muse hub repo transfer-ownership --new-owner bob |
| 964 | muse hub repo transfer-ownership --new-owner bob --json |
| 965 | """ |
| 966 | ), |
| 967 | ) |
| 968 | repo_transfer_ownership_p.add_argument("--new-owner", dest="new_owner", required=True, |
| 969 | metavar="HANDLE", help="MSign handle of the new owner.") |
| 970 | repo_transfer_ownership_p.add_argument("--hub", dest="hub", default=None, metavar="URL", |
| 971 | help="MuseHub base URL (overrides config).") |
| 972 | repo_transfer_ownership_p.add_argument("--json", "-j", action="store_true", dest="json_output", |
| 973 | default=False, help="Emit JSON on success.") |
| 974 | repo_transfer_ownership_p.set_defaults(func=run_repo_transfer_ownership) |
| 975 | |
| 976 | # ── repo list ───────────────────────────────────────────────────────────── |
| 977 | repo_list_p = repo_subs.add_parser( |
| 978 | "list", |
| 979 | help="List repositories owned by or collaborated on by the authenticated user.", |
| 980 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 981 | description=textwrap.dedent( |
| 982 | """\ |
| 983 | List MuseHub repositories owned by or collaborated on by the |
| 984 | authenticated user. Results are ordered newest-first. |
| 985 | |
| 986 | Examples: |
| 987 | muse hub repo list --json |
| 988 | muse hub repo list --limit 50 --json |
| 989 | muse hub repo list --cursor "<cursor>" --json |
| 990 | """ |
| 991 | ), |
| 992 | ) |
| 993 | repo_list_p.add_argument( |
| 994 | "--limit", dest="limit", type=int, default=20, metavar="N", |
| 995 | help="Maximum repos per page (default 20, max 100).", |
| 996 | ) |
| 997 | repo_list_p.add_argument( |
| 998 | "--cursor", dest="cursor", default=None, metavar="CURSOR", |
| 999 | help="Pagination cursor from a previous next_cursor field.", |
| 1000 | ) |
| 1001 | repo_list_p.add_argument( |
| 1002 | "--hub", dest="hub", default=None, metavar="URL", |
| 1003 | help="MuseHub base URL (overrides config).", |
| 1004 | ) |
| 1005 | repo_list_p.add_argument( |
| 1006 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1007 | help="Emit JSON to stdout.", |
| 1008 | ) |
| 1009 | repo_list_p.set_defaults(func=run_repo_list) |
| 1010 | |
| 1011 | # ── repo show ───────────────────────────────────────────────────────────── |
| 1012 | repo_show_p = repo_subs.add_parser( |
| 1013 | "show", |
| 1014 | help="Show metadata for a single MuseHub repository.", |
| 1015 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1016 | description=textwrap.dedent( |
| 1017 | """\ |
| 1018 | Show metadata for a MuseHub repository. Pass OWNER/SLUG to target |
| 1019 | a specific repo, or omit to resolve from the current directory's |
| 1020 | hub remote config. |
| 1021 | |
| 1022 | Examples: |
| 1023 | muse hub repo show gabriel/jazz-standards --json |
| 1024 | muse hub repo show --json |
| 1025 | """ |
| 1026 | ), |
| 1027 | ) |
| 1028 | repo_show_p.add_argument( |
| 1029 | "target", nargs="?", default=None, metavar="OWNER/SLUG", |
| 1030 | help="Repository to show (e.g. gabriel/my-repo). Omit to use current repo.", |
| 1031 | ) |
| 1032 | repo_show_p.add_argument( |
| 1033 | "--hub", dest="hub", default=None, metavar="URL", |
| 1034 | help="MuseHub base URL (overrides config).", |
| 1035 | ) |
| 1036 | repo_show_p.add_argument( |
| 1037 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1038 | help="Emit JSON to stdout.", |
| 1039 | ) |
| 1040 | repo_show_p.set_defaults(func=run_repo_show) |
| 1041 | |
| 1042 | repo_p.set_defaults(func=lambda a: repo_p.print_help()) |
| 1043 | |
| 1044 | # ── issue ───────────────────────────────────────────────────────────────── |
| 1045 | issue_p = subs.add_parser( |
| 1046 | "issue", |
| 1047 | help="Manage issues on MuseHub.", |
| 1048 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1049 | ) |
| 1050 | issue_subs = issue_p.add_subparsers(dest="issue_subcommand", metavar="ISSUE_COMMAND") |
| 1051 | issue_subs.required = True |
| 1052 | |
| 1053 | issue_create_p = issue_subs.add_parser( |
| 1054 | "create", |
| 1055 | help="Open a new issue.", |
| 1056 | description=( |
| 1057 | "Open a new issue on MuseHub.\n\n" |
| 1058 | f"Title must be non-empty and ≤ {_MAX_ISSUE_TITLE_LEN} characters.\n" |
| 1059 | "Use --label (repeatable) to apply labels at creation time.\n" |
| 1060 | "Use --anchor (repeatable) to link to Muse symbols (path/to/file.py::Symbol).\n" |
| 1061 | "Use --commit-anchor (repeatable) to link to specific Muse commits.\n" |
| 1062 | "Use --agent-id / --model-id when filing on behalf of an AI agent.\n" |
| 1063 | "In text mode the issue URL is printed to stdout — capture it with $().\n\n" |
| 1064 | "Agent quickstart:\n" |
| 1065 | " muse hub issue create --title 'bug: crash in create_issue' \\\n" |
| 1066 | " --anchor musehub/services/musehub_issues.py::create_issue \\\n" |
| 1067 | " --agent-id agentception-worker-42 --model-id claude-sonnet-4-6 --json\n" |
| 1068 | " URL=$(muse hub issue create --title 'feat: X' --label enhancement)\n\n" |
| 1069 | "Exit codes: 0 created, 1 validation/auth error, 2 not in repo, 3 API error." |
| 1070 | ), |
| 1071 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1072 | ) |
| 1073 | issue_create_p.add_argument( |
| 1074 | "--hub", dest="hub", default=None, metavar="URL", |
| 1075 | help="Override the hub URL from config (e.g. http://host.docker.internal:10003/owner/repo).", |
| 1076 | ) |
| 1077 | issue_create_p.add_argument( |
| 1078 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1079 | help="Alias for --hub: specify repo as owner/repo (hub base URL taken from config).", |
| 1080 | ) |
| 1081 | issue_create_p.add_argument("--title", "-t", required=True, help="Issue title.") |
| 1082 | issue_create_p.add_argument("--body", "-b", default="", help="Issue body.") |
| 1083 | issue_create_p.add_argument( |
| 1084 | "--label", "-l", dest="labels", action="append", default=[], |
| 1085 | help="Label name to apply (repeatable).", |
| 1086 | ) |
| 1087 | issue_create_p.add_argument( |
| 1088 | "--anchor", "-a", dest="symbol_anchors", action="append", default=[], |
| 1089 | metavar="FILE::SYMBOL", |
| 1090 | help=( |
| 1091 | "Muse symbol address to anchor this issue to (repeatable). " |
| 1092 | "Format: path/to/file.py::SymbolName. " |
| 1093 | "Example: --anchor musehub/services/musehub_issues.py::create_issue" |
| 1094 | ), |
| 1095 | ) |
| 1096 | issue_create_p.add_argument( |
| 1097 | "--commit-anchor", dest="commit_anchors", action="append", default=[], |
| 1098 | metavar="COMMIT_ID", |
| 1099 | help="Muse commit ID to anchor this issue to (repeatable).", |
| 1100 | ) |
| 1101 | issue_create_p.add_argument( |
| 1102 | "--agent-id", dest="agent_id", default="", |
| 1103 | help="Agent identifier when filing on behalf of an AI agent (e.g. agentception-worker-42).", |
| 1104 | ) |
| 1105 | issue_create_p.add_argument( |
| 1106 | "--model-id", dest="model_id", default="", |
| 1107 | help="Model identifier when filing on behalf of an AI agent (e.g. claude-sonnet-4-6).", |
| 1108 | ) |
| 1109 | issue_create_p.add_argument( |
| 1110 | "--json", "-j", action="store_true", dest="json_output", |
| 1111 | help="Emit JSON with the created issue.", |
| 1112 | ) |
| 1113 | issue_create_p.set_defaults(func=run_issue_create) |
| 1114 | |
| 1115 | issue_update_p = issue_subs.add_parser( |
| 1116 | "update", |
| 1117 | help="Update an existing issue.", |
| 1118 | description=( |
| 1119 | "Update an existing issue on MuseHub.\n\n" |
| 1120 | "Updates title, body, and/or anchors; omitted fields are left unchanged.\n" |
| 1121 | "At least one of --title, --body, --anchor, or --commit-anchor must be provided.\n" |
| 1122 | f"If --title is given it must be non-empty and ≤ {_MAX_ISSUE_TITLE_LEN} characters.\n" |
| 1123 | "--anchor / --commit-anchor replace the full anchor list (send all desired anchors).\n\n" |
| 1124 | "Agent quickstart:\n" |
| 1125 | " muse hub issue update 42 --body 'updated description' --json\n" |
| 1126 | " muse hub issue update 42 \\\n" |
| 1127 | " --anchor musehub/services/musehub_issues.py::create_issue \\\n" |
| 1128 | " --anchor musehub/db/musehub_models.py::MusehubIssue --json\n\n" |
| 1129 | "Exit codes: 0 updated, 1 validation/auth error, 2 not in repo, 3 API error." |
| 1130 | ), |
| 1131 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1132 | ) |
| 1133 | issue_update_p.add_argument("number", type=int, help="Issue number.") |
| 1134 | issue_update_p.add_argument( |
| 1135 | "--hub", dest="hub", default=None, metavar="URL", |
| 1136 | help="Override the hub URL from config.", |
| 1137 | ) |
| 1138 | issue_update_p.add_argument( |
| 1139 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1140 | help="Alias for --hub: specify repo as owner/repo (hub base URL taken from config).", |
| 1141 | ) |
| 1142 | issue_update_p.add_argument("--title", "-t", default=None, help="New title.") |
| 1143 | issue_update_p.add_argument("--body", "-b", default=None, help="New body.") |
| 1144 | issue_update_p.add_argument( |
| 1145 | "--anchor", "-a", dest="symbol_anchors", action="append", default=None, |
| 1146 | metavar="FILE::SYMBOL", |
| 1147 | help=( |
| 1148 | "Replacement symbol anchor (repeatable; replaces all existing anchors). " |
| 1149 | "Format: path/to/file.py::SymbolName." |
| 1150 | ), |
| 1151 | ) |
| 1152 | issue_update_p.add_argument( |
| 1153 | "--commit-anchor", dest="commit_anchors", action="append", default=None, |
| 1154 | metavar="COMMIT_ID", |
| 1155 | help="Replacement commit anchor (repeatable; replaces all existing anchors).", |
| 1156 | ) |
| 1157 | issue_update_p.add_argument( |
| 1158 | "--json", "-j", action="store_true", dest="json_output", |
| 1159 | help="Emit JSON with the updated issue.", |
| 1160 | ) |
| 1161 | issue_update_p.set_defaults(func=run_issue_update) |
| 1162 | |
| 1163 | issue_read_p = issue_subs.add_parser( |
| 1164 | "read", |
| 1165 | help="Show a single issue.", |
| 1166 | description=( |
| 1167 | "Fetch a single issue by its per-repo number.\n\n" |
| 1168 | "Agent quickstart:\n" |
| 1169 | " muse hub issue read 42 --json\n" |
| 1170 | " muse hub issue read 42 --json | jq '{number,title,state}'\n\n" |
| 1171 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 1172 | ), |
| 1173 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1174 | ) |
| 1175 | issue_read_p.add_argument("number", type=int, help="Issue number.") |
| 1176 | issue_read_p.add_argument( |
| 1177 | "--hub", dest="hub", default=None, metavar="URL", |
| 1178 | help="Override the hub URL from config.", |
| 1179 | ) |
| 1180 | issue_read_p.add_argument( |
| 1181 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1182 | help="Specify repo as owner/repo.", |
| 1183 | ) |
| 1184 | issue_read_p.add_argument( |
| 1185 | "--json", "-j", action="store_true", dest="json_output", |
| 1186 | help="Emit JSON with the issue.", |
| 1187 | ) |
| 1188 | issue_read_p.set_defaults(func=run_issue_read) |
| 1189 | |
| 1190 | issue_list_p = issue_subs.add_parser( |
| 1191 | "list", |
| 1192 | help="List issues for this repo.", |
| 1193 | description=( |
| 1194 | "List issues on MuseHub for the current repo.\n\n" |
| 1195 | "Agent quickstart:\n" |
| 1196 | " muse hub issue list --json\n" |
| 1197 | " muse hub issue list --state closed --json\n" |
| 1198 | " muse hub issue list --label bug --json\n\n" |
| 1199 | "Exit codes: 0 success (including empty list), 1 auth error, 2 not in repo, 3 API error." |
| 1200 | ), |
| 1201 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1202 | ) |
| 1203 | issue_list_p.add_argument( |
| 1204 | "--hub", dest="hub", default=None, metavar="URL", |
| 1205 | help="Override the hub URL from config.", |
| 1206 | ) |
| 1207 | issue_list_p.add_argument( |
| 1208 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1209 | help="Specify repo as owner/repo.", |
| 1210 | ) |
| 1211 | issue_list_p.add_argument( |
| 1212 | "--state", dest="state", default="open", |
| 1213 | choices=["open", "closed", "all"], |
| 1214 | help="Filter by state (default: open).", |
| 1215 | ) |
| 1216 | issue_list_p.add_argument( |
| 1217 | "--label", dest="label", default=None, metavar="LABEL", |
| 1218 | help="Filter by label string.", |
| 1219 | ) |
| 1220 | issue_list_p.add_argument( |
| 1221 | "--limit", dest="limit", type=int, default=100, metavar="N", |
| 1222 | help="Maximum number of issues to return (default: 100).", |
| 1223 | ) |
| 1224 | issue_list_p.add_argument( |
| 1225 | "--json", "-j", action="store_true", dest="json_output", |
| 1226 | help="Emit JSON array of issues.", |
| 1227 | ) |
| 1228 | issue_list_p.set_defaults(func=run_issue_list) |
| 1229 | |
| 1230 | issue_close_p = issue_subs.add_parser( |
| 1231 | "close", |
| 1232 | help="Close an open issue.", |
| 1233 | description=( |
| 1234 | "Close an issue on MuseHub.\n\n" |
| 1235 | "Agent quickstart:\n" |
| 1236 | " muse hub issue close 42\n" |
| 1237 | " muse hub issue close 42 --json\n\n" |
| 1238 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 1239 | ), |
| 1240 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1241 | ) |
| 1242 | issue_close_p.add_argument("number", type=int, help="Issue number.") |
| 1243 | issue_close_p.add_argument( |
| 1244 | "--hub", dest="hub", default=None, metavar="URL", |
| 1245 | help="Override the hub URL from config.", |
| 1246 | ) |
| 1247 | issue_close_p.add_argument( |
| 1248 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1249 | help="Specify repo as owner/repo.", |
| 1250 | ) |
| 1251 | issue_close_p.add_argument( |
| 1252 | "--json", "-j", action="store_true", dest="json_output", |
| 1253 | help="Emit JSON with the updated issue.", |
| 1254 | ) |
| 1255 | issue_close_p.set_defaults(func=run_issue_close) |
| 1256 | |
| 1257 | issue_reopen_p = issue_subs.add_parser( |
| 1258 | "reopen", |
| 1259 | help="Reopen a closed issue.", |
| 1260 | description=( |
| 1261 | "Reopen a closed issue on MuseHub.\n\n" |
| 1262 | "Agent quickstart:\n" |
| 1263 | " muse hub issue reopen 42\n" |
| 1264 | " muse hub issue reopen 42 --json\n\n" |
| 1265 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 1266 | ), |
| 1267 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1268 | ) |
| 1269 | issue_reopen_p.add_argument("number", type=int, help="Issue number.") |
| 1270 | issue_reopen_p.add_argument( |
| 1271 | "--hub", dest="hub", default=None, metavar="URL", |
| 1272 | help="Override the hub URL from config.", |
| 1273 | ) |
| 1274 | issue_reopen_p.add_argument( |
| 1275 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1276 | help="Specify repo as owner/repo.", |
| 1277 | ) |
| 1278 | issue_reopen_p.add_argument( |
| 1279 | "--json", "-j", action="store_true", dest="json_output", |
| 1280 | help="Emit JSON with the updated issue.", |
| 1281 | ) |
| 1282 | issue_reopen_p.set_defaults(func=run_issue_reopen) |
| 1283 | |
| 1284 | issue_comment_p = issue_subs.add_parser( |
| 1285 | "comment", |
| 1286 | help="Post a comment on an issue.", |
| 1287 | description=( |
| 1288 | "Post a Markdown comment on an issue on MuseHub.\n\n" |
| 1289 | "Agent quickstart:\n" |
| 1290 | " muse hub issue comment 42 --body 'Fixed in commit abc123'\n" |
| 1291 | " muse hub issue comment 42 --body 'see also #43' --json\n\n" |
| 1292 | "Exit codes: 0 success, 1 validation/auth error, 2 not in repo, 3 API error." |
| 1293 | ), |
| 1294 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1295 | ) |
| 1296 | issue_comment_p.add_argument("number", type=int, help="Issue number.") |
| 1297 | issue_comment_p.add_argument( |
| 1298 | "--hub", dest="hub", default=None, metavar="URL", |
| 1299 | help="Override the hub URL from config.", |
| 1300 | ) |
| 1301 | issue_comment_p.add_argument( |
| 1302 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1303 | help="Specify repo as owner/repo.", |
| 1304 | ) |
| 1305 | issue_comment_p.add_argument( |
| 1306 | "--body", "-b", required=True, dest="body", |
| 1307 | help="Comment body (Markdown).", |
| 1308 | ) |
| 1309 | issue_comment_p.add_argument( |
| 1310 | "--json", "-j", action="store_true", dest="json_output", |
| 1311 | help="Emit JSON with the updated comment list.", |
| 1312 | ) |
| 1313 | issue_comment_p.set_defaults(func=run_issue_comment) |
| 1314 | |
| 1315 | issue_comment_delete_p = issue_subs.add_parser( |
| 1316 | "comment-delete", |
| 1317 | help="Soft-delete a comment from an issue.", |
| 1318 | description=( |
| 1319 | "Soft-delete a comment on an issue on MuseHub.\n\n" |
| 1320 | "The comment is hidden from list results but preserved in the audit log.\n" |
| 1321 | "Requires write/admin access or repo ownership.\n\n" |
| 1322 | "Agent quickstart:\n" |
| 1323 | " muse hub issue comment-delete 42 --comment-id <uuid>\n" |
| 1324 | " muse hub issue comment-delete 42 --comment-id <uuid> --json\n\n" |
| 1325 | "Exit codes: 0 success, 1 validation/auth error, 2 not in repo, 3 API error." |
| 1326 | ), |
| 1327 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1328 | ) |
| 1329 | issue_comment_delete_p.add_argument("number", type=int, help="Issue number.") |
| 1330 | issue_comment_delete_p.add_argument( |
| 1331 | "--comment-id", dest="comment_id", required=True, metavar="UUID", |
| 1332 | help="UUID of the comment to delete.", |
| 1333 | ) |
| 1334 | issue_comment_delete_p.add_argument( |
| 1335 | "--hub", dest="hub", default=None, metavar="URL", |
| 1336 | help="Override the hub URL from config.", |
| 1337 | ) |
| 1338 | issue_comment_delete_p.add_argument( |
| 1339 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1340 | help="Specify repo as owner/repo.", |
| 1341 | ) |
| 1342 | issue_comment_delete_p.add_argument( |
| 1343 | "--json", "-j", action="store_true", dest="json_output", |
| 1344 | help="Emit JSON confirmation on success.", |
| 1345 | ) |
| 1346 | issue_comment_delete_p.set_defaults(func=run_issue_comment_delete) |
| 1347 | |
| 1348 | issue_assign_p = issue_subs.add_parser( |
| 1349 | "assign", |
| 1350 | help="Assign or unassign a collaborator on an issue.", |
| 1351 | description=( |
| 1352 | "Assign or unassign a collaborator on an issue on MuseHub.\n\n" |
| 1353 | "Agent quickstart:\n" |
| 1354 | " muse hub issue assign 42 --assignee gabriel\n" |
| 1355 | " muse hub issue assign 42 --assignee '' # unassign\n" |
| 1356 | " muse hub issue assign 42 --assignee gabriel --json\n\n" |
| 1357 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 1358 | ), |
| 1359 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1360 | ) |
| 1361 | issue_assign_p.add_argument("number", type=int, help="Issue number.") |
| 1362 | issue_assign_p.add_argument( |
| 1363 | "--assignee", dest="assignee", required=True, metavar="USER", |
| 1364 | help="Username to assign, or empty string to unassign.", |
| 1365 | ) |
| 1366 | issue_assign_p.add_argument( |
| 1367 | "--hub", dest="hub", default=None, metavar="URL", |
| 1368 | help="Override the hub URL from config.", |
| 1369 | ) |
| 1370 | issue_assign_p.add_argument( |
| 1371 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1372 | help="Specify repo as owner/repo.", |
| 1373 | ) |
| 1374 | issue_assign_p.add_argument( |
| 1375 | "--json", "-j", action="store_true", dest="json_output", |
| 1376 | help="Emit JSON with the updated issue.", |
| 1377 | ) |
| 1378 | issue_assign_p.set_defaults(func=run_issue_assign) |
| 1379 | |
| 1380 | issue_label_p = issue_subs.add_parser( |
| 1381 | "label", |
| 1382 | help="Manage labels on an issue.", |
| 1383 | description=( |
| 1384 | "Add or remove labels on an issue on MuseHub.\n\n" |
| 1385 | "Agent quickstart:\n" |
| 1386 | " muse hub issue label 42 --set bug enhancement\n" |
| 1387 | " muse hub issue label 42 --remove bug\n" |
| 1388 | " muse hub issue label 42 --set bug --json\n\n" |
| 1389 | "--set replaces the entire label list. --remove removes a single label.\n" |
| 1390 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 1391 | ), |
| 1392 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1393 | ) |
| 1394 | issue_label_p.add_argument("number", type=int, help="Issue number.") |
| 1395 | _issue_label_mutex = issue_label_p.add_mutually_exclusive_group(required=True) |
| 1396 | _issue_label_mutex.add_argument( |
| 1397 | "--set", dest="set_labels", nargs="+", metavar="LABEL", |
| 1398 | help="Replace the entire label list with these labels.", |
| 1399 | ) |
| 1400 | _issue_label_mutex.add_argument( |
| 1401 | "--remove", dest="remove_label", metavar="LABEL", |
| 1402 | help="Remove a single label from the issue.", |
| 1403 | ) |
| 1404 | issue_label_p.add_argument( |
| 1405 | "--hub", dest="hub", default=None, metavar="URL", |
| 1406 | help="Override the hub URL from config.", |
| 1407 | ) |
| 1408 | issue_label_p.add_argument( |
| 1409 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1410 | help="Specify repo as owner/repo.", |
| 1411 | ) |
| 1412 | issue_label_p.add_argument( |
| 1413 | "--json", "-j", action="store_true", dest="json_output", |
| 1414 | help="Emit JSON with the updated issue.", |
| 1415 | ) |
| 1416 | issue_label_p.set_defaults(func=run_issue_label) |
| 1417 | |
| 1418 | issue_p.set_defaults(func=lambda a: issue_p.print_help()) |
| 1419 | |
| 1420 | # ── label ───────────────────────────────────────────────────────────────── |
| 1421 | label_p = subs.add_parser( |
| 1422 | "label", |
| 1423 | help="Manage labels on MuseHub.", |
| 1424 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1425 | ) |
| 1426 | label_subs = label_p.add_subparsers(dest="label_subcommand", metavar="LABEL_COMMAND") |
| 1427 | label_subs.required = True |
| 1428 | |
| 1429 | label_create_p = label_subs.add_parser( |
| 1430 | "create", |
| 1431 | help="Create a new label.", |
| 1432 | description=( |
| 1433 | "Create a repo-scoped label with a name, hex colour, and optional description.\n\n" |
| 1434 | f"Name must be non-empty and ≤ {_MAX_LABEL_NAME_LEN} characters.\n" |
| 1435 | "Colour must be a 7-character hex string starting with '#' (e.g. '#d73a4a').\n" |
| 1436 | "Names must be unique within the repository.\n\n" |
| 1437 | "Agent quickstart:\n" |
| 1438 | " muse hub label create --name bug --color '#d73a4a' --json\n" |
| 1439 | " muse hub label create --name enhancement --color '#a2eeef' " |
| 1440 | "--description 'New feature or request' --json\n\n" |
| 1441 | "JSON output keys: label_id, repo_id, name, color, description\n\n" |
| 1442 | "Exit codes: 0 created, 1 validation/conflict/auth error,\n" |
| 1443 | " 2 not in repo, 3 API/network error." |
| 1444 | ), |
| 1445 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1446 | ) |
| 1447 | label_create_p.add_argument( |
| 1448 | "--hub", dest="hub", default=None, metavar="URL", |
| 1449 | help="Override the hub URL from config.", |
| 1450 | ) |
| 1451 | label_create_p.add_argument( |
| 1452 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1453 | help="Specify repo as owner/repo (hub base URL taken from config).", |
| 1454 | ) |
| 1455 | label_create_p.add_argument( |
| 1456 | "--name", "-n", required=True, |
| 1457 | help="Label name (must be unique within the repo).", |
| 1458 | ) |
| 1459 | label_create_p.add_argument( |
| 1460 | "--color", "-c", required=True, |
| 1461 | help="Hex colour string, e.g. '#d73a4a'.", |
| 1462 | ) |
| 1463 | label_create_p.add_argument( |
| 1464 | "--description", "-d", default=None, |
| 1465 | help="Optional label description (≤ 200 characters).", |
| 1466 | ) |
| 1467 | label_create_p.add_argument( |
| 1468 | "--json", "-j", action="store_true", dest="json_output", |
| 1469 | help="Emit JSON with the created label.", |
| 1470 | ) |
| 1471 | label_create_p.set_defaults(func=run_label_create) |
| 1472 | |
| 1473 | label_list_p = label_subs.add_parser( |
| 1474 | "list", |
| 1475 | help="List all labels for the current repo.", |
| 1476 | description=( |
| 1477 | "List every label defined in the repository.\n\n" |
| 1478 | "The list endpoint is public — no authentication required for public repos.\n\n" |
| 1479 | "Agent quickstart:\n" |
| 1480 | " muse hub label list --json\n\n" |
| 1481 | "JSON output: array of label objects (label_id, name, color, description)\n\n" |
| 1482 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 1483 | ), |
| 1484 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1485 | ) |
| 1486 | label_list_p.add_argument( |
| 1487 | "--hub", dest="hub", default=None, metavar="URL", |
| 1488 | help="Override the hub URL from config.", |
| 1489 | ) |
| 1490 | label_list_p.add_argument( |
| 1491 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1492 | help="Specify repo as owner/repo.", |
| 1493 | ) |
| 1494 | label_list_p.add_argument( |
| 1495 | "--json", "-j", action="store_true", dest="json_output", |
| 1496 | help="Emit JSON array of labels.", |
| 1497 | ) |
| 1498 | label_list_p.set_defaults(func=run_label_list) |
| 1499 | |
| 1500 | label_update_p = label_subs.add_parser( |
| 1501 | "update", |
| 1502 | help="Update an existing label's name, colour, or description.", |
| 1503 | description=( |
| 1504 | "Partially update a label identified by its current name.\n\n" |
| 1505 | "Only provided flags are changed; omitted fields are left unchanged.\n" |
| 1506 | f"New name must be ≤ {_MAX_LABEL_NAME_LEN} characters.\n" |
| 1507 | "Colour must be a 7-character hex string starting with '#'.\n\n" |
| 1508 | "Agent quickstart:\n" |
| 1509 | " muse hub label update --name bug --new-color '#b60205' --json\n" |
| 1510 | " muse hub label update --name bug --new-name bug-report --json\n\n" |
| 1511 | "JSON output keys: label_id, repo_id, name, color, description\n\n" |
| 1512 | "Exit codes: 0 updated, 1 validation/auth error, 2 not in repo, 3 API error." |
| 1513 | ), |
| 1514 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1515 | ) |
| 1516 | label_update_p.add_argument( |
| 1517 | "--hub", dest="hub", default=None, metavar="URL", |
| 1518 | help="Override the hub URL from config.", |
| 1519 | ) |
| 1520 | label_update_p.add_argument( |
| 1521 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1522 | help="Specify repo as owner/repo.", |
| 1523 | ) |
| 1524 | label_update_p.add_argument( |
| 1525 | "--name", "-n", required=True, |
| 1526 | help="Current name of the label to update.", |
| 1527 | ) |
| 1528 | label_update_p.add_argument( |
| 1529 | "--new-name", dest="new_name", default=None, |
| 1530 | help="New name for the label.", |
| 1531 | ) |
| 1532 | label_update_p.add_argument( |
| 1533 | "--new-color", dest="new_color", default=None, |
| 1534 | help="New hex colour, e.g. '#b60205'.", |
| 1535 | ) |
| 1536 | label_update_p.add_argument( |
| 1537 | "--new-description", dest="new_description", default=None, |
| 1538 | help="New description (pass empty string to clear).", |
| 1539 | ) |
| 1540 | label_update_p.add_argument( |
| 1541 | "--json", "-j", action="store_true", dest="json_output", |
| 1542 | help="Emit JSON with the updated label.", |
| 1543 | ) |
| 1544 | label_update_p.set_defaults(func=run_label_update) |
| 1545 | |
| 1546 | label_delete_p = label_subs.add_parser( |
| 1547 | "delete", |
| 1548 | help="Delete a label and remove it from all issues.", |
| 1549 | description=( |
| 1550 | "Permanently delete a label by name and remove it from every\n" |
| 1551 | "issue and proposal it is currently attached to.\n\n" |
| 1552 | "This operation is irreversible.\n\n" |
| 1553 | "Agent quickstart:\n" |
| 1554 | " muse hub label delete --name bug --json\n\n" |
| 1555 | "Exit codes: 0 deleted, 1 auth/not-found error, 2 not in repo, 3 API error." |
| 1556 | ), |
| 1557 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1558 | ) |
| 1559 | label_delete_p.add_argument( |
| 1560 | "--hub", dest="hub", default=None, metavar="URL", |
| 1561 | help="Override the hub URL from config.", |
| 1562 | ) |
| 1563 | label_delete_p.add_argument( |
| 1564 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1565 | help="Specify repo as owner/repo.", |
| 1566 | ) |
| 1567 | label_delete_p.add_argument( |
| 1568 | "--name", "-n", required=True, |
| 1569 | help="Name of the label to delete.", |
| 1570 | ) |
| 1571 | label_delete_p.add_argument( |
| 1572 | "--json", "-j", action="store_true", dest="json_output", |
| 1573 | help="Emit JSON confirmation on success.", |
| 1574 | ) |
| 1575 | label_delete_p.set_defaults(func=run_label_delete) |
| 1576 | |
| 1577 | label_p.set_defaults(func=lambda a: label_p.print_help()) |
| 1578 | |
| 1579 | # ── collaborator ────────────────────────────────────────────────────────── |
| 1580 | collab_p = subs.add_parser( |
| 1581 | "collaborator", |
| 1582 | help="Manage repository collaborators on MuseHub.", |
| 1583 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1584 | ) |
| 1585 | collab_subs = collab_p.add_subparsers(dest="collaborator_subcommand", metavar="COLLAB_COMMAND") |
| 1586 | collab_subs.required = True |
| 1587 | |
| 1588 | collab_list_p = collab_subs.add_parser( |
| 1589 | "list", |
| 1590 | help="List collaborators on a repo.", |
| 1591 | description=( |
| 1592 | "List all collaborators and their permission levels for a repository.\n\n" |
| 1593 | "Agent quickstart:\n" |
| 1594 | " muse hub collaborator list --json\n\n" |
| 1595 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 1596 | ), |
| 1597 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1598 | ) |
| 1599 | collab_list_p.add_argument( |
| 1600 | "--hub", dest="hub", default=None, metavar="URL", |
| 1601 | help="Override the hub URL from config.", |
| 1602 | ) |
| 1603 | collab_list_p.add_argument( |
| 1604 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1605 | help="Specify repo as owner/repo.", |
| 1606 | ) |
| 1607 | collab_list_p.add_argument( |
| 1608 | "--json", "-j", action="store_true", dest="json_output", |
| 1609 | help="Emit JSON list of collaborators.", |
| 1610 | ) |
| 1611 | collab_list_p.set_defaults(func=run_collaborator_list) |
| 1612 | |
| 1613 | collab_invite_p = collab_subs.add_parser( |
| 1614 | "invite", |
| 1615 | help="Invite a user as a collaborator.", |
| 1616 | description=( |
| 1617 | "Invite a user to collaborate on a repository.\n" |
| 1618 | "Requires admin or owner access.\n\n" |
| 1619 | "Agent quickstart:\n" |
| 1620 | " muse hub collaborator invite carol --permission write --json\n\n" |
| 1621 | "Exit codes: 0 success, 1 auth/conflict error, 2 not in repo, 3 API error." |
| 1622 | ), |
| 1623 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1624 | ) |
| 1625 | collab_invite_p.add_argument("handle", help="MSign handle of the user to invite.") |
| 1626 | collab_invite_p.add_argument( |
| 1627 | "--permission", "-p", default="write", choices=["read", "write", "admin"], |
| 1628 | help="Permission level: read, write (default), or admin.", |
| 1629 | ) |
| 1630 | collab_invite_p.add_argument( |
| 1631 | "--hub", dest="hub", default=None, metavar="URL", |
| 1632 | help="Override the hub URL from config.", |
| 1633 | ) |
| 1634 | collab_invite_p.add_argument( |
| 1635 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1636 | help="Specify repo as owner/repo.", |
| 1637 | ) |
| 1638 | collab_invite_p.add_argument( |
| 1639 | "--json", "-j", action="store_true", dest="json_output", |
| 1640 | help="Emit JSON collaborator record on success.", |
| 1641 | ) |
| 1642 | collab_invite_p.set_defaults(func=run_collaborator_invite) |
| 1643 | |
| 1644 | collab_update_permission_p = collab_subs.add_parser( |
| 1645 | "update-permission", |
| 1646 | help="Update a collaborator's permission level.", |
| 1647 | description=( |
| 1648 | "Update an existing collaborator's permission level.\n" |
| 1649 | "Requires admin or owner access.\n\n" |
| 1650 | "Agent quickstart:\n" |
| 1651 | " muse hub collaborator update-permission carol --permission admin --json\n\n" |
| 1652 | "Exit codes: 0 success, 1 auth/not-found error, 2 not in repo, 3 API error." |
| 1653 | ), |
| 1654 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1655 | ) |
| 1656 | collab_update_permission_p.add_argument("handle", help="MSign handle of the collaborator to update.") |
| 1657 | collab_update_permission_p.add_argument( |
| 1658 | "--permission", "-p", required=True, choices=["read", "write", "admin"], |
| 1659 | help="New permission level.", |
| 1660 | ) |
| 1661 | collab_update_permission_p.add_argument( |
| 1662 | "--hub", dest="hub", default=None, metavar="URL", |
| 1663 | help="Override the hub URL from config.", |
| 1664 | ) |
| 1665 | collab_update_permission_p.add_argument( |
| 1666 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1667 | help="Specify repo as owner/repo.", |
| 1668 | ) |
| 1669 | collab_update_permission_p.add_argument( |
| 1670 | "--json", "-j", action="store_true", dest="json_output", |
| 1671 | help="Emit JSON with the updated collaborator record.", |
| 1672 | ) |
| 1673 | collab_update_permission_p.set_defaults(func=run_collaborator_update_permission) |
| 1674 | |
| 1675 | collab_remove_p = collab_subs.add_parser( |
| 1676 | "remove", |
| 1677 | help="Remove a collaborator from a repo.", |
| 1678 | description=( |
| 1679 | "Remove a collaborator from a repository.\n" |
| 1680 | "Requires admin or owner access. The owner cannot be removed.\n\n" |
| 1681 | "Agent quickstart:\n" |
| 1682 | " muse hub collaborator remove carol --json\n\n" |
| 1683 | "Exit codes: 0 success, 1 auth/not-found error, 2 not in repo, 3 API error." |
| 1684 | ), |
| 1685 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1686 | ) |
| 1687 | collab_remove_p.add_argument("handle", help="MSign handle of the collaborator to remove.") |
| 1688 | collab_remove_p.add_argument( |
| 1689 | "--hub", dest="hub", default=None, metavar="URL", |
| 1690 | help="Override the hub URL from config.", |
| 1691 | ) |
| 1692 | collab_remove_p.add_argument( |
| 1693 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1694 | help="Specify repo as owner/repo.", |
| 1695 | ) |
| 1696 | collab_remove_p.add_argument( |
| 1697 | "--json", "-j", action="store_true", dest="json_output", |
| 1698 | help="Emit JSON confirmation on success.", |
| 1699 | ) |
| 1700 | collab_remove_p.set_defaults(func=run_collaborator_remove) |
| 1701 | |
| 1702 | collab_p.set_defaults(func=lambda a: collab_p.print_help()) |
| 1703 | |
| 1704 | # ── webhook ─────────────────────────────────────────────────────────────── |
| 1705 | webhook_p = subs.add_parser( |
| 1706 | "webhook", |
| 1707 | help="Manage webhook subscriptions on MuseHub.", |
| 1708 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1709 | ) |
| 1710 | webhook_subs = webhook_p.add_subparsers(dest="webhook_subcommand", metavar="WEBHOOK_COMMAND") |
| 1711 | webhook_subs.required = True |
| 1712 | |
| 1713 | webhook_create_p = webhook_subs.add_parser( |
| 1714 | "create", |
| 1715 | help="Register a new webhook subscription.", |
| 1716 | description=( |
| 1717 | "Register a webhook that receives HTTP POST payloads for repository events.\n" |
| 1718 | "Valid event types: push, proposal, issue, release, branch, tag, session, analysis.\n" |
| 1719 | "Requires write/admin access or repo ownership.\n\n" |
| 1720 | "Agent quickstart:\n" |
| 1721 | " muse hub webhook create --url https://example.com/hook --events push release --json\n\n" |
| 1722 | "Exit codes: 0 success, 1 validation/auth error, 2 not in repo, 3 API error." |
| 1723 | ), |
| 1724 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1725 | ) |
| 1726 | webhook_create_p.add_argument( |
| 1727 | "--url", required=True, help="HTTPS URL to receive event payloads.", |
| 1728 | ) |
| 1729 | webhook_create_p.add_argument( |
| 1730 | "--events", nargs="+", required=True, metavar="EVENT", |
| 1731 | help="Event types to subscribe to (e.g. push release issue).", |
| 1732 | ) |
| 1733 | webhook_create_p.add_argument( |
| 1734 | "--secret", default="", help="HMAC-SHA256 signing secret (optional, plaintext).", |
| 1735 | ) |
| 1736 | webhook_create_p.add_argument( |
| 1737 | "--hub", dest="hub", default=None, metavar="URL", |
| 1738 | help="Override the hub URL from config.", |
| 1739 | ) |
| 1740 | webhook_create_p.add_argument( |
| 1741 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1742 | help="Specify repo as owner/repo.", |
| 1743 | ) |
| 1744 | webhook_create_p.add_argument( |
| 1745 | "--json", "-j", action="store_true", dest="json_output", |
| 1746 | help="Emit JSON webhook record on success.", |
| 1747 | ) |
| 1748 | webhook_create_p.set_defaults(func=run_webhook_create) |
| 1749 | |
| 1750 | webhook_list_p = webhook_subs.add_parser( |
| 1751 | "list", |
| 1752 | help="List webhook subscriptions for a repo.", |
| 1753 | description=( |
| 1754 | "List all registered webhook subscriptions for a repository.\n\n" |
| 1755 | "Agent quickstart:\n" |
| 1756 | " muse hub webhook list --json\n\n" |
| 1757 | "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error." |
| 1758 | ), |
| 1759 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1760 | ) |
| 1761 | webhook_list_p.add_argument( |
| 1762 | "--hub", dest="hub", default=None, metavar="URL", |
| 1763 | help="Override the hub URL from config.", |
| 1764 | ) |
| 1765 | webhook_list_p.add_argument( |
| 1766 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1767 | help="Specify repo as owner/repo.", |
| 1768 | ) |
| 1769 | webhook_list_p.add_argument( |
| 1770 | "--json", "-j", action="store_true", dest="json_output", |
| 1771 | help="Emit JSON list of webhooks.", |
| 1772 | ) |
| 1773 | webhook_list_p.set_defaults(func=run_webhook_list) |
| 1774 | |
| 1775 | webhook_delete_p = webhook_subs.add_parser( |
| 1776 | "delete", |
| 1777 | help="Delete a webhook subscription.", |
| 1778 | description=( |
| 1779 | "Delete a webhook subscription and all its delivery history.\n" |
| 1780 | "Requires write/admin access or repo ownership.\n\n" |
| 1781 | "Agent quickstart:\n" |
| 1782 | " muse hub webhook delete <webhook-id> --json\n\n" |
| 1783 | "Exit codes: 0 success, 1 auth/not-found error, 2 not in repo, 3 API error." |
| 1784 | ), |
| 1785 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1786 | ) |
| 1787 | webhook_delete_p.add_argument("webhook_id", help="UUID of the webhook to delete.") |
| 1788 | webhook_delete_p.add_argument( |
| 1789 | "--hub", dest="hub", default=None, metavar="URL", |
| 1790 | help="Override the hub URL from config.", |
| 1791 | ) |
| 1792 | webhook_delete_p.add_argument( |
| 1793 | "--repo", dest="repo", default=None, metavar="OWNER/REPO", |
| 1794 | help="Specify repo as owner/repo.", |
| 1795 | ) |
| 1796 | webhook_delete_p.add_argument( |
| 1797 | "--json", "-j", action="store_true", dest="json_output", |
| 1798 | help="Emit JSON confirmation on success.", |
| 1799 | ) |
| 1800 | webhook_delete_p.set_defaults(func=run_webhook_delete) |
| 1801 | |
| 1802 | webhook_p.set_defaults(func=lambda a: webhook_p.print_help()) |
| 1803 | |
| 1804 | # ── ping ────────────────────────────────────────────────────────────────── |
| 1805 | ping_p = subs.add_parser( |
| 1806 | "ping", |
| 1807 | help="Test HTTP connectivity to the configured hub.", |
| 1808 | description=( |
| 1809 | "Send GET <hub>/health and report reachability. No auth token\n" |
| 1810 | "is sent — the health endpoint is intentionally public.\n" |
| 1811 | "HTTP redirects are refused.\n\n" |
| 1812 | "Agent readiness loop:\n" |
| 1813 | " until muse hub ping --json 2>/dev/null; do sleep 2; done\n\n" |
| 1814 | "JSON keys: status, hub_url, hostname, reachable, message\n" |
| 1815 | "Exit 0 = reachable, 5 = unreachable, 1 = no hub, 2 = no repo" |
| 1816 | ), |
| 1817 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1818 | ) |
| 1819 | ping_p.add_argument( |
| 1820 | "--hub", dest="hub", default=None, metavar="URL", |
| 1821 | help="Override the hub URL from config.", |
| 1822 | ) |
| 1823 | ping_p.add_argument( |
| 1824 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 1825 | help="Emit a JSON object to stdout with the ping result.", |
| 1826 | ) |
| 1827 | ping_p.set_defaults(func=run_ping) |
| 1828 | |
| 1829 | # ── proposal ────────────────────────────────────────────────────────────── |
| 1830 | proposal_p = subs.add_parser( |
| 1831 | "proposal", |
| 1832 | help="Manage proposals on MuseHub.", |
| 1833 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1834 | ) |
| 1835 | proposal_subs = proposal_p.add_subparsers(dest="proposal_subcommand", metavar="PROPOSAL_COMMAND") |
| 1836 | proposal_subs.required = True |
| 1837 | |
| 1838 | proposal_create_p = proposal_subs.add_parser( |
| 1839 | "create", |
| 1840 | help="Open a new proposal.", |
| 1841 | description=( |
| 1842 | "Open a new proposal on MuseHub.\n\n" |
| 1843 | "Uses the current branch as the source if --from-branch is omitted.\n" |
| 1844 | "Both Muse-native flags (--from-branch / --to-branch) and familiar\n" |
| 1845 | "aliases (--head / --base) are accepted.\n\n" |
| 1846 | f"Title must be non-empty and ≤ {_MAX_PROPOSAL_TITLE_LEN} characters.\n" |
| 1847 | "Detached HEAD is rejected — pass --from-branch explicitly.\n\n" |
| 1848 | "Agent quickstart:\n" |
| 1849 | " muse hub proposal create --title 'feat: x' --from-branch feat/x --json\n" |
| 1850 | " PROPOSAL=$(muse hub proposal create --title 'feat: x' --json)\n" |
| 1851 | " echo \"$PROPOSAL\" | jq '.proposalId'\n\n" |
| 1852 | "Exit codes: 0 created, 1 validation/branch error, 2 not in repo, 3 API error." |
| 1853 | ), |
| 1854 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1855 | ) |
| 1856 | proposal_create_p.add_argument( |
| 1857 | "--hub", dest="hub", default=None, metavar="URL", |
| 1858 | help="Override the hub URL from config.", |
| 1859 | ) |
| 1860 | proposal_create_p.add_argument("--title", "-t", required=True, help="Proposal title.") |
| 1861 | proposal_create_p.add_argument("--body", "-b", default="", help="Proposal body / description.") |
| 1862 | proposal_create_p.add_argument( |
| 1863 | "--from-branch", "--head", "-f", dest="from_branch", default=None, |
| 1864 | help=( |
| 1865 | "Source branch (default: current branch). " |
| 1866 | "--head is accepted as a git-familiar alias." |
| 1867 | ), |
| 1868 | ) |
| 1869 | proposal_create_p.add_argument( |
| 1870 | "--to-branch", "--base", dest="to_branch", default="dev", |
| 1871 | help="Target branch (default: dev). --base is accepted as a git-familiar alias.", |
| 1872 | ) |
| 1873 | proposal_create_p.add_argument( |
| 1874 | "--json", "-j", action="store_true", dest="json_output", |
| 1875 | help="Emit JSON with the created proposal.", |
| 1876 | ) |
| 1877 | proposal_create_p.set_defaults(func=run_proposal_create) |
| 1878 | |
| 1879 | proposal_list_p = proposal_subs.add_parser( |
| 1880 | "list", |
| 1881 | help="List proposals.", |
| 1882 | description=( |
| 1883 | "List proposals on the configured MuseHub.\n\n" |
| 1884 | "Filters by state (default: open). --limit caps the number of results.\n" |
| 1885 | "Use --state all to see every proposal regardless of state.\n" |
| 1886 | "Use --verbose to also show author and creation date per proposal.\n\n" |
| 1887 | "Agent quickstart:\n" |
| 1888 | " muse hub proposal list --state open --json\n" |
| 1889 | " muse hub proposal list --state all -n 100 --json | jq '.[] | .proposalId'\n\n" |
| 1890 | "Exit codes: 0 success, 1 not authenticated, 2 not in repo, 3 API error." |
| 1891 | ), |
| 1892 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1893 | ) |
| 1894 | proposal_list_p.add_argument( |
| 1895 | "--hub", dest="hub", default=None, metavar="URL", |
| 1896 | help="Override the hub URL from config (e.g. http://host.docker.internal:10003/owner/repo).", |
| 1897 | ) |
| 1898 | proposal_list_p.add_argument( |
| 1899 | "--state", default="open", choices=["open", "merged", "closed", "all"], |
| 1900 | help="Filter by state: open (default), merged, closed, all.", |
| 1901 | ) |
| 1902 | proposal_list_p.add_argument( |
| 1903 | "--limit", "-n", type=int, default=20, |
| 1904 | help="Maximum number of proposals to return (default: 20).", |
| 1905 | ) |
| 1906 | proposal_list_p.add_argument( |
| 1907 | "--verbose", "-v", action="store_true", dest="verbose", |
| 1908 | help="Show author and creation date for each proposal.", |
| 1909 | ) |
| 1910 | proposal_list_p.add_argument( |
| 1911 | "--json", "-j", action="store_true", dest="json_output", |
| 1912 | help="Emit JSON to stdout instead of human-readable output.", |
| 1913 | ) |
| 1914 | proposal_list_p.set_defaults(func=run_proposal_list) |
| 1915 | |
| 1916 | proposal_merge_p = proposal_subs.add_parser( |
| 1917 | "merge", |
| 1918 | help="Merge a proposal.", |
| 1919 | description=( |
| 1920 | "Merge a proposal immediately — no confirmation dialog.\n\n" |
| 1921 | "The source branch is deleted by default; pass --no-delete-branch\n" |
| 1922 | "to keep it. Merge strategies: merge_commit (default), squash, rebase.\n\n" |
| 1923 | "Exit code 3 is returned on merge rejection in BOTH text and JSON mode\n" |
| 1924 | "so agent pipelines can always rely on the exit code:\n" |
| 1925 | " muse hub proposal merge af54753d --json && echo ok || echo conflict\n\n" |
| 1926 | "Agent quickstart:\n" |
| 1927 | " muse hub proposal merge af54753d --strategy squash --json\n" |
| 1928 | " # → {\"merged\": true, \"mergeCommitId\": \"...\", ...}\n\n" |
| 1929 | "Exit codes: 0 merged, 1 not found/not authenticated,\n" |
| 1930 | " 2 not in repo, 3 merge rejected or API error." |
| 1931 | ), |
| 1932 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1933 | ) |
| 1934 | proposal_merge_p.add_argument("proposal_id", help="Proposal ID (full UUID or 8-char prefix).") |
| 1935 | proposal_merge_p.add_argument( |
| 1936 | "--hub", dest="hub", default=None, metavar="URL", |
| 1937 | help="Override the hub URL from config.", |
| 1938 | ) |
| 1939 | proposal_merge_p.add_argument( |
| 1940 | "--strategy", default="merge_commit", |
| 1941 | choices=["merge_commit", "squash", "rebase"], |
| 1942 | help="Merge strategy (default: merge_commit).", |
| 1943 | ) |
| 1944 | proposal_merge_p.add_argument( |
| 1945 | "--no-delete-branch", dest="delete_branch", action="store_false", |
| 1946 | help="Do not delete the source branch after merging.", |
| 1947 | ) |
| 1948 | proposal_merge_p.add_argument( |
| 1949 | "--json", "-j", action="store_true", dest="json_output", |
| 1950 | help="Emit JSON with the proposal merge result.", |
| 1951 | ) |
| 1952 | proposal_merge_p.set_defaults(func=run_proposal_merge, delete_branch=True) |
| 1953 | |
| 1954 | proposal_read_p = proposal_subs.add_parser( |
| 1955 | "read", |
| 1956 | help="Show a single proposal.", |
| 1957 | description=( |
| 1958 | "Show a single proposal by ID.\n\n" |
| 1959 | "Accepts the full UUID or an 8-character prefix. Prefix resolution\n" |
| 1960 | f"fetches the most recent {_PROPOSAL_PREFIX_RESOLVE_LIMIT} proposals — use the full UUID\n" |
| 1961 | "on large repositories to avoid ambiguity.\n\n" |
| 1962 | "Text output shows state, title, ID, branches, author, date, and\n" |
| 1963 | f"up to {_MAX_PROPOSAL_BODY_LINES} body lines (truncation hint printed when exceeded).\n" |
| 1964 | "JSON output is the raw API response (camelCase field names).\n\n" |
| 1965 | "Agent quickstart:\n" |
| 1966 | " muse hub proposal read af54753d --json | jq '.state'\n" |
| 1967 | " muse hub proposal read af54753d --json | jq '{state,title,author}'\n\n" |
| 1968 | "Exit codes: 0 found, 1 not found/ambiguous, 2 not in repo, 3 API error." |
| 1969 | ), |
| 1970 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1971 | ) |
| 1972 | proposal_read_p.add_argument("proposal_id", help="Proposal ID (full UUID or 8-char prefix).") |
| 1973 | proposal_read_p.add_argument( |
| 1974 | "--hub", dest="hub", default=None, metavar="URL", |
| 1975 | help="Override the hub URL from config.", |
| 1976 | ) |
| 1977 | proposal_read_p.add_argument( |
| 1978 | "--json", "-j", action="store_true", dest="json_output", help="Emit JSON.", |
| 1979 | ) |
| 1980 | proposal_read_p.set_defaults(func=run_proposal_read) |
| 1981 | |
| 1982 | # ── proposal comment ────────────────────────────────────────────────────── |
| 1983 | proposal_comment_p = proposal_subs.add_parser( |
| 1984 | "comment", |
| 1985 | help="Manage proposal comments.", |
| 1986 | description="Manage review comments on a merge proposal.", |
| 1987 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1988 | ) |
| 1989 | proposal_comment_subs = proposal_comment_p.add_subparsers( |
| 1990 | dest="proposal_comment_subcommand", metavar="COMMENT_COMMAND", |
| 1991 | ) |
| 1992 | proposal_comment_subs.required = True |
| 1993 | |
| 1994 | proposal_comment_list_p = proposal_comment_subs.add_parser( |
| 1995 | "list", help="List all comments on a proposal (threaded).", |
| 1996 | ) |
| 1997 | proposal_comment_list_p.add_argument("proposal_id", help="Proposal ID (full UUID or 8-char prefix).") |
| 1998 | proposal_comment_list_p.add_argument("--hub", dest="hub", default=None, metavar="URL") |
| 1999 | proposal_comment_list_p.add_argument("--json", "-j", action="store_true", dest="json_output") |
| 2000 | proposal_comment_list_p.set_defaults(func=run_proposal_comment_list) |
| 2001 | |
| 2002 | proposal_comment_p.set_defaults(func=lambda a: proposal_comment_p.print_help()) |
| 2003 | |
| 2004 | # ── proposal reviewer ───────────────────────────────────────────────────── |
| 2005 | proposal_reviewer_p = proposal_subs.add_parser( |
| 2006 | "reviewer", |
| 2007 | help="Manage proposal reviewers.", |
| 2008 | description="Request or remove reviewers on a merge proposal.", |
| 2009 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 2010 | ) |
| 2011 | proposal_reviewer_subs = proposal_reviewer_p.add_subparsers( |
| 2012 | dest="proposal_reviewer_subcommand", metavar="REVIEWER_COMMAND", |
| 2013 | ) |
| 2014 | proposal_reviewer_subs.required = True |
| 2015 | |
| 2016 | proposal_reviewer_request_p = proposal_reviewer_subs.add_parser( |
| 2017 | "request", help="Request reviewers on a proposal.", |
| 2018 | ) |
| 2019 | proposal_reviewer_request_p.add_argument("proposal_id", help="Proposal ID (full UUID or 8-char prefix).") |
| 2020 | proposal_reviewer_request_p.add_argument("reviewers", nargs="+", help="MSign handles to request.") |
| 2021 | proposal_reviewer_request_p.add_argument("--hub", dest="hub", default=None, metavar="URL") |
| 2022 | proposal_reviewer_request_p.add_argument("--json", "-j", action="store_true", dest="json_output") |
| 2023 | proposal_reviewer_request_p.set_defaults(func=run_proposal_reviewer_request) |
| 2024 | |
| 2025 | proposal_reviewer_remove_p = proposal_reviewer_subs.add_parser( |
| 2026 | "remove", help="Remove a pending reviewer from a proposal.", |
| 2027 | ) |
| 2028 | proposal_reviewer_remove_p.add_argument("proposal_id", help="Proposal ID (full UUID or 8-char prefix).") |
| 2029 | proposal_reviewer_remove_p.add_argument("reviewer", help="MSign handle of the reviewer to remove.") |
| 2030 | proposal_reviewer_remove_p.add_argument("--hub", dest="hub", default=None, metavar="URL") |
| 2031 | proposal_reviewer_remove_p.add_argument("--json", "-j", action="store_true", dest="json_output") |
| 2032 | proposal_reviewer_remove_p.set_defaults(func=run_proposal_reviewer_remove) |
| 2033 | |
| 2034 | proposal_reviewer_p.set_defaults(func=lambda a: proposal_reviewer_p.print_help()) |
| 2035 | |
| 2036 | # ── proposal review ─────────────────────────────────────────────────────── |
| 2037 | proposal_review_p = proposal_subs.add_parser( |
| 2038 | "review", |
| 2039 | help="Manage proposal reviews.", |
| 2040 | description="List or filter formal reviews on a merge proposal.", |
| 2041 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 2042 | ) |
| 2043 | proposal_review_subs = proposal_review_p.add_subparsers( |
| 2044 | dest="proposal_review_subcommand", metavar="REVIEW_COMMAND", |
| 2045 | ) |
| 2046 | proposal_review_subs.required = True |
| 2047 | |
| 2048 | proposal_review_list_p = proposal_review_subs.add_parser( |
| 2049 | "list", help="List all reviews on a proposal.", |
| 2050 | ) |
| 2051 | proposal_review_list_p.add_argument("proposal_id", help="Proposal ID (full UUID or 8-char prefix).") |
| 2052 | proposal_review_list_p.add_argument( |
| 2053 | "--state", |
| 2054 | choices=["pending", "approved", "changes_requested", "dismissed"], |
| 2055 | default=None, |
| 2056 | help="Filter by review state.", |
| 2057 | ) |
| 2058 | proposal_review_list_p.add_argument("--hub", dest="hub", default=None, metavar="URL") |
| 2059 | proposal_review_list_p.add_argument("--json", "-j", action="store_true", dest="json_output") |
| 2060 | proposal_review_list_p.set_defaults(func=run_proposal_review_list) |
| 2061 | |
| 2062 | proposal_review_p.set_defaults(func=lambda a: proposal_review_p.print_help()) |
| 2063 | |
| 2064 | proposal_p.set_defaults(func=lambda a: proposal_p.print_help()) |
| 2065 | |
| 2066 | # ── status ──────────────────────────────────────────────────────────────── |
| 2067 | status_p = subs.add_parser( |
| 2068 | "status", |
| 2069 | help="Show the hub connection and identity for this repository.", |
| 2070 | description=( |
| 2071 | "Display the hub URL stored in .muse/config.toml and the identity\n" |
| 2072 | "stored in ~/.muse/identity.toml. Makes no network calls.\n\n" |
| 2073 | "Agent quickstart:\n" |
| 2074 | " muse hub status --json || muse hub connect https://musehub.ai --json\n\n" |
| 2075 | "JSON keys (always present): hub_url, hostname, authenticated,\n" |
| 2076 | " identity_type, identity_name, identity_id, capabilities" |
| 2077 | ), |
| 2078 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 2079 | ) |
| 2080 | status_p.add_argument( |
| 2081 | "--hub", dest="hub", default=None, metavar="URL", |
| 2082 | help="Override the hub URL from config (e.g. http://host.docker.internal:10003/owner/repo).", |
| 2083 | ) |
| 2084 | status_p.add_argument( |
| 2085 | "--json", "-j", action="store_true", dest="json_output", |
| 2086 | help="Emit JSON to stdout instead of human-readable output.", |
| 2087 | ) |
| 2088 | status_p.set_defaults(func=run_status) |
| 2089 | |
| 2090 | |
| 2091 | # ── Proposal subcommand handlers ────────────────────────────────────────────── |
| 2092 | |
| 2093 | |
| 2094 | def run_proposal_list(args: argparse.Namespace) -> None: |
| 2095 | """List proposals on MuseHub. |
| 2096 | |
| 2097 | Filters by state (default: ``open``) and respects ``--limit`` (default: 20). |
| 2098 | Use ``--state all`` to list every proposal regardless of state. |
| 2099 | Use ``--verbose`` / ``-v`` to show author and creation date per proposal. |
| 2100 | |
| 2101 | JSON output goes to stdout; human-readable text goes to stderr. |
| 2102 | With ``--json`` the response is always a JSON **array** (``[]`` when empty). |
| 2103 | ``--verbose`` has no effect when ``--json`` is set — callers receive the |
| 2104 | full API response and can select any field with ``jq``. |
| 2105 | |
| 2106 | Agent quickstart |
| 2107 | ---------------- |
| 2108 | :: |
| 2109 | |
| 2110 | muse hub proposal list --state open --json # open proposals as JSON array |
| 2111 | muse hub proposal list --state all -n 100 --json | jq '.[] | .proposalId' |
| 2112 | muse hub proposal list --verbose # show author + date in text mode |
| 2113 | |
| 2114 | Exit codes |
| 2115 | ---------- |
| 2116 | 0 Success (including empty list). |
| 2117 | 1 Not authenticated or no hub configured. |
| 2118 | 2 Not inside a Muse repository. |
| 2119 | 3 API / network error. |
| 2120 | """ |
| 2121 | state: str = args.state |
| 2122 | limit: int = clamp_int(args.limit, 1, 10000, "limit") |
| 2123 | json_output: bool = args.json_output |
| 2124 | verbose: bool = getattr(args, "verbose", False) |
| 2125 | |
| 2126 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 2127 | repo_id = _resolve_repo_id(hub_url, identity) |
| 2128 | |
| 2129 | params = f"?limit={limit}" |
| 2130 | if state != "all": |
| 2131 | params += f"&state={state}" |
| 2132 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/proposals{params}") |
| 2133 | proposals_val = data.get("proposals", []) |
| 2134 | proposals: list[_ProposalEntry] = ( |
| 2135 | [r for r in proposals_val if isinstance(r, dict)] |
| 2136 | if isinstance(proposals_val, list) else [] |
| 2137 | ) |
| 2138 | |
| 2139 | if json_output: |
| 2140 | print(json.dumps(proposals)) |
| 2141 | return |
| 2142 | |
| 2143 | if not proposals: |
| 2144 | print(f" No proposals found (state={state}).", file=sys.stderr) |
| 2145 | return |
| 2146 | |
| 2147 | print( |
| 2148 | f"\n Proposals — {sanitize_display(hub_url)} ({len(proposals)} shown)", |
| 2149 | file=sys.stderr, |
| 2150 | ) |
| 2151 | print(" " + "─" * 60, file=sys.stderr) |
| 2152 | for proposal in proposals: |
| 2153 | print(_format_proposal(proposal, verbose=verbose), file=sys.stderr) |
| 2154 | print("", file=sys.stderr) |
| 2155 | |
| 2156 | |
| 2157 | def run_proposal_read(args: argparse.Namespace) -> None: |
| 2158 | """Show a single proposal by ID. |
| 2159 | |
| 2160 | Accepts the full UUID or an 8-character prefix. Prefix resolution fetches |
| 2161 | the most recent :data:`_PROPOSAL_PREFIX_RESOLVE_LIMIT` proposals — use the full UUID |
| 2162 | on large repositories to avoid ambiguity. |
| 2163 | |
| 2164 | Text output (stderr) shows: state icon, title, ID, branches, author, |
| 2165 | creation date, and the first :data:`_MAX_PROPOSAL_BODY_LINES` lines of the body. |
| 2166 | When the body is truncated a hint is printed; pass ``--json`` to retrieve |
| 2167 | the full body. |
| 2168 | |
| 2169 | JSON output is the raw API response object (passthrough) — field names |
| 2170 | follow MuseHub's camelCase convention (``proposalId``, ``fromBranch``, etc.). |
| 2171 | |
| 2172 | Agent quickstart |
| 2173 | ---------------- |
| 2174 | :: |
| 2175 | |
| 2176 | muse hub proposal view af54753d --json | jq '.state' |
| 2177 | muse hub proposal view af54753d --json | jq '{state,title,author}' |
| 2178 | |
| 2179 | Exit codes |
| 2180 | ---------- |
| 2181 | 0 Proposal found and printed. |
| 2182 | 1 Prefix not found, ambiguous, or not authenticated. |
| 2183 | 2 Not inside a Muse repository. |
| 2184 | 3 API / network error. |
| 2185 | """ |
| 2186 | proposal_id: str = args.proposal_id |
| 2187 | json_output: bool = args.json_output |
| 2188 | |
| 2189 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 2190 | repo_id = _resolve_repo_id(hub_url, identity) |
| 2191 | proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id) |
| 2192 | |
| 2193 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/proposals/{proposal_id}") |
| 2194 | |
| 2195 | if json_output: |
| 2196 | print(json.dumps(data)) |
| 2197 | return |
| 2198 | |
| 2199 | title = sanitize_display(str(data.get("title", "(no title)"))) |
| 2200 | state = str(data.get("state", "?")) |
| 2201 | from_b = sanitize_display(str(data.get("fromBranch", "?"))) |
| 2202 | to_b = sanitize_display(str(data.get("toBranch", "?"))) |
| 2203 | full_id = sanitize_display(str(data.get("proposalId", proposal_id))) |
| 2204 | author = sanitize_display(str(data.get("author", ""))) |
| 2205 | created = sanitize_display(str(data.get("createdAt", ""))[:10]) |
| 2206 | body = str(data.get("body", "")) |
| 2207 | # state_icon lookup uses the raw state string as a dict key — safe; the |
| 2208 | # fallback "❓" handles any unexpected value. The display string is |
| 2209 | # sanitized separately so ANSI-injected state values don't reach the terminal. |
| 2210 | state_icon = {"open": "🟢", "merged": "🟣", "closed": "⛔"}.get(state, "❓") |
| 2211 | state_display = sanitize_display(state) |
| 2212 | |
| 2213 | print(f"\n {state_icon} [{state_display.upper()}] {title}", file=sys.stderr) |
| 2214 | print(f" ID: {full_id}", file=sys.stderr) |
| 2215 | print(f" Branch: {from_b} → {to_b}", file=sys.stderr) |
| 2216 | if author: |
| 2217 | print(f" By: {author} {created}", file=sys.stderr) |
| 2218 | if body: |
| 2219 | body_lines = body.strip().splitlines() |
| 2220 | print(" Body:", file=sys.stderr) |
| 2221 | for line in body_lines[:_MAX_PROPOSAL_BODY_LINES]: |
| 2222 | print(f" {sanitize_display(line)}", file=sys.stderr) |
| 2223 | if len(body_lines) > _MAX_PROPOSAL_BODY_LINES: |
| 2224 | remaining = len(body_lines) - _MAX_PROPOSAL_BODY_LINES |
| 2225 | print( |
| 2226 | f" ... ({remaining} more line{'s' if remaining != 1 else ''}" |
| 2227 | " — use --json to see full body)", |
| 2228 | file=sys.stderr, |
| 2229 | ) |
| 2230 | print("", file=sys.stderr) |
| 2231 | |
| 2232 | |
| 2233 | def run_proposal_create(args: argparse.Namespace) -> None: |
| 2234 | """Open a new proposal on MuseHub. |
| 2235 | |
| 2236 | Uses the current branch as the source if ``--from-branch`` is not given. |
| 2237 | Both Muse-native (``--from-branch`` / ``--to-branch``) and git-familiar |
| 2238 | aliases (``--head`` / ``--base``) are accepted. |
| 2239 | |
| 2240 | All local validation (title length, branch detection) runs before any |
| 2241 | network I/O, so errors are reported immediately without contacting the hub. |
| 2242 | |
| 2243 | Branch detection falls back to the current branch in ``.muse/HEAD``. |
| 2244 | When the repository is in detached HEAD state and ``--from-branch`` is |
| 2245 | not given, a clear error is printed and the command exits with code 1. |
| 2246 | |
| 2247 | Title is validated client-side: must be non-empty and no longer than |
| 2248 | :data:`_MAX_PROPOSAL_TITLE_LEN` characters. |
| 2249 | |
| 2250 | JSON output is the raw API response (passthrough). Human-readable text |
| 2251 | goes to stderr. |
| 2252 | |
| 2253 | Agent quickstart |
| 2254 | ---------------- |
| 2255 | :: |
| 2256 | |
| 2257 | muse hub proposal create --title "feat: x" --from-branch feat/x --json |
| 2258 | # → {"proposalId": "...", "state": "open", ...} |
| 2259 | |
| 2260 | # Full idiomatic flow: |
| 2261 | PROPOSAL=$(muse hub proposal create --title "feat: x" --json) |
| 2262 | echo "$PROPOSAL" | jq '.proposalId' |
| 2263 | |
| 2264 | Exit codes |
| 2265 | ---------- |
| 2266 | 0 Proposal created. |
| 2267 | 1 Title empty/too long, branch cannot be determined, or not authenticated. |
| 2268 | 2 Not inside a Muse repository. |
| 2269 | 3 API / network error. |
| 2270 | """ |
| 2271 | title: str = args.title |
| 2272 | body: str = args.body |
| 2273 | to_branch: str = args.to_branch |
| 2274 | json_output: bool = args.json_output |
| 2275 | |
| 2276 | # ── Local validation first — fail fast before any network I/O ──────────── |
| 2277 | |
| 2278 | # Client-side title validation — better UX than a raw API 400. |
| 2279 | if not title.strip(): |
| 2280 | print("❌ Proposal title must not be empty.", file=sys.stderr) |
| 2281 | raise SystemExit(ExitCode.USER_ERROR) |
| 2282 | if len(title) > _MAX_PROPOSAL_TITLE_LEN: |
| 2283 | print( |
| 2284 | f"❌ Proposal title is too long ({len(title)} chars); " |
| 2285 | f"maximum is {_MAX_PROPOSAL_TITLE_LEN}.", |
| 2286 | file=sys.stderr, |
| 2287 | ) |
| 2288 | raise SystemExit(ExitCode.USER_ERROR) |
| 2289 | |
| 2290 | # Resolve source branch before making any network calls so that a |
| 2291 | # detached-HEAD error or missing branch is caught locally and quickly. |
| 2292 | from_branch: str = args.from_branch or "" |
| 2293 | if not from_branch: |
| 2294 | ctx = find_repo_root() |
| 2295 | if ctx is not None: |
| 2296 | try: |
| 2297 | from_branch = read_current_branch(ctx) |
| 2298 | except ValueError as exc: |
| 2299 | # Detached HEAD — read_current_branch raises ValueError. |
| 2300 | print(f"❌ {exc}", file=sys.stderr) |
| 2301 | print( |
| 2302 | " Use --from-branch to specify the source branch explicitly.", |
| 2303 | file=sys.stderr, |
| 2304 | ) |
| 2305 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 2306 | if not from_branch: |
| 2307 | print( |
| 2308 | "❌ Could not determine current branch. Use --from-branch.", |
| 2309 | file=sys.stderr, |
| 2310 | ) |
| 2311 | raise SystemExit(ExitCode.USER_ERROR) |
| 2312 | |
| 2313 | # ── Network calls ───────────────────────────────────────────────────────── |
| 2314 | |
| 2315 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 2316 | repo_id = _resolve_repo_id(hub_url, identity) |
| 2317 | |
| 2318 | payload: _HubPayload = { |
| 2319 | "title": title, |
| 2320 | "body": body, |
| 2321 | "fromBranch": from_branch, |
| 2322 | "toBranch": to_branch, |
| 2323 | } |
| 2324 | data = _hub_api( |
| 2325 | hub_url, identity, "POST", |
| 2326 | f"/api/repos/{repo_id}/proposals", |
| 2327 | body=payload, |
| 2328 | ) |
| 2329 | |
| 2330 | if json_output: |
| 2331 | print(json.dumps(data)) |
| 2332 | return |
| 2333 | |
| 2334 | proposal_id = str(data.get("proposalId", "")) |
| 2335 | print( |
| 2336 | f"✅ Proposal created: {sanitize_display(proposal_id[:8])}", |
| 2337 | file=sys.stderr, |
| 2338 | ) |
| 2339 | print( |
| 2340 | f" {sanitize_display(from_branch)} → {sanitize_display(to_branch)}: " |
| 2341 | f"{sanitize_display(title)}", |
| 2342 | file=sys.stderr, |
| 2343 | ) |
| 2344 | parsed = urllib.parse.urlparse(hub_url) |
| 2345 | parts = parsed.path.strip("/").split("/") |
| 2346 | if len(parts) >= 2: |
| 2347 | owner, slug = parts[0], parts[1] |
| 2348 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 2349 | print( |
| 2350 | f" URL: {sanitize_display(server_root)}/{sanitize_display(owner)}" |
| 2351 | f"/{sanitize_display(slug)}/proposals/{sanitize_display(proposal_id)}", |
| 2352 | file=sys.stderr, |
| 2353 | ) |
| 2354 | |
| 2355 | |
| 2356 | def run_proposal_merge(args: argparse.Namespace) -> None: |
| 2357 | """Merge a proposal. |
| 2358 | |
| 2359 | Merges immediately — no confirmation dialog. The source branch is deleted |
| 2360 | by default; pass ``--no-delete-branch`` to keep it. |
| 2361 | |
| 2362 | Merge strategies: ``merge_commit`` (default), ``squash``, ``rebase``. |
| 2363 | |
| 2364 | The hub may return HTTP 200 with ``{"merged": false}`` when it refuses the |
| 2365 | merge (e.g. conflict, branch-protection rule). This command treats that |
| 2366 | as a failure in **both** text and JSON modes — exit code 3 is returned so |
| 2367 | that agent pipelines can reliably use the exit code to detect success:: |
| 2368 | |
| 2369 | muse hub proposal merge af54753d --json && echo "merged" || echo "conflict" |
| 2370 | |
| 2371 | JSON output is the raw API response (passthrough) to stdout; human-readable |
| 2372 | text goes to stderr. |
| 2373 | |
| 2374 | Agent quickstart |
| 2375 | ---------------- |
| 2376 | :: |
| 2377 | |
| 2378 | muse hub proposal merge af54753d --strategy squash --json |
| 2379 | # → {"merged": true, "mergeCommitId": "...", ...} |
| 2380 | |
| 2381 | # Safe pipeline — exit code 3 on merge failure, even with --json: |
| 2382 | PROPOSAL_RESULT=$(muse hub proposal merge af54753d --json) || exit 1 |
| 2383 | echo "$PROPOSAL_RESULT" | jq '.mergeCommitId' |
| 2384 | |
| 2385 | Exit codes |
| 2386 | ---------- |
| 2387 | 0 Merged successfully. |
| 2388 | 1 Prefix not found, ambiguous, or not authenticated. |
| 2389 | 2 Not inside a Muse repository. |
| 2390 | 3 Merge rejected by hub (conflict, branch protection, etc.) or API error. |
| 2391 | """ |
| 2392 | proposal_id: str = args.proposal_id |
| 2393 | strategy: str = args.strategy |
| 2394 | delete_branch: bool = args.delete_branch |
| 2395 | json_output: bool = args.json_output |
| 2396 | |
| 2397 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 2398 | repo_id = _resolve_repo_id(hub_url, identity) |
| 2399 | proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id) |
| 2400 | |
| 2401 | payload: _HubPayload = { |
| 2402 | "mergeStrategy": strategy, |
| 2403 | "deleteBranch": delete_branch, |
| 2404 | } |
| 2405 | data = _hub_api( |
| 2406 | hub_url, identity, "POST", |
| 2407 | f"/api/repos/{repo_id}/proposals/{proposal_id}/merge", |
| 2408 | body=payload, |
| 2409 | ) |
| 2410 | |
| 2411 | merged: bool = bool(data.get("merged", False)) |
| 2412 | |
| 2413 | if json_output: |
| 2414 | print(json.dumps(data)) |
| 2415 | # Exit nonzero even in JSON mode so agent pipelines can rely on the |
| 2416 | # exit code — a hub-side merge rejection must never silently exit 0. |
| 2417 | if not merged: |
| 2418 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 2419 | return |
| 2420 | |
| 2421 | commit_id: str = str(data.get("mergeCommitId", "")) or "" |
| 2422 | if merged: |
| 2423 | sha = sanitize_display(commit_id[:8]) if commit_id else "(no SHA)" |
| 2424 | print( |
| 2425 | f"✅ Proposal {sanitize_display(proposal_id[:8])} merged → {sha}", |
| 2426 | file=sys.stderr, |
| 2427 | ) |
| 2428 | if delete_branch: |
| 2429 | print(" Source branch deleted.", file=sys.stderr) |
| 2430 | else: |
| 2431 | msg = str(data.get("message", "unknown error")) |
| 2432 | print(f"❌ Merge failed: {sanitize_display(msg)}", file=sys.stderr) |
| 2433 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 2434 | |
| 2435 | |
| 2436 | # ── Proposal comment / reviewer / review handlers ───────────────────────────── |
| 2437 | |
| 2438 | |
| 2439 | def run_proposal_comment_list(args: argparse.Namespace) -> None: |
| 2440 | """List all comments on a proposal, threaded by parent. |
| 2441 | |
| 2442 | Each top-level comment includes its direct replies nested under it.:: |
| 2443 | |
| 2444 | muse hub proposal comment list af54753d |
| 2445 | muse hub proposal comment list af54753d --json |
| 2446 | |
| 2447 | Exit codes: |
| 2448 | 0 Success. |
| 2449 | 1 Auth error or proposal not found. |
| 2450 | 2 Not inside a Muse repository. |
| 2451 | 3 API error. |
| 2452 | """ |
| 2453 | proposal_id_or_prefix: str = args.proposal_id |
| 2454 | json_output: bool = args.json_output |
| 2455 | |
| 2456 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 2457 | repo_id = _resolve_repo_id(hub_url, identity) |
| 2458 | proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix) |
| 2459 | |
| 2460 | data = _hub_api( |
| 2461 | hub_url, identity, "GET", |
| 2462 | f"/api/repos/{repo_id}/proposals/{proposal_id}/comments", |
| 2463 | ) |
| 2464 | |
| 2465 | if json_output: |
| 2466 | print(json.dumps(data)) |
| 2467 | return |
| 2468 | |
| 2469 | comments = data.get("comments", []) |
| 2470 | total = data.get("total", len(comments)) |
| 2471 | if not comments: |
| 2472 | print(f"No comments on proposal {sanitize_display(proposal_id[:8])}.", file=sys.stderr) |
| 2473 | return |
| 2474 | print(f"Comments ({total}):", file=sys.stderr) |
| 2475 | for c in comments: |
| 2476 | author = sanitize_display(str(c.get("author", ""))) |
| 2477 | body = sanitize_display(str(c.get("body", "")))[:80] |
| 2478 | print(f" [{author}] {body}") |
| 2479 | for r in c.get("replies", []): |
| 2480 | r_author = sanitize_display(str(r.get("author", ""))) |
| 2481 | r_body = sanitize_display(str(r.get("body", "")))[:70] |
| 2482 | print(f" ↳ [{r_author}] {r_body}") |
| 2483 | |
| 2484 | |
| 2485 | def run_proposal_reviewer_request(args: argparse.Namespace) -> None: |
| 2486 | """Request one or more reviewers on a proposal. |
| 2487 | |
| 2488 | Reviewer handles are passed as positional arguments:: |
| 2489 | |
| 2490 | muse hub proposal reviewer request af54753d alice bob |
| 2491 | muse hub proposal reviewer request af54753d alice --json |
| 2492 | |
| 2493 | Exit codes: |
| 2494 | 0 Success. |
| 2495 | 1 Auth error or not authorized. |
| 2496 | 2 Not inside a Muse repository. |
| 2497 | 3 API error. |
| 2498 | """ |
| 2499 | proposal_id_or_prefix: str = args.proposal_id |
| 2500 | reviewers: list[str] = args.reviewers |
| 2501 | json_output: bool = args.json_output |
| 2502 | |
| 2503 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 2504 | repo_id = _resolve_repo_id(hub_url, identity) |
| 2505 | proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix) |
| 2506 | |
| 2507 | data = _hub_api( |
| 2508 | hub_url, identity, "POST", |
| 2509 | f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers", |
| 2510 | body={"reviewers": reviewers}, |
| 2511 | ) |
| 2512 | |
| 2513 | if json_output: |
| 2514 | print(json.dumps(data)) |
| 2515 | return |
| 2516 | |
| 2517 | reviews = data.get("reviews", []) |
| 2518 | print(f"✅ Reviewers requested ({len(reviews)} total):", file=sys.stderr) |
| 2519 | for rv in reviews: |
| 2520 | handle = sanitize_display(str(rv.get("reviewerUsername", rv.get("reviewer", "")))) |
| 2521 | state = sanitize_display(str(rv.get("state", ""))) |
| 2522 | print(f" {handle:<32} {state}") |
| 2523 | |
| 2524 | |
| 2525 | def run_proposal_reviewer_remove(args: argparse.Namespace) -> None: |
| 2526 | """Remove a pending reviewer from a proposal. |
| 2527 | |
| 2528 | Only pending review requests can be removed — submitted reviews are immutable:: |
| 2529 | |
| 2530 | muse hub proposal reviewer remove af54753d alice |
| 2531 | muse hub proposal reviewer remove af54753d alice --json |
| 2532 | |
| 2533 | Exit codes: |
| 2534 | 0 Success. |
| 2535 | 1 Auth error, reviewer not found, or review already submitted. |
| 2536 | 2 Not inside a Muse repository. |
| 2537 | 3 API error. |
| 2538 | """ |
| 2539 | proposal_id_or_prefix: str = args.proposal_id |
| 2540 | reviewer: str = args.reviewer |
| 2541 | json_output: bool = args.json_output |
| 2542 | |
| 2543 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 2544 | repo_id = _resolve_repo_id(hub_url, identity) |
| 2545 | proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix) |
| 2546 | |
| 2547 | data = _hub_api( |
| 2548 | hub_url, identity, "DELETE", |
| 2549 | f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/{reviewer}", |
| 2550 | ) |
| 2551 | |
| 2552 | if json_output: |
| 2553 | print(json.dumps(data)) |
| 2554 | return |
| 2555 | |
| 2556 | reviews = data.get("reviews", []) |
| 2557 | print( |
| 2558 | f"✅ Reviewer {sanitize_display(reviewer)} removed " |
| 2559 | f"({len(reviews)} reviewers remaining).", |
| 2560 | file=sys.stderr, |
| 2561 | ) |
| 2562 | |
| 2563 | |
| 2564 | def run_proposal_review_list(args: argparse.Namespace) -> None: |
| 2565 | """List all reviews for a proposal. |
| 2566 | |
| 2567 | Includes pending assignments and submitted reviews (approved, changes_requested, |
| 2568 | dismissed). Pass ``--state`` to filter:: |
| 2569 | |
| 2570 | muse hub proposal review list af54753d |
| 2571 | muse hub proposal review list af54753d --state approved --json |
| 2572 | |
| 2573 | Exit codes: |
| 2574 | 0 Success. |
| 2575 | 1 Auth error or proposal not found. |
| 2576 | 2 Not inside a Muse repository. |
| 2577 | 3 API error. |
| 2578 | """ |
| 2579 | proposal_id_or_prefix: str = args.proposal_id |
| 2580 | state: str | None = getattr(args, "state", None) or None |
| 2581 | json_output: bool = args.json_output |
| 2582 | |
| 2583 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 2584 | repo_id = _resolve_repo_id(hub_url, identity) |
| 2585 | proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix) |
| 2586 | |
| 2587 | path = f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews" |
| 2588 | if state: |
| 2589 | path += f"?state={state}" |
| 2590 | data = _hub_api(hub_url, identity, "GET", path) |
| 2591 | |
| 2592 | if json_output: |
| 2593 | print(json.dumps(data)) |
| 2594 | return |
| 2595 | |
| 2596 | reviews = data.get("reviews", []) |
| 2597 | total = data.get("total", len(reviews)) |
| 2598 | if not reviews: |
| 2599 | print(f"No reviews on proposal {sanitize_display(proposal_id[:8])}.", file=sys.stderr) |
| 2600 | return |
| 2601 | print(f"Reviews ({total}):", file=sys.stderr) |
| 2602 | for rv in reviews: |
| 2603 | handle = sanitize_display(str(rv.get("reviewerUsername", rv.get("reviewer", "")))) |
| 2604 | s = sanitize_display(str(rv.get("state", ""))) |
| 2605 | print(f" {handle:<32} {s}") |
| 2606 | |
| 2607 | |
| 2608 | # ── Repo subcommand handlers ────────────────────────────────────────────────── |
| 2609 | |
| 2610 | |
| 2611 | def run_repo_create(args: argparse.Namespace) -> None: # noqa: C901 |
| 2612 | """Create a new repository on MuseHub. |
| 2613 | |
| 2614 | All local validation (name format, length) runs before any network I/O so |
| 2615 | errors are reported immediately without contacting the hub. |
| 2616 | |
| 2617 | The owner defaults to the authenticated identity's handle. Pass |
| 2618 | ``--owner`` to create under a different handle (requires the caller to |
| 2619 | have permission on the server). |
| 2620 | |
| 2621 | The repo is created with ``initialize=True`` by default so the default |
| 2622 | branch exists and the repo is immediately browsable and pushable. Pass |
| 2623 | ``--no-init`` to skip the initial commit (useful when you are about to |
| 2624 | push an existing history). |
| 2625 | |
| 2626 | JSON output (``--json``, stdout) |
| 2627 | -------------------------------- |
| 2628 | :: |
| 2629 | |
| 2630 | { |
| 2631 | "repo_id": "<uuid>", |
| 2632 | "name": "<name>", |
| 2633 | "owner": "<owner>", |
| 2634 | "slug": "<url-safe-slug>", |
| 2635 | "visibility": "public" | "private", |
| 2636 | "description": "<desc>", |
| 2637 | "clone_url": "<url>", |
| 2638 | "tags": ["<tag>", ...], |
| 2639 | "created_at": "<iso8601>" |
| 2640 | } |
| 2641 | |
| 2642 | Agent quickstart |
| 2643 | ---------------- |
| 2644 | :: |
| 2645 | |
| 2646 | muse hub repo create --name my-repo --json |
| 2647 | # → {"repo_id": "...", "slug": "my-repo", "clone_url": "...", ...} |
| 2648 | |
| 2649 | # Create private repo, push immediately: |
| 2650 | muse hub repo create --name my-repo --private --no-init --json |
| 2651 | muse push <remote> main |
| 2652 | |
| 2653 | Exit codes |
| 2654 | ---------- |
| 2655 | 0 Repo created. |
| 2656 | 1 Validation error, name conflict (409), or not authenticated. |
| 2657 | 2 Not inside a Muse repository (when hub URL is inferred from config). |
| 2658 | 3 API / network error. |
| 2659 | """ |
| 2660 | name: str = args.name.strip() |
| 2661 | owner_override: str = getattr(args, "owner", "") or "" |
| 2662 | description: str = getattr(args, "description", "") or "" |
| 2663 | visibility: str = "private" if getattr(args, "private", False) else "public" |
| 2664 | tags: list[str] = getattr(args, "tags", []) or [] |
| 2665 | initialize: bool = not getattr(args, "no_init", False) |
| 2666 | default_branch: str = getattr(args, "default_branch", "main") or "main" |
| 2667 | json_output: bool = args.json_output |
| 2668 | |
| 2669 | # ── Local validation — fail fast before any network I/O ────────────────── |
| 2670 | |
| 2671 | if not name: |
| 2672 | print("❌ Repo name must not be empty.", file=sys.stderr) |
| 2673 | raise SystemExit(ExitCode.USER_ERROR) |
| 2674 | if len(name) > _MAX_REPO_NAME_LEN: |
| 2675 | print( |
| 2676 | f"❌ Repo name is too long ({len(name)} chars); " |
| 2677 | f"maximum is {_MAX_REPO_NAME_LEN}.", |
| 2678 | file=sys.stderr, |
| 2679 | ) |
| 2680 | raise SystemExit(ExitCode.USER_ERROR) |
| 2681 | if len(description) > _MAX_REPO_DESC_LEN: |
| 2682 | print( |
| 2683 | f"❌ Description is too long ({len(description)} chars); " |
| 2684 | f"maximum is {_MAX_REPO_DESC_LEN}.", |
| 2685 | file=sys.stderr, |
| 2686 | ) |
| 2687 | raise SystemExit(ExitCode.USER_ERROR) |
| 2688 | if visibility not in ("public", "private"): |
| 2689 | print( |
| 2690 | f"❌ Invalid visibility '{sanitize_display(visibility)}'. " |
| 2691 | "Use 'public' or 'private'.", |
| 2692 | file=sys.stderr, |
| 2693 | ) |
| 2694 | raise SystemExit(ExitCode.USER_ERROR) |
| 2695 | if not default_branch.strip(): |
| 2696 | print("❌ Default branch name must not be empty.", file=sys.stderr) |
| 2697 | raise SystemExit(ExitCode.USER_ERROR) |
| 2698 | |
| 2699 | # ── Network calls ───────────────────────────────────────────────────────── |
| 2700 | |
| 2701 | hub_url, identity = _get_hub_and_identity(hub_url_override=getattr(args, "hub", None)) |
| 2702 | |
| 2703 | # Resolve owner: use --owner if given, else fall back to authenticated handle. |
| 2704 | owner = owner_override.strip() or str(identity.get("handle", "")) |
| 2705 | if not owner: |
| 2706 | print( |
| 2707 | "❌ Could not determine owner. Pass --owner or authenticate with " |
| 2708 | "`muse auth register`.", |
| 2709 | file=sys.stderr, |
| 2710 | ) |
| 2711 | raise SystemExit(ExitCode.USER_ERROR) |
| 2712 | |
| 2713 | payload: _HubPayload = { |
| 2714 | "name": name, |
| 2715 | "owner": owner, |
| 2716 | "visibility": visibility, |
| 2717 | "description": description, |
| 2718 | "tags": tags, |
| 2719 | "initialize": initialize, |
| 2720 | "defaultBranch": default_branch, |
| 2721 | } |
| 2722 | |
| 2723 | # _hub_api exits on 4xx/5xx — but 409 Conflict deserves a friendlier message. |
| 2724 | # We intercept it by wrapping the call so we can re-raise with a clear hint. |
| 2725 | parsed_hub = urllib.parse.urlparse(hub_url) |
| 2726 | server_root = f"{parsed_hub.scheme}://{parsed_hub.netloc}" |
| 2727 | |
| 2728 | # Determine the correct API path. _hub_api prepends server_root internally. |
| 2729 | api_path = "/api/repos" |
| 2730 | |
| 2731 | try: |
| 2732 | data = _hub_api(hub_url, identity, "POST", api_path, body=payload) |
| 2733 | except SystemExit as exc: |
| 2734 | # _hub_api already printed the error; re-raise unless we can improve it. |
| 2735 | # We can't distinguish 409 at this point (it was already printed), so just propagate. |
| 2736 | raise exc |
| 2737 | |
| 2738 | slug = str(data.get("slug", name)) |
| 2739 | repo_id = str(data.get("repoId", data.get("repo_id", ""))) |
| 2740 | clone_url = str(data.get("cloneUrl", data.get("clone_url", ""))) |
| 2741 | created_at = str(data.get("createdAt", data.get("created_at", ""))) |
| 2742 | resp_tags: list[str] = [str(t) for t in data.get("tags", [])] if isinstance(data.get("tags"), list) else [] |
| 2743 | repo_url = f"{server_root}/{sanitize_display(owner)}/{sanitize_display(slug)}" |
| 2744 | |
| 2745 | if json_output: |
| 2746 | out: _RepoJson = { |
| 2747 | "repo_id": repo_id, |
| 2748 | "name": name, |
| 2749 | "owner": owner, |
| 2750 | "slug": slug, |
| 2751 | "visibility": visibility, |
| 2752 | "description": description, |
| 2753 | "clone_url": clone_url, |
| 2754 | "tags": resp_tags, |
| 2755 | "created_at": created_at, |
| 2756 | } |
| 2757 | print(json.dumps(out)) |
| 2758 | return |
| 2759 | |
| 2760 | print( |
| 2761 | f"✅ Repository created: {sanitize_display(owner)}/{sanitize_display(slug)}", |
| 2762 | file=sys.stderr, |
| 2763 | ) |
| 2764 | print(f" URL: {sanitize_display(repo_url)}", file=sys.stderr) |
| 2765 | if clone_url: |
| 2766 | print(f" Clone: {sanitize_display(clone_url)}", file=sys.stderr) |
| 2767 | print( |
| 2768 | f" Visibility: {sanitize_display(visibility)} " |
| 2769 | f"Branch: {sanitize_display(default_branch)} " |
| 2770 | f"Init: {'yes' if initialize else 'no'}", |
| 2771 | file=sys.stderr, |
| 2772 | ) |
| 2773 | print( |
| 2774 | f"\n To push an existing repo:\n" |
| 2775 | f" muse remote add origin {sanitize_display(repo_url)}\n" |
| 2776 | f" muse push origin {sanitize_display(default_branch)}", |
| 2777 | file=sys.stderr, |
| 2778 | ) |
| 2779 | |
| 2780 | |
| 2781 | # ── Repo management handlers ───────────────────────────────────────────────── |
| 2782 | |
| 2783 | |
| 2784 | def run_repo_delete(args: argparse.Namespace) -> None: |
| 2785 | """Soft-delete a repository on MuseHub. |
| 2786 | |
| 2787 | Only the repository owner may delete. All data is retained for audit; |
| 2788 | subsequent reads return 404. Requires explicit confirmation via ``--yes``. |
| 2789 | |
| 2790 | When ``target`` is provided it may be ``OWNER/SLUG`` or a repo UUID, |
| 2791 | allowing bulk deletion without being inside the target repo's directory. |
| 2792 | When omitted the repo is resolved from the current directory's remote |
| 2793 | config (original behaviour):: |
| 2794 | |
| 2795 | muse hub repo delete --yes |
| 2796 | muse hub repo delete gabriel/my-repo --yes |
| 2797 | muse hub repo delete a3f2c9d1-... --yes --json |
| 2798 | |
| 2799 | Exit codes: |
| 2800 | 0 Deleted successfully. |
| 2801 | 1 Auth error or not authorized. |
| 2802 | 2 Not inside a Muse repository (and no target given). |
| 2803 | 3 API error. |
| 2804 | """ |
| 2805 | yes: bool = args.yes |
| 2806 | json_output: bool = args.json_output |
| 2807 | target: str | None = getattr(args, "target", None) |
| 2808 | |
| 2809 | if not yes: |
| 2810 | print( |
| 2811 | "❌ Pass --yes to confirm deletion. This action cannot be undone.", |
| 2812 | file=sys.stderr, |
| 2813 | ) |
| 2814 | raise SystemExit(ExitCode.USER_ERROR) |
| 2815 | |
| 2816 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 2817 | |
| 2818 | if target is not None: |
| 2819 | if "/" in target: |
| 2820 | # OWNER/SLUG — resolve to repo_id via the show endpoint |
| 2821 | owner, slug = target.split("/", 1) |
| 2822 | resp = _hub_api(hub_url, identity, "GET", f"/api/{owner}/{slug}") |
| 2823 | repo_id = resp.get("repoId") or resp.get("repo_id", "") |
| 2824 | else: |
| 2825 | # Treat as a bare repo_id UUID |
| 2826 | repo_id = target |
| 2827 | else: |
| 2828 | repo_id = _resolve_repo_id(hub_url, identity) |
| 2829 | |
| 2830 | _hub_api(hub_url, identity, "DELETE", f"/api/repos/{repo_id}") |
| 2831 | |
| 2832 | if json_output: |
| 2833 | print(json.dumps({"deleted": True, "repo_id": repo_id})) |
| 2834 | return |
| 2835 | |
| 2836 | print(f"✅ Repository {sanitize_display(repo_id[:8])} deleted.", file=sys.stderr) |
| 2837 | |
| 2838 | |
| 2839 | def run_repo_update(args: argparse.Namespace) -> None: |
| 2840 | """Show or update repository settings on MuseHub. |
| 2841 | |
| 2842 | Without flags, prints the current settings. Pass update flags to patch |
| 2843 | specific fields — only provided flags are written:: |
| 2844 | |
| 2845 | muse hub repo settings |
| 2846 | muse hub repo settings --visibility private |
| 2847 | muse hub repo settings --description "My project" --json |
| 2848 | |
| 2849 | Exit codes: |
| 2850 | 0 Success. |
| 2851 | 1 Auth error or not authorized. |
| 2852 | 2 Not inside a Muse repository. |
| 2853 | 3 API error. |
| 2854 | """ |
| 2855 | json_output: bool = args.json_output |
| 2856 | |
| 2857 | # Build patch body from flags |
| 2858 | patch: dict[str, object] = {} |
| 2859 | for field in ("name", "description", "visibility", "default_branch", "homepage_url"): |
| 2860 | val = getattr(args, field, None) |
| 2861 | if val is not None: |
| 2862 | patch[field] = val |
| 2863 | for bool_field in ("has_issues", "has_wiki", "allow_merge_commit", |
| 2864 | "allow_squash_merge", "allow_rebase_merge", "delete_branch_on_merge"): |
| 2865 | val = getattr(args, bool_field, None) |
| 2866 | if val is not None: |
| 2867 | patch[bool_field] = val |
| 2868 | |
| 2869 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 2870 | repo_id = _resolve_repo_id(hub_url, identity) |
| 2871 | |
| 2872 | if patch: |
| 2873 | data = _hub_api(hub_url, identity, "PATCH", f"/api/repos/{repo_id}/settings", body=patch) |
| 2874 | else: |
| 2875 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/settings") |
| 2876 | |
| 2877 | if json_output: |
| 2878 | print(json.dumps(data)) |
| 2879 | return |
| 2880 | |
| 2881 | print(f"Name: {sanitize_display(str(data.get('name', '')))}", file=sys.stderr) |
| 2882 | print(f"Description: {sanitize_display(str(data.get('description', '')))}", file=sys.stderr) |
| 2883 | print(f"Visibility: {sanitize_display(str(data.get('visibility', '')))}", file=sys.stderr) |
| 2884 | print(f"Branch: {sanitize_display(str(data.get('defaultBranch', '')))}", file=sys.stderr) |
| 2885 | print(f"Issues: {data.get('hasIssues', True)}", file=sys.stderr) |
| 2886 | print(f"Wiki: {data.get('hasWiki', False)}", file=sys.stderr) |
| 2887 | |
| 2888 | |
| 2889 | def run_repo_list(args: argparse.Namespace) -> None: |
| 2890 | """List repositories owned by or collaborated on by the authenticated user. |
| 2891 | |
| 2892 | Results are ordered newest-first. Pass ``--limit`` to control page size and |
| 2893 | ``--cursor`` to page through results using the ``next_cursor`` value from a |
| 2894 | previous call. |
| 2895 | |
| 2896 | JSON output (``--json``, stdout) |
| 2897 | -------------------------------- |
| 2898 | :: |
| 2899 | |
| 2900 | { |
| 2901 | "total": 42, |
| 2902 | "next_cursor": "<opaque-string> | null", |
| 2903 | "repos": [ |
| 2904 | { |
| 2905 | "repo_id": "<uuid>", |
| 2906 | "name": "<name>", |
| 2907 | "owner": "<handle>", |
| 2908 | "slug": "<slug>", |
| 2909 | "visibility": "public" | "private", |
| 2910 | "description": "<desc>", |
| 2911 | "tags": ["<tag>", ...], |
| 2912 | "default_branch": "<branch>", |
| 2913 | "created_at": "<iso8601>", |
| 2914 | "pushed_at": "<iso8601>" |
| 2915 | }, |
| 2916 | ... |
| 2917 | ] |
| 2918 | } |
| 2919 | |
| 2920 | Agent quickstart |
| 2921 | ---------------- |
| 2922 | :: |
| 2923 | |
| 2924 | muse hub repo list --json |
| 2925 | muse hub repo list --limit 50 --json |
| 2926 | muse hub repo list --cursor "<cursor>" --json |
| 2927 | |
| 2928 | Exit codes |
| 2929 | ---------- |
| 2930 | 0 Success. |
| 2931 | 1 Not authenticated. |
| 2932 | 2 Not inside a Muse repository (when hub URL is inferred from config). |
| 2933 | 3 API / network error. |
| 2934 | """ |
| 2935 | limit: int = getattr(args, "limit", 20) or 20 |
| 2936 | cursor: str | None = getattr(args, "cursor", None) or None |
| 2937 | json_output: bool = args.json_output |
| 2938 | |
| 2939 | hub_url, identity = _get_hub_and_identity(hub_url_override=getattr(args, "hub", None)) |
| 2940 | |
| 2941 | params: list[str] = [f"limit={min(max(1, limit), 100)}"] |
| 2942 | if cursor: |
| 2943 | params.append(f"cursor={cursor}") |
| 2944 | api_path = "/api/repos?" + "&".join(params) |
| 2945 | |
| 2946 | data = _hub_api(hub_url, identity, "GET", api_path) |
| 2947 | |
| 2948 | repos: list[dict[str, object]] = data.get("repos", []) # type: ignore[assignment] |
| 2949 | total: int = int(data.get("total", len(repos))) |
| 2950 | next_cursor: str | None = data.get("nextCursor") or data.get("next_cursor") # type: ignore[assignment] |
| 2951 | |
| 2952 | if json_output: |
| 2953 | out: dict[str, object] = { |
| 2954 | "total": total, |
| 2955 | "next_cursor": next_cursor, |
| 2956 | "repos": [ |
| 2957 | { |
| 2958 | "repo_id": str(r.get("repoId", r.get("repo_id", ""))), |
| 2959 | "name": str(r.get("name", "")), |
| 2960 | "owner": str(r.get("owner", "")), |
| 2961 | "slug": str(r.get("slug", "")), |
| 2962 | "visibility": str(r.get("visibility", "public")), |
| 2963 | "description": str(r.get("description", "")), |
| 2964 | "tags": r.get("tags", []), |
| 2965 | "default_branch": str(r.get("defaultBranch", r.get("default_branch", "main"))), |
| 2966 | "created_at": str(r.get("createdAt", r.get("created_at", ""))), |
| 2967 | "pushed_at": str(r.get("pushedAt", r.get("pushed_at", ""))), |
| 2968 | } |
| 2969 | for r in repos |
| 2970 | if isinstance(r, dict) |
| 2971 | ], |
| 2972 | } |
| 2973 | print(json.dumps(out)) |
| 2974 | return |
| 2975 | |
| 2976 | if not repos: |
| 2977 | print("No repositories found.", file=sys.stderr) |
| 2978 | return |
| 2979 | |
| 2980 | print(f"Repositories ({total} total):", file=sys.stderr) |
| 2981 | for r in repos: |
| 2982 | if not isinstance(r, dict): |
| 2983 | continue |
| 2984 | owner = r.get("owner", "") |
| 2985 | slug = r.get("slug", r.get("name", "")) |
| 2986 | visibility = r.get("visibility", "public") |
| 2987 | desc = r.get("description", "") |
| 2988 | marker = "🔒 " if visibility == "private" else " " |
| 2989 | print(f" {marker}{sanitize_display(str(owner))}/{sanitize_display(str(slug))}", file=sys.stderr) |
| 2990 | if desc: |
| 2991 | print(f" {sanitize_display(str(desc))[:72]}", file=sys.stderr) |
| 2992 | if next_cursor: |
| 2993 | print(f"\n (more results — pass --cursor {sanitize_display(str(next_cursor))} to continue)", file=sys.stderr) |
| 2994 | |
| 2995 | |
| 2996 | def run_repo_show(args: argparse.Namespace) -> None: |
| 2997 | """Show metadata for a single MuseHub repository. |
| 2998 | |
| 2999 | Resolves by ``owner/slug`` (positional ``OWNER/SLUG`` argument) or by the |
| 3000 | hub remote config of the current directory when no argument is given. |
| 3001 | |
| 3002 | JSON output (``--json``, stdout) |
| 3003 | -------------------------------- |
| 3004 | :: |
| 3005 | |
| 3006 | { |
| 3007 | "repo_id": "<uuid>", |
| 3008 | "name": "<name>", |
| 3009 | "owner": "<handle>", |
| 3010 | "slug": "<slug>", |
| 3011 | "visibility": "public" | "private", |
| 3012 | "description": "<desc>", |
| 3013 | "tags": ["<tag>", ...], |
| 3014 | "default_branch": "<branch>", |
| 3015 | "clone_url": "<url>", |
| 3016 | "created_at": "<iso8601>", |
| 3017 | "updated_at": "<iso8601>", |
| 3018 | "pushed_at": "<iso8601>" |
| 3019 | } |
| 3020 | |
| 3021 | Agent quickstart |
| 3022 | ---------------- |
| 3023 | :: |
| 3024 | |
| 3025 | muse hub repo show gabriel/my-repo --json |
| 3026 | muse hub repo show --json # resolves from current repo's hub config |
| 3027 | |
| 3028 | Exit codes |
| 3029 | ---------- |
| 3030 | 0 Success. |
| 3031 | 1 Not authenticated or not found (404). |
| 3032 | 2 Not inside a Muse repository (when hub URL is inferred from config). |
| 3033 | 3 API / network error. |
| 3034 | """ |
| 3035 | target: str | None = getattr(args, "target", None) |
| 3036 | json_output: bool = args.json_output |
| 3037 | |
| 3038 | hub_url, identity = _get_hub_and_identity(hub_url_override=getattr(args, "hub", None)) |
| 3039 | |
| 3040 | if target and "/" in target: |
| 3041 | # owner/slug form — use the human-readable URL |
| 3042 | parts = target.split("/", 1) |
| 3043 | owner_part = parts[0].strip() |
| 3044 | slug_part = parts[1].strip() |
| 3045 | api_path = f"/api/{owner_part}/{slug_part}" |
| 3046 | else: |
| 3047 | # Resolve from the current repo's hub remote config |
| 3048 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3049 | api_path = f"/api/repos/{repo_id}" |
| 3050 | |
| 3051 | data = _hub_api(hub_url, identity, "GET", api_path) |
| 3052 | |
| 3053 | repo_id_val = str(data.get("repoId", data.get("repo_id", ""))) |
| 3054 | name = str(data.get("name", "")) |
| 3055 | owner = str(data.get("owner", "")) |
| 3056 | slug = str(data.get("slug", "")) |
| 3057 | visibility = str(data.get("visibility", "public")) |
| 3058 | description = str(data.get("description", "")) |
| 3059 | tags: list[str] = [str(t) for t in data.get("tags", [])] if isinstance(data.get("tags"), list) else [] |
| 3060 | default_branch = str(data.get("defaultBranch", data.get("default_branch", "main"))) |
| 3061 | clone_url = str(data.get("cloneUrl", data.get("clone_url", ""))) |
| 3062 | created_at = str(data.get("createdAt", data.get("created_at", ""))) |
| 3063 | updated_at = str(data.get("updatedAt", data.get("updated_at", ""))) |
| 3064 | pushed_at = str(data.get("pushedAt", data.get("pushed_at", ""))) |
| 3065 | |
| 3066 | if json_output: |
| 3067 | out: dict[str, object] = { |
| 3068 | "repo_id": repo_id_val, |
| 3069 | "name": name, |
| 3070 | "owner": owner, |
| 3071 | "slug": slug, |
| 3072 | "visibility": visibility, |
| 3073 | "description": description, |
| 3074 | "tags": tags, |
| 3075 | "default_branch": default_branch, |
| 3076 | "clone_url": clone_url, |
| 3077 | "created_at": created_at, |
| 3078 | "updated_at": updated_at, |
| 3079 | "pushed_at": pushed_at, |
| 3080 | } |
| 3081 | print(json.dumps(out)) |
| 3082 | return |
| 3083 | |
| 3084 | visibility_icon = "🔒 private" if visibility == "private" else "public" |
| 3085 | print(f" {sanitize_display(owner)}/{sanitize_display(slug)} [{visibility_icon}]", file=sys.stderr) |
| 3086 | if description: |
| 3087 | print(f" {sanitize_display(description)}", file=sys.stderr) |
| 3088 | if tags: |
| 3089 | print(f" Tags: {', '.join(sanitize_display(t) for t in tags)}", file=sys.stderr) |
| 3090 | print(f" Branch: {sanitize_display(default_branch)}", file=sys.stderr) |
| 3091 | if clone_url: |
| 3092 | print(f" Clone: {sanitize_display(clone_url)}", file=sys.stderr) |
| 3093 | if pushed_at: |
| 3094 | print(f" Last push: {sanitize_display(pushed_at)}", file=sys.stderr) |
| 3095 | elif created_at: |
| 3096 | print(f" Created: {sanitize_display(created_at)}", file=sys.stderr) |
| 3097 | |
| 3098 | |
| 3099 | def run_repo_transfer_ownership(args: argparse.Namespace) -> None: |
| 3100 | """Transfer ownership of a repository to another user. |
| 3101 | |
| 3102 | Only the current owner may initiate. After transfer the calling user loses |
| 3103 | owner privileges immediately. Requires ``--new-owner``:: |
| 3104 | |
| 3105 | muse hub repo transfer --new-owner alice |
| 3106 | muse hub repo transfer --new-owner alice --json |
| 3107 | |
| 3108 | Exit codes: |
| 3109 | 0 Transfer succeeded. |
| 3110 | 1 Auth error or not authorized. |
| 3111 | 2 Not inside a Muse repository. |
| 3112 | 3 API error. |
| 3113 | """ |
| 3114 | new_owner: str = args.new_owner |
| 3115 | json_output: bool = args.json_output |
| 3116 | |
| 3117 | hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub) |
| 3118 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3119 | |
| 3120 | data = _hub_api( |
| 3121 | hub_url, identity, "POST", |
| 3122 | f"/api/repos/{repo_id}/transfer", |
| 3123 | body={"newOwner": new_owner}, |
| 3124 | ) |
| 3125 | |
| 3126 | if json_output: |
| 3127 | print(json.dumps(data)) |
| 3128 | return |
| 3129 | |
| 3130 | new_owner_out = sanitize_display(str(data.get("ownerUserId", new_owner))) |
| 3131 | print(f"✅ Repository transferred to {new_owner_out}.", file=sys.stderr) |
| 3132 | |
| 3133 | |
| 3134 | # ── Issue subcommand handlers ───────────────────────────────────────────────── |
| 3135 | |
| 3136 | |
| 3137 | def _resolve_hub_override(args: argparse.Namespace) -> str | None: |
| 3138 | """Return the effective hub URL override, resolving --repo as an alias for --hub. |
| 3139 | |
| 3140 | ``--repo owner/repo`` is the idiomatic git/gh CLI style. When provided, |
| 3141 | the hub base URL is read from the repo config and the owner/repo path is |
| 3142 | appended, producing a full ``--hub``-compatible URL. |
| 3143 | """ |
| 3144 | if args.hub: |
| 3145 | return args.hub |
| 3146 | repo_arg: str | None = getattr(args, "repo", None) |
| 3147 | if not repo_arg: |
| 3148 | return None |
| 3149 | # Read the hub base URL from config (without owner/repo path). |
| 3150 | root = find_repo_root() |
| 3151 | if root is not None: |
| 3152 | configured = get_hub_url(root) |
| 3153 | if configured: |
| 3154 | # Strip any trailing owner/repo path — keep only scheme://host[:port]. |
| 3155 | parsed = urllib.parse.urlparse(configured) |
| 3156 | base = f"{parsed.scheme}://{parsed.netloc}" |
| 3157 | return f"{base}/{repo_arg.strip('/')}" |
| 3158 | # Fallback: no config — caller will get a helpful error from _get_hub_and_identity. |
| 3159 | return None |
| 3160 | |
| 3161 | |
| 3162 | def run_issue_create(args: argparse.Namespace) -> None: |
| 3163 | """Open a new issue on MuseHub. |
| 3164 | |
| 3165 | Validates title length and emptiness before making any network calls. |
| 3166 | Prints the issue URL to stdout in text mode (scriptable); use ``--json`` |
| 3167 | for the full API response:: |
| 3168 | |
| 3169 | muse hub issue create --title "feat: add thing" |
| 3170 | muse hub issue create --title "fix: bug" --body "details" --label bug --json |
| 3171 | muse hub issue create --hub http://host.docker.internal:10003/owner/repo \\ |
| 3172 | --title "feat: X" --label "phase/1" |
| 3173 | |
| 3174 | Exit codes: |
| 3175 | 0 Issue created successfully. |
| 3176 | 1 Validation error or not authenticated. |
| 3177 | 2 Not inside a Muse repository. |
| 3178 | 3 API error. |
| 3179 | """ |
| 3180 | title: str = args.title |
| 3181 | body: str = args.body |
| 3182 | labels: list[str] = args.labels |
| 3183 | symbol_anchors: list[str] = args.symbol_anchors |
| 3184 | commit_anchors: list[str] = args.commit_anchors |
| 3185 | agent_id: str = args.agent_id |
| 3186 | model_id: str = args.model_id |
| 3187 | json_output: bool = args.json_output |
| 3188 | |
| 3189 | # ── Local validation first — fail fast before any network I/O ──────────── |
| 3190 | if not title.strip(): |
| 3191 | print("❌ Issue title must not be empty.", file=sys.stderr) |
| 3192 | raise SystemExit(ExitCode.USER_ERROR) |
| 3193 | if len(title) > _MAX_ISSUE_TITLE_LEN: |
| 3194 | print( |
| 3195 | f"❌ Issue title is too long ({len(title)} chars); " |
| 3196 | f"maximum is {_MAX_ISSUE_TITLE_LEN}.", |
| 3197 | file=sys.stderr, |
| 3198 | ) |
| 3199 | raise SystemExit(ExitCode.USER_ERROR) |
| 3200 | |
| 3201 | # ── Network calls ───────────────────────────────────────────────────────── |
| 3202 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3203 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3204 | |
| 3205 | payload: _ProposalPayload = { |
| 3206 | "title": title, |
| 3207 | "body": body, |
| 3208 | "labels": labels, |
| 3209 | "symbol_anchors": symbol_anchors, |
| 3210 | "commit_anchors": commit_anchors, |
| 3211 | "agent_id": agent_id, |
| 3212 | "model_id": model_id, |
| 3213 | } |
| 3214 | data = _hub_api(hub_url, identity, "POST", f"/api/repos/{repo_id}/issues", body=payload) |
| 3215 | |
| 3216 | _num = data.get("number") |
| 3217 | try: |
| 3218 | number = int(_num) if _num is not None else 0 |
| 3219 | except (ValueError, TypeError): |
| 3220 | number = 0 |
| 3221 | |
| 3222 | parsed = urllib.parse.urlparse(hub_url) |
| 3223 | parts = parsed.path.strip("/").split("/") |
| 3224 | if len(parts) >= 2: |
| 3225 | owner, slug = parts[0], parts[1] |
| 3226 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 3227 | issue_url = f"{server_root}/{owner}/{slug}/issues/{number}" |
| 3228 | else: |
| 3229 | issue_url = str(data.get("issueId", "")) |
| 3230 | |
| 3231 | if json_output: |
| 3232 | print(json.dumps(data)) |
| 3233 | return |
| 3234 | |
| 3235 | print(f"✅ Issue #{number} created.", file=sys.stderr) |
| 3236 | print(sanitize_display(issue_url)) |
| 3237 | |
| 3238 | |
| 3239 | def run_issue_update(args: argparse.Namespace) -> None: |
| 3240 | """Update an existing issue on MuseHub. |
| 3241 | |
| 3242 | Updates title and/or body; omitted fields are left unchanged. |
| 3243 | All validation runs before any network I/O: |
| 3244 | |
| 3245 | - Issue number must be a positive integer. |
| 3246 | - If ``--title`` is given it must be non-empty and ≤ ``_MAX_ISSUE_TITLE_LEN`` chars. |
| 3247 | - At least one of ``--title`` or ``--body`` must be provided. |
| 3248 | |
| 3249 | :: |
| 3250 | |
| 3251 | muse hub issue edit 42 --body "updated description" |
| 3252 | muse hub issue edit 42 --title "new title" --body "new body" --json |
| 3253 | |
| 3254 | Exit codes: |
| 3255 | 0 Issue updated successfully. |
| 3256 | 1 Validation error or not authenticated. |
| 3257 | 2 Not inside a Muse repository. |
| 3258 | 3 API error. |
| 3259 | """ |
| 3260 | number: int = args.number |
| 3261 | json_output: bool = args.json_output |
| 3262 | |
| 3263 | # ── Local validation first — fail fast before any network I/O ──────────── |
| 3264 | if number <= 0: |
| 3265 | print(f"❌ Issue number must be a positive integer, got {number}.", file=sys.stderr) |
| 3266 | raise SystemExit(ExitCode.USER_ERROR) |
| 3267 | |
| 3268 | patch: _ProposalPayload = {} |
| 3269 | if args.title is not None: |
| 3270 | if not args.title.strip(): |
| 3271 | print("❌ Issue title must not be empty.", file=sys.stderr) |
| 3272 | raise SystemExit(ExitCode.USER_ERROR) |
| 3273 | if len(args.title) > _MAX_ISSUE_TITLE_LEN: |
| 3274 | print( |
| 3275 | f"❌ Issue title is too long ({len(args.title)} chars); " |
| 3276 | f"maximum is {_MAX_ISSUE_TITLE_LEN}.", |
| 3277 | file=sys.stderr, |
| 3278 | ) |
| 3279 | raise SystemExit(ExitCode.USER_ERROR) |
| 3280 | patch["title"] = args.title |
| 3281 | if args.body is not None: |
| 3282 | patch["body"] = args.body |
| 3283 | if args.symbol_anchors is not None: |
| 3284 | patch["symbol_anchors"] = args.symbol_anchors |
| 3285 | if args.commit_anchors is not None: |
| 3286 | patch["commit_anchors"] = args.commit_anchors |
| 3287 | |
| 3288 | if not patch: |
| 3289 | print( |
| 3290 | "❌ Nothing to update — provide --title, --body, --anchor, and/or --commit-anchor.", |
| 3291 | file=sys.stderr, |
| 3292 | ) |
| 3293 | raise SystemExit(ExitCode.USER_ERROR) |
| 3294 | |
| 3295 | # ── Network calls ───────────────────────────────────────────────────────── |
| 3296 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3297 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3298 | |
| 3299 | data = _hub_api( |
| 3300 | hub_url, identity, "PATCH", |
| 3301 | f"/api/repos/{repo_id}/issues/{number}", |
| 3302 | body=patch, |
| 3303 | ) |
| 3304 | |
| 3305 | if json_output: |
| 3306 | print(json.dumps(data)) |
| 3307 | return |
| 3308 | |
| 3309 | print(f"✅ Issue #{number} updated.", file=sys.stderr) |
| 3310 | |
| 3311 | |
| 3312 | def run_issue_read(args: argparse.Namespace) -> None: |
| 3313 | """Fetch a single issue by its per-repo number. |
| 3314 | |
| 3315 | Prints a one-line summary to stderr in text mode; use ``--json`` for the |
| 3316 | full API response:: |
| 3317 | |
| 3318 | muse hub issue read 42 --json | jq '{number,title,state}' |
| 3319 | |
| 3320 | Exit codes: |
| 3321 | 0 Issue found and printed. |
| 3322 | 1 Not authenticated. |
| 3323 | 2 Not inside a Muse repository. |
| 3324 | 3 API error (includes 404 if the issue does not exist). |
| 3325 | """ |
| 3326 | number: int = args.number |
| 3327 | json_output: bool = args.json_output |
| 3328 | |
| 3329 | if number <= 0: |
| 3330 | print(f"❌ Issue number must be a positive integer, got {number}.", file=sys.stderr) |
| 3331 | raise SystemExit(ExitCode.USER_ERROR) |
| 3332 | |
| 3333 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3334 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3335 | |
| 3336 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/issues/{number}") |
| 3337 | |
| 3338 | if json_output: |
| 3339 | print(json.dumps(data)) |
| 3340 | return |
| 3341 | |
| 3342 | title = sanitize_display(str(data.get("title", "(no title)"))) |
| 3343 | state = sanitize_display(str(data.get("state", "?"))) |
| 3344 | state_icon = {"open": "🟢", "closed": "⛔"}.get(str(data.get("state", "")), "❓") |
| 3345 | author = sanitize_display(str(data.get("author", ""))) |
| 3346 | created = sanitize_display(str(data.get("createdAt", ""))[:10]) |
| 3347 | print(f"\n {state_icon} [{state.upper()}] #{number} — {title}", file=sys.stderr) |
| 3348 | print(f" Author: {author} Created: {created}", file=sys.stderr) |
| 3349 | |
| 3350 | |
| 3351 | def run_issue_list(args: argparse.Namespace) -> None: |
| 3352 | """List issues for the current repo. |
| 3353 | |
| 3354 | Returns open issues by default; filter with ``--state`` and ``--label``. |
| 3355 | |
| 3356 | User inputs are sanitized before use: |
| 3357 | - ``--state`` is constrained by argparse ``choices`` and URL-encoded. |
| 3358 | - ``--label`` is length-capped locally then percent-encoded in the query |
| 3359 | string — no raw label value is ever interpolated into the URL directly. |
| 3360 | - ``--limit`` is clamped to a safe integer range by :func:`clamp_int`. |
| 3361 | |
| 3362 | :: |
| 3363 | |
| 3364 | muse hub issue list --json |
| 3365 | muse hub issue list --state closed --label bug --json |
| 3366 | |
| 3367 | Exit codes: |
| 3368 | 0 Success (including empty list). |
| 3369 | 1 Validation error or not authenticated. |
| 3370 | 2 Not inside a Muse repository. |
| 3371 | 3 API error. |
| 3372 | """ |
| 3373 | state: str = args.state |
| 3374 | label: str | None = args.label |
| 3375 | limit: int = clamp_int(args.limit, 1, 10000, "limit") |
| 3376 | json_output: bool = args.json_output |
| 3377 | |
| 3378 | # ── Local validation first — fail fast before any network I/O ──────────── |
| 3379 | if label is not None and len(label) > _MAX_ISSUE_LABEL_LEN: |
| 3380 | print( |
| 3381 | f"❌ Label is too long ({len(label)} chars); " |
| 3382 | f"maximum is {_MAX_ISSUE_LABEL_LEN}.", |
| 3383 | file=sys.stderr, |
| 3384 | ) |
| 3385 | raise SystemExit(ExitCode.USER_ERROR) |
| 3386 | |
| 3387 | # ── Network calls ───────────────────────────────────────────────────────── |
| 3388 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3389 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3390 | |
| 3391 | # URL-encode state even though argparse already constrains it to a known |
| 3392 | # set — defense-in-depth against any future code path that bypasses argparse. |
| 3393 | params = f"?state={urllib.parse.quote(state, safe='')}&per_page={limit}" |
| 3394 | if label: |
| 3395 | params += f"&label={urllib.parse.quote(label, safe='')}" |
| 3396 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/issues{params}") |
| 3397 | |
| 3398 | issues_val = data.get("issues", []) |
| 3399 | issues: list[_HubApiResponse] = ( |
| 3400 | [r for r in issues_val if isinstance(r, dict)] |
| 3401 | if isinstance(issues_val, list) else [] |
| 3402 | ) |
| 3403 | |
| 3404 | if json_output: |
| 3405 | print(json.dumps(issues)) |
| 3406 | return |
| 3407 | |
| 3408 | if not issues: |
| 3409 | print(f" No issues found (state={sanitize_display(state)}).", file=sys.stderr) |
| 3410 | return |
| 3411 | |
| 3412 | print(f"\n Issues — {sanitize_display(hub_url)} ({len(issues)} shown)", file=sys.stderr) |
| 3413 | print(" " + "─" * 60, file=sys.stderr) |
| 3414 | for issue in issues: |
| 3415 | _state = str(issue.get("state", "?")) |
| 3416 | state_icon = {"open": "🟢", "closed": "⛔"}.get(_state, "❓") |
| 3417 | _num = sanitize_display(str(issue.get("number", "?"))) |
| 3418 | _title = sanitize_display(str(issue.get("title", "(no title)"))) |
| 3419 | _author = sanitize_display(str(issue.get("author", ""))) |
| 3420 | print(f" {state_icon} #{_num} {_title} [{_author}]", file=sys.stderr) |
| 3421 | print("", file=sys.stderr) |
| 3422 | |
| 3423 | |
| 3424 | def run_issue_close(args: argparse.Namespace) -> None: |
| 3425 | """Close an open issue on MuseHub. |
| 3426 | |
| 3427 | Idempotent — closing an already-closed issue returns the issue unchanged:: |
| 3428 | |
| 3429 | muse hub issue close 42 |
| 3430 | muse hub issue close 42 --json |
| 3431 | |
| 3432 | Exit codes: |
| 3433 | 0 Issue closed successfully. |
| 3434 | 1 Not authenticated. |
| 3435 | 2 Not inside a Muse repository. |
| 3436 | 3 API error (includes 404 if the issue does not exist). |
| 3437 | """ |
| 3438 | number: int = args.number |
| 3439 | json_output: bool = args.json_output |
| 3440 | |
| 3441 | if number <= 0: |
| 3442 | print(f"❌ Issue number must be a positive integer, got {number}.", file=sys.stderr) |
| 3443 | raise SystemExit(ExitCode.USER_ERROR) |
| 3444 | |
| 3445 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3446 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3447 | |
| 3448 | data = _hub_api( |
| 3449 | hub_url, identity, "POST", |
| 3450 | f"/api/repos/{repo_id}/issues/{number}/close", |
| 3451 | ) |
| 3452 | |
| 3453 | if json_output: |
| 3454 | print(json.dumps(data)) |
| 3455 | return |
| 3456 | |
| 3457 | print(f"✅ Issue #{number} closed.", file=sys.stderr) |
| 3458 | |
| 3459 | |
| 3460 | def run_issue_reopen(args: argparse.Namespace) -> None: |
| 3461 | """Reopen a closed issue on MuseHub. |
| 3462 | |
| 3463 | Idempotent — reopening an already-open issue returns the issue unchanged:: |
| 3464 | |
| 3465 | muse hub issue reopen 42 |
| 3466 | muse hub issue reopen 42 --json |
| 3467 | |
| 3468 | Exit codes: |
| 3469 | 0 Issue reopened successfully. |
| 3470 | 1 Not authenticated. |
| 3471 | 2 Not inside a Muse repository. |
| 3472 | 3 API error (includes 404 if the issue does not exist). |
| 3473 | """ |
| 3474 | number: int = args.number |
| 3475 | json_output: bool = args.json_output |
| 3476 | |
| 3477 | if number <= 0: |
| 3478 | print(f"❌ Issue number must be a positive integer, got {number}.", file=sys.stderr) |
| 3479 | raise SystemExit(ExitCode.USER_ERROR) |
| 3480 | |
| 3481 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3482 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3483 | |
| 3484 | data = _hub_api( |
| 3485 | hub_url, identity, "POST", |
| 3486 | f"/api/repos/{repo_id}/issues/{number}/reopen", |
| 3487 | ) |
| 3488 | |
| 3489 | if json_output: |
| 3490 | print(json.dumps(data)) |
| 3491 | return |
| 3492 | |
| 3493 | print(f"✅ Issue #{number} reopened.", file=sys.stderr) |
| 3494 | |
| 3495 | |
| 3496 | def run_issue_comment(args: argparse.Namespace) -> None: |
| 3497 | """Post a Markdown comment on an issue on MuseHub. |
| 3498 | |
| 3499 | Returns the updated comment list (all comments on the issue):: |
| 3500 | |
| 3501 | muse hub issue comment 42 --body 'Fixed in abc123' |
| 3502 | muse hub issue comment 42 --body 'see also #43' --json |
| 3503 | |
| 3504 | Exit codes: |
| 3505 | 0 Comment posted successfully. |
| 3506 | 1 Validation or auth error. |
| 3507 | 2 Not inside a Muse repository. |
| 3508 | 3 API error (includes 404 if the issue does not exist). |
| 3509 | """ |
| 3510 | number: int = args.number |
| 3511 | body: str = args.body |
| 3512 | json_output: bool = args.json_output |
| 3513 | |
| 3514 | if number <= 0: |
| 3515 | print(f"❌ Issue number must be a positive integer, got {number}.", file=sys.stderr) |
| 3516 | raise SystemExit(ExitCode.USER_ERROR) |
| 3517 | if not body.strip(): |
| 3518 | print("❌ Comment body must not be empty.", file=sys.stderr) |
| 3519 | raise SystemExit(ExitCode.USER_ERROR) |
| 3520 | if len(body) > _MAX_ISSUE_COMMENT_LEN: |
| 3521 | print( |
| 3522 | f"❌ Comment body is too long ({len(body)} chars); " |
| 3523 | f"maximum is {_MAX_ISSUE_COMMENT_LEN}.", |
| 3524 | file=sys.stderr, |
| 3525 | ) |
| 3526 | raise SystemExit(ExitCode.USER_ERROR) |
| 3527 | |
| 3528 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3529 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3530 | |
| 3531 | payload: _ProposalPayload = {"body": body} |
| 3532 | data = _hub_api( |
| 3533 | hub_url, identity, "POST", |
| 3534 | f"/api/repos/{repo_id}/issues/{number}/comments", |
| 3535 | body=payload, |
| 3536 | ) |
| 3537 | |
| 3538 | if json_output: |
| 3539 | print(json.dumps(data)) |
| 3540 | return |
| 3541 | |
| 3542 | comments_val = data.get("comments", []) |
| 3543 | count = len(comments_val) if isinstance(comments_val, list) else 0 |
| 3544 | print(f"✅ Comment posted on issue #{number} ({count} comment(s) total).", file=sys.stderr) |
| 3545 | |
| 3546 | |
| 3547 | def run_issue_comment_delete(args: argparse.Namespace) -> None: |
| 3548 | """Soft-delete a comment from an issue on MuseHub. |
| 3549 | |
| 3550 | Deleted comments are hidden from list results but preserved in the audit log. |
| 3551 | Requires write/admin access or repo ownership:: |
| 3552 | |
| 3553 | muse hub issue comment-delete 42 --comment-id <uuid> |
| 3554 | muse hub issue comment-delete 42 --comment-id <uuid> --json |
| 3555 | |
| 3556 | Exit codes: |
| 3557 | 0 Comment deleted successfully. |
| 3558 | 1 Validation or auth error. |
| 3559 | 2 Not inside a Muse repository. |
| 3560 | 3 API error (includes 404 if the comment does not exist). |
| 3561 | """ |
| 3562 | number: int = args.number |
| 3563 | comment_id: str = args.comment_id |
| 3564 | json_output: bool = args.json_output |
| 3565 | |
| 3566 | if number <= 0: |
| 3567 | print(f"❌ Issue number must be a positive integer, got {number}.", file=sys.stderr) |
| 3568 | raise SystemExit(ExitCode.USER_ERROR) |
| 3569 | if not comment_id.strip(): |
| 3570 | print("❌ --comment-id must not be empty.", file=sys.stderr) |
| 3571 | raise SystemExit(ExitCode.USER_ERROR) |
| 3572 | |
| 3573 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3574 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3575 | |
| 3576 | _hub_api( |
| 3577 | hub_url, identity, "DELETE", |
| 3578 | f"/api/repos/{repo_id}/issues/{number}/comments/{comment_id}", |
| 3579 | ) |
| 3580 | |
| 3581 | if json_output: |
| 3582 | print(json.dumps({"deleted": True, "comment_id": comment_id})) |
| 3583 | return |
| 3584 | |
| 3585 | print(f"✅ Comment {comment_id} deleted from issue #{number}.", file=sys.stderr) |
| 3586 | |
| 3587 | |
| 3588 | def run_issue_assign(args: argparse.Namespace) -> None: |
| 3589 | """Assign or unassign a collaborator on an issue on MuseHub. |
| 3590 | |
| 3591 | Pass an empty string for ``--assignee`` to clear the current assignee:: |
| 3592 | |
| 3593 | muse hub issue assign 42 --assignee gabriel |
| 3594 | muse hub issue assign 42 --assignee '' # unassign |
| 3595 | muse hub issue assign 42 --assignee gabriel --json |
| 3596 | |
| 3597 | Exit codes: |
| 3598 | 0 Assignee updated successfully. |
| 3599 | 1 Not authenticated. |
| 3600 | 2 Not inside a Muse repository. |
| 3601 | 3 API error (includes 404 if the issue does not exist). |
| 3602 | """ |
| 3603 | number: int = args.number |
| 3604 | assignee: str = args.assignee |
| 3605 | json_output: bool = args.json_output |
| 3606 | |
| 3607 | if number <= 0: |
| 3608 | print(f"❌ Issue number must be a positive integer, got {number}.", file=sys.stderr) |
| 3609 | raise SystemExit(ExitCode.USER_ERROR) |
| 3610 | |
| 3611 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3612 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3613 | |
| 3614 | payload: dict[str, str | None] = {"assignee": assignee if assignee else None} |
| 3615 | data = _hub_api( |
| 3616 | hub_url, identity, "POST", |
| 3617 | f"/api/repos/{repo_id}/issues/{number}/assign", |
| 3618 | body=payload, |
| 3619 | ) |
| 3620 | |
| 3621 | if json_output: |
| 3622 | print(json.dumps(data)) |
| 3623 | return |
| 3624 | |
| 3625 | if assignee: |
| 3626 | print(f"✅ Issue #{number} assigned to {assignee}.", file=sys.stderr) |
| 3627 | else: |
| 3628 | print(f"✅ Issue #{number} unassigned.", file=sys.stderr) |
| 3629 | |
| 3630 | |
| 3631 | def run_issue_label(args: argparse.Namespace) -> None: |
| 3632 | """Manage labels on an issue on MuseHub. |
| 3633 | |
| 3634 | Use ``--set`` to replace the entire label list, or ``--remove`` to strip |
| 3635 | a single label without affecting others:: |
| 3636 | |
| 3637 | muse hub issue label 42 --set bug enhancement |
| 3638 | muse hub issue label 42 --remove bug |
| 3639 | muse hub issue label 42 --set bug --json |
| 3640 | |
| 3641 | Exit codes: |
| 3642 | 0 Labels updated successfully. |
| 3643 | 1 Not authenticated. |
| 3644 | 2 Not inside a Muse repository. |
| 3645 | 3 API error (includes 404 if the issue does not exist). |
| 3646 | """ |
| 3647 | number: int = args.number |
| 3648 | set_labels: list[str] | None = args.set_labels |
| 3649 | remove_label: str | None = args.remove_label |
| 3650 | json_output: bool = args.json_output |
| 3651 | |
| 3652 | if number <= 0: |
| 3653 | print(f"❌ Issue number must be a positive integer, got {number}.", file=sys.stderr) |
| 3654 | raise SystemExit(ExitCode.USER_ERROR) |
| 3655 | |
| 3656 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3657 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3658 | |
| 3659 | if set_labels is not None: |
| 3660 | label_body: _ProposalPayload = {"labels": set_labels} |
| 3661 | data = _hub_api( |
| 3662 | hub_url, identity, "POST", |
| 3663 | f"/api/repos/{repo_id}/issues/{number}/labels", |
| 3664 | body=label_body, |
| 3665 | ) |
| 3666 | if json_output: |
| 3667 | print(json.dumps(data)) |
| 3668 | return |
| 3669 | print(f"✅ Issue #{number} labels set to {set_labels}.", file=sys.stderr) |
| 3670 | else: |
| 3671 | # remove_label is guaranteed non-None (mutually exclusive group) |
| 3672 | label_name: str = remove_label or "" |
| 3673 | data = _hub_api( |
| 3674 | hub_url, identity, "DELETE", |
| 3675 | f"/api/repos/{repo_id}/issues/{number}/labels/{urllib.parse.quote(label_name, safe='')}", |
| 3676 | ) |
| 3677 | if json_output: |
| 3678 | print(json.dumps(data)) |
| 3679 | return |
| 3680 | print(f"✅ Label '{label_name}' removed from issue #{number}.", file=sys.stderr) |
| 3681 | |
| 3682 | |
| 3683 | # ── Label subcommand handlers ───────────────────────────────────────────────── |
| 3684 | |
| 3685 | |
| 3686 | def _validate_hex_color(color: str) -> bool: |
| 3687 | """Return True if *color* is a valid 7-character hex color string (e.g. '#d73a4a'). |
| 3688 | |
| 3689 | Validates without importing ``re`` — checks length, ``#`` prefix, and hex |
| 3690 | digit range explicitly so there is no import overhead on every CLI invocation. |
| 3691 | """ |
| 3692 | if len(color) != 7 or color[0] != "#": |
| 3693 | return False |
| 3694 | try: |
| 3695 | int(color[1:], 16) |
| 3696 | except ValueError: |
| 3697 | return False |
| 3698 | return True |
| 3699 | |
| 3700 | |
| 3701 | def _lookup_label_by_name( |
| 3702 | hub_url: str, |
| 3703 | identity: IdentityEntry, |
| 3704 | repo_id: str, |
| 3705 | name: str, |
| 3706 | ) -> _LabelEntry | None: |
| 3707 | """Fetch the label list and return the entry whose name matches *name*. |
| 3708 | |
| 3709 | Returns ``None`` if no label with that name exists. Case-sensitive match. |
| 3710 | """ |
| 3711 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/labels") |
| 3712 | items_val = data.get("items", []) |
| 3713 | items: list[_LabelEntry] = ( |
| 3714 | [r for r in items_val if isinstance(r, dict)] |
| 3715 | if isinstance(items_val, list) else [] |
| 3716 | ) |
| 3717 | for item in items: |
| 3718 | if item.get("name") == name: |
| 3719 | return item |
| 3720 | return None |
| 3721 | |
| 3722 | |
| 3723 | def run_label_create(args: argparse.Namespace) -> None: |
| 3724 | """Create a new label on MuseHub. |
| 3725 | |
| 3726 | Validates name length and colour format locally before any network call. |
| 3727 | Prints the label_id to stdout in text mode; use ``--json`` for the full |
| 3728 | API response:: |
| 3729 | |
| 3730 | muse hub label create --name bug --color '#d73a4a' |
| 3731 | muse hub label create --name enhancement --color '#a2eeef' --json |
| 3732 | |
| 3733 | Exit codes: |
| 3734 | 0 Label created. |
| 3735 | 1 Validation error, conflict (name already exists), or not authenticated. |
| 3736 | 2 Not inside a Muse repository. |
| 3737 | 3 API error. |
| 3738 | """ |
| 3739 | name: str = args.name.strip() |
| 3740 | color: str = args.color.strip() |
| 3741 | description: str | None = args.description |
| 3742 | json_output: bool = args.json_output |
| 3743 | |
| 3744 | # ── Local validation — fail fast before any network I/O ────────────────── |
| 3745 | if not name: |
| 3746 | print("❌ Label name must not be empty.", file=sys.stderr) |
| 3747 | raise SystemExit(ExitCode.USER_ERROR) |
| 3748 | if len(name) > _MAX_LABEL_NAME_LEN: |
| 3749 | print( |
| 3750 | f"❌ Label name is too long ({len(name)} chars); " |
| 3751 | f"maximum is {_MAX_LABEL_NAME_LEN}.", |
| 3752 | file=sys.stderr, |
| 3753 | ) |
| 3754 | raise SystemExit(ExitCode.USER_ERROR) |
| 3755 | if not _validate_hex_color(color): |
| 3756 | print( |
| 3757 | f"❌ Invalid colour '{sanitize_display(color)}'. " |
| 3758 | "Must be a 7-character hex string starting with '#' (e.g. '#d73a4a').", |
| 3759 | file=sys.stderr, |
| 3760 | ) |
| 3761 | raise SystemExit(ExitCode.USER_ERROR) |
| 3762 | if description is not None and len(description) > _MAX_LABEL_DESC_LEN: |
| 3763 | print( |
| 3764 | f"❌ Description is too long ({len(description)} chars); " |
| 3765 | f"maximum is {_MAX_LABEL_DESC_LEN}.", |
| 3766 | file=sys.stderr, |
| 3767 | ) |
| 3768 | raise SystemExit(ExitCode.USER_ERROR) |
| 3769 | |
| 3770 | # ── Network calls ───────────────────────────────────────────────────────── |
| 3771 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3772 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3773 | |
| 3774 | payload: _HubPayload = {"name": name, "color": color} |
| 3775 | if description is not None: |
| 3776 | payload["description"] = description |
| 3777 | data = _hub_api(hub_url, identity, "POST", f"/api/repos/{repo_id}/labels", body=payload) |
| 3778 | |
| 3779 | if json_output: |
| 3780 | print(json.dumps(data)) |
| 3781 | return |
| 3782 | |
| 3783 | label_id = sanitize_display(str(data.get("label_id", ""))) |
| 3784 | print(f"✅ Label '{sanitize_display(name)}' created ({label_id}).", file=sys.stderr) |
| 3785 | print(label_id) |
| 3786 | |
| 3787 | |
| 3788 | def run_label_list(args: argparse.Namespace) -> None: |
| 3789 | """List all labels for the current repo. |
| 3790 | |
| 3791 | The list endpoint is public — no authentication required for public repos:: |
| 3792 | |
| 3793 | muse hub label list |
| 3794 | muse hub label list --json |
| 3795 | |
| 3796 | Exit codes: |
| 3797 | 0 Success (including empty list). |
| 3798 | 1 Not authenticated (private repo). |
| 3799 | 2 Not inside a Muse repository. |
| 3800 | 3 API error. |
| 3801 | """ |
| 3802 | json_output: bool = args.json_output |
| 3803 | |
| 3804 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3805 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3806 | |
| 3807 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/labels") |
| 3808 | items_val = data.get("items", []) |
| 3809 | items: list[_LabelEntry] = ( |
| 3810 | [r for r in items_val if isinstance(r, dict)] |
| 3811 | if isinstance(items_val, list) else [] |
| 3812 | ) |
| 3813 | |
| 3814 | if json_output: |
| 3815 | print(json.dumps(items)) |
| 3816 | return |
| 3817 | |
| 3818 | if not items: |
| 3819 | print(" No labels defined in this repo.", file=sys.stderr) |
| 3820 | return |
| 3821 | |
| 3822 | print(f"\n Labels — {sanitize_display(hub_url)}", file=sys.stderr) |
| 3823 | print(" " + "─" * 50, file=sys.stderr) |
| 3824 | for lbl in items: |
| 3825 | _name = sanitize_display(str(lbl.get("name", "?"))) |
| 3826 | _color = sanitize_display(str(lbl.get("color", ""))) |
| 3827 | _desc = sanitize_display(str(lbl.get("description") or "")) |
| 3828 | suffix = f" {_desc}" if _desc else "" |
| 3829 | print(f" {_color} {_name}{suffix}", file=sys.stderr) |
| 3830 | print("", file=sys.stderr) |
| 3831 | |
| 3832 | |
| 3833 | def run_label_update(args: argparse.Namespace) -> None: |
| 3834 | """Update an existing label's name, colour, or description on MuseHub. |
| 3835 | |
| 3836 | Looks up the label by its current name, then sends a PATCH request with |
| 3837 | only the provided fields. At least one of --new-name, --new-color, |
| 3838 | or --new-description must be supplied:: |
| 3839 | |
| 3840 | muse hub label update --name bug --new-color '#b60205' |
| 3841 | muse hub label update --name bug --new-name bug-report --json |
| 3842 | |
| 3843 | Exit codes: |
| 3844 | 0 Label updated. |
| 3845 | 1 Validation error, label not found, or not authenticated. |
| 3846 | 2 Not inside a Muse repository. |
| 3847 | 3 API error. |
| 3848 | """ |
| 3849 | name: str = args.name.strip() |
| 3850 | new_name: str | None = args.new_name |
| 3851 | new_color: str | None = args.new_color |
| 3852 | new_description: str | None = args.new_description |
| 3853 | json_output: bool = args.json_output |
| 3854 | |
| 3855 | # ── Local validation ────────────────────────────────────────────────────── |
| 3856 | if not name: |
| 3857 | print("❌ Label name must not be empty.", file=sys.stderr) |
| 3858 | raise SystemExit(ExitCode.USER_ERROR) |
| 3859 | if new_name is None and new_color is None and new_description is None: |
| 3860 | print( |
| 3861 | "❌ Provide at least one of --new-name, --new-color, --new-description.", |
| 3862 | file=sys.stderr, |
| 3863 | ) |
| 3864 | raise SystemExit(ExitCode.USER_ERROR) |
| 3865 | if new_name is not None: |
| 3866 | new_name = new_name.strip() |
| 3867 | if not new_name: |
| 3868 | print("❌ New label name must not be empty.", file=sys.stderr) |
| 3869 | raise SystemExit(ExitCode.USER_ERROR) |
| 3870 | if len(new_name) > _MAX_LABEL_NAME_LEN: |
| 3871 | print( |
| 3872 | f"❌ New name is too long ({len(new_name)} chars); " |
| 3873 | f"maximum is {_MAX_LABEL_NAME_LEN}.", |
| 3874 | file=sys.stderr, |
| 3875 | ) |
| 3876 | raise SystemExit(ExitCode.USER_ERROR) |
| 3877 | if new_color is not None: |
| 3878 | new_color = new_color.strip() |
| 3879 | if not _validate_hex_color(new_color): |
| 3880 | print( |
| 3881 | f"❌ Invalid colour '{sanitize_display(new_color)}'. " |
| 3882 | "Must be a 7-character hex string starting with '#' (e.g. '#d73a4a').", |
| 3883 | file=sys.stderr, |
| 3884 | ) |
| 3885 | raise SystemExit(ExitCode.USER_ERROR) |
| 3886 | if new_description is not None and len(new_description) > _MAX_LABEL_DESC_LEN: |
| 3887 | print( |
| 3888 | f"❌ Description is too long ({len(new_description)} chars); " |
| 3889 | f"maximum is {_MAX_LABEL_DESC_LEN}.", |
| 3890 | file=sys.stderr, |
| 3891 | ) |
| 3892 | raise SystemExit(ExitCode.USER_ERROR) |
| 3893 | |
| 3894 | # ── Network calls ───────────────────────────────────────────────────────── |
| 3895 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3896 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3897 | |
| 3898 | # Look up label by name to get the UUID. |
| 3899 | label = _lookup_label_by_name(hub_url, identity, repo_id, name) |
| 3900 | if label is None: |
| 3901 | print( |
| 3902 | f"❌ Label '{sanitize_display(name)}' not found in this repo.", |
| 3903 | file=sys.stderr, |
| 3904 | ) |
| 3905 | raise SystemExit(ExitCode.USER_ERROR) |
| 3906 | |
| 3907 | label_id = str(label.get("label_id", "")) |
| 3908 | patch: _HubPayload = {} |
| 3909 | if new_name is not None: |
| 3910 | patch["name"] = new_name |
| 3911 | if new_color is not None: |
| 3912 | patch["color"] = new_color |
| 3913 | if new_description is not None: |
| 3914 | patch["description"] = new_description |
| 3915 | |
| 3916 | data = _hub_api( |
| 3917 | hub_url, identity, "PATCH", |
| 3918 | f"/api/repos/{repo_id}/labels/{urllib.parse.quote(label_id, safe='')}", |
| 3919 | body=patch, |
| 3920 | ) |
| 3921 | |
| 3922 | if json_output: |
| 3923 | print(json.dumps(data)) |
| 3924 | return |
| 3925 | |
| 3926 | display_name = sanitize_display(str(data.get("name", name))) |
| 3927 | print(f"✅ Label '{sanitize_display(name)}' updated → '{display_name}'.", file=sys.stderr) |
| 3928 | |
| 3929 | |
| 3930 | def run_label_delete(args: argparse.Namespace) -> None: |
| 3931 | """Delete a label from MuseHub and remove it from all issues and proposals. |
| 3932 | |
| 3933 | Looks up the label by name, then sends a DELETE request. This operation |
| 3934 | is irreversible:: |
| 3935 | |
| 3936 | muse hub label delete --name bug |
| 3937 | muse hub label delete --name obsolete-label --json |
| 3938 | |
| 3939 | Exit codes: |
| 3940 | 0 Label deleted. |
| 3941 | 1 Label not found, or not authenticated. |
| 3942 | 2 Not inside a Muse repository. |
| 3943 | 3 API error. |
| 3944 | """ |
| 3945 | name: str = args.name.strip() |
| 3946 | json_output: bool = args.json_output |
| 3947 | |
| 3948 | if not name: |
| 3949 | print("❌ Label name must not be empty.", file=sys.stderr) |
| 3950 | raise SystemExit(ExitCode.USER_ERROR) |
| 3951 | |
| 3952 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3953 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3954 | |
| 3955 | # Look up label by name to get the UUID. |
| 3956 | label = _lookup_label_by_name(hub_url, identity, repo_id, name) |
| 3957 | if label is None: |
| 3958 | print( |
| 3959 | f"❌ Label '{sanitize_display(name)}' not found in this repo.", |
| 3960 | file=sys.stderr, |
| 3961 | ) |
| 3962 | raise SystemExit(ExitCode.USER_ERROR) |
| 3963 | |
| 3964 | label_id = str(label.get("label_id", "")) |
| 3965 | # DELETE returns 204 with no body; _hub_api returns {} for empty responses. |
| 3966 | _hub_api( |
| 3967 | hub_url, identity, "DELETE", |
| 3968 | f"/api/repos/{repo_id}/labels/{urllib.parse.quote(label_id, safe='')}", |
| 3969 | ) |
| 3970 | |
| 3971 | if json_output: |
| 3972 | print(json.dumps({"deleted": True, "name": name, "label_id": label_id})) |
| 3973 | return |
| 3974 | |
| 3975 | print(f"✅ Label '{sanitize_display(name)}' deleted.", file=sys.stderr) |
| 3976 | |
| 3977 | |
| 3978 | # ── Collaborator commands ────────────────────────────────────────────────────── |
| 3979 | |
| 3980 | |
| 3981 | def run_collaborator_list(args: argparse.Namespace) -> None: |
| 3982 | """List all collaborators and their permission levels for a repository. |
| 3983 | |
| 3984 | Returns the list ordered by permission level:: |
| 3985 | |
| 3986 | muse hub collaborator list |
| 3987 | muse hub collaborator list --json |
| 3988 | |
| 3989 | Exit codes: |
| 3990 | 0 Success. |
| 3991 | 1 Auth error. |
| 3992 | 2 Not inside a Muse repository. |
| 3993 | 3 API error. |
| 3994 | """ |
| 3995 | json_output: bool = args.json_output |
| 3996 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 3997 | repo_id = _resolve_repo_id(hub_url, identity) |
| 3998 | |
| 3999 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/collaborators") |
| 4000 | |
| 4001 | if json_output: |
| 4002 | print(json.dumps(data)) |
| 4003 | return |
| 4004 | |
| 4005 | collaborators = data.get("collaborators", []) |
| 4006 | if not collaborators: |
| 4007 | print("No collaborators.", file=sys.stderr) |
| 4008 | return |
| 4009 | for c in collaborators: |
| 4010 | handle = c.get("handle", "") |
| 4011 | perm = c.get("permission", "") |
| 4012 | print(f" {handle:<32} {perm}") |
| 4013 | |
| 4014 | |
| 4015 | def run_collaborator_invite(args: argparse.Namespace) -> None: |
| 4016 | """Invite a user as a collaborator on a repository. |
| 4017 | |
| 4018 | Requires admin or owner access:: |
| 4019 | |
| 4020 | muse hub collaborator invite carol --permission write |
| 4021 | muse hub collaborator invite carol --permission admin --json |
| 4022 | |
| 4023 | Exit codes: |
| 4024 | 0 Collaborator invited. |
| 4025 | 1 Auth or conflict error. |
| 4026 | 2 Not inside a Muse repository. |
| 4027 | 3 API error. |
| 4028 | """ |
| 4029 | handle: str = args.handle |
| 4030 | permission: str = args.permission |
| 4031 | json_output: bool = args.json_output |
| 4032 | |
| 4033 | if not handle.strip(): |
| 4034 | print("❌ Handle must not be empty.", file=sys.stderr) |
| 4035 | raise SystemExit(ExitCode.USER_ERROR) |
| 4036 | |
| 4037 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 4038 | repo_id = _resolve_repo_id(hub_url, identity) |
| 4039 | |
| 4040 | data = _hub_api( |
| 4041 | hub_url, identity, "POST", |
| 4042 | f"/api/repos/{repo_id}/collaborators", |
| 4043 | body={"handle": handle, "permission": permission}, |
| 4044 | ) |
| 4045 | |
| 4046 | if json_output: |
| 4047 | print(json.dumps(data)) |
| 4048 | return |
| 4049 | |
| 4050 | print(f"✅ Invited '{sanitize_display(handle)}' as {permission} collaborator.", file=sys.stderr) |
| 4051 | |
| 4052 | |
| 4053 | def run_collaborator_update_permission(args: argparse.Namespace) -> None: |
| 4054 | """Update a collaborator's permission level. |
| 4055 | |
| 4056 | Requires admin or owner access:: |
| 4057 | |
| 4058 | muse hub collaborator update carol --permission admin |
| 4059 | muse hub collaborator update carol --permission read --json |
| 4060 | |
| 4061 | Exit codes: |
| 4062 | 0 Permission updated. |
| 4063 | 1 Auth or not-found error. |
| 4064 | 2 Not inside a Muse repository. |
| 4065 | 3 API error. |
| 4066 | """ |
| 4067 | handle: str = args.handle |
| 4068 | permission: str = args.permission |
| 4069 | json_output: bool = args.json_output |
| 4070 | |
| 4071 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 4072 | repo_id = _resolve_repo_id(hub_url, identity) |
| 4073 | |
| 4074 | data = _hub_api( |
| 4075 | hub_url, identity, "PUT", |
| 4076 | f"/api/repos/{repo_id}/collaborators/{handle}/permission", |
| 4077 | body={"permission": permission}, |
| 4078 | ) |
| 4079 | |
| 4080 | if json_output: |
| 4081 | print(json.dumps(data)) |
| 4082 | return |
| 4083 | |
| 4084 | print(f"✅ Updated '{sanitize_display(handle)}' permission to {permission}.", file=sys.stderr) |
| 4085 | |
| 4086 | |
| 4087 | def run_collaborator_remove(args: argparse.Namespace) -> None: |
| 4088 | """Remove a collaborator from a repository. |
| 4089 | |
| 4090 | Requires admin or owner access. The owner cannot be removed:: |
| 4091 | |
| 4092 | muse hub collaborator remove carol |
| 4093 | muse hub collaborator remove carol --json |
| 4094 | |
| 4095 | Exit codes: |
| 4096 | 0 Collaborator removed. |
| 4097 | 1 Auth or not-found error. |
| 4098 | 2 Not inside a Muse repository. |
| 4099 | 3 API error. |
| 4100 | """ |
| 4101 | handle: str = args.handle |
| 4102 | json_output: bool = args.json_output |
| 4103 | |
| 4104 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 4105 | repo_id = _resolve_repo_id(hub_url, identity) |
| 4106 | |
| 4107 | _hub_api(hub_url, identity, "DELETE", f"/api/repos/{repo_id}/collaborators/{handle}") |
| 4108 | |
| 4109 | if json_output: |
| 4110 | print(json.dumps({"removed": True, "handle": handle})) |
| 4111 | return |
| 4112 | |
| 4113 | print(f"✅ Removed '{sanitize_display(handle)}' from collaborators.", file=sys.stderr) |
| 4114 | |
| 4115 | |
| 4116 | # ── Webhook commands ─────────────────────────────────────────────────────────── |
| 4117 | |
| 4118 | |
| 4119 | def run_webhook_create(args: argparse.Namespace) -> None: |
| 4120 | """Register a new webhook subscription for a repository. |
| 4121 | |
| 4122 | Requires write/admin access or repo ownership:: |
| 4123 | |
| 4124 | muse hub webhook create --url https://example.com/hook --events push release |
| 4125 | muse hub webhook create --url https://ci.example.com/hook --events push --json |
| 4126 | |
| 4127 | Exit codes: |
| 4128 | 0 Webhook registered. |
| 4129 | 1 Validation or auth error. |
| 4130 | 2 Not inside a Muse repository. |
| 4131 | 3 API error. |
| 4132 | """ |
| 4133 | url: str = args.url |
| 4134 | events: list[str] = args.events |
| 4135 | secret: str = args.secret |
| 4136 | json_output: bool = args.json_output |
| 4137 | |
| 4138 | if not url.strip(): |
| 4139 | print("❌ --url must not be empty.", file=sys.stderr) |
| 4140 | raise SystemExit(ExitCode.USER_ERROR) |
| 4141 | if not events: |
| 4142 | print("❌ --events must include at least one event type.", file=sys.stderr) |
| 4143 | raise SystemExit(ExitCode.USER_ERROR) |
| 4144 | |
| 4145 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 4146 | repo_id = _resolve_repo_id(hub_url, identity) |
| 4147 | |
| 4148 | data = _hub_api( |
| 4149 | hub_url, identity, "POST", |
| 4150 | f"/api/repos/{repo_id}/webhooks", |
| 4151 | body={"url": url, "events": events, "secret": secret}, |
| 4152 | ) |
| 4153 | |
| 4154 | if json_output: |
| 4155 | print(json.dumps(data)) |
| 4156 | return |
| 4157 | |
| 4158 | webhook_id = data.get("webhookId", data.get("webhook_id", "")) |
| 4159 | print(f"✅ Webhook registered: {webhook_id}", file=sys.stderr) |
| 4160 | print(f" URL: {url}", file=sys.stderr) |
| 4161 | print(f" Events: {', '.join(events)}", file=sys.stderr) |
| 4162 | |
| 4163 | |
| 4164 | def run_webhook_list(args: argparse.Namespace) -> None: |
| 4165 | """List all webhook subscriptions for a repository. |
| 4166 | |
| 4167 | Authentication required:: |
| 4168 | |
| 4169 | muse hub webhook list |
| 4170 | muse hub webhook list --json |
| 4171 | |
| 4172 | Exit codes: |
| 4173 | 0 Success. |
| 4174 | 1 Auth error. |
| 4175 | 2 Not inside a Muse repository. |
| 4176 | 3 API error. |
| 4177 | """ |
| 4178 | json_output: bool = args.json_output |
| 4179 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 4180 | repo_id = _resolve_repo_id(hub_url, identity) |
| 4181 | |
| 4182 | data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/webhooks") |
| 4183 | |
| 4184 | if json_output: |
| 4185 | print(json.dumps(data)) |
| 4186 | return |
| 4187 | |
| 4188 | webhooks = data.get("webhooks", []) |
| 4189 | if not webhooks: |
| 4190 | print("No webhooks registered.", file=sys.stderr) |
| 4191 | return |
| 4192 | for wh in webhooks: |
| 4193 | wid = wh.get("webhookId", wh.get("webhook_id", "")) |
| 4194 | wurl = wh.get("url", "") |
| 4195 | evts = ", ".join(wh.get("events", [])) |
| 4196 | print(f" {wid} {wurl} [{evts}]") |
| 4197 | |
| 4198 | |
| 4199 | def run_webhook_delete(args: argparse.Namespace) -> None: |
| 4200 | """Delete a webhook subscription and all its delivery history. |
| 4201 | |
| 4202 | Requires write/admin access or repo ownership:: |
| 4203 | |
| 4204 | muse hub webhook delete <webhook-id> |
| 4205 | muse hub webhook delete <webhook-id> --json |
| 4206 | |
| 4207 | Exit codes: |
| 4208 | 0 Webhook deleted. |
| 4209 | 1 Auth or not-found error. |
| 4210 | 2 Not inside a Muse repository. |
| 4211 | 3 API error. |
| 4212 | """ |
| 4213 | webhook_id: str = args.webhook_id |
| 4214 | json_output: bool = args.json_output |
| 4215 | |
| 4216 | hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args)) |
| 4217 | repo_id = _resolve_repo_id(hub_url, identity) |
| 4218 | |
| 4219 | _hub_api(hub_url, identity, "DELETE", f"/api/repos/{repo_id}/webhooks/{webhook_id}") |
| 4220 | |
| 4221 | if json_output: |
| 4222 | print(json.dumps({"deleted": True, "webhook_id": webhook_id})) |
| 4223 | return |
| 4224 | |
| 4225 | print(f"✅ Webhook {webhook_id} deleted.", file=sys.stderr) |
| 4226 | |
| 4227 | |
| 4228 | # ── Connection management commands ──────────────────────────────────────────── |
| 4229 | |
| 4230 | |
| 4231 | def run_connect(args: argparse.Namespace) -> None: |
| 4232 | """Attach this repository to a MuseHub instance. |
| 4233 | |
| 4234 | Writes ``[hub] url`` to ``.muse/config.toml``. Does **not** modify |
| 4235 | credentials — authenticate separately with ``muse auth register``. |
| 4236 | |
| 4237 | URL normalisation |
| 4238 | ----------------- |
| 4239 | - Bare hostnames (``musehub.ai``) are promoted to ``https://musehub.ai``. |
| 4240 | - Trailing slashes are stripped. |
| 4241 | - ``http://`` is rejected for non-loopback hosts; loopback addresses |
| 4242 | (``localhost``, ``127.0.0.1``, ``[::1]``) are accepted for local dev. |
| 4243 | - Disallowed schemes (``file://``, ``ftp://``, etc.) are rejected. |
| 4244 | |
| 4245 | Idempotent |
| 4246 | ---------- |
| 4247 | Re-connecting to the same hub URL is a no-op (no warning, no write). |
| 4248 | Connecting to a *different* hub prints a warning on stderr and overwrites |
| 4249 | the stored URL. |
| 4250 | |
| 4251 | Agent quickstart |
| 4252 | ---------------- |
| 4253 | :: |
| 4254 | |
| 4255 | muse hub connect https://musehub.ai --json && muse auth register --agent --json |
| 4256 | |
| 4257 | JSON output (``--json``, stdout) |
| 4258 | -------------------------------- |
| 4259 | :: |
| 4260 | |
| 4261 | { |
| 4262 | "status": "ok", |
| 4263 | "hub_url": "https://musehub.ai", ← normalised URL, no trailing slash |
| 4264 | "hostname": "musehub.ai", ← host[:port] display string |
| 4265 | "authenticated": true | false, ← true if identity stored |
| 4266 | "identity_name": "<name>" | "", ← display name or empty string |
| 4267 | "identity_type": "human" | "agent" | "" ← identity type or empty string |
| 4268 | } |
| 4269 | |
| 4270 | All diagnostic messages (warnings, errors) always go to stderr. |
| 4271 | |
| 4272 | Exit codes |
| 4273 | ---------- |
| 4274 | 0 Connected successfully (or no-op re-connect to same hub). |
| 4275 | 1 Bad URL: disallowed scheme, http:// for non-loopback host. |
| 4276 | 2 Not inside a Muse repository. |
| 4277 | """ |
| 4278 | url: str = args.url |
| 4279 | json_output: bool = args.json_output |
| 4280 | |
| 4281 | root = find_repo_root() |
| 4282 | if root is None: |
| 4283 | print("❌ Not inside a Muse repository. Run `muse init` first.", file=sys.stderr) |
| 4284 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 4285 | |
| 4286 | try: |
| 4287 | normalised = _normalise_url(url) |
| 4288 | except ValueError as exc: |
| 4289 | print(f"❌ {exc}", file=sys.stderr) |
| 4290 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 4291 | hostname = _hub_hostname(normalised) |
| 4292 | |
| 4293 | # Warn before overwriting an existing connection. |
| 4294 | existing = get_hub_url(root) |
| 4295 | if existing and existing != normalised: |
| 4296 | existing_host = _hub_hostname(existing) |
| 4297 | print( |
| 4298 | f"⚠️ This repo was connected to {sanitize_display(existing_host)}.\n" |
| 4299 | f" Switching to {sanitize_display(hostname)}.\n" |
| 4300 | f" Your credentials for {sanitize_display(existing_host)} remain " |
| 4301 | "in ~/.muse/identity.toml.\n" |
| 4302 | f" To remove them: muse auth logout --hub {sanitize_display(existing_host)}", |
| 4303 | file=sys.stderr, |
| 4304 | ) |
| 4305 | |
| 4306 | set_hub_url(normalised, root) |
| 4307 | |
| 4308 | identity = load_identity(normalised) |
| 4309 | authenticated = identity is not None |
| 4310 | identity_name = "" |
| 4311 | identity_type = "" |
| 4312 | if identity is not None: |
| 4313 | identity_name = str(identity.get("handle") or "") |
| 4314 | identity_type = str(identity.get("type") or "") |
| 4315 | |
| 4316 | if json_output: |
| 4317 | out: _ConnectJson = { |
| 4318 | "status": "ok", |
| 4319 | "hub_url": normalised, |
| 4320 | "hostname": hostname, |
| 4321 | "authenticated": authenticated, |
| 4322 | "identity_name": identity_name, |
| 4323 | "identity_type": identity_type, |
| 4324 | } |
| 4325 | print(json.dumps(out)) |
| 4326 | else: |
| 4327 | print(f"✅ Connected to {sanitize_display(hostname)}", file=sys.stderr) |
| 4328 | if authenticated: |
| 4329 | print( |
| 4330 | f" Authenticated as {sanitize_display(identity_type)} " |
| 4331 | f"'{sanitize_display(identity_name)}'", |
| 4332 | file=sys.stderr, |
| 4333 | ) |
| 4334 | else: |
| 4335 | print(" No identity stored yet — run: muse auth register", file=sys.stderr) |
| 4336 | |
| 4337 | |
| 4338 | def run_status(args: argparse.Namespace) -> None: |
| 4339 | """Show the hub connection and identity for this repository. |
| 4340 | |
| 4341 | Reads ``.muse/config.toml`` for the hub URL and ``~/.muse/identity.toml`` |
| 4342 | for the stored identity. Makes **no network calls**. |
| 4343 | |
| 4344 | ``--hub`` override |
| 4345 | ------------------ |
| 4346 | Pass ``--hub <url>`` to inspect a hub URL that differs from the one stored |
| 4347 | in ``.muse/config.toml``. Useful for containerised agents that reach the |
| 4348 | hub at a different address (e.g. ``http://host.docker.internal:10003``). |
| 4349 | The override is not persisted. |
| 4350 | |
| 4351 | Agent quickstart |
| 4352 | ---------------- |
| 4353 | :: |
| 4354 | |
| 4355 | muse hub status --json || muse hub connect https://musehub.ai --json |
| 4356 | |
| 4357 | JSON output (``--json``, stdout) |
| 4358 | -------------------------------- |
| 4359 | All keys are always present — agents never receive a ``KeyError``:: |
| 4360 | |
| 4361 | { |
| 4362 | "hub_url": "https://musehub.ai", ← URL as stored in config |
| 4363 | "hostname": "musehub.ai", ← host[:port] display form |
| 4364 | "authenticated": true | false, |
| 4365 | "identity_type": "human" | "agent" | "", ← "" when not authenticated |
| 4366 | "identity_name": "<name>" | "", |
| 4367 | "identity_id": "<id>" | "", |
| 4368 | "capabilities": ["read:*", ...] | [] ← [] for humans / unauthenticated |
| 4369 | } |
| 4370 | |
| 4371 | All text output (labels, warnings, errors) goes to stderr. |
| 4372 | |
| 4373 | Exit codes |
| 4374 | ---------- |
| 4375 | 0 Status printed successfully. |
| 4376 | 1 No hub connected (no ``[hub] url`` in config and no ``--hub`` override). |
| 4377 | 2 Not inside a Muse repository. |
| 4378 | """ |
| 4379 | json_output: bool = args.json_output |
| 4380 | |
| 4381 | root = find_repo_root() |
| 4382 | if root is None: |
| 4383 | print("❌ Not inside a Muse repository.", file=sys.stderr) |
| 4384 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 4385 | |
| 4386 | hub_url = args.hub or get_hub_url(root) |
| 4387 | if hub_url is None: |
| 4388 | print("No hub connected.\nRun: muse hub connect <url>", file=sys.stderr) |
| 4389 | raise SystemExit(ExitCode.USER_ERROR) |
| 4390 | |
| 4391 | hostname = _hub_hostname(hub_url) |
| 4392 | identity = load_identity(hub_url) |
| 4393 | |
| 4394 | authenticated = identity is not None |
| 4395 | identity_type = str(identity.get("type") or "") if identity else "" |
| 4396 | identity_name = str(identity.get("handle") or "") if identity else "" |
| 4397 | identity_id = str(identity.get("fingerprint") or "") if identity else "" |
| 4398 | capabilities: list[str] = list(identity.get("capabilities") or []) if identity else [] |
| 4399 | |
| 4400 | if json_output: |
| 4401 | out: _StatusJson = { |
| 4402 | "hub_url": hub_url, |
| 4403 | "hostname": hostname, |
| 4404 | "authenticated": authenticated, |
| 4405 | "identity_type": identity_type, |
| 4406 | "identity_name": identity_name, |
| 4407 | "identity_id": identity_id, |
| 4408 | "capabilities": capabilities, |
| 4409 | } |
| 4410 | print(json.dumps(out)) |
| 4411 | return |
| 4412 | |
| 4413 | print("", file=sys.stderr) |
| 4414 | print(" Hub", file=sys.stderr) |
| 4415 | print(f" URL: {sanitize_display(hub_url)}", file=sys.stderr) |
| 4416 | |
| 4417 | if not authenticated: |
| 4418 | print(" Auth: not authenticated — run `muse auth register`", file=sys.stderr) |
| 4419 | else: |
| 4420 | handle = identity.get("handle", "") if identity else "" |
| 4421 | fingerprint = identity.get("fingerprint", "") if identity else "" |
| 4422 | print(f" Type: {sanitize_display(identity_type) or 'unknown'}", file=sys.stderr) |
| 4423 | print(f" Name: {sanitize_display(identity_name) or '—'}", file=sys.stderr) |
| 4424 | print(f" ID: {sanitize_display(identity_id) or '—'}", file=sys.stderr) |
| 4425 | print( |
| 4426 | f" Auth: {'Ed25519 key set (handle: ' + handle + ')' if handle else 'not set — run muse auth keygen'}", |
| 4427 | file=sys.stderr, |
| 4428 | ) |
| 4429 | if capabilities: |
| 4430 | caps_display = " ".join(sanitize_display(str(c)) for c in capabilities) |
| 4431 | print(f" Caps: {caps_display}", file=sys.stderr) |
| 4432 | |
| 4433 | print("", file=sys.stderr) |
| 4434 | |
| 4435 | |
| 4436 | def run_disconnect(args: argparse.Namespace) -> None: |
| 4437 | """Remove the hub association from this repository. |
| 4438 | |
| 4439 | Removes ``[hub] url`` from ``.muse/config.toml``. Credentials in |
| 4440 | ``~/.muse/identity.toml`` are **preserved** — use ``muse auth logout`` |
| 4441 | to remove them as well. Makes no network calls. |
| 4442 | |
| 4443 | Idempotent |
| 4444 | ---------- |
| 4445 | Disconnecting when no hub is configured exits 0 with |
| 4446 | ``status: "nothing_to_do"`` — safe to call unconditionally in scripts. |
| 4447 | |
| 4448 | Agent quickstart |
| 4449 | ---------------- |
| 4450 | Full teardown (disconnect + revoke credentials):: |
| 4451 | |
| 4452 | muse hub disconnect --json | python3 -c " |
| 4453 | import json, subprocess, sys |
| 4454 | d = json.load(sys.stdin) |
| 4455 | if d['hub_url']: |
| 4456 | subprocess.run(['muse', 'auth', 'logout', '--hub', d['hub_url']], check=True) |
| 4457 | " |
| 4458 | |
| 4459 | JSON output (``--json``, stdout) |
| 4460 | -------------------------------- |
| 4461 | :: |
| 4462 | |
| 4463 | { |
| 4464 | "status": "ok" | "nothing_to_do", |
| 4465 | "hub_url": "<url>" | "", ← full normalised URL; "" on nothing_to_do |
| 4466 | "hostname": "<host>" | "" ← host[:port]; "" on nothing_to_do |
| 4467 | } |
| 4468 | |
| 4469 | All text (success messages, hints) goes to stderr. |
| 4470 | |
| 4471 | Exit codes |
| 4472 | ---------- |
| 4473 | 0 Disconnected successfully, or nothing was connected. |
| 4474 | 2 Not inside a Muse repository. |
| 4475 | """ |
| 4476 | json_output: bool = args.json_output |
| 4477 | |
| 4478 | root = find_repo_root() |
| 4479 | if root is None: |
| 4480 | print("❌ Not inside a Muse repository.", file=sys.stderr) |
| 4481 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 4482 | |
| 4483 | hub_url = get_hub_url(root) |
| 4484 | if hub_url is None: |
| 4485 | if json_output: |
| 4486 | out: _DisconnectJson = { |
| 4487 | "status": "nothing_to_do", |
| 4488 | "hub_url": "", |
| 4489 | "hostname": "", |
| 4490 | } |
| 4491 | print(json.dumps(out)) |
| 4492 | else: |
| 4493 | print("No hub connected — nothing to do.", file=sys.stderr) |
| 4494 | return |
| 4495 | |
| 4496 | hostname = _hub_hostname(hub_url) |
| 4497 | clear_hub_url(root) |
| 4498 | |
| 4499 | if json_output: |
| 4500 | result: _DisconnectJson = { |
| 4501 | "status": "ok", |
| 4502 | "hub_url": hub_url, |
| 4503 | "hostname": hostname, |
| 4504 | } |
| 4505 | print(json.dumps(result)) |
| 4506 | else: |
| 4507 | print(f"✅ Disconnected from {sanitize_display(hostname)}.", file=sys.stderr) |
| 4508 | print( |
| 4509 | " Credentials in ~/.muse/identity.toml are preserved.\n" |
| 4510 | f" To remove them too: muse auth logout --hub {sanitize_display(hub_url)}", |
| 4511 | file=sys.stderr, |
| 4512 | ) |
| 4513 | |
| 4514 | |
| 4515 | def run_ping(args: argparse.Namespace) -> None: |
| 4516 | """Test HTTP connectivity to the configured hub. |
| 4517 | |
| 4518 | Sends a ``GET <hub_url>/health`` request and reports the result. |
| 4519 | No authentication token is sent — the health endpoint is intentionally |
| 4520 | unauthenticated. HTTP redirects are refused (the hub URL in config |
| 4521 | should be the final destination). |
| 4522 | |
| 4523 | ``--hub`` override |
| 4524 | ------------------ |
| 4525 | Pass ``--hub <url>`` to test a URL that differs from the one in config |
| 4526 | (e.g. for containerised agents: ``--hub http://host.docker.internal:10003``). |
| 4527 | The URL is not persisted. |
| 4528 | |
| 4529 | Agent quickstart |
| 4530 | ---------------- |
| 4531 | Health-check before any operation:: |
| 4532 | |
| 4533 | muse hub ping --json || { echo "hub unreachable"; exit 1; } |
| 4534 | |
| 4535 | Startup readiness loop:: |
| 4536 | |
| 4537 | until muse hub ping --json 2>/dev/null; do sleep 2; done |
| 4538 | |
| 4539 | JSON output (``--json``, stdout) |
| 4540 | -------------------------------- |
| 4541 | :: |
| 4542 | |
| 4543 | { |
| 4544 | "status": "ok" | "error", |
| 4545 | "hub_url": "<url>", ← URL that was pinged |
| 4546 | "hostname": "<host[:port]>", |
| 4547 | "reachable": true | false, |
| 4548 | "message": "HTTP 200 OK" | "<error reason>" |
| 4549 | } |
| 4550 | |
| 4551 | All text output (progress, errors) goes to stderr. |
| 4552 | |
| 4553 | Exit codes |
| 4554 | ---------- |
| 4555 | 0 Hub reachable (HTTP 2xx). |
| 4556 | 1 No hub connected (no ``[hub] url`` in config and no ``--hub`` flag). |
| 4557 | 2 Not inside a Muse repository. |
| 4558 | 5 Hub unreachable (connection refused, timeout, non-2xx, bad response). |
| 4559 | """ |
| 4560 | json_output: bool = args.json_output |
| 4561 | |
| 4562 | root = find_repo_root() |
| 4563 | if root is None: |
| 4564 | print("❌ Not inside a Muse repository.", file=sys.stderr) |
| 4565 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 4566 | |
| 4567 | hub_url = args.hub or get_hub_url(root) |
| 4568 | if hub_url is None: |
| 4569 | print("No hub connected.\nRun: muse hub connect <url>", file=sys.stderr) |
| 4570 | raise SystemExit(ExitCode.USER_ERROR) |
| 4571 | |
| 4572 | hostname = _hub_hostname(hub_url) |
| 4573 | |
| 4574 | if not json_output: |
| 4575 | print(f"Pinging {sanitize_display(hostname)}…", end="", flush=True, file=sys.stderr) |
| 4576 | |
| 4577 | reachable, message = _ping_hub(hub_url) |
| 4578 | |
| 4579 | if json_output: |
| 4580 | out: _PingJson = { |
| 4581 | "status": "ok" if reachable else "error", |
| 4582 | "hub_url": hub_url, |
| 4583 | "hostname": hostname, |
| 4584 | "reachable": reachable, |
| 4585 | "message": message, |
| 4586 | } |
| 4587 | print(json.dumps(out)) |
| 4588 | if not reachable: |
| 4589 | raise SystemExit(ExitCode.REMOTE_ERROR) |
| 4590 | else: |
| 4591 | if reachable: |
| 4592 | print(f" ✅ {sanitize_display(message)}", file=sys.stderr) |
| 4593 | else: |
| 4594 | print(f" ❌ {sanitize_display(message)}", file=sys.stderr) |
| 4595 | raise SystemExit(ExitCode.REMOTE_ERROR) |
File History
2 commits
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa
feat: Muse — version control for the agent era
Human
100 days ago