gabriel / muse public
connection.py python
473 lines 18.2 KB
Raw
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