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