workspace.py
python
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
1 day ago
| 1 | """``muse workspace`` — compose multiple Muse repositories. |
| 2 | |
| 3 | A workspace links several independent Muse repos together under a single |
| 4 | manifest, giving you a unified status view, one-shot sync, and a clear model |
| 5 | for multi-repo projects. |
| 6 | |
| 7 | Subcommands:: |
| 8 | |
| 9 | muse workspace add <name> <url> [--path repos/<name>] [--branch main] |
| 10 | muse workspace update <name> [--url URL] [--path PATH] [--branch BRANCH] |
| 11 | muse workspace list [--json] |
| 12 | muse workspace remove <name> |
| 13 | muse workspace status [<name>] [--json] |
| 14 | muse workspace sync [<name>] [--dry-run] [--workers N] [--json] |
| 15 | |
| 16 | Agent workflow:: |
| 17 | |
| 18 | # Register members (no network I/O) |
| 19 | muse workspace add core https://musehub.ai/acme/core |
| 20 | muse workspace add sounds https://musehub.ai/acme/sounds --branch v2 |
| 21 | |
| 22 | # Clone / pull everything, 8 parallel workers, structured output |
| 23 | muse workspace sync --workers 8 --json |
| 24 | |
| 25 | # Inspect state |
| 26 | muse workspace status --json |
| 27 | |
| 28 | JSON envelope fields (all subcommands) |
| 29 | --------------------------------------- |
| 30 | ``exit_code`` |
| 31 | Integer exit code: 0 on success, 1 on any failure. Mirrors the process |
| 32 | exit code so callers can act without inspecting inner fields. |
| 33 | |
| 34 | ``duration_ms`` |
| 35 | Wall-clock time for the operation in milliseconds (float). |
| 36 | |
| 37 | ``members`` (list and status only) |
| 38 | Array of member status objects. Each object has ``branch_mismatch: bool`` |
| 39 | which is ``true`` when the checked-out branch differs from the configured |
| 40 | tracking branch. |
| 41 | """ |
| 42 | |
| 43 | import argparse |
| 44 | import json |
| 45 | import pathlib |
| 46 | import sys |
| 47 | import logging |
| 48 | from typing import TypedDict |
| 49 | |
| 50 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 51 | from muse.core.errors import ExitCode |
| 52 | from muse.core.timing import start_timer |
| 53 | from muse.core.validation import sanitize_display |
| 54 | from muse.core.workspace import ( |
| 55 | WorkspaceMemberStatus, |
| 56 | WorkspaceSyncResult, |
| 57 | add_workspace_member, |
| 58 | find_workspace_root, |
| 59 | get_workspace_member, |
| 60 | list_workspace_members, |
| 61 | remove_workspace_member, |
| 62 | require_workspace_root, |
| 63 | sync_workspace, |
| 64 | update_workspace_member, |
| 65 | ) |
| 66 | |
| 67 | logger = logging.getLogger(__name__) |
| 68 | |
| 69 | # --------------------------------------------------------------------------- |
| 70 | # JSON wire formats |
| 71 | # --------------------------------------------------------------------------- |
| 72 | |
| 73 | class _WorkspaceAddJson(EnvelopeJson): |
| 74 | name: str |
| 75 | url: str |
| 76 | path: str |
| 77 | branch: str |
| 78 | |
| 79 | class _WorkspaceUpdateJson(EnvelopeJson): |
| 80 | name: str |
| 81 | url: str |
| 82 | path: str |
| 83 | branch: str |
| 84 | |
| 85 | class _WorkspaceMemberJson(TypedDict): |
| 86 | name: str |
| 87 | url: str |
| 88 | path: str |
| 89 | branch: str # configured tracking branch from workspace.toml |
| 90 | present: bool |
| 91 | head_commit: str | None # actual HEAD commit (what HEAD resolves to) |
| 92 | dirty: bool |
| 93 | actual_branch: str | None # currently checked-out branch |
| 94 | shelf_count: int # number of shelved changesets |
| 95 | feature_branches: list[str] # local branches other than main / dev |
| 96 | branch_mismatch: bool # True when actual_branch != configured branch |
| 97 | |
| 98 | class _WorkspaceListJson(EnvelopeJson): |
| 99 | members: list[_WorkspaceMemberJson] |
| 100 | |
| 101 | class _WorkspaceStatusJson(EnvelopeJson): |
| 102 | members: list[_WorkspaceMemberJson] |
| 103 | |
| 104 | class _WorkspaceRemoveJson(EnvelopeJson): |
| 105 | name: str |
| 106 | removed: bool |
| 107 | |
| 108 | class _WorkspaceSyncResultJson(TypedDict): |
| 109 | name: str |
| 110 | status: str |
| 111 | ok: bool |
| 112 | |
| 113 | class _WorkspaceSyncJson(EnvelopeJson): |
| 114 | dry_run: bool |
| 115 | workers: int |
| 116 | results: list[_WorkspaceSyncResultJson] |
| 117 | total: int |
| 118 | ok_count: int |
| 119 | error_count: int |
| 120 | |
| 121 | # --------------------------------------------------------------------------- |
| 122 | # Helpers |
| 123 | # --------------------------------------------------------------------------- |
| 124 | |
| 125 | def _member_to_json(m: WorkspaceMemberStatus) -> _WorkspaceMemberJson: |
| 126 | return _WorkspaceMemberJson( |
| 127 | name=sanitize_display(m.name), |
| 128 | url=sanitize_display(m.url), |
| 129 | path=sanitize_display(str(m.path)), |
| 130 | branch=sanitize_display(m.branch), |
| 131 | present=m.present, |
| 132 | head_commit=m.head_commit, |
| 133 | dirty=m.dirty, |
| 134 | actual_branch=sanitize_display(m.actual_branch) if m.actual_branch else None, |
| 135 | shelf_count=m.shelf_count, |
| 136 | feature_branches=[sanitize_display(b) for b in m.feature_branches], |
| 137 | branch_mismatch=bool(m.actual_branch and m.actual_branch != m.branch), |
| 138 | ) |
| 139 | |
| 140 | def _sync_result_to_json(r: WorkspaceSyncResult) -> _WorkspaceSyncResultJson: |
| 141 | return _WorkspaceSyncResultJson( |
| 142 | name=r["name"], |
| 143 | status=r["status"], |
| 144 | ok=not r["status"].startswith("error"), |
| 145 | ) |
| 146 | |
| 147 | # --------------------------------------------------------------------------- |
| 148 | # Registration |
| 149 | # --------------------------------------------------------------------------- |
| 150 | |
| 151 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 152 | """Register the ``muse workspace`` subcommand tree.""" |
| 153 | parser = subparsers.add_parser( |
| 154 | "workspace", |
| 155 | help="Compose multiple Muse repositories.", |
| 156 | description=__doc__, |
| 157 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 158 | ) |
| 159 | subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") |
| 160 | subs.required = True |
| 161 | |
| 162 | # workspace add |
| 163 | add_p = subs.add_parser( |
| 164 | "add", |
| 165 | help="Add a member repository to the workspace manifest.", |
| 166 | description=( |
| 167 | "Register a member repository in .muse/workspace.toml.\n" |
| 168 | "No network I/O — run 'muse workspace sync' to clone it.\n\n" |
| 169 | "NAME must be 1–64 alphanumeric characters, hyphens, underscores,\n" |
| 170 | "or dots. URL must be https://, http://, or a bare filesystem\n" |
| 171 | "path. PATH must not escape the workspace root.\n\n" |
| 172 | "Agent quickstart\n" |
| 173 | "----------------\n" |
| 174 | " muse workspace add core https://musehub.ai/acme/core --json\n" |
| 175 | " muse workspace add data /local/dataset --branch v2 --json\n\n" |
| 176 | "JSON output schema\n" |
| 177 | "------------------\n" |
| 178 | ' {"name": "<name>", "url": "<url>",\n' |
| 179 | ' "path": "<relative-path>", "branch": "<branch>"}\n\n' |
| 180 | "Exit codes\n" |
| 181 | "----------\n" |
| 182 | " 0 — success\n" |
| 183 | " 1 — invalid name/URL/path, duplicate member, or invalid branch\n" |
| 184 | " 2 — not inside a Muse repository\n" |
| 185 | ), |
| 186 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 187 | ) |
| 188 | add_p.add_argument("name", metavar="NAME", help="Member name (alphanumeric, hyphens, underscores, dots).") |
| 189 | add_p.add_argument("url", metavar="URL", help="Remote URL (https/http) or local path of the member repository.") |
| 190 | add_p.add_argument("--path", default="", metavar="PATH", help="Relative checkout path (default: repos/<name>).") |
| 191 | add_p.add_argument("--branch", "-b", default="main", metavar="BRANCH", help="Branch to track (default: main).") |
| 192 | add_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.") |
| 193 | add_p.set_defaults(func=run_workspace_add, json_out=False) |
| 194 | |
| 195 | # workspace list |
| 196 | list_p = subs.add_parser( |
| 197 | "list", |
| 198 | help="List all workspace members from the manifest.", |
| 199 | description=( |
| 200 | "List every registered workspace member with its checkout status.\n\n" |
| 201 | "Each entry shows whether the directory is present, whether the\n" |
| 202 | "working tree is dirty, and the current HEAD commit. All output\n" |
| 203 | "fields are sanitized — ANSI control sequences are stripped.\n\n" |
| 204 | "Agent quickstart\n" |
| 205 | "----------------\n" |
| 206 | " muse workspace list --json\n\n" |
| 207 | "JSON output schema (array element)\n" |
| 208 | "----------------------------------\n" |
| 209 | ' {"name": "<name>", "url": "<url>", "path": "<absolute-path>",\n' |
| 210 | ' "branch": "<branch>", "present": true|false,\n' |
| 211 | ' "head_commit": "<sha256> | null", "dirty": true|false}\n\n' |
| 212 | "Exit codes\n" |
| 213 | "----------\n" |
| 214 | " 0 — success (empty list when no members registered)\n" |
| 215 | " 2 — not inside a Muse repository\n" |
| 216 | ), |
| 217 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 218 | ) |
| 219 | list_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.") |
| 220 | list_p.set_defaults(func=run_workspace_list, json_out=False) |
| 221 | |
| 222 | # workspace remove |
| 223 | remove_p = subs.add_parser( |
| 224 | "remove", |
| 225 | help="Remove a member from the workspace manifest (does not delete its directory).", |
| 226 | description=( |
| 227 | "Unregister a member from .muse/workspace.toml.\n" |
| 228 | "The member's checked-out directory is left untouched — only\n" |
| 229 | "the manifest entry is deleted.\n\n" |
| 230 | "Agent quickstart\n" |
| 231 | "----------------\n" |
| 232 | " muse workspace remove sounds --json\n\n" |
| 233 | "JSON output schema\n" |
| 234 | "------------------\n" |
| 235 | ' {"name": "<name>", "removed": true}\n\n' |
| 236 | "Exit codes\n" |
| 237 | "----------\n" |
| 238 | " 0 — member removed successfully\n" |
| 239 | " 1 — member not found, or no workspace manifest exists\n" |
| 240 | " 2 — not inside a Muse repository\n" |
| 241 | ), |
| 242 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 243 | ) |
| 244 | remove_p.add_argument("name", metavar="NAME", help="Member name to remove.") |
| 245 | remove_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.") |
| 246 | remove_p.set_defaults(func=run_workspace_remove, json_out=False) |
| 247 | |
| 248 | # workspace status |
| 249 | status_p = subs.add_parser( |
| 250 | "status", |
| 251 | help="Show status of all (or one named) workspace member.", |
| 252 | description=( |
| 253 | "Report checkout status for every registered workspace member,\n" |
| 254 | "or for a single named member. Shows whether the directory is\n" |
| 255 | "present, the current HEAD commit, and whether the working tree\n" |
| 256 | "is dirty. All output fields are sanitized — ANSI control\n" |
| 257 | "sequences are stripped.\n\n" |
| 258 | "Agent quickstart\n" |
| 259 | "----------------\n" |
| 260 | " muse workspace status --json\n" |
| 261 | " muse workspace status core --json\n\n" |
| 262 | "JSON output schema (array element)\n" |
| 263 | "----------------------------------\n" |
| 264 | ' {"name": "<name>", "url": "<url>", "path": "<absolute-path>",\n' |
| 265 | ' "branch": "<branch>", "present": true|false,\n' |
| 266 | ' "head_commit": "<sha256> | null", "dirty": true|false}\n\n' |
| 267 | "Exit codes\n" |
| 268 | "----------\n" |
| 269 | " 0 — success (empty array when no members registered)\n" |
| 270 | " 1 — named member not found, or no workspace manifest\n" |
| 271 | " 2 — not inside a Muse repository\n" |
| 272 | ), |
| 273 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 274 | ) |
| 275 | status_p.add_argument("name", nargs="?", default=None, metavar="NAME", help="Show only this member.") |
| 276 | status_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.") |
| 277 | status_p.set_defaults(func=run_workspace_status, json_out=False) |
| 278 | |
| 279 | # workspace sync |
| 280 | sync_p = subs.add_parser( |
| 281 | "sync", |
| 282 | help="Clone or pull the latest state for workspace members.", |
| 283 | description=( |
| 284 | "Clone members that do not exist locally; pull members that do.\n" |
| 285 | "Use --workers to parallelise across members." |
| 286 | ), |
| 287 | ) |
| 288 | sync_p.add_argument("name", nargs="?", default=None, metavar="NAME", help="Sync only this member (default: all).") |
| 289 | sync_p.add_argument("-n", "--dry-run", action="store_true", dest="dry_run", help="Show what would happen without doing it.") |
| 290 | sync_p.add_argument("--workers", type=int, default=1, metavar="N", help="Parallel sync workers (default: 1).") |
| 291 | sync_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.") |
| 292 | sync_p.set_defaults(func=run_workspace_sync, json_out=False) |
| 293 | |
| 294 | # workspace update |
| 295 | update_p = subs.add_parser( |
| 296 | "update", |
| 297 | help="Update the URL, path, or branch for an existing member.", |
| 298 | description=( |
| 299 | "Modify a registered workspace member without re-adding it.\n" |
| 300 | "Only the supplied flags are changed; omitted fields keep their\n" |
| 301 | "current values. At least one of --url, --path, or --branch\n" |
| 302 | "must be supplied.\n\n" |
| 303 | "Agent quickstart\n" |
| 304 | "----------------\n" |
| 305 | " muse workspace update core --branch dev --json\n" |
| 306 | " muse workspace update data --url https://musehub.ai/acme/data2 --json\n\n" |
| 307 | "JSON output schema\n" |
| 308 | "------------------\n" |
| 309 | ' {"name": "<name>", "url": "<url>",\n' |
| 310 | ' "path": "<relative-path>", "branch": "<branch>"}\n\n' |
| 311 | "Exit codes\n" |
| 312 | "----------\n" |
| 313 | " 0 — success\n" |
| 314 | " 1 — member not found, no flags supplied, or invalid URL/path/branch\n" |
| 315 | " 2 — not inside a Muse repository\n" |
| 316 | ), |
| 317 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 318 | ) |
| 319 | update_p.add_argument("name", metavar="NAME", help="Member name to update.") |
| 320 | update_p.add_argument("--url", default=None, metavar="URL", help="New remote URL.") |
| 321 | update_p.add_argument("--path", default=None, metavar="PATH", help="New relative checkout path.") |
| 322 | update_p.add_argument("--branch", "-b", default=None, metavar="BRANCH", help="New branch to track.") |
| 323 | update_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.") |
| 324 | update_p.set_defaults(func=run_workspace_update, json_out=False) |
| 325 | |
| 326 | # --------------------------------------------------------------------------- |
| 327 | # Subcommand handlers |
| 328 | # --------------------------------------------------------------------------- |
| 329 | |
| 330 | def run_workspace_add(args: argparse.Namespace) -> None: |
| 331 | """Register a member repository in the workspace manifest. |
| 332 | |
| 333 | Writes to ``.muse/workspace.toml`` without any network I/O — run |
| 334 | ``muse workspace sync`` afterward to clone the member. Rejects invalid |
| 335 | names, disallowed URL schemes, and paths that escape the workspace root. |
| 336 | |
| 337 | Agent quickstart:: |
| 338 | |
| 339 | muse workspace add core https://musehub.ai/acme/core --json |
| 340 | muse workspace add sounds https://musehub.ai/acme/sounds --branch v2 --json |
| 341 | |
| 342 | JSON fields:: |
| 343 | |
| 344 | name Registered member name. |
| 345 | url Remote URL. |
| 346 | path Relative checkout path (default: repos/<name>). |
| 347 | branch Tracking branch (default: main). |
| 348 | muse_version Muse release that produced this output. |
| 349 | schema Envelope schema version (int). |
| 350 | exit_code 0 success, 1 validation error. |
| 351 | duration_ms Wall-clock milliseconds for the command. |
| 352 | timestamp ISO-8601 UTC timestamp of command completion. |
| 353 | warnings List of non-fatal advisory messages. |
| 354 | |
| 355 | Exit codes:: |
| 356 | |
| 357 | 0 Success. |
| 358 | 1 Invalid name/URL/path, duplicate member, or invalid branch. |
| 359 | """ |
| 360 | name: str = args.name |
| 361 | url: str = args.url |
| 362 | path: str = args.path |
| 363 | branch: str = args.branch |
| 364 | json_out: bool = args.json_out |
| 365 | |
| 366 | elapsed = start_timer() |
| 367 | # Use CWD as workspace root when no workspace.toml exists yet, enabling |
| 368 | # workspace add as a bootstrap operation without a pre-existing workspace. |
| 369 | root = find_workspace_root() or pathlib.Path.cwd() |
| 370 | try: |
| 371 | add_workspace_member(root, name, url, path=path, branch=branch) |
| 372 | except ValueError as exc: |
| 373 | if json_out: |
| 374 | print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)), |
| 375 | "error": "add_failed", "name": name, "message": str(exc)})) |
| 376 | print(f"❌ {exc}", file=sys.stderr) |
| 377 | raise SystemExit(ExitCode.USER_ERROR) |
| 378 | |
| 379 | effective_path = path or f"repos/{name}" |
| 380 | if json_out: |
| 381 | payload = _WorkspaceAddJson( |
| 382 | **make_envelope(elapsed), |
| 383 | name=sanitize_display(name), |
| 384 | url=sanitize_display(url), |
| 385 | path=sanitize_display(effective_path), |
| 386 | branch=sanitize_display(branch), |
| 387 | ) |
| 388 | print(json.dumps(payload)) |
| 389 | else: |
| 390 | print(f"✅ Added workspace member '{sanitize_display(name)}' ({sanitize_display(url)})") |
| 391 | print(" Run 'muse workspace sync' to clone it.") |
| 392 | |
| 393 | def run_workspace_update(args: argparse.Namespace) -> None: |
| 394 | """Update the URL, path, or branch for an existing workspace member. |
| 395 | |
| 396 | Only the supplied flags are changed; omitted fields keep their current |
| 397 | values. At least one of ``--url``, ``--path``, or ``--branch`` must be |
| 398 | given. All string fields are sanitized before JSON serialisation. |
| 399 | |
| 400 | Agent quickstart:: |
| 401 | |
| 402 | muse workspace update core --branch dev --json |
| 403 | muse workspace update data --url https://musehub.ai/acme/data2 --json |
| 404 | |
| 405 | JSON fields:: |
| 406 | |
| 407 | name Updated member name. |
| 408 | url Current remote URL. |
| 409 | path Current relative checkout path. |
| 410 | branch Current tracking branch. |
| 411 | muse_version Muse release that produced this output. |
| 412 | schema Envelope schema version (int). |
| 413 | exit_code 0 success, 1 error. |
| 414 | duration_ms Wall-clock milliseconds for the command. |
| 415 | timestamp ISO-8601 UTC timestamp of command completion. |
| 416 | warnings List of non-fatal advisory messages. |
| 417 | |
| 418 | Exit codes:: |
| 419 | |
| 420 | 0 Success. |
| 421 | 1 Member not found, no flags supplied, or invalid URL/path/branch. |
| 422 | """ |
| 423 | name: str = args.name |
| 424 | url: str | None = args.url |
| 425 | path: str | None = args.path |
| 426 | branch: str | None = args.branch |
| 427 | json_out: bool = args.json_out |
| 428 | |
| 429 | elapsed = start_timer() |
| 430 | if url is None and path is None and branch is None: |
| 431 | if json_out: |
| 432 | print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)), |
| 433 | "error": "no_flags", "name": name, |
| 434 | "message": "specify at least one of --url, --path, or --branch"})) |
| 435 | print("❌ Specify at least one of --url, --path, or --branch.", file=sys.stderr) |
| 436 | raise SystemExit(ExitCode.USER_ERROR) |
| 437 | |
| 438 | root = find_workspace_root() |
| 439 | if root is None: |
| 440 | if json_out: |
| 441 | print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)), |
| 442 | "error": "not_found", "name": name, |
| 443 | "message": f"workspace member '{name}' not found"})) |
| 444 | print(f"❌ Workspace member '{name}' not found.", file=sys.stderr) |
| 445 | raise SystemExit(ExitCode.USER_ERROR) |
| 446 | try: |
| 447 | update_workspace_member(root, name, url=url, path=path, branch=branch) |
| 448 | except ValueError as exc: |
| 449 | if json_out: |
| 450 | print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)), |
| 451 | "error": "update_failed", "name": name, "message": str(exc)})) |
| 452 | print(f"❌ {exc}", file=sys.stderr) |
| 453 | raise SystemExit(ExitCode.USER_ERROR) |
| 454 | |
| 455 | member = get_workspace_member(root, name) |
| 456 | if json_out: |
| 457 | payload = _WorkspaceUpdateJson( |
| 458 | **make_envelope(elapsed), |
| 459 | name=sanitize_display(member.name), |
| 460 | url=sanitize_display(member.url), |
| 461 | path=sanitize_display(str(member.path)), |
| 462 | branch=sanitize_display(member.branch), |
| 463 | ) |
| 464 | print(json.dumps(payload)) |
| 465 | else: |
| 466 | print(f"✅ Updated workspace member '{sanitize_display(name)}'.") |
| 467 | |
| 468 | def run_workspace_remove(args: argparse.Namespace) -> None: |
| 469 | """Remove a member from the workspace manifest (does not delete files). |
| 470 | |
| 471 | Only its registration in ``.muse/workspace.toml`` is removed. The |
| 472 | checkout directory on disk is left untouched. |
| 473 | |
| 474 | Agent quickstart:: |
| 475 | |
| 476 | muse workspace remove sounds --json |
| 477 | |
| 478 | JSON fields:: |
| 479 | |
| 480 | name Member name that was removed. |
| 481 | removed Always true on success. |
| 482 | muse_version Muse release that produced this output. |
| 483 | schema Envelope schema version (int). |
| 484 | exit_code 0 success, 1 error. |
| 485 | duration_ms Wall-clock milliseconds for the command. |
| 486 | timestamp ISO-8601 UTC timestamp of command completion. |
| 487 | warnings List of non-fatal advisory messages. |
| 488 | |
| 489 | Exit codes:: |
| 490 | |
| 491 | 0 Member removed successfully. |
| 492 | 1 Member not found, or no workspace manifest exists. |
| 493 | |
| 494 | Examples:: |
| 495 | |
| 496 | muse workspace remove sounds |
| 497 | muse workspace remove sounds --json |
| 498 | """ |
| 499 | name: str = args.name |
| 500 | json_out: bool = args.json_out |
| 501 | |
| 502 | elapsed = start_timer() |
| 503 | root = find_workspace_root() |
| 504 | if root is None: |
| 505 | if json_out: |
| 506 | print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)), |
| 507 | "error": "not_found", "name": name, |
| 508 | "message": f"workspace member '{name}' not found"})) |
| 509 | print(f"❌ Workspace member '{name}' not found.", file=sys.stderr) |
| 510 | raise SystemExit(ExitCode.USER_ERROR) |
| 511 | try: |
| 512 | remove_workspace_member(root, name) |
| 513 | except ValueError as exc: |
| 514 | if json_out: |
| 515 | print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)), |
| 516 | "error": "remove_failed", "name": name, "message": str(exc)})) |
| 517 | print(f"❌ {exc}", file=sys.stderr) |
| 518 | raise SystemExit(ExitCode.USER_ERROR) |
| 519 | |
| 520 | if json_out: |
| 521 | payload = _WorkspaceRemoveJson(**make_envelope(elapsed), name=sanitize_display(name), removed=True) |
| 522 | print(json.dumps(payload)) |
| 523 | else: |
| 524 | print(f"✅ Removed workspace member '{sanitize_display(name)}'.") |
| 525 | |
| 526 | def run_workspace_list(args: argparse.Namespace) -> None: |
| 527 | """List all workspace members and their current status. |
| 528 | |
| 529 | Returns status for every registered member: presence, actual branch, |
| 530 | HEAD commit, dirty state, shelf count, and feature branches. An empty |
| 531 | member list exits 0 — no workspace manifest is silently treated as |
| 532 | an empty workspace. |
| 533 | |
| 534 | Agent quickstart:: |
| 535 | |
| 536 | muse workspace list --json |
| 537 | |
| 538 | JSON fields:: |
| 539 | |
| 540 | members List of member status objects (name, url, path, branch, |
| 541 | present, head_commit, dirty, actual_branch, shelf_count, |
| 542 | feature_branches, branch_mismatch). |
| 543 | muse_version Muse release that produced this output. |
| 544 | schema Envelope schema version (int). |
| 545 | exit_code Always 0. |
| 546 | duration_ms Wall-clock milliseconds for the command. |
| 547 | timestamp ISO-8601 UTC timestamp of command completion. |
| 548 | warnings List of non-fatal advisory messages. |
| 549 | |
| 550 | Exit codes:: |
| 551 | |
| 552 | 0 Success (empty list when no members registered). |
| 553 | """ |
| 554 | json_out: bool = args.json_out |
| 555 | elapsed = start_timer() |
| 556 | root = find_workspace_root() |
| 557 | members = list_workspace_members(root) if root is not None else [] |
| 558 | |
| 559 | if json_out: |
| 560 | payload = _WorkspaceListJson( |
| 561 | **make_envelope(elapsed), |
| 562 | members=[_member_to_json(m) for m in members], |
| 563 | ) |
| 564 | print(json.dumps(payload)) |
| 565 | return |
| 566 | |
| 567 | if not members: |
| 568 | print("No workspace members. Add one with 'muse workspace add'.") |
| 569 | return |
| 570 | header = f"{'name':<20} {'on branch':<18} {'tracking':<14} {'HEAD':<12} flags" |
| 571 | print(header) |
| 572 | print("-" * 80) |
| 573 | for m in members: |
| 574 | if not m.present: |
| 575 | print( |
| 576 | f"{'❌ ' + sanitize_display(m.name):<20} " |
| 577 | f"{'(not cloned)':<18} " |
| 578 | f"{sanitize_display(m.branch):<14} " |
| 579 | f"{'—':<12} run: muse workspace sync {sanitize_display(m.name)}" |
| 580 | ) |
| 581 | continue |
| 582 | actual = sanitize_display(m.actual_branch or "unknown") |
| 583 | tracking = sanitize_display(m.branch) |
| 584 | branch_mismatch = m.actual_branch and m.actual_branch != m.branch |
| 585 | head_str = m.head_commit if m.head_commit else "unknown" |
| 586 | flags: list[str] = [] |
| 587 | if m.dirty: |
| 588 | flags.append("dirty") |
| 589 | if m.shelf_count: |
| 590 | flags.append(f"{m.shelf_count} shelf") |
| 591 | if m.feature_branches: |
| 592 | flags.append(f"branches:{','.join(sanitize_display(b) for b in m.feature_branches)}") |
| 593 | if branch_mismatch: |
| 594 | flags.append("⚠️ branch-mismatch") |
| 595 | flags_str = " ".join(flags) if flags else "clean" |
| 596 | print( |
| 597 | f"{sanitize_display(m.name):<20} " |
| 598 | f"{actual:<18} " |
| 599 | f"{tracking:<14} " |
| 600 | f"{head_str:<12} {flags_str}" |
| 601 | ) |
| 602 | |
| 603 | def run_workspace_status(args: argparse.Namespace) -> None: |
| 604 | """Show status of all (or one named) workspace members. |
| 605 | |
| 606 | Without NAME, reports every registered member. With NAME, reports only |
| 607 | that member. ``branch_mismatch`` is true when ``actual_branch`` (currently |
| 608 | checked out) differs from the configured tracking branch. |
| 609 | |
| 610 | Agent quickstart:: |
| 611 | |
| 612 | muse workspace status --json |
| 613 | muse workspace status core --json |
| 614 | |
| 615 | JSON fields:: |
| 616 | |
| 617 | members List of member status objects (name, url, path, branch, |
| 618 | present, head_commit, dirty, actual_branch, shelf_count, |
| 619 | feature_branches, branch_mismatch). |
| 620 | muse_version Muse release that produced this output. |
| 621 | schema Envelope schema version (int). |
| 622 | exit_code 0 success, 1 named member not found. |
| 623 | duration_ms Wall-clock milliseconds for the command. |
| 624 | timestamp ISO-8601 UTC timestamp of command completion. |
| 625 | warnings List of non-fatal advisory messages. |
| 626 | |
| 627 | Exit codes:: |
| 628 | |
| 629 | 0 Success (empty array when no members registered). |
| 630 | 1 Named member not found, or no workspace manifest. |
| 631 | """ |
| 632 | json_out: bool = args.json_out |
| 633 | name: str | None = args.name |
| 634 | elapsed = start_timer() |
| 635 | root = find_workspace_root() |
| 636 | |
| 637 | if name is not None: |
| 638 | if root is None: |
| 639 | if json_out: |
| 640 | print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)), |
| 641 | "error": "no_workspace", "message": "no workspace manifest found"})) |
| 642 | print("❌ No workspace manifest found.", file=sys.stderr) |
| 643 | raise SystemExit(ExitCode.USER_ERROR) |
| 644 | try: |
| 645 | members = [get_workspace_member(root, name)] |
| 646 | except ValueError as exc: |
| 647 | if json_out: |
| 648 | print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)), |
| 649 | "error": "not_found", "name": name, "message": str(exc)})) |
| 650 | print(f"❌ {exc}", file=sys.stderr) |
| 651 | raise SystemExit(ExitCode.USER_ERROR) |
| 652 | else: |
| 653 | members = list_workspace_members(root) if root is not None else [] |
| 654 | |
| 655 | if json_out: |
| 656 | payload = _WorkspaceStatusJson( |
| 657 | **make_envelope(elapsed), |
| 658 | members=[_member_to_json(m) for m in members], |
| 659 | ) |
| 660 | print(json.dumps(payload)) |
| 661 | return |
| 662 | |
| 663 | if not members: |
| 664 | print("No workspace members. Add one with 'muse workspace add'.") |
| 665 | return |
| 666 | print(f"Workspace: {root}\n") |
| 667 | for m in members: |
| 668 | if not m.present: |
| 669 | print( |
| 670 | f"❌ {sanitize_display(m.name):<20} " |
| 671 | f"NOT CHECKED OUT branch={sanitize_display(m.branch)}" |
| 672 | ) |
| 673 | print(f" url: {sanitize_display(m.url)}") |
| 674 | print(f" hint: muse workspace sync {sanitize_display(m.name)}") |
| 675 | continue |
| 676 | head = m.head_commit if m.head_commit else "unknown" |
| 677 | actual = m.actual_branch or "unknown" |
| 678 | tracking = m.branch |
| 679 | branch_mismatch = m.actual_branch and m.actual_branch != m.branch |
| 680 | if branch_mismatch: |
| 681 | branch_display = ( |
| 682 | f"{sanitize_display(actual)} " |
| 683 | f"⚠️ (tracking: {sanitize_display(tracking)})" |
| 684 | ) |
| 685 | else: |
| 686 | branch_display = sanitize_display(actual) |
| 687 | dirty_tag = " ⚠️ dirty" if m.dirty else "" |
| 688 | print( |
| 689 | f"✅ {sanitize_display(m.name):<20} " |
| 690 | f"branch={branch_display} head={head}{dirty_tag}" |
| 691 | ) |
| 692 | print(f" path: {sanitize_display(str(m.path))}") |
| 693 | print(f" url: {sanitize_display(m.url)}") |
| 694 | if m.shelf_count: |
| 695 | print(f" ⚠️ shelved: {m.shelf_count} — run 'muse shelf list' to review") |
| 696 | if m.feature_branches: |
| 697 | fb = ", ".join(sanitize_display(b) for b in m.feature_branches) |
| 698 | print(f" ⚠️ feature branches: {fb}") |
| 699 | |
| 700 | def run_workspace_sync(args: argparse.Namespace) -> None: |
| 701 | """Clone or pull the latest state for workspace members. |
| 702 | |
| 703 | Without NAME, syncs all members. With NAME, syncs only that one. |
| 704 | Parallel workers default to 1; use ``--workers N`` to clone members |
| 705 | concurrently. ``--dry-run`` shows what would happen without touching disk. |
| 706 | |
| 707 | Agent quickstart:: |
| 708 | |
| 709 | muse workspace sync --json |
| 710 | muse workspace sync --workers 8 --json |
| 711 | muse workspace sync core --json |
| 712 | muse workspace sync --dry-run --json |
| 713 | |
| 714 | JSON fields:: |
| 715 | |
| 716 | dry_run true when --dry-run was passed. |
| 717 | workers Number of parallel workers used. |
| 718 | results List of {name, status, ok} per member. |
| 719 | total Total member count. |
| 720 | ok_count Members that succeeded. |
| 721 | error_count Members that failed. |
| 722 | muse_version Muse release that produced this output. |
| 723 | schema Envelope schema version (int). |
| 724 | exit_code 0 all ok, 3 any member failed. |
| 725 | duration_ms Wall-clock milliseconds for the command. |
| 726 | timestamp ISO-8601 UTC timestamp of command completion. |
| 727 | warnings List of non-fatal advisory messages. |
| 728 | |
| 729 | Exit codes:: |
| 730 | |
| 731 | 0 All members synced successfully. |
| 732 | 3 One or more members failed to sync. |
| 733 | """ |
| 734 | name: str | None = args.name |
| 735 | dry_run: bool = args.dry_run |
| 736 | workers: int = args.workers |
| 737 | json_out: bool = args.json_out |
| 738 | |
| 739 | elapsed = start_timer() |
| 740 | root = find_workspace_root() |
| 741 | results = sync_workspace(root, member_name=name, dry_run=dry_run, workers=workers) if root is not None else [] |
| 742 | |
| 743 | if not results: |
| 744 | if json_out: |
| 745 | payload = _WorkspaceSyncJson( |
| 746 | **make_envelope(elapsed), |
| 747 | dry_run=dry_run, |
| 748 | workers=workers, |
| 749 | results=[], |
| 750 | total=0, |
| 751 | ok_count=0, |
| 752 | error_count=0, |
| 753 | ) |
| 754 | print(json.dumps(payload)) |
| 755 | else: |
| 756 | print("No members to sync. Add one with 'muse workspace add'.") |
| 757 | return |
| 758 | |
| 759 | json_results = [_sync_result_to_json(r) for r in results] |
| 760 | ok_count = sum(1 for r in json_results if r["ok"]) |
| 761 | error_count = len(json_results) - ok_count |
| 762 | exit_code = int(ExitCode.INTERNAL_ERROR) if error_count else 0 |
| 763 | |
| 764 | if json_out: |
| 765 | payload = _WorkspaceSyncJson( |
| 766 | **make_envelope(elapsed, exit_code=exit_code), |
| 767 | dry_run=dry_run, |
| 768 | workers=workers, |
| 769 | results=json_results, |
| 770 | total=len(json_results), |
| 771 | ok_count=ok_count, |
| 772 | error_count=error_count, |
| 773 | ) |
| 774 | print(json.dumps(payload)) |
| 775 | return |
| 776 | |
| 777 | for r in results: |
| 778 | icon = "✅" if not r["status"].startswith("error") else "❌" |
| 779 | print(f"{icon} {sanitize_display(r['name'])}: {sanitize_display(r['status'])}") |
| 780 | if error_count: |
| 781 | print(f"\n⚠️ {error_count} member(s) failed to sync.", file=sys.stderr) |
| 782 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
File History
7 commits
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
1 day ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8
chore: prebuild timing test
Sonnet 4.6
8 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1
merge: pull staging/dev — advance to 0.2.0rc12
Sonnet 4.6
patch
10 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea
fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub …
Sonnet 4.6
21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
24 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a
fix: repair four test failures from post-migration audit
Sonnet 4.6
patch
30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf
fix: unified object store migration — idempotent writes, JS…
Sonnet 4.6
minor
⚠
30 days ago