connection.py
python
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
1 day ago
| 1 | import argparse |
| 2 | from ._core import * |
| 3 | |
| 4 | def run_connect(args: argparse.Namespace) -> None: |
| 5 | """Attach this repository to a MuseHub instance. |
| 6 | |
| 7 | Writes ``[hub] url`` to ``.muse/config.toml``. Does **not** modify |
| 8 | credentials — authenticate separately with ``muse auth register``. |
| 9 | |
| 10 | URL normalisation |
| 11 | ----------------- |
| 12 | - Bare hostnames (``musehub.ai``) are promoted to ``https://musehub.ai``. |
| 13 | - Trailing slashes are stripped. |
| 14 | - ``http://`` is rejected for non-loopback hosts; loopback addresses |
| 15 | (``localhost``, ``127.0.0.1``, ``[::1]``) are accepted for local dev. |
| 16 | - Disallowed schemes (``file://``, ``ftp://``, etc.) are rejected. |
| 17 | |
| 18 | Idempotent |
| 19 | ---------- |
| 20 | Re-connecting to the same hub URL is a no-op (no warning, no write). |
| 21 | Connecting to a *different* hub prints a warning on stderr and overwrites |
| 22 | the stored URL. |
| 23 | |
| 24 | Agent quickstart |
| 25 | ---------------- |
| 26 | :: |
| 27 | |
| 28 | muse hub connect https://musehub.ai --json && muse auth register --agent --json |
| 29 | |
| 30 | JSON output (``--json``, stdout) |
| 31 | -------------------------------- |
| 32 | :: |
| 33 | |
| 34 | { |
| 35 | "status": "ok", |
| 36 | "hub_url": "https://musehub.ai", ← normalised URL, no trailing slash |
| 37 | "hostname": "musehub.ai", ← host[:port] display string |
| 38 | "authenticated": true | false, ← true if identity stored |
| 39 | "identity_name": "<name>" | "", ← display name or empty string |
| 40 | "identity_type": "human" | "agent" | "" ← identity type or empty string |
| 41 | } |
| 42 | |
| 43 | All diagnostic messages (warnings, errors) always go to stderr. |
| 44 | |
| 45 | Exit codes |
| 46 | ---------- |
| 47 | 0 Connected successfully (or no-op re-connect to same hub). |
| 48 | 1 Bad URL: disallowed scheme, http:// for non-loopback host. |
| 49 | 2 Not inside a Muse repository. |
| 50 | """ |
| 51 | elapsed = start_timer() |
| 52 | url: str = args.url |
| 53 | json_output: bool = args.json_output |
| 54 | |
| 55 | root = find_repo_root() |
| 56 | if root is None: |
| 57 | print("❌ Not inside a Muse repository. Run `muse init` first.", file=sys.stderr) |
| 58 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 59 | |
| 60 | try: |
| 61 | normalised = _normalise_url(url) |
| 62 | except ValueError as exc: |
| 63 | print(f"❌ {exc}", file=sys.stderr) |
| 64 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 65 | hostname = _hub_hostname(normalised) |
| 66 | |
| 67 | # Warn before overwriting an existing connection. |
| 68 | existing = get_hub_url(root) |
| 69 | if existing and existing != normalised: |
| 70 | existing_host = _hub_hostname(existing) |
| 71 | print( |
| 72 | f"⚠️ This repo was connected to {sanitize_display(existing_host)}.\n" |
| 73 | f" Switching to {sanitize_display(hostname)}.\n" |
| 74 | f" Your credentials for {sanitize_display(existing_host)} remain " |
| 75 | "in ~/.muse/identity.toml.\n" |
| 76 | f" To remove them: muse auth logout --hub {sanitize_display(existing_host)}", |
| 77 | file=sys.stderr, |
| 78 | ) |
| 79 | |
| 80 | set_hub_url(normalised, root) |
| 81 | |
| 82 | identity = load_identity(normalised) |
| 83 | authenticated = identity is not None |
| 84 | identity_name = "" |
| 85 | identity_type = "" |
| 86 | if identity is not None: |
| 87 | identity_name = str(identity.get("handle") or "") |
| 88 | identity_type = str(identity.get("type") or "") |
| 89 | |
| 90 | if json_output: |
| 91 | payload = {**make_envelope(elapsed), **{ |
| 92 | "status": "ok", |
| 93 | "hub_url": normalised, |
| 94 | "hostname": hostname, |
| 95 | "authenticated": authenticated, |
| 96 | "identity_name": identity_name, |
| 97 | "identity_type": identity_type, |
| 98 | }} |
| 99 | payload.pop("timestamp", None) |
| 100 | payload.pop("duration_ms", None) |
| 101 | print(json.dumps(payload)) |
| 102 | else: |
| 103 | print(f"✅ Connected to {sanitize_display(hostname)}", file=sys.stderr) |
| 104 | if authenticated: |
| 105 | print( |
| 106 | f" Authenticated as {sanitize_display(identity_type)} " |
| 107 | f"'{sanitize_display(identity_name)}'", |
| 108 | file=sys.stderr, |
| 109 | ) |
| 110 | else: |
| 111 | print(" No identity stored yet — run: muse auth register", file=sys.stderr) |
| 112 | |
| 113 | def run_status(args: argparse.Namespace) -> None: |
| 114 | """Show the hub connection and identity for this repository. |
| 115 | |
| 116 | Reads ``.muse/config.toml`` for the hub URL and ``~/.muse/identity.toml`` |
| 117 | for the stored identity. Makes **no network calls**. |
| 118 | |
| 119 | ``--hub`` override |
| 120 | ------------------ |
| 121 | Pass ``--hub <url>`` to inspect a hub URL that differs from the one stored |
| 122 | in ``.muse/config.toml``. Useful for containerised agents that reach the |
| 123 | hub at a different address (e.g. ``http://host.docker.internal:10003``). |
| 124 | The override is not persisted. |
| 125 | |
| 126 | Agent quickstart |
| 127 | ---------------- |
| 128 | :: |
| 129 | |
| 130 | muse hub status --json || muse hub connect https://musehub.ai --json |
| 131 | |
| 132 | JSON output (``--json``, stdout) |
| 133 | -------------------------------- |
| 134 | All keys are always present — agents never receive a ``KeyError``:: |
| 135 | |
| 136 | { |
| 137 | "hub_url": "https://musehub.ai", ← URL as stored in config |
| 138 | "hostname": "musehub.ai", ← host[:port] display form |
| 139 | "authenticated": true | false, |
| 140 | "identity_type": "human" | "agent" | "", ← "" when not authenticated |
| 141 | "identity_name": "<name>" | "", |
| 142 | "identity_id": "<id>" | "", |
| 143 | "capabilities": ["read:*", ...] | [] ← [] for humans / unauthenticated |
| 144 | } |
| 145 | |
| 146 | All text output (labels, warnings, errors) goes to stderr. |
| 147 | |
| 148 | Exit codes |
| 149 | ---------- |
| 150 | 0 Status printed successfully. |
| 151 | 1 No hub connected (no ``[hub] url`` in config and no ``--hub`` override). |
| 152 | 2 Not inside a Muse repository. |
| 153 | """ |
| 154 | elapsed = start_timer() |
| 155 | json_output: bool = args.json_output |
| 156 | |
| 157 | root = find_repo_root() |
| 158 | if root is None: |
| 159 | print("❌ Not inside a Muse repository.", file=sys.stderr) |
| 160 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 161 | |
| 162 | hub_url = args.hub or get_hub_url(root) |
| 163 | if hub_url is None: |
| 164 | print("No hub connected.\nRun: muse hub connect <url>", file=sys.stderr) |
| 165 | raise SystemExit(ExitCode.USER_ERROR) |
| 166 | |
| 167 | hostname = _hub_hostname(hub_url) |
| 168 | identity = load_identity(hub_url) |
| 169 | |
| 170 | authenticated = identity is not None |
| 171 | identity_type = str(identity.get("type") or "") if identity else "" |
| 172 | identity_name = str(identity.get("handle") or "") if identity else "" |
| 173 | identity_id = str(identity.get("fingerprint") or "") if identity else "" |
| 174 | capabilities: list[str] = list(identity.get("capabilities") or []) if identity else [] |
| 175 | |
| 176 | if json_output: |
| 177 | payload = {**make_envelope(elapsed), **{ |
| 178 | "hub_url": hub_url, |
| 179 | "hostname": hostname, |
| 180 | "authenticated": authenticated, |
| 181 | "identity_type": identity_type, |
| 182 | "identity_name": identity_name, |
| 183 | "identity_id": identity_id, |
| 184 | "capabilities": capabilities, |
| 185 | }} |
| 186 | payload.pop("timestamp", None) |
| 187 | payload.pop("duration_ms", None) |
| 188 | print(json.dumps(payload)) |
| 189 | return |
| 190 | |
| 191 | print("", file=sys.stderr) |
| 192 | print(" Hub", file=sys.stderr) |
| 193 | print(f" URL: {sanitize_display(hub_url)}", file=sys.stderr) |
| 194 | |
| 195 | if not authenticated: |
| 196 | print(" Auth: not authenticated — run `muse auth register`", file=sys.stderr) |
| 197 | else: |
| 198 | handle = identity.get("handle", "") if identity else "" |
| 199 | fingerprint = identity.get("fingerprint", "") if identity else "" |
| 200 | print(f" Type: {sanitize_display(identity_type) or 'unknown'}", file=sys.stderr) |
| 201 | print(f" Name: {sanitize_display(identity_name) or '—'}", file=sys.stderr) |
| 202 | print(f" ID: {sanitize_display(identity_id) or '—'}", file=sys.stderr) |
| 203 | print( |
| 204 | f" Auth: {'Ed25519 key set (handle: ' + handle + ')' if handle else 'not set — run muse auth keygen'}", |
| 205 | file=sys.stderr, |
| 206 | ) |
| 207 | if capabilities: |
| 208 | caps_display = " ".join(sanitize_display(str(c)) for c in capabilities) |
| 209 | print(f" Caps: {caps_display}", file=sys.stderr) |
| 210 | |
| 211 | print("", file=sys.stderr) |
| 212 | |
| 213 | def run_disconnect(args: argparse.Namespace) -> None: |
| 214 | """Remove the hub association from this repository. |
| 215 | |
| 216 | Removes ``[hub] url`` from ``.muse/config.toml``. Credentials in |
| 217 | ``~/.muse/identity.toml`` are **preserved** — use ``muse auth logout`` |
| 218 | to remove them as well. Makes no network calls. |
| 219 | |
| 220 | Idempotent |
| 221 | ---------- |
| 222 | Disconnecting when no hub is configured exits 0 with |
| 223 | ``status: "nothing_to_do"`` — safe to call unconditionally in scripts. |
| 224 | |
| 225 | Agent quickstart |
| 226 | ---------------- |
| 227 | Full teardown (disconnect + revoke credentials):: |
| 228 | |
| 229 | muse hub disconnect --json | python3 -c " |
| 230 | import json, subprocess, sys |
| 231 | d = json.load(sys.stdin) |
| 232 | if d['hub_url']: |
| 233 | subprocess.run(['muse', 'auth', 'logout', '--hub', d['hub_url']], check=True) |
| 234 | " |
| 235 | |
| 236 | JSON output (``--json``, stdout) |
| 237 | -------------------------------- |
| 238 | :: |
| 239 | |
| 240 | { |
| 241 | "status": "ok" | "nothing_to_do", |
| 242 | "hub_url": "<url>" | "", ← full normalised URL; "" on nothing_to_do |
| 243 | "hostname": "<host>" | "" ← host[:port]; "" on nothing_to_do |
| 244 | } |
| 245 | |
| 246 | All text (success messages, hints) goes to stderr. |
| 247 | |
| 248 | Exit codes |
| 249 | ---------- |
| 250 | 0 Disconnected successfully, or nothing was connected. |
| 251 | 2 Not inside a Muse repository. |
| 252 | """ |
| 253 | elapsed = start_timer() |
| 254 | json_output: bool = args.json_output |
| 255 | |
| 256 | root = find_repo_root() |
| 257 | if root is None: |
| 258 | print("❌ Not inside a Muse repository.", file=sys.stderr) |
| 259 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 260 | |
| 261 | hub_url = get_hub_url(root) |
| 262 | if hub_url is None: |
| 263 | if json_output: |
| 264 | print(json.dumps({**make_envelope(elapsed), **{ |
| 265 | "status": "nothing_to_do", |
| 266 | "hub_url": "", |
| 267 | "hostname": "", |
| 268 | }})) |
| 269 | else: |
| 270 | print("No hub connected — nothing to do.", file=sys.stderr) |
| 271 | return |
| 272 | |
| 273 | hostname = _hub_hostname(hub_url) |
| 274 | clear_hub_url(root) |
| 275 | |
| 276 | if json_output: |
| 277 | print(json.dumps({**make_envelope(elapsed), **{ |
| 278 | "status": "ok", |
| 279 | "hub_url": hub_url, |
| 280 | "hostname": hostname, |
| 281 | }})) |
| 282 | else: |
| 283 | print(f"✅ Disconnected from {sanitize_display(hostname)}.", file=sys.stderr) |
| 284 | print( |
| 285 | " Credentials in ~/.muse/identity.toml are preserved.\n" |
| 286 | f" To remove them too: muse auth logout --hub {sanitize_display(hub_url)}", |
| 287 | file=sys.stderr, |
| 288 | ) |
| 289 | |
| 290 | def run_ping(args: argparse.Namespace) -> None: |
| 291 | """Test HTTP connectivity to the configured hub. |
| 292 | |
| 293 | Sends a ``GET <hub_url>/health`` request and reports the result. |
| 294 | No authentication token is sent — the health endpoint is intentionally |
| 295 | unauthenticated. HTTP redirects are refused (the hub URL in config |
| 296 | should be the final destination). |
| 297 | |
| 298 | ``--hub`` override |
| 299 | ------------------ |
| 300 | Pass ``--hub <url>`` to test a URL that differs from the one in config |
| 301 | (e.g. for containerised agents: ``--hub http://host.docker.internal:10003``). |
| 302 | The URL is not persisted. |
| 303 | |
| 304 | Agent quickstart |
| 305 | ---------------- |
| 306 | Health-check before any operation:: |
| 307 | |
| 308 | muse hub ping --json || { echo "hub unreachable"; exit 1; } |
| 309 | |
| 310 | Startup readiness loop:: |
| 311 | |
| 312 | until muse hub ping --json 2>/dev/null; do sleep 2; done |
| 313 | |
| 314 | JSON output (``--json``, stdout) |
| 315 | -------------------------------- |
| 316 | :: |
| 317 | |
| 318 | { |
| 319 | "status": "ok" | "error", |
| 320 | "hub_url": "<url>", ← URL that was pinged |
| 321 | "hostname": "<host[:port]>", |
| 322 | "reachable": true | false, |
| 323 | "message": "HTTP 200 OK" | "<error reason>" |
| 324 | } |
| 325 | |
| 326 | All text output (progress, errors) goes to stderr. |
| 327 | |
| 328 | Exit codes |
| 329 | ---------- |
| 330 | 0 Hub reachable (HTTP 2xx). |
| 331 | 1 No hub connected (no ``[hub] url`` in config and no ``--hub`` flag). |
| 332 | 2 Not inside a Muse repository. |
| 333 | 5 Hub unreachable (connection refused, timeout, non-2xx, bad response). |
| 334 | """ |
| 335 | elapsed = start_timer() |
| 336 | json_output: bool = args.json_output |
| 337 | |
| 338 | root = find_repo_root() |
| 339 | if root is None: |
| 340 | print("❌ Not inside a Muse repository.", file=sys.stderr) |
| 341 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 342 | |
| 343 | hub_url = args.hub or get_hub_url(root) |
| 344 | if hub_url is None: |
| 345 | print("No hub connected.\nRun: muse hub connect <url>", file=sys.stderr) |
| 346 | raise SystemExit(ExitCode.USER_ERROR) |
| 347 | |
| 348 | hostname = _hub_hostname(hub_url) |
| 349 | |
| 350 | if not json_output: |
| 351 | print(f"Pinging {sanitize_display(hostname)}…", end="", flush=True, file=sys.stderr) |
| 352 | |
| 353 | reachable, message = _ping_hub(hub_url) |
| 354 | |
| 355 | if json_output: |
| 356 | payload = {**make_envelope(elapsed), **{ |
| 357 | "status": "ok" if reachable else "error", |
| 358 | "hub_url": hub_url, |
| 359 | "hostname": hostname, |
| 360 | "reachable": reachable, |
| 361 | "message": message, |
| 362 | }} |
| 363 | payload.pop("timestamp", None) |
| 364 | payload.pop("duration_ms", None) |
| 365 | print(json.dumps(payload)) |
| 366 | if not reachable: |
| 367 | raise SystemExit(ExitCode.REMOTE_ERROR) |
| 368 | else: |
| 369 | if reachable: |
| 370 | print(f" ✅ {sanitize_display(message)}", file=sys.stderr) |
| 371 | else: |
| 372 | print(f" ❌ {sanitize_display(message)}", file=sys.stderr) |
| 373 | raise SystemExit(ExitCode.REMOTE_ERROR) |
| 374 | |
| 375 | def register(subs: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 376 | """Register connection subcommands.""" |
| 377 | # ── connect ─────────────────────────────────────────────────────────────── |
| 378 | connect_p = subs.add_parser( |
| 379 | "connect", |
| 380 | help="Attach this repository to a MuseHub instance.", |
| 381 | description=( |
| 382 | "Write [hub] url to .muse/config.toml and confirm auth status.\n" |
| 383 | "Does not touch credentials — authenticate with 'muse auth register'.\n\n" |
| 384 | "URL is normalised: bare hostnames gain https://, trailing slashes\n" |
| 385 | "are stripped, http:// is rejected for non-loopback hosts.\n\n" |
| 386 | "Agent quickstart:\n" |
| 387 | " muse hub connect https://musehub.ai --json && muse auth register --agent --json\n\n" |
| 388 | "JSON output keys: status, hub_url, hostname, authenticated,\n" |
| 389 | " identity_name, identity_type" |
| 390 | ), |
| 391 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 392 | ) |
| 393 | connect_p.add_argument( |
| 394 | "url", metavar="URL", |
| 395 | help="MuseHub URL (e.g. https://musehub.ai or just musehub.ai).", |
| 396 | ) |
| 397 | connect_p.add_argument( |
| 398 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 399 | help="Emit a JSON object to stdout on success.", |
| 400 | ) |
| 401 | connect_p.set_defaults(func=run_connect) |
| 402 | |
| 403 | # ── disconnect ──────────────────────────────────────────────────────────── |
| 404 | disconnect_p = subs.add_parser( |
| 405 | "disconnect", |
| 406 | help="Remove the hub association from this repository.", |
| 407 | description=( |
| 408 | "Remove [hub] url from .muse/config.toml. Credentials in\n" |
| 409 | "~/.muse/identity.toml are preserved — use 'muse auth logout'\n" |
| 410 | "to remove them too. Makes no network calls.\n\n" |
| 411 | "Operation is idempotent: exits 0 with status 'nothing_to_do'\n" |
| 412 | "when no hub is configured.\n\n" |
| 413 | "Agent quickstart:\n" |
| 414 | " muse hub disconnect --json # get hub_url for cleanup\n\n" |
| 415 | "JSON keys: status, hub_url, hostname" |
| 416 | ), |
| 417 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 418 | ) |
| 419 | disconnect_p.add_argument( |
| 420 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 421 | help="Emit a JSON object to stdout on completion.", |
| 422 | ) |
| 423 | disconnect_p.set_defaults(func=run_disconnect) |
| 424 | |
| 425 | # ── ping ────────────────────────────────────────────────────────────────── |
| 426 | ping_p = subs.add_parser( |
| 427 | "ping", |
| 428 | help="Test HTTP connectivity to the configured hub.", |
| 429 | description=( |
| 430 | "Send GET <hub>/health and report reachability. No auth token\n" |
| 431 | "is sent — the health endpoint is intentionally public.\n" |
| 432 | "HTTP redirects are refused.\n\n" |
| 433 | "Agent readiness loop:\n" |
| 434 | " until muse hub ping --json 2>/dev/null; do sleep 2; done\n\n" |
| 435 | "JSON keys: status, hub_url, hostname, reachable, message\n" |
| 436 | "Exit 0 = reachable, 5 = unreachable, 1 = no hub, 2 = no repo" |
| 437 | ), |
| 438 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 439 | ) |
| 440 | ping_p.add_argument( |
| 441 | "--hub", dest="hub", default=None, metavar="URL", |
| 442 | help="Override the hub URL from config.", |
| 443 | ) |
| 444 | ping_p.add_argument( |
| 445 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 446 | help="Emit a JSON object to stdout with the ping result.", |
| 447 | ) |
| 448 | ping_p.set_defaults(func=run_ping) |
| 449 | |
| 450 | # ── status ──────────────────────────────────────────────────────────────── |
| 451 | status_p = subs.add_parser( |
| 452 | "status", |
| 453 | help="Show the hub connection and identity for this repository.", |
| 454 | description=( |
| 455 | "Display the hub URL stored in .muse/config.toml and the identity\n" |
| 456 | "stored in ~/.muse/identity.toml. Makes no network calls.\n\n" |
| 457 | "Agent quickstart:\n" |
| 458 | " muse hub status --json || muse hub connect https://musehub.ai --json\n\n" |
| 459 | "JSON keys (always present): hub_url, hostname, authenticated,\n" |
| 460 | " identity_type, identity_name, identity_id, capabilities" |
| 461 | ), |
| 462 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 463 | ) |
| 464 | status_p.add_argument( |
| 465 | "--hub", dest="hub", default=None, metavar="URL", |
| 466 | help="Override the hub URL from config (e.g. http://host.docker.internal:10003/owner/repo).", |
| 467 | ) |
| 468 | status_p.add_argument( |
| 469 | "--json", "-j", action="store_true", dest="json_output", |
| 470 | help="Emit JSON to stdout instead of human-readable output.", |
| 471 | ) |
| 472 | status_p.set_defaults(func=run_status) |
| 473 |
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
⚠
31 days ago