gabriel / muse public
hub.py python
4,595 lines 179.2 KB
Raw
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