Themelio compatibility reference. This page describes the retained Themelio implementation or its historical design. Check the parallel-operation guide before applying it to the current platform.
DD-0021: Themelio CLI — Implementation
Related ADR: ADR-0021: Themelio Command-Line Interface
Status: Draft
Date: 2026-07-06
Authors: TBD
ADR-derived technical notes
Implementation detail that surfaced while authoring ADR-0021. A complete working
specification also exists at plans/themelio-cli/SPEC.md and is the primary input
for this DD.
- Package / stack: Python 3.11+, console entry point
themelio, distributed via acurl … | shstandalone installer andpipx install themelio-cli/uvx themelio. Stack: Typer (commands), httpx (HTTP + SSE), rich (tables/progress), PyYAML (pre-parse ofactions.yaml), keyring (token storage).git/git-lfsare the user's, not a runtime dependency. - Auth / config precedence:
--host/--tokenflags →THEMELIO_HOST/THEMELIO_TOKEN→~/.themelio/config(or keyring) → built-in default hosthttps://dev.themelio.pharos.grnet.gr. SinglePRIVATE-TOKENheader. - git credential helper:
git-setupprintsgit config --global credential.https://<host>.helper '!themelio git-credential'by default;--applywrites it. Hiddenthemelio git-credentialspeaks git's credential-helper protocol (emitsusername=oauth2+password=<token>). - Content commands print git, never shell out; no single-file API shortcut.
- New endpoint
POST /api/actions/validatereuses the web action library and delegatesref:resolution tok8s-root-actor(populateRefs).
Overview
This document specifies how to build the themelio command-line client: a
standalone Python package that is a thin, typed client of the Themelio web API,
with a themelio <type> <ns/repo> <verb> grammar, keyring-backed api-scoped PAT
auth, git hand-off for content transfer (via a git-setup credential helper), and
action-driven compute that yields observe-only jobs and deployments. It also
specifies the one new backend endpoint the CLI depends on, POST /api/actions/validate.
Goals and Non-Goals
Goals
- Installable two ways: a zero-prerequisite
curl … | shstandalone installer, andpipx install themelio-cli/uvx themeliofor Python users — both yielding athemeliocommand covering auth, repository lifecycle, actions, and job/deployment observation against the existing web API. - Resource-first command grammar with the shared verb set, implemented cleanly on top of Typer/Click.
- One credential (
apiPAT) for both API and git; stored in the OS keyring. - Scriptable output (
--json,--quiet) and stable exit codes. - The
POST /api/actions/validateendpoint, reusing existing web/k8s-root-actor logic.
Non-Goals
- Moving repository bytes through the CLI (delegated to the user's
git/git-lfs). - A browser device-login flow (deferred; see Open Questions).
- Repo deletion, file-level commits, branches/tags, and a local content cache (unsupported by the backend today).
Design
1. Dependencies
Build backend: hatchling, src/ layout, requires-python = ">=3.11".
2. File structure
The error hierarchy is plain exceptions (not click.ClickException) because the
pinned Typer vendors its own Click; a root RootGroup(TyperGroup) catches
ThemelioError in invoke() and raises typer.Exit(code), giving stable exit
codes under both real runs and CliRunner.
3. Command grammar (grammar.py)
The resource-first grammar themelio <type> <ns/repo> <verb> is not Click's
default (group verb args). A repo reference always contains exactly one /;
verbs never do. We exploit that with a custom group that pops a leading repo token
into the context before resolving the verb:
- Each resource sub-app is
typer.Typer(cls=ResourceGroup); commands read the repo viactx.obj["repo"](Typer injectsctx: typer.Context). ls(no repo) and theactions/jobs/deploymentsgroups dispatch normally because their first token has no/.- Those sub-apps are mounted on the resource app, so
models vkefallinos/bert actions run train(or… jobs ls) pops the repo at the resource level and reaches the nested verb withctx.obj["repo"]set;models actions validate fnever pops a repo, and repo-scoped verbs raise a usage error (exit 2) whenrepois absent.
4. Root app & resource factory
cli.py builds the root Typer app, defines global options as a top-level callback,
and mounts sub-apps:
models/spaces/repos additionally mount the same jobs.app/deployments.app
as repo-scoped shortcuts (e.g. themelio models vkefallinos/bert jobs ls), so the
verbs run with the repo already in context.
make_resource_app(resource_type) (in commands/resource.py) returns a
ResourceGroup Typer with the shared verbs, closing over resource_type so all
four types share one implementation. has_compute = type in {models, spaces, repos}
mounts the actions, jobs, and deployments sub-apps (datasets get none);
has_search = type in {models, datasets} toggles --search. The jobs/
deployments sub-apps are the same Typer apps as the top-level jobs.app/
deployments.app — mounting them under a ResourceGroup means the repo is popped
into ctx.obj["repo"] before their verbs run, so those verbs read the repo from
context instead of requiring --repo. Verb → endpoint mapping:
5. HTTP client (client.py)
A thin wrapper around one reused httpx.Client:
error_from_response maps 401/403 → AuthError, 404 → NotFoundError,
409 → ConflictError, 400 → ValidationError (carrying fieldErrors), else
ThemelioError. Requests without a resolved token short-circuit to AuthError
("run themelio auth login").
6. Config & credential storage (config.py, keyring_store.py)
Settingsis a pydantic model (host: HttpUrl,token: SecretStr | None).resolve_settings()applies the ADR precedence: flags → env → config file → default hosthttps://dev.themelio.pharos.grnet.gr(token has no default).- Config file
~/.themelio/config(TOML, mode0600) storeshostonly when the token lives in the keyring; if no keyring backend exists it also storestoken. keyring_storewrapskeyring.get/set/delete_password("themelio", host), catchingkeyring.errors.NoKeyringErrorto fall back to the config file.logoutclears both.
7. Content transfer (gitcmd.py, commands/resource.py, git_setup.py)
repo_git_url(host, type, ns, repo) -> f"{host}/{type}s/{ns}/{repo}.git".download/uploadprint the exact commands (never execute git): clone →git clone <url>; upload →cd <repo> && git add -A && git commit -m … && git push, prefixed with a one-timethemelio git-setup --apply && git lfs installhint.git-setup(commands/git_setup.py):- Default: print
git config --global credential.https://<host>.helper '!themelio git-credential'. --apply: run it viasubprocess.run([... "git", "config", ...]).- Hidden
themelio git-credentialcommand implements the credential-helper protocol: readkey=valuelines from stdin until blank; ifhostmatches the configured host, printprotocol=https,host=<host>,username=oauth2,password=<token-from-keyring>.store/eraseverbs are no-ops (the token is owned bythemelio auth, not git).
- Default: print
8. Actions (commands/actions.py)
actions ls/describeread theactionsarray embedded inGET /api/{type}/{ns}/{repo}(there is no list endpoint);describerenders the whole action as a panel — its type, the job/deployment it yields, and every parameter (type, required, default, enum options, description). Note the info payload exposes onlyname/title/label/type/parametersper action; action-leveloptions(cpus/gpus/ image/script) are not returned, sodescribecannot show them.actions run <action> [--param k=v]… [--branch main] [--follow]→POST /api/{type}/{ns}/{repo}/action/{branch}with{action, parameters}(route exists for models/spaces/repos only). The web route relays the k8s-root-actor response verbatim (201 create / 409 already-running); the id is embedded in that upstream payload, so the CLI extracts it defensively (triesid/jobId/deploymentId/nameand a nestedjob/deployment). Whether it is a job or deployment is decided by the actiontype(slurm-job→ job, else deployment); on--followthe CLI points at the relevantlogscommand.actions validate [file=./actions.yaml]→ offlineyaml.safe_loadsanity check, thenPOST /api/actions/validatewith the raw content; printserrors/warningsand exits non-zero whenvalidis false.
actions is read/run-only: there is no init verb. Authoring actions.yaml (and any
commit to it) is done in the repository itself, not through the CLI.
9. Jobs, deployments & log streaming (commands/jobs.py, deployments.py, sse.py)
- Observe-only verbs:
ls [--repo|--namespace] [--status],get <id> --repo,logs <id> --repo [--follow],wait <id…> --repo [--timeout],destroy <id> --repo. These map to the repo-scoped…/{jobs,deployments}[/{id}]routes and the namespace routes/api/{jobs,deployments}/namespaces/{ns}. - Repo resolution: each verb resolves the repo from
ctx.obj["repo"]first (set when reached via the<type> <ns/repo> jobs|deployments …shortcut), else the--repooption. The samejobs.app/deployments.apptherefore serve both the top-level and the per-resource shortcut with no duplicated code;--namespacelisting is only valid on the top-level form. waitis client-side polling ofgetuntil a terminal status, honoring--timeout; exit non-zero if any target failed.logs: there is no separate log-stream endpoint; thegetresponse (…/{jobs,deployments}/{id}) embeds an orderedlogsarray of{id, timestamp, level, message, source_status}.logsreads that array and prints each line timestamped, coloringerror/warnand dimming empty-message status transitions. This is identical for jobs and deployments — both k8s-root-actorGET /jobs/:idandGET /deployments/:idreturn the ordered logs.--followre-fetches every 2 s and prints only lines not seen (tracked byid), stopping at a terminal status.--jsonemits the rawlogsarray. (Thespaces/{ns}/{repo}/logs/runroute — a hardcoded stub — is not used.sse.pyremains for a future true-stream endpoint, when--followcan switch from polling to SSE.)
10. Output & exit codes (output.py, errors.py)
render(data, *, columns)picks human tables (rich) by default, raw JSON with--json, and id-only lines with--quiet.- Platform-native view (
normalize.py). The web API leaks its GitLab backing store — list items carry a GraphQL global id (gid://gitlab/Project/74) rather than a usable reference, plusgitlabUrl/namespaceName/cardData/slugand, on jobs/ deployments,gitlab_project_id/gitlab_user_id. The CLI strips these in both human and--jsonoutput and surfaces the canonicalnamespace/reporeference (from theslug) asid, solsleads with a copy-pasteableRepositorycolumn and-qprints refs usable in the next command. Topics/tags are preserved verbatim. Jobs/deployments tables read the real API fields (action_name→ Action,created_at→ Created). - Collection vs repo verbs.
lsis a collection verb (whole type); giving it a repo (<type> <ns/repo> ls) is rejected with exit 2 pointing atls-files. On thejobs/deploymentsrepo-scoped mount,--repo/--typeare hidden (the repo comes from the path and the type is fixed); they remain on the top-level mount. - The root
RootGroup.invoke()catches the exception hierarchy and converts it to a rendered message +typer.Exit(code):0ok ·2usage ·3auth ·4not-found ·5conflict ·6validation (rendersfieldErrorsper parameter) ·1other.
Data Model
Config file (~/.themelio/config, TOML):
Keyring entry: service themelio, username <host>, password <api-scoped PAT>.
Pydantic response models in models.py mirror the API payloads the CLI renders
(Repo, FileEntry, TokenInfo, WhoAmI, Job, Deployment, Action,
ActionParameter, ValidationResult). Unknown fields are ignored
(model_config = ConfigDict(extra="ignore")) so the CLI tolerates API additions.
API Design
New: POST /api/actions/validate (web service)
New route services/web/src/app/api/actions/validate/route.ts. Auth: standard
PRIVATE-TOKEN (no repo scope — validates content).
Request:
Response:
Implementation (src/lib/actions/action-validator.ts holds the reusable logic; the
route orchestrates):
- Require
private-token(401 if absent). Parse via a newparseActionsForValidation()that callsjs-yamldirectly and surfaces errors — unlike the executor'sparseActionsYaml/loadActionsYaml, which swallow them and return{}. Malformed YAML or a non-mapping top level →400. validateActionsStructure()reusesaction-parameters.ts(SUPPORTED_PARAMETER_TYPES,validateParameterValue) andaction-types.tsfor structural + per-parameter checks: parametertype∈string|enum, enumoptions/enumpresent and default a member, actiontype∈slurm-job|k8s-deployment|k8s-inferenceservice, and$name(incl. hyphens) reference warnings (undefined refs / unused params). Invalid content returns200withvalid:false+ issues (only malformed input is a 400).- For each action carrying a
ref:,kubeFetch('/actions/validate', …)(baseK8S_ROOT_ACTOR_URLfromsrc/config.ts) posts{ actionConfig }to the newk8s-root-actorroutePOST /actions/validate(src/routes/actions-validate.ts, registered insrc/index.ts). That route runspopulateRefs(src/utils/actions-yaml-references.ts), maps its error classes to structured{ path, message }issues, and checks the resolvedtypeplus a config-free GPU coherence rule (gpuLabelrequired whengpusset). ThegpuLabelallow-list check is intentionally left to the executor at run time so the validate route stays decoupled from DB-bound config. Its issues merge into the web response.
The web authoring UI ("Setup deployment") can call the same endpoint, keeping one source of truth.
Existing endpoints consumed
whoami-v2 (unwrapped UserProfile); settings/tokens[/{id}] (POST body
{name, scopes[], expiresAt?} → {message, token}; GET → bare array); {type} (list;
author is ?namespace=); {type}/search?search= (models/datasets only — no
spaces/search); {type}/{ns}/{repo}[/readme|/tree/{rev}|/settings]; repos/create
(singular type); fork/{…path} (catch-all, body {namespacePath});
{type}/{ns}/{repo}/action/{branch} (models/spaces/repos; response relayed from
k8s-root-actor); and the {jobs,deployments} repo- and namespace-scoped routes,
whose …/{id} detail embeds the ordered logs array the logs verb reads. The
spaces/{ns}/{repo}/logs/run stub is not consumed. No changes required to any of
these; the CLI is coded against these verified shapes.
Testing Strategy
- Grammar (
test_grammar.py):ResourceGroup.parse_argspops repos only when a/token precedes a verb;lsandactions validatenever pop; missing-repo on a repo-scoped verb exits 2. - Commands (
test_resource/actions/jobs/deployments.py): drive Typer'sCliRunnerwithrespx-mocked httpx routes; assert method+path+body and rendered output for human/--json/--quiet, and exit-code mapping for 401/404/409/400. - Config (
test_config.py): precedence order; keyring fallback via a monkeypatchedNoKeyringError. - git-setup (
test_git_setup.py): printed config string;git-credentialreads a stdin request and emitsusername=oauth2+ password for the matching host only. - Endpoint: web-side
action-validator.test.ts(node:test) covering malformed/non-mapping YAML, unsupported action/parameter types, enum options/default,$namewarnings, and the single-action filter; plus k8s-root-actorroutes/actions-validate.test.ts(Fastifyinject) exercisingpopulateRefssuccess, a missing ref, an unsupported type, and a missing body. - CI: CLI —
ruff,mypy,pytest(3.11/3.12) + wheel build; backend —pnpm testinservices/webandservices/k8s-root-actor.
Deployment & Rollout
- Package:
themelio-cliin its own repository (Python is outside the pnpm monorepo build). Two distribution channels, both producing the samethemeliocommand:- Python users: wheel/sdist on the internal index / GitLab Package Registry —
pipx install themelio-clioruvx themelio(isolated environments). - Zero-prerequisite:
curl -LsSf https://<host>/cli/install.sh | sh(andinstall.ps1for Windows). The script detects OS/arch, downloads the matching PyInstaller single-file executable from the release artifacts, installs it to~/.local/bin/themelio(%LOCALAPPDATA%on Windows),chmod +x, and prints PATH guidance.themelio --versionself-reports the channel. - CI build matrix (GitLab CI) produces
pyinstaller --onefilebinaries for linuxx86_64/aarch64, macOSarm64/x86_64, and Windowsx86_64, plus the wheel; theinstall.sh/install.ps1scripts and binaries are attached to each tagged release. The credential helper works under both channels because git invokesthemelio git-credentialfrom PATH regardless of how it was installed.
- Python users: wheel/sdist on the internal index / GitLab Package Registry —
- Backend: the
POST /api/actions/validateroute ships with the normal web deploy; the companionk8s-root-actorPOST /actions/validateroute ships with its service. Both are additive and backward-compatible. - Prerequisite already shipped: the Settings token UI exposes the
apiscope. - Phasing (from the spec): M1 read +
auth+git-setup; M2 write + actions (needs the validate endpoint); M3 log streaming, keyring, completions, polish. - Rollback: removing the CLI has no server impact; the two new endpoints can be withdrawn independently since nothing else depends on them.
Open Questions
Distribution channel?Resolved: ship both — PyInstaller single-file binaries behind acurl … | shinstaller (à lahf) and a wheel viapipx/uvx. Open sub-question: which host serves/cli/install.shand the release artifacts (the web host vs the GitLab release page).- Should
POST /api/actions/validatealso accept a repo +refto validate an already-committedactions.yaml, in addition to raw content? - Device-login flow (reusing OIDC + impersonation tokens) as a future
auth loginmode — out of scope for v1, tracked in ADR-0021.
References
- ADR-0021: Themelio Command-Line Interface
- ADR-0012: Job and Deployment Management System
- ADR-0013: actions.yaml Automation System
- Full command specification:
plans/themelio-cli/SPEC.md - Typer · httpx · keyring · Rich
- git credential helper protocol
Page source
Source snapshot · Revision f928834c8.