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:
- Reads
token-refresh. - POSTs
{ refresh_token }to/auth/refresh. - On success, replaces
tokenwith the new access token and retries the original request once. - 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'sspot_sdk.testing.metricsasplugin testdoes, and saves them tometrics.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.