remote.py
python
| 1 | """muse remote — manage remote Muse Hub connections. |
| 2 | |
| 3 | Subcommands: |
| 4 | |
| 5 | muse remote add <name> <url> |
| 6 | Write ``[remotes.<name>] url = "<url>"`` to ``.muse/config.toml``. |
| 7 | Creates the config file if it does not exist. |
| 8 | |
| 9 | muse remote remove <name> |
| 10 | Remove a configured remote and all its refs/remotes/<name>/ tracking refs. |
| 11 | |
| 12 | muse remote rename <old> <new> |
| 13 | Rename a remote in config and move its tracking ref paths. |
| 14 | |
| 15 | muse remote set-url <name> <url> |
| 16 | Update the URL of an existing remote without touching tracking refs. |
| 17 | |
| 18 | muse remote -v / --verbose |
| 19 | Print all configured remotes with their URLs. |
| 20 | Token values in [auth] are masked — this command is safe to run in CI. |
| 21 | |
| 22 | Exit codes follow the Muse CLI contract (``errors.ExitCode``): |
| 23 | 0 — success |
| 24 | 1 — user error (bad arguments) |
| 25 | 2 — not a Muse repository |
| 26 | """ |
| 27 | from __future__ import annotations |
| 28 | |
| 29 | import logging |
| 30 | |
| 31 | import typer |
| 32 | |
| 33 | from maestro.muse_cli._repo import require_repo |
| 34 | from maestro.muse_cli.config import get_remote, list_remotes, remove_remote, rename_remote, set_remote |
| 35 | from maestro.muse_cli.errors import ExitCode |
| 36 | |
| 37 | logger = logging.getLogger(__name__) |
| 38 | |
| 39 | app = typer.Typer(invoke_without_command=True) |
| 40 | |
| 41 | |
| 42 | @app.callback(invoke_without_command=True) |
| 43 | def remote( |
| 44 | ctx: typer.Context, |
| 45 | verbose: bool = typer.Option( |
| 46 | False, |
| 47 | "-v", |
| 48 | "--verbose", |
| 49 | help="Print all configured remotes and their URLs.", |
| 50 | is_eager=False, |
| 51 | ), |
| 52 | ) -> None: |
| 53 | """Manage remote Muse Hub connections. |
| 54 | |
| 55 | Run ``muse remote add <name> <url>`` to register a remote, then |
| 56 | ``muse push`` / ``muse pull`` to sync with it. |
| 57 | """ |
| 58 | root = require_repo() |
| 59 | |
| 60 | # When invoked as `muse remote -v` (no subcommand), show remotes list. |
| 61 | if ctx.invoked_subcommand is None: |
| 62 | remotes = list_remotes(root) |
| 63 | if not remotes: |
| 64 | typer.echo("(no remotes configured — run `muse remote add <name> <url>`)") |
| 65 | return |
| 66 | for r in remotes: |
| 67 | typer.echo(f"{r['name']}\t{r['url']}") |
| 68 | |
| 69 | |
| 70 | @app.command("add") |
| 71 | def remote_add( |
| 72 | name: str = typer.Argument(..., help="Remote name (e.g. 'origin')."), |
| 73 | url: str = typer.Argument( |
| 74 | ..., |
| 75 | help="Remote URL (e.g. 'https://hub.example.com/musehub/repos/<repo-id>').", |
| 76 | ), |
| 77 | ) -> None: |
| 78 | """Register a named remote Hub URL in .muse/config.toml. |
| 79 | |
| 80 | Example:: |
| 81 | |
| 82 | muse remote add origin https://story.audio/musehub/repos/my-repo-id |
| 83 | |
| 84 | After adding a remote, use ``muse push`` and ``muse pull`` to sync. |
| 85 | """ |
| 86 | root = require_repo() |
| 87 | |
| 88 | if not name.strip(): |
| 89 | typer.echo("❌ Remote name cannot be empty.") |
| 90 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 91 | |
| 92 | if not url.strip().startswith(("http://", "https://")): |
| 93 | typer.echo(f"❌ URL must start with http:// or https:// — got: {url!r}") |
| 94 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 95 | |
| 96 | set_remote(name.strip(), url.strip(), root) |
| 97 | typer.echo(f"✅ Remote '{name}' set to {url}") |
| 98 | logger.info("✅ muse remote add %r %s", name, url) |
| 99 | |
| 100 | |
| 101 | @app.command("remove") |
| 102 | def remote_remove( |
| 103 | name: str = typer.Argument(..., help="Remote name to remove (e.g. 'origin')."), |
| 104 | ) -> None: |
| 105 | """Remove a configured remote and all its local tracking refs. |
| 106 | |
| 107 | Deletes ``[remotes.<name>]`` from ``.muse/config.toml`` and removes the |
| 108 | ``.muse/remotes/<name>/`` directory tree. Errors if the remote does not |
| 109 | exist. |
| 110 | |
| 111 | Example:: |
| 112 | |
| 113 | muse remote remove origin |
| 114 | """ |
| 115 | root = require_repo() |
| 116 | |
| 117 | try: |
| 118 | remove_remote(name.strip(), root) |
| 119 | except KeyError: |
| 120 | typer.echo(f"❌ Remote '{name}' does not exist.") |
| 121 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) from None |
| 122 | |
| 123 | typer.echo(f"✅ Remote '{name}' removed.") |
| 124 | logger.info("✅ muse remote remove %r", name) |
| 125 | |
| 126 | |
| 127 | @app.command("rename") |
| 128 | def remote_rename( |
| 129 | old_name: str = typer.Argument(..., help="Current remote name."), |
| 130 | new_name: str = typer.Argument(..., help="New remote name."), |
| 131 | ) -> None: |
| 132 | """Rename a remote in config and move its tracking ref paths. |
| 133 | |
| 134 | Updates ``[remotes.<old>]`` → ``[remotes.<new>]`` in ``.muse/config.toml`` |
| 135 | and moves ``.muse/remotes/<old>/`` → ``.muse/remotes/<new>/``. Errors if |
| 136 | the old remote does not exist or the new name is already taken. |
| 137 | |
| 138 | Example:: |
| 139 | |
| 140 | muse remote rename origin upstream |
| 141 | """ |
| 142 | root = require_repo() |
| 143 | |
| 144 | if not new_name.strip(): |
| 145 | typer.echo("❌ New remote name cannot be empty.") |
| 146 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 147 | |
| 148 | try: |
| 149 | rename_remote(old_name.strip(), new_name.strip(), root) |
| 150 | except KeyError: |
| 151 | typer.echo(f"❌ Remote '{old_name}' does not exist.") |
| 152 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) from None |
| 153 | except ValueError: |
| 154 | typer.echo(f"❌ Remote '{new_name}' already exists.") |
| 155 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) from None |
| 156 | |
| 157 | typer.echo(f"✅ Remote '{old_name}' renamed to '{new_name}'.") |
| 158 | logger.info("✅ muse remote rename %r → %r", old_name, new_name) |
| 159 | |
| 160 | |
| 161 | @app.command("set-url") |
| 162 | def remote_set_url( |
| 163 | name: str = typer.Argument(..., help="Remote name (e.g. 'origin')."), |
| 164 | url: str = typer.Argument(..., help="New URL for the remote."), |
| 165 | ) -> None: |
| 166 | """Update the URL of an existing remote without touching tracking refs. |
| 167 | |
| 168 | Updates ``[remotes.<name>] url`` in ``.muse/config.toml``. Unlike |
| 169 | ``muse remote add``, this command errors if the remote does not already |
| 170 | exist — use ``add`` for first-time registration. |
| 171 | |
| 172 | Example:: |
| 173 | |
| 174 | muse remote set-url origin https://new-hub.example.com/musehub/repos/my-repo |
| 175 | """ |
| 176 | root = require_repo() |
| 177 | |
| 178 | if not url.strip().startswith(("http://", "https://")): |
| 179 | typer.echo(f"❌ URL must start with http:// or https:// — got: {url!r}") |
| 180 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 181 | |
| 182 | if get_remote(name.strip(), root) is None: |
| 183 | typer.echo(f"❌ Remote '{name}' does not exist. Use `muse remote add` to create it.") |
| 184 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 185 | |
| 186 | set_remote(name.strip(), url.strip(), root) |
| 187 | typer.echo(f"✅ Remote '{name}' URL changed to {url}") |
| 188 | logger.info("✅ muse remote set-url %r %s", name, url) |