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

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:

  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.

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.