"""Tiny REST helper for the handful of endpoints MCP doesn't cover. Today's only consumers: ``helix health`` and ``helix ready``. Both are unauthenticated probes that mirror what an external load-balancer would issue, so they intentionally do **not** attach the PAT (sending a credential to a public probe would just leak it into proxy logs). If a future read-only command needs PAT auth via REST (e.g. exposing a download endpoint MCP can't carry), pass ``token=...``; the helper will attach ``Authorization: Bearer `` and otherwise behave identically. """ from __future__ import annotations from dataclasses import dataclass from typing import Any import httpx from helix_cli import __version__ from helix_cli.config import CliConfig DEFAULT_TIMEOUT_SECONDS = 30.0 #: Carries the real CLI version so the server can measure the fleet and #: refuse known-bad / too-old clients (server-side min-version #: kill-switch in app/mcp/middleware.py). Conventional product/version. USER_AGENT = f"helix-cli/{__version__}" @dataclass(frozen=True) class RestResponse: status_code: int body: Any # parsed JSON or raw text fallback raw_text: str class CliRestError(Exception): """Raised when a REST call cannot be classified as a clean success / structured error envelope. Carries an ``exit_code`` so :mod:`helix_cli.main` can pass it straight through to ``sys.exit``. """ def __init__(self, message: str, *, exit_code: int = 5, code: str | None = None): super().__init__(message) self.exit_code = exit_code self.code = code def _build_client( config: CliConfig, *, timeout: float, token: str | None, ) -> httpx.Client: headers: dict[str, str] = {"User-Agent": USER_AGENT} if token: headers["Authorization"] = f"Bearer {token}" # ``trust_env=False`` matches the Helix-wide convention (see all # cloud-driver httpx clients): don't pick up ambient ``HTTP_PROXY`` # env vars in CI / dev shells, which routinely point at MITM debug # proxies and would leak PAT-bearing traffic to a recorder. # ``verify=True`` is explicit (defense-in-depth) so a future httpx # default flip can't silently downgrade TLS verification on PAT- # bearing traffic. return httpx.Client( base_url=config.base_url, timeout=httpx.Timeout(timeout), headers=headers, trust_env=False, follow_redirects=False, verify=True, ) def get( config: CliConfig, path: str, *, token: str | None = None, timeout: float = DEFAULT_TIMEOUT_SECONDS, ) -> RestResponse: """Issue a GET, returning a :class:`RestResponse`. Network/protocol failures raise :class:`CliRestError` with an ``exit_code`` of 5; HTTP-level non-2xx responses are returned to the caller (with the body parsed) so the caller can decide whether a 4xx/5xx is fatal — ``health`` for instance treats 503 as "not ready, exit 4," not "broken, exit 5." """ if not path.startswith("/"): path = "/" + path try: with _build_client(config, timeout=timeout, token=token) as client: r = client.get(path) except httpx.TimeoutException as exc: raise CliRestError( f"request to {config.base_url} timed out after {timeout}s", exit_code=5, code="TIMEOUT", ) from exc except httpx.HTTPError as exc: raise CliRestError( f"network error calling {config.base_url}: {type(exc).__name__}", exit_code=5, code="NETWORK", ) from exc body: Any try: body = r.json() except ValueError: body = None return RestResponse(status_code=r.status_code, body=body, raw_text=r.text) def envelope_data(resp: RestResponse) -> Any: """Pull ``.data`` out of a Helix REST envelope or raise. Helix REST responses use ``{success, data, meta, errors}``. When a handler returns 200 with ``success=true`` we return ``data``; on any other shape we raise :class:`CliRestError` with the right exit code so the caller's main loop just propagates it. """ if resp.status_code == 401: raise CliRestError("unauthorized", exit_code=2, code="UNAUTHORIZED") if resp.status_code == 403: raise CliRestError("forbidden", exit_code=3, code="FORBIDDEN") if not isinstance(resp.body, dict): raise CliRestError( f"unexpected REST response (status {resp.status_code})", exit_code=5, code="BAD_RESPONSE", ) if resp.body.get("success") is True: return resp.body.get("data") errors = resp.body.get("errors") or [] if errors and isinstance(errors[0], dict): msg = errors[0].get("message") or "remote error" code = errors[0].get("code") raise CliRestError(msg, exit_code=4, code=code) raise CliRestError( f"remote error (status {resp.status_code})", exit_code=4, code="REMOTE_ERROR", ) __all__ = [ "DEFAULT_TIMEOUT_SECONDS", "USER_AGENT", "RestResponse", "CliRestError", "get", "envelope_data", ]