Skip to content

spot-cli architecture

This document captures how the CLI is organised, how it talks to the api-gateway, and which assumptions it leans on. It is aimed at contributors changing CLI internals, not at operators using the tool.

Module layout

services/cli/
├── src/spot_cli/
│   ├── __init__.py            # __version__
│   ├── __main__.py            # python -m spot_cli entry point
│   ├── main.py                # Typer app + sub-app registration
│   ├── api_client.py          # Thin httpx2 wrapper, token refresh, error mapping
│   ├── config_paths.py        # Token-dir resolution
│   ├── output.py              # Coloured info / success / error / warn
│   ├── profiles.py            # Install profiles, generated secrets, the .env
│   └── commands/
│       ├── auth.py
│       ├── health.py
│       ├── version.py
│       ├── audit.py
│       ├── watch.py
│       ├── plugin.py
│       ├── profile.py
│       ├── workflow.py
│       ├── routing.py
│       ├── analyze.py
│       ├── config.py
│       ├── queues.py
│       └── benchmark/         # Benchmark suite (own subpackage)
└── tests/

There is no business logic in the CLI beyond argument parsing, authentication, HTTP calls, and rendering, the benchmark suite and the install profiles aside (below). Everything substantive -- plugin lifecycle, workflow validation, configuration parsing -- lives behind api-gateway endpoints. This is by design: the CLI must work against any deployment of the platform, including ones the operator cannot SSH into.

Communication contract

CLI subcommand HTTP call RBAC role
auth login POST /auth/token (form-encoded) n/a
auth refresh POST /auth/refresh viewer
auth whoami GET /auth/me viewer
health GET /api/v1/system/health viewer
version GET /api/v1/system/version viewer
plugin list GET /api/v1/config/plugins, GET /api/v1/config/plugin/{kind}/{id}, and GET /api/v1/plugin-catalog when a plugin was installed from a source (a 503 there is a warning) admin
plugin list --catalog GET /api/v1/plugin-catalog (?kind= with --kind) viewer
plugin info GET /api/v1/config/plugin/{kind}/{id}, then GET /api/v1/plugin-catalog?source_id= for an installed plugin (a 503 there is a warning) admin
plugin info --catalog GET /api/v1/plugin-catalog/{id} viewer
plugin schema GET /api/v1/config/plugin/{kind}/{id}/schema admin
plugin install POST /api/v1/plugins/install admin
plugin remove DELETE /api/v1/plugins/{id} admin
plugin enable / disable POST /api/v1/config/plugin/{kind}/{id}/enable / disable admin
plugin restart POST /api/v1/plugins/{id}/restart admin
plugin update POST /api/v1/plugins/{id}/update admin
plugin rollback GET /api/v1/plugins/installed/{id}, then POST /api/v1/plugins/{id}/update with its previous_version admin
plugin settings PUT /api/v1/plugins/{id}/settings admin
plugin resources GET /api/v1/plugins/installed/{id}; with --memory-mb or --cpus, PUT /api/v1/plugins/{id}/resources; with --reset, DELETE /api/v1/plugins/{id}/resources viewer to show, admin to change
plugin token POST /api/v1/plugins/{id}/token (with --kind, first POST /api/v1/config/plugin/{kind} for a plugin not in spot.yaml) admin
plugin mode PUT /api/v1/config/plugin/mail_retriever/{id}/mode admin
plugin test none: it runs the plugin's image through the Docker socket, with the SDK's engine (spot_sdk.testing.container), and serves the plugin a fake SPOT n/a
plugin new none: the wrapper runs copier on the host (deploy/scripts/ops/plugin-new.sh); the image is not used n/a
workflow list / get GET /api/v1/workflows[/{id}] viewer
workflow create / update POST / PUT /api/v1/workflows[/{id}] admin
workflow delete DELETE /api/v1/workflows/{id} admin
workflow history GET /api/v1/workflows/{id}/history viewer
workflow restore POST /api/v1/workflows/{id}/history/{commit_id}/restore admin
workflow clone POST /api/v1/workflows/{id}/clone admin
routing show GET /api/v1/routing analyst
routing apply PUT /api/v1/routing admin
analyze submit POST /api/v1/analyze (JSON: the message, base64); with --wait or --require-verdict, then GET /api/v1/analyze/{job_id} every 2 s analyst
analyze status GET /api/v1/analyze/{job_id} viewer
analyze list GET /api/v1/analyze viewer
analyze cancel DELETE /api/v1/analyze/{job_id}, one per job analyst
config show GET /api/v1/config/ admin
config platform GET /api/v1/config/platform admin
config validate POST /api/v1/config/validate analyst
config history GET /api/v1/config/history admin
config diff GET /api/v1/config/history/{commit_id} admin
config rollback GET /api/v1/config/history/{commit_id}, then POST /api/v1/config/history/{commit_id}/rollback admin
queues replay POST /api/v1/observability/queues/{queue}/replay admin
profile list / requirements / env none: the profiles are data of the CLI n/a
profile apply GET /api/v1/config/plugins; for the plugins missing, GET /api/v1/plugin-catalog (again after POST /api/v1/plugin-catalog/refresh when one is not listed), then per plugin POST /api/v1/plugins/install and PUT /api/v1/plugins/{id}/resources; GET /api/v1/workflows/{id} (404: none), POST /api/v1/workflows; GET /api/v1/workflows/default, POST /api/v1/workflows/{id}/set-default; GET /api/v1/plugins/{id}/status every 5 s until healthy admin
audit GET /api/v1/audit admin
watch GET /api/v1/system/health and GET /api/v1/analyze?limit=N, every --interval s (a 503 or an unreachable gateway is shown, then read again) viewer

info, schema, enable, disable and token first find the plugin's section of spot.yaml in GET /api/v1/config/plugins; with a --kind that is not that section, they stop and name the plugin's kind, without reading its entry.

The RBAC column reflects the gateway's enforcement; if a token does not satisfy the role the gateway returns 403 and spot-cli surfaces that as Forbidden: your role lacks permission for <method> <path> and exits 1.

Token storage

Tokens live in two files under $SPOT_TOKEN_DIR (default ~/.config/spot-cli/):

~/.config/spot-cli/
├── token          # access_token (short-lived)
└── token-refresh  # refresh_token (long-lived, optional)

api_client.py reads them lazily, attaches them as Authorization: Bearer <token>, and on a 401 transparently:

  1. Reads token-refresh.
  2. POSTs { refresh_token } to /auth/refresh.
  3. On success, replaces token with the new access token and retries the original request once.
  4. On failure, deletes both files and bubbles the 401 up so the caller can re-run auth login.

Error mapping

api_client.request() is the single entry point for every gateway call. It maps the failure modes operators care about into human-friendly messages:

Failure Message printed
httpx2.ConnectError Cannot connect to <url>. Is the platform running?
httpx2.TimeoutException Request to <url> timed out after <N>s
Any other httpx2.TransportError (a connection lost mid-request) Lost the connection to <url> during <method> <path>
HTTP 401 (after refresh) Not authenticated. Run spot-cli auth login first.
HTTP 403 Forbidden: your role lacks permission for <method> <path>
Any other 4xx or 5xx <method> <path> → <status>: <message>, the message of the gateway's error body ({error, message, details}), with each refused field and why for a 422, else the body as given

Every mapped path raises typer.Exit(1); unmapped exceptions still propagate to give a stack trace useful for bug reports. Three callers ask for an answer instead: with raise_unavailable, a 503 raises UnavailableError, for a caller that can do without the answer; with raise_not_found, a 404 raises NotFoundError, for a caller that names what was not found or asks whether something exists; with raise_unreachable, a gateway that cannot be reached or does not answer in time raises UnreachableError, for a caller that reads again, as watch does.

Rendering

Text that comes from outside SPOT, such as an email's subject, an analyzer's evidence or an image's labels, goes through output.plain() before a table shows it: Rich markup in it is not interpreted, and each control character becomes U+FFFD, so it cannot move the terminal's cursor or rewrite what is already shown.

A command that shows data fetches it, then calls output.emit with the format --output gave, the data and a function that prints it for a person. table calls that function, which prints what the command printed before --output; json and yaml print the data alone on standard output, in ASCII.

Profiles

profiles.py holds the install profiles (minimal, standard, full) as data, versioned with the images they install: each one's plugins, with their settings, whether they need the content consent and their memory; its workflow, built from spot-sdk-python's models; its compose profiles and .env settings; and what its host needs. The memory figures come from measurements on a test instance, each named next to its constant. The module also generates a new install's secrets (Python's secrets) and renders its .env from .env.example.

commands/profile.py exposes them. requirements and env read no gateway: the deployment wrapper's setup calls them before the stack runs. apply reads what the platform holds and changes only what is missing; it reads time.monotonic and time.sleep through the time module, as analyze submit --wait does, so tests replace both.

Benchmark subpackage

commands/benchmark/ is the command group with the most local logic. It owns:

  • datasets.py -- registry of supported corpora and their formats.
  • models.py -- Pydantic models for normalised emails (each an RFC 5322 message, base64 in the JSONL), run state, metrics.
  • normalize.py -- Format-specific parsers (CSV, JSON, Maildir) that build each message, or keep it byte for byte (Maildir).
  • run.py -- Submits batches, polls, persists results.
  • report.py -- Computes metrics from run results, with the SDK's spot_sdk.testing.metrics as plugin test does, and saves them to metrics.json.
  • compare.py -- Side-by-side comparison of two runs.

Datasets are intentionally not committed to this repo; they live in a separate dataset repository operators clone next to spot/. SPOT_DATASETS_DIR resolves the location.

Coverage

The CLI is held to 100% line coverage (--cov-fail-under=100). The genuinely unreachable async networking branches in benchmark/run.py are marked # pragma: no cover with a one-line justification next to each.

Why no shared library

A previous iteration extracted helpers into a cli-common library so they could be reused by hypothetical "operator scripts." That never materialised, so the abstraction was removed. The CLI is small enough that a flat layout under src/spot_cli/ reads better than a layered one. Add a shared module only when there's a concrete second consumer.