"""``muse workspace`` — compose multiple Muse repositories. A workspace links several independent Muse repos together under a single manifest, giving you a unified status view, one-shot sync, and a clear model for multi-repo projects. Subcommands:: muse workspace add [--path repos/] [--branch main] muse workspace update [--url URL] [--path PATH] [--branch BRANCH] muse workspace list [--json] muse workspace remove muse workspace status [] [--json] muse workspace sync [] [--dry-run] [--workers N] [--json] Agent workflow:: # Register members (no network I/O) muse workspace add core https://musehub.ai/acme/core muse workspace add sounds https://musehub.ai/acme/sounds --branch v2 # Clone / pull everything, 8 parallel workers, structured output muse workspace sync --workers 8 --json # Inspect state muse workspace status --json """ from __future__ import annotations import argparse import json import pathlib import sys import logging from typing import TypedDict from muse.core.errors import ExitCode from muse.core.validation import sanitize_display from muse.core.workspace import ( WorkspaceMemberStatus, WorkspaceSyncResult, add_workspace_member, find_workspace_root, get_workspace_member, list_workspace_members, remove_workspace_member, require_workspace_root, sync_workspace, update_workspace_member, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # JSON wire formats # --------------------------------------------------------------------------- class _WorkspaceAddJson(TypedDict): name: str url: str path: str branch: str class _WorkspaceUpdateJson(TypedDict): name: str url: str path: str branch: str class _WorkspaceMemberJson(TypedDict): name: str url: str path: str branch: str # configured tracking branch from workspace.toml present: bool head_commit: str | None # actual HEAD commit (what HEAD resolves to) dirty: bool actual_branch: str | None # currently checked-out branch stash_count: int # number of stashed changesets feature_branches: list[str] # local branches other than main / dev class _WorkspaceRemoveJson(TypedDict): name: str removed: bool class _WorkspaceSyncResultJson(TypedDict): name: str status: str ok: bool class _WorkspaceSyncJson(TypedDict): dry_run: bool workers: int results: list[_WorkspaceSyncResultJson] total: int ok_count: int error_count: int # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _member_to_json(m: WorkspaceMemberStatus) -> _WorkspaceMemberJson: return _WorkspaceMemberJson( name=sanitize_display(m.name), url=sanitize_display(m.url), path=sanitize_display(str(m.path)), branch=sanitize_display(m.branch), present=m.present, head_commit=m.head_commit, dirty=m.dirty, actual_branch=sanitize_display(m.actual_branch) if m.actual_branch else None, stash_count=m.stash_count, feature_branches=[sanitize_display(b) for b in m.feature_branches], ) def _sync_result_to_json(r: WorkspaceSyncResult) -> _WorkspaceSyncResultJson: return _WorkspaceSyncResultJson( name=r["name"], status=r["status"], ok=not r["status"].startswith("error"), ) # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the ``muse workspace`` subcommand tree.""" parser = subparsers.add_parser( "workspace", help="Compose multiple Muse repositories.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") subs.required = True # workspace add add_p = subs.add_parser( "add", help="Add a member repository to the workspace manifest.", description=( "Register a member repository in .muse/workspace.toml.\n" "No network I/O — run 'muse workspace sync' to clone it.\n\n" "NAME must be 1–64 alphanumeric characters, hyphens, underscores,\n" "or dots. URL must be https://, http://, or a bare filesystem\n" "path. PATH must not escape the workspace root.\n\n" "Agent quickstart\n" "----------------\n" " muse workspace add core https://musehub.ai/acme/core --json\n" " muse workspace add data /local/dataset --branch v2 --json\n\n" "JSON output schema\n" "------------------\n" ' {"name": "", "url": "",\n' ' "path": "", "branch": ""}\n\n' "Exit codes\n" "----------\n" " 0 — success\n" " 1 — invalid name/URL/path, duplicate member, or invalid branch\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) add_p.add_argument("name", metavar="NAME", help="Member name (alphanumeric, hyphens, underscores, dots).") add_p.add_argument("url", metavar="URL", help="Remote URL (https/http) or local path of the member repository.") add_p.add_argument("--path", default="", metavar="PATH", help="Relative checkout path (default: repos/).") add_p.add_argument("--branch", "-b", default="main", metavar="BRANCH", help="Branch to track (default: main).") add_p.add_argument("--json", "-j", action="store_true", dest="output_json", help="Emit JSON on stdout.") add_p.set_defaults(func=run_workspace_add) # workspace list list_p = subs.add_parser( "list", help="List all workspace members from the manifest.", description=( "List every registered workspace member with its checkout status.\n\n" "Each entry shows whether the directory is present, whether the\n" "working tree is dirty, and the current HEAD commit. All output\n" "fields are sanitized — ANSI control sequences are stripped.\n\n" "Agent quickstart\n" "----------------\n" " muse workspace list --json\n\n" "JSON output schema (array element)\n" "----------------------------------\n" ' {"name": "", "url": "", "path": "",\n' ' "branch": "", "present": true|false,\n' ' "head_commit": " | null", "dirty": true|false}\n\n' "Exit codes\n" "----------\n" " 0 — success (empty list when no members registered)\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) list_p.add_argument("--json", "-j", action="store_true", dest="output_json", help="Emit JSON on stdout.") list_p.set_defaults(func=run_workspace_list) # workspace remove remove_p = subs.add_parser( "remove", help="Remove a member from the workspace manifest (does not delete its directory).", description=( "Unregister a member from .muse/workspace.toml.\n" "The member's checked-out directory is left untouched — only\n" "the manifest entry is deleted.\n\n" "Agent quickstart\n" "----------------\n" " muse workspace remove sounds --json\n\n" "JSON output schema\n" "------------------\n" ' {"name": "", "removed": true}\n\n' "Exit codes\n" "----------\n" " 0 — member removed successfully\n" " 1 — member not found, or no workspace manifest exists\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) remove_p.add_argument("name", metavar="NAME", help="Member name to remove.") remove_p.add_argument("--json", "-j", action="store_true", dest="output_json", help="Emit JSON on stdout.") remove_p.set_defaults(func=run_workspace_remove) # workspace status status_p = subs.add_parser( "status", help="Show status of all (or one named) workspace member.", description=( "Report checkout status for every registered workspace member,\n" "or for a single named member. Shows whether the directory is\n" "present, the current HEAD commit, and whether the working tree\n" "is dirty. All output fields are sanitized — ANSI control\n" "sequences are stripped.\n\n" "Agent quickstart\n" "----------------\n" " muse workspace status --json\n" " muse workspace status core --json\n\n" "JSON output schema (array element)\n" "----------------------------------\n" ' {"name": "", "url": "", "path": "",\n' ' "branch": "", "present": true|false,\n' ' "head_commit": " | null", "dirty": true|false}\n\n' "Exit codes\n" "----------\n" " 0 — success (empty array when no members registered)\n" " 1 — named member not found, or no workspace manifest\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) status_p.add_argument("name", nargs="?", default=None, metavar="NAME", help="Show only this member.") status_p.add_argument("--json", "-j", action="store_true", dest="output_json", help="Emit JSON on stdout.") status_p.set_defaults(func=run_workspace_status) # workspace sync sync_p = subs.add_parser( "sync", help="Clone or pull the latest state for workspace members.", description=( "Clone members that do not exist locally; pull members that do.\n" "Use --workers to parallelise across members." ), ) sync_p.add_argument("name", nargs="?", default=None, metavar="NAME", help="Sync only this member (default: all).") sync_p.add_argument("-n", "--dry-run", action="store_true", dest="dry_run", help="Show what would happen without doing it.") sync_p.add_argument("--workers", type=int, default=1, metavar="N", help="Parallel sync workers (default: 1).") sync_p.add_argument("--json", action="store_true", dest="output_json", help="Emit JSON on stdout.") sync_p.set_defaults(func=run_workspace_sync) # workspace update update_p = subs.add_parser( "update", help="Update the URL, path, or branch for an existing member.", description=( "Modify a registered workspace member without re-adding it.\n" "Only the supplied flags are changed; omitted fields keep their\n" "current values. At least one of --url, --path, or --branch\n" "must be supplied.\n\n" "Agent quickstart\n" "----------------\n" " muse workspace update core --branch dev --json\n" " muse workspace update data --url https://musehub.ai/acme/data2 --json\n\n" "JSON output schema\n" "------------------\n" ' {"name": "", "url": "",\n' ' "path": "", "branch": ""}\n\n' "Exit codes\n" "----------\n" " 0 — success\n" " 1 — member not found, no flags supplied, or invalid URL/path/branch\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) update_p.add_argument("name", metavar="NAME", help="Member name to update.") update_p.add_argument("--url", default=None, metavar="URL", help="New remote URL.") update_p.add_argument("--path", default=None, metavar="PATH", help="New relative checkout path.") update_p.add_argument("--branch", "-b", default=None, metavar="BRANCH", help="New branch to track.") update_p.add_argument("--json", "-j", action="store_true", dest="output_json", help="Emit JSON on stdout.") update_p.set_defaults(func=run_workspace_update) # --------------------------------------------------------------------------- # Subcommand handlers # --------------------------------------------------------------------------- def run_workspace_add(args: argparse.Namespace) -> None: """Add a member repository to the workspace manifest. The member is *registered* in ``.muse/workspace.toml``. No network I/O is performed — run ``muse workspace sync`` to clone it. Validation rejects invalid member names, disallowed URL schemes (only https/http and bare paths are allowed), and paths that escape the workspace root. All output fields are sanitized — ANSI control sequences are stripped before display or JSON serialisation. JSON schema:: { "name": "", "url": "", "path": "", "branch": "" } Exit codes:: 0 — success 1 — invalid name/URL/path, duplicate member, or invalid branch 2 — not inside a Muse repository """ name: str = args.name url: str = args.url path: str = args.path branch: str = args.branch output_json: bool = args.output_json # Use CWD as workspace root when no workspace.toml exists yet, enabling # workspace add as a bootstrap operation without a pre-existing workspace. root = find_workspace_root() or pathlib.Path.cwd() try: add_workspace_member(root, name, url, path=path, branch=branch) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) effective_path = path or f"repos/{name}" if output_json: payload = _WorkspaceAddJson( name=sanitize_display(name), url=sanitize_display(url), path=sanitize_display(effective_path), branch=sanitize_display(branch), ) print(json.dumps(payload)) else: print(f"✅ Added workspace member '{sanitize_display(name)}' ({sanitize_display(url)})") print(" Run 'muse workspace sync' to clone it.") def run_workspace_update(args: argparse.Namespace) -> None: """Update the URL, path, or branch for an existing workspace member. Only the supplied flags are changed; omitted fields keep their current values. Useful for re-pointing a member at a new remote or switching the tracked branch without removing and re-adding it. At least one of ``--url``, ``--path``, or ``--branch`` must be given. All output fields are sanitized — ANSI control sequences are stripped before display or JSON serialisation. JSON schema:: { "name": "", "url": "", "path": "", "branch": "" } Exit codes:: 0 — success 1 — member not found, no flags supplied, or invalid URL/path/branch 2 — not inside a Muse repository """ name: str = args.name url: str | None = args.url path: str | None = args.path branch: str | None = args.branch output_json: bool = args.output_json if url is None and path is None and branch is None: print("❌ Specify at least one of --url, --path, or --branch.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) root = find_workspace_root() if root is None: print(f"❌ Workspace member '{name}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) try: update_workspace_member(root, name, url=url, path=path, branch=branch) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) member = get_workspace_member(root, name) if output_json: payload = _WorkspaceUpdateJson( name=sanitize_display(member.name), url=sanitize_display(member.url), path=sanitize_display(str(member.path)), branch=sanitize_display(member.branch), ) print(json.dumps(payload)) else: print(f"✅ Updated workspace member '{sanitize_display(name)}'.") def run_workspace_remove(args: argparse.Namespace) -> None: """Remove a member from the workspace manifest. This does **not** delete the member's directory — only its registration in the workspace manifest is removed. JSON schema:: {"name": "", "removed": true} Exit codes: 0 — member removed successfully 1 — member not found, or no workspace manifest exists 2 — not inside a Muse repository Examples:: muse workspace remove sounds muse workspace remove sounds --json """ name: str = args.name output_json: bool = args.output_json root = find_workspace_root() if root is None: print(f"❌ Workspace member '{name}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) try: remove_workspace_member(root, name) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if output_json: payload = _WorkspaceRemoveJson(name=sanitize_display(name), removed=True) print(json.dumps(payload)) else: print(f"✅ Removed workspace member '{sanitize_display(name)}'.") def run_workspace_list(args: argparse.Namespace) -> None: """List all workspace members from the manifest. Returns status for every registered member: whether the checkout directory is present, the actual checked-out branch (which may differ from the configured tracking branch), the HEAD commit, whether the working tree is dirty, stash count, and any lingering feature branches. All string fields in both text and JSON output are sanitized — ANSI control sequences are stripped. JSON schema (array element):: { "name": "", "url": "", "path": "", "branch": "", "present": true | false, "head_commit": "" | null, "dirty": true | false, "actual_branch": "" | null, "stash_count": 0, "feature_branches": [] } ``branch`` is the tracking branch from workspace.toml. ``actual_branch`` is what is currently checked out. When they differ the member is not on the expected branch — surface this to the user. Exit codes:: 0 — success (empty list when no members registered) 2 — not inside a Muse repository """ output_json: bool = args.output_json root = find_workspace_root() members = list_workspace_members(root) if root is not None else [] if output_json: print(json.dumps([_member_to_json(m) for m in members])) return if not members: print("No workspace members. Add one with 'muse workspace add'.") return header = f"{'name':<20} {'on branch':<18} {'tracking':<14} {'HEAD':<12} flags" print(header) print("-" * 80) for m in members: if not m.present: print( f"{'❌ ' + sanitize_display(m.name):<20} " f"{'(not cloned)':<18} " f"{sanitize_display(m.branch):<14} " f"{'—':<12} run: muse workspace sync {sanitize_display(m.name)}" ) continue actual = sanitize_display(m.actual_branch or "unknown") tracking = sanitize_display(m.branch) branch_mismatch = m.actual_branch and m.actual_branch != m.branch head_str = m.head_commit[:12] if m.head_commit else "unknown" flags: list[str] = [] if m.dirty: flags.append("dirty") if m.stash_count: flags.append(f"{m.stash_count} stash") if m.feature_branches: flags.append(f"branches:{','.join(sanitize_display(b) for b in m.feature_branches)}") if branch_mismatch: flags.append("⚠️ branch-mismatch") flags_str = " ".join(flags) if flags else "clean" print( f"{sanitize_display(m.name):<20} " f"{actual:<18} " f"{tracking:<14} " f"{head_str:<12} {flags_str}" ) def run_workspace_status(args: argparse.Namespace) -> None: """Show status of all (or one named) workspace members. Without a NAME argument, reports every registered member. With NAME, reports only that member. All string fields in both text and JSON output are sanitized — ANSI control sequences are stripped. JSON schema (array element):: { "name": "", "url": "", "path": "", "branch": "", "present": true | false, "head_commit": "" | null, "dirty": true | false, "actual_branch": "" | null, "stash_count": 0, "feature_branches": [] } ``branch`` is the tracking branch from workspace.toml (the branch this member *should* be on). ``actual_branch`` is the branch currently checked out. When they differ the member is unexpectedly off-track. Exit codes: 0 — success (empty array when no members are registered) 1 — named member not found, or no workspace manifest 2 — not inside a Muse repository Examples:: muse workspace status muse workspace status core muse workspace status --json muse workspace status core --json """ output_json: bool = args.output_json name: str | None = args.name root = find_workspace_root() if name is not None: if root is None: print("❌ No workspace manifest found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) try: members = [get_workspace_member(root, name)] except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) else: members = list_workspace_members(root) if root is not None else [] if output_json: print(json.dumps([_member_to_json(m) for m in members])) return if not members: print("No workspace members. Add one with 'muse workspace add'.") return print(f"Workspace: {root}\n") for m in members: if not m.present: print( f"❌ {sanitize_display(m.name):<20} " f"NOT CHECKED OUT branch={sanitize_display(m.branch)}" ) print(f" url: {sanitize_display(m.url)}") print(f" hint: muse workspace sync {sanitize_display(m.name)}") continue head = m.head_commit[:12] if m.head_commit else "unknown" actual = m.actual_branch or "unknown" tracking = m.branch branch_mismatch = m.actual_branch and m.actual_branch != m.branch if branch_mismatch: branch_display = ( f"{sanitize_display(actual)} " f"⚠️ (tracking: {sanitize_display(tracking)})" ) else: branch_display = sanitize_display(actual) dirty_tag = " ⚠️ dirty" if m.dirty else "" print( f"✅ {sanitize_display(m.name):<20} " f"branch={branch_display} head={head}{dirty_tag}" ) print(f" path: {sanitize_display(str(m.path))}") print(f" url: {sanitize_display(m.url)}") if m.stash_count: print(f" ⚠️ stashes: {m.stash_count} — run 'muse stash list' to review") if m.feature_branches: fb = ", ".join(sanitize_display(b) for b in m.feature_branches) print(f" ⚠️ feature branches: {fb}") def run_workspace_sync(args: argparse.Namespace) -> None: """Clone or pull the latest state for workspace members. Run without arguments to sync all members. Provide a member name to sync only that one. Use ``--workers`` to parallelise across members. Examples:: muse workspace sync # sync everything, sequential muse workspace sync --workers 8 # 8 parallel workers muse workspace sync core # sync only 'core' muse workspace sync --dry-run --json # show plan, machine-readable """ name: str | None = args.name dry_run: bool = args.dry_run workers: int = args.workers output_json: bool = args.output_json root = find_workspace_root() results = sync_workspace(root, member_name=name, dry_run=dry_run, workers=workers) if root is not None else [] if not results: if output_json: payload = _WorkspaceSyncJson( dry_run=dry_run, workers=workers, results=[], total=0, ok_count=0, error_count=0, ) print(json.dumps(payload)) else: print("No members to sync. Add one with 'muse workspace add'.") return json_results = [_sync_result_to_json(r) for r in results] ok_count = sum(1 for r in json_results if r["ok"]) error_count = len(json_results) - ok_count if output_json: payload = _WorkspaceSyncJson( dry_run=dry_run, workers=workers, results=json_results, total=len(json_results), ok_count=ok_count, error_count=error_count, ) print(json.dumps(payload)) return for r in results: icon = "✅" if not r["status"].startswith("error") else "❌" print(f"{icon} {sanitize_display(r['name'])}: {sanitize_display(r['status'])}") if error_count: print(f"\n⚠️ {error_count} member(s) failed to sync.", file=sys.stderr) raise SystemExit(ExitCode.INTERNAL_ERROR)