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¶
core/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 httpx wrapper, token refresh, error mapping
│ ├── config_paths.py # Token-dir resolution + legacy migration
│ ├── output.py # Coloured info / success / error / warn
│ └── commands/
│ ├── auth.py
│ ├── health.py
│ ├── version.py
│ ├── plugin.py
│ ├── workflow.py
│ ├── analyze.py
│ ├── config.py
│ └── benchmark/ # Benchmark suite (own subpackage)
└── tests/
There is no business logic in the CLI beyond argument parsing, authentication, HTTP calls, and rendering. 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/plugin/{kind} | viewer |
plugin info | GET /api/v1/config/plugin/{kind}/{id} | viewer |
plugin schema | GET /api/v1/config/plugin/{kind}/{id}/schema | viewer |
plugin install | POST /api/v1/plugins/install | admin |
plugin remove | DELETE /api/v1/plugins/{id} | admin |
plugin enable / disable / update | PUT /api/v1/config/plugin/{kind}/{id} | admin |
workflow list / get | GET /api/v1/workflows[/{id}] | viewer |
workflow create / update | POST / PUT /api/v1/workflows[/{id}] | analyst |
workflow delete | DELETE /api/v1/workflows/{id} | admin |
analyze submit | POST /api/v1/analyze | analyst |
analyze status | GET /api/v1/analyze/{job_id} | analyst |
config show | GET /api/v1/config | viewer |
config validate | POST /api/v1/config/validate | analyst |
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 Permission denied 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.
config_paths.migrate_legacy_tokens() runs once per invocation (via @app.callback() on the root Typer app) and moves any <cwd>/.spot-token / <cwd>/.spot-token-refresh files left behind by older versions into the new location. The migration is idempotent and silent if there is nothing to migrate.
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 |
|---|---|
httpx.ConnectError | Cannot connect to <url> |
httpx.ReadTimeout | Request timed out after <Ns> |
| HTTP 400 with JSON body | The body's detail field, formatted |
| HTTP 401 (after refresh) | Authentication required - run 'spot-cli auth login' |
| HTTP 403 | Permission denied (need role: <role>) if the gateway provides it |
| HTTP 404 | Not found: <resource> |
| HTTP 422 | Pretty-printed list of {path, message} pairs |
| HTTP 5xx | Raw status + body |
Every mapped path raises typer.Exit(1); unmapped exceptions still propagate to give a stack trace useful for bug reports.
Benchmark subpackage¶
commands/benchmark/ is the only command group with substantial local logic. It owns:
datasets.py-- registry of supported corpora and their formats.models.py-- Pydantic models for normalised emails, run state, metrics.normalize.py-- Format-specific parsers (CSV, JSON, Maildir).run.py-- Submits batches, polls, persists results.report.py-- Computes / re-renders metrics, diffs against a baseline.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 core/. 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.