tripl (CLI)
Operator CLI for a running tripl instance — diagnostics, health and live
monitoring. Like tripl-mcp, it is a pure HTTP client of the
tripl REST API (/api/v1): it imports no backend code and never touches the
database.
The distribution is tripl, the console script is tripl, and the
import package is tripl_cli. The last one is deliberate: the service's own
source package is backend/src/tripl/, so a distribution that installed an
importable tripl would shadow it in any environment holding both — a
contributor's backend venv, for one. The service is packaged as the separate
tripl-server distribution (tripl-ey6j.6), so tripl names this CLI alone.
This package also owns the shared async REST client (tripl_cli.client) and
the shared request layer above it (tripl_cli.api): every REST path, query
parameter and response projection either surface uses is spelled once, here.
tripl-mcp depends on tripl and imports both rather than carrying a copy, and
a contract test in each package fails the build if a path literal appears
anywhere else.
Install
The only runtime dependency is httpx. Python 3.12+ is required for the
pip path only — uvx downloads a suitable interpreter itself, so a host
with uv and no Python at all can still run the CLI.
From PyPI — tripl is published, so this is the ordinary path:
uvx tripl --version
# tripl 0.1.0
pip install tripl
From a local checkout, when you are working on the CLI itself:
uv run --project /path/to/tripl/cli tripl --version
From git without a checkout — how you get a revision that is not released yet. It resolves, but it is not exercised by CI:
uvx --from "git+https://github.com/vladenisov/tripl.git#subdirectory=cli" tripl --version
Commands
A command acting on the instance as a whole is one word; a command acting on a
class of objects is <plural-noun> <verb>. Every verb takes --json and
--timeout SECONDS; the ones that report on many projects also take
--project SLUG (repeatable) and --include-demo. The ones that name a single
object require --project exactly once. A bare tripl scans or
tripl drifts prints that group's help on stderr and exits 2.
tripl doctor # check the instance and report what is broken
tripl doctor --json # one JSON document on stdout, human lines on stderr
tripl doctor --strict # exit 3 on warnings too (never on skipped checks)
tripl status # projects, events, scans, signals, coverage
tripl watch # follow jobs, signals and delivery failures live
tripl watch --json # JSON Lines on stdout, one object per event
tripl scans list # scan configs, their schedule, and whether they dispatch
tripl scans jobs <scan> --project SLUG # recent jobs, newest first
tripl scans run <scan> --project SLUG # trigger a run now (WRITE)
tripl scans cancel <scan> <job-id> --project SLUG # cancel an active job (WRITE)
tripl drifts list # schema drifts; untriaged by default
tripl drifts dismiss <drift-id> --project SLUG # false_positive or snooze (WRITE)
tripl drifts reopen <drift-id> --project SLUG # back to open; drops the note (WRITE)
tripl annotate "Deployed web 2026.09.25" --project SLUG --url URL # deploy marker on monitoring charts (WRITE)
tripl check # validate the tracking calls in this checkout against the plan
tripl check --payloads events.ndjson # validate captured events; a missing required field fails
tripl check --format sarif > tripl.sarif # SARIF 2.1.0 for code scanning
tripl codegen # typed tracking code (Swift, Kotlin, TypeScript) from the plan
tripl codegen --check # CI: exit 1 when the committed generated files are out of date
tripl export --out plan-schemas # one JSON Schema (2020-12) per event, plus the bundle
tripl install --app-url https://tripl.example.com --version 1.5.0 # provision a stack and start it (HOST)
tripl install --app-url https://tripl.example.com --dry-run # print the plan, write nothing
tripl upgrade --to 1.6.0 # move an installed stack to a new image tag (HOST)
--version defaults to latest; pin a released tag in production so a re-run
cannot move you onto an image you have not read the notes for. The command says
so on stderr when you leave it at the default.
doctor, status, watch, scans list, scans jobs and drifts list are
read-only — a tk_r_ key is enough. The five marked WRITE need a tk_w_
key backed by an editor or owner, and the CLI does not pre-judge that: the key
prefix is derived from the scope's first letter server-side and says nothing
about the user's role, so the request is sent and the API's own 403 is printed.
scans cancel, drifts dismiss and drifts reopen prompt on a terminal and
take --yes; when stdin is not a terminal and --yes was not given they
refuse with exit 2 rather than hanging a cron job or proceeding silently.
scans run and annotate do not prompt and have no --yes at all — passing one
is exit 2, because a no-op flag here is a flag a script author will assume works
on the next command too. All five writes take --dry-run, which resolves everything, prints
the exact request (method, path, params, body — never a credential) and sends
nothing.
annotate posts a chart marker with source api, for a deploy step in CI. On
this one command --url is the release link the marker opens, not the instance:
give the instance with TRIPL_BASE_URL, --base-url, or --url before the
command name. The API de-duplicates the same api label within 24 hours (manual
annotations are never de-duplicated) and answers
200 with the existing marker; annotate says so and still exits 0, so a retried
job is harmless.
check validates code against the plan. .tripl/check.yml (found at or above
the current directory, up to the repository root; --check-config PATH
otherwise) names the project and, per event type, how its calls look — your
own wrapper first, SDK presets as shorthands:
project: my-app
sources: ["Sources/**", "web/src/**"]
enums: [{file: "Sources/**/Events.swift", languages: [swift]}]
event_types:
se:
calls:
- function: "Analytics.shared.log" # or `pattern:` (a regex)
args: {category: category, action: action, label: label, properties: properties}
- objc_selector: "trackWithCategory:action:label:"
page: {preset: snowplow_screen_view}
legacy: {calls: [{function: "Analytics.shared.logEvent", name_arg: 0}]}
Presets: segment_track, amplitude_log_event, snowplow_structured,
snowplow_screen_view, snowplow_self_describing. Swift, Objective-C, Kotlin,
Java and TypeScript/JavaScript are read; enum shorthand (.home) and qualified
cases resolve through the enum files, interpolated names become plan variables
("promo_sheet_\(id)_shown" is promo_sheet_${id}_shown), Kotlin/Java
.name / name() and Swift .description read an enum case's own name, and a
value only known at runtime is sent as unknown, never as an error (--strict
reports it). --payloads validates captured events instead, where a missing
required field IS an error; a value over the validator's size limits (name 500,
event type 100, field value 2000 characters, 200 fields) is sent as unknown and
flagged as an oversize_value warning on its line.
check reads nothing but the plan: any member's key works, tk_r_ included.
codegen turns the plan into typed tracking code, from the same
.tripl/check.yml. It generates per event-type style, never a function per
event, and the generated code calls your own wrapper (the transport) —
or, with none configured, the shared TriplDestination in
TriplTransport.swift / .kt / triplTransport.ts that you implement once.
It never imports an SDK.
style |
What is generated |
|---|---|
structured |
an enum per plan field (its cases are the plan's values; a variable-backed value contributes its allowed values; free text stays String), your wrapper's call with its argument types narrowed — log(category: Category, action: Action, label: String, properties:) — and a compiler-checked knownEvents list |
screen_view |
the same, with ScreenType / ScreenId enums |
named |
one generic track(event): Swift enum LegacyEvent { case homeScreenView(HomeScreenView) … } with name and properties; Kotlin a sealed interface of data classes/objects; TypeScript track<K extends LegacyEventName>(name: K, props: LegacyEventProps[K]) |
self_describing |
one data class per schema with its fields, sharing one track |
codegen:
out: {swift: Sources/Tracking/Generated, kotlin: app/src/main/java/tracking, ts: web/src/tracking}
kotlin_package: com.example.tracking
event_types:
se:
calls: [{function: "Analytics.shared.log", args: {category: category, action: action, label: label, properties: properties}}]
codegen:
style: structured # default: from the preset, else structured when the type has a name rule
transport:
swift: "Analytics.shared.log" # reuses the `calls` entry's args mapping
ts: {function: "analytics.log", positional: [category, action, label, properties],
import: "import { analytics } from './analytics';"}
type_names: {namespace: AppEvents, category: EventCategory, function: log}
template: {swift: .tripl/templates/structured.swift.mustache} # optional override
legacy:
calls: [{function: "Analytics.shared.logEvent", name_arg: 0, properties_arg: parameters}]
codegen: {style: named, languages: [swift, kotlin]}
A transport given as a bare function reuses the args / positional /
object_arg mapping of the calls entry with the same function. Swift passes
labelled arguments with their labels, Kotlin as named arguments (give
positional for a Java wrapper), TypeScript positionally — or as one object
literal with object_arg. type_names renames namespace, event (the named
/ self-describing event type), function, and any field's enum by field name.
An event's parameters are the fields it does not fix plus one per ${token} in
its name (promo_sheet_${sheet_id}_shown takes sheetId, typed by the
variable's allowed values). Plan strings become identifiers by splitting on
anything that is not a letter or digit and on camelCase: Home Screen View ->
homeScreenView (HOME_SCREEN_VIEW for Kotlin enum entries), checkout:start
-> checkoutStart; a leading digit gets _ (_1stRun), a reserved word a
trailing _ (default_), a reserved type name Value (TypeValue), and a
collision 2, 3 in sorted order. The raw plan string is always what is sent.
Deprecated events are marked (@available(*, deprecated), @Deprecated,
@deprecated); archived ones are not generated.
A custom template uses the built-in one's context (copy it from
tripl_cli/codegen/templates/): a Mustache subset — {{name}}, {{a.b}},
{{#list}}…{{/list}}, {{^empty}}…{{/empty}}, {{! comment }} — where every
plan string arrives already quoted or escaped for the language, and an unknown
{{name}} is an error rather than an empty string.
Output is deterministic (sorted, no timestamps) with a Generated by tripl codegen — do not edit header naming the project, branch and the plan's
content hash (not its revision, so a new revision that changes nothing is not
drift), so it can be committed: tripl codegen --check writes nothing and exits
1 when a file is missing, differs, or is stale (generated earlier, no longer
produced — a normal run deletes those, and only files carrying the header for
the same project, in a language the run writes into that directory). --model FILE
generates from a saved tripl export --format codegen_model without an
instance. export writes bundle.json and <event type>/<identity>.schema.json
per event (file names sanitised to [A-Za-z0-9._-]), or prints the export to
stdout without --out. Both read nothing but the plan: any member's key works.
drifts reopen is the one whose prompt is worth reading: reopening clears the
drift's resolution_note, resolved_by and resolved_at, and dismissing it
again does not bring them back.
install and upgrade are the two marked HOST: they act on a directory and
the local Docker daemon, not on a running instance, so they take neither
--url nor --api-key — passing either explicitly is exit 2 rather than a
silently ignored flag. Both take --dir (default ./tripl, always reported
absolute), --wait SECONDS (default 300, 0 skips), --dry-run, --yes and
--json, and both are idempotent: install re-run converges, and upgrade to
the tag already pinned prints already at X; nothing to do. and exits 0.
install writes compose.yaml, infra/rabbitmq/rabbitmq.conf and a generated
0600 .env into --dir, then runs docker compose pull and
docker compose up -d in it and polls <--app-url>/health. It never
overwrites an existing .env: that file is created, or appended to with your
confirmation, or left alone. --force reaches compose.yaml and
rabbitmq.conf only, and there is no flag that reaches .env, because losing
ENCRYPTION_KEY permanently destroys every stored warehouse credential.
--no-start writes the files and runs nothing (and skips the Docker probe
entirely). Note that the health poll targets the public --app-url, so on a
host whose TLS terminator is not up yet, use --wait 0 and curl
http://127.0.0.1:8000/health from the box.
upgrade --to is required and a downgrade is refused outright with no
override flag; an unorderable pair (latest, sha-abc1234, 1.4) says so and
demands --yes. It pulls, then moves the TRIPL_VERSION pin in .env
keeping a 0600 .env.bak.<UTC> copy, then restarts — the pull is first so a
bad tag leaves .env untouched. The pg_dump backup command is printed for
you to run and always prompts: a dump this tool invoked and then called
"your backup" would be a promise it cannot keep, not least because the dump does
not contain ENCRYPTION_KEY. Your database lives in the named volume
pgdata18, not in --dir.
Neither creates the owner account, connects a warehouse or runs the first scan.
The first two are unreachable with an API key of any scope — they need an
interactive owner session — so install finishes by reading the instance's real
bootstrap state from the unauthenticated /auth/status and printing the URL to
open in a browser.
docker compose pull and up -d write straight to your terminal and are never
captured, so their progress and their errors are live; the exact invocation is
printed first and is safe to paste. That is why --json carries the argv, the
cwd and the returncode of each command rather than its output. No generated
secret is ever printed — the document lists secrets_generated by name.
Two operations are deliberately not offered here. A bounded metrics replay
is owner-session-only server-side (deps.get_owner_user rejects every
request carrying an API key scope), so no tripl command could reach it.
Accepting a schema drift deletes the field definition on a missing_field
drift — the damage doctor's schema_field_deleted_by_accept finding exists to
report — so that decision stays in the tripl UI. The API refuses the accept
outright (409) when a scan config's event name format builds event names from
that column, and its force override for that refusal is likewise not spelled
here.
doctor runs six checks, always in this order and always exactly once each:
connectivity, auth, projects, data_sources, scans, drifts.
Per-project results are findings inside a check, so a consumer selects by
id and gets one row.
tripl doctor - https://tripl.example.com (from $TRIPL_BASE_URL)
PASS connectivity Reached https://tripl.example.com (from $TRIPL_BASE_URL); the API and its database are up.
PASS auth The API key authenticates as an instance-wide key (role: owner).
PASS projects 1 project selected.
FAIL data_sources 1 referenced data source(s); see below.
- fail: data_source_probe_failed 'warehouse-prod'
Data source 'warehouse-prod' (used by scan config 'prod events', 'checkout funnel') last failed its connection test at 2026-07-29T19:08:09Z: 'FATAL: password authentication failed for user "tripl"'.
FAIL scans 1 of 2 scheduled scan configs is not collecting.
- fail: scan_config_failing [prod] 'prod events'
Scan config 'prod events' (1h) has failed 5 consecutive scheduled runs since 2026-07-31T14:08:09Z. Last error: 'Scan failed due to an internal error.' - that is the backend's generic fallback, not the real cause, so the cause is in the worker log for job job-0.
- warn: scan_backoff_active [prod] 'prod events'
The scheduler has deliberately deferred the next attempt to not before 2026-07-31T22:08:09Z (about 4h after the last failure): 3 or more consecutive failures trigger a backoff, so the worker is not stuck.
WARN drifts 1 event type(s) examined; see below.
- warn: schema_field_deleted_by_accept [prod] 'app.screen_view'
Field 'user_id' was deleted from event type 'app.screen_view' on 2026-07-26T19:08:09Z when a missing_field drift was accepted (by user uid-7).
- warn: schema_drift_open [prod]
Project 'prod' has 1 untriaged schema drifts (oldest detected 2026-07-28T19:08:09Z): app.screen_view.cart_value (type_changed)
6 checks: 3 pass, 1 warn, 2 fail. Exit 3.
Re-run with --json for the machine-readable form of every finding.
Output is ASCII only and byte-identical whether stdout is a TTY or a pipe, so
tripl doctor | tee incident.log and the terminal view are the same artifact.
Two behaviours are worth knowing before you read a report: a non-200 is never
treated as an empty list (it becomes endpoint_unexpected_status, because a
404 read as "no drifts" is the class of mistake this tool exists to remove), and
the scheduler's retry backoff is reported as expected behaviour rather than
as a hang.
watch answers the other question: not what is broken at one instant, but
what is happening right now. It polls (there is no daemon and no subscription)
and prints one line per change — a replay advancing a chunk, a job finishing, a
signal opening, an alert delivery failing to page anyone. It reaches no
verdict: a completed run exits 0 whatever it saw, and it never exits 3. The
same ASCII-only, pipe-identical rule applies, so tripl watch | tee incident.log is the artifact you actually saw.
2026-07-31T19:10:41Z watch.started 1 project, 1 scan config, poll 10s.
2026-07-31T19:10:51Z job.progress [prod] 'nightly replay' job job-91c2 chunk 4 of 18 (22.2%) collecting 2026-07-05T00:00:00Z..2026-07-06T00:00:00Z, 2m elapsed.
2026-07-31T19:11:11Z delivery.failed [prod] 'Checkout drop' -> slack 'oncall' failed: 'channel_not_found'. Nobody was paged; delivery del-4f21.
2026-07-31T19:11:21Z watch.stopped stopped (interrupted) after 40s, 5 ticks, 12 requests.
Useful flags beyond the shared three: --scan NAME_OR_ID (repeatable, exact
match, narrows the job lines only), --interval SECONDS (default 10),
--duration SECONDS (stop and exit 0; the default is to run until Ctrl-C) and
--stall-after SECONDS (default 120, report a running job whose progress has
not moved).
| Exit | Meaning |
|---|---|
| 0 | Every check passed, or only warned and --strict was not given. status, whenever it completed. watch, whenever the run completed — a failed job or a new signal is still 0. The scans / drifts verbs and annotate, whenever every read arrived or the write was accepted (--dry-run included, and a de-duplicated annotate too). |
| 1 | The tool itself broke (doctor turns every API failure into a finding), or any other command could not complete a request — unreachable, or the API refused it. For watch this includes a key revoked mid-run. For scans list / drifts list it includes any failed read in the fan-out; for scans run, a job returned already failed; for scans cancel / drifts dismiss / drifts reopen, a declined prompt. |
| 2 | Usage or configuration error. For doctor and status that is resolved before any socket opens; watch also refuses after reading the listings, when --scan matches nothing or more than 24 scan configs are selected. The scans / drifts verbs add a bare group, a missing or repeated --project, an unresolved or ambiguous <scan>, and a prompting write on a non-TTY without --yes; annotate adds a --url that is not http(s), an --at that is not RFC 3339, and half a scope. Either way no JSON is emitted and no write is sent. |
| 3 | doctor only: at least one check failed, or --strict and at least one warning. Nothing else ever exits 3. |
check uses 0, 1 and 2: 1 when any call site or event has an error (or, with
--strict, a warning), and 2 for a bad check config or payload file.
codegen uses them too: 1 for drift under --check (or a plan without a
configured event type), 2 for a bad check config or template.
| 130 | Interrupted (SIGINT). For watch this is the normal ending — a run without --duration has no other way to stop. |
An unreachable instance therefore exits 3 out of doctor, not 1 — it
becomes a finding like everything else doctor reads, which is what makes an exit
1 out of doctor a meaningful bug signal. Every other command exits 1 on an
unreachable instance, because none of them turns a failed read into a verdict.
doctor and status put exactly one JSON document on stdout, and so do the
scans / drifts verbs — but only when the command completes: a write the API
refused, a read that failed outright and a declined prompt all leave stdout
empty and put the reason on stderr, so a consumer checks the exit code
before it parses. watch --json
puts JSON Lines, one object per event, flushed as produced. Within one
schema_version, key names are never removed or retyped, and check ids,
finding codes, status/severity values and watch event tokens are never
renamed or repurposed. New keys, ids, codes and tokens may appear in any
release. title, summary and message are prose. Assert on code and
evidence — or, for watch, on event and data — never on prose.
Full reference — every check, every finding code with its evidence keys, every
watch event token and the JSON Lines envelope, and what an operator should
actually do about each one:
https://vladenisov.github.io/tripl/run/cli (source:
website/docs/run/cli.md).
Configuration
Resolved per field, highest precedence first:
- command-line flag —
--url/--base-url,--api-key - environment —
TRIPL_BASE_URL,TRIPL_API_KEY - config file —
base_url,api_key
Per field, not per source: --url https://staging with the key still coming
from the config file works.
| Variable | Meaning |
|---|---|
TRIPL_BASE_URL |
Base URL of the tripl instance (the same variable tripl-mcp reads) |
TRIPL_API_KEY |
API key — tk_r_ for read-only, tk_w_ for write |
XDG_CONFIG_HOME |
Overrides the config file location on every platform |
There is deliberately no TRIPL_URL: two supported spellings for one
setting is how configuration drifts. If it is set and TRIPL_BASE_URL is not,
the error says so by name.
Config file
| Platform | Path |
|---|---|
| Linux / BSD / macOS | $XDG_CONFIG_HOME/tripl/config.toml, else ~/.config/tripl/config.toml |
| Windows | %APPDATA%\tripl\config.toml, else the ~/.config fallback |
# ~/.config/tripl/config.toml
base_url = "https://tripl.example.com"
api_key = "tk_r_..."
TOML because tomllib is stdlib on the supported Pythons, so the file format
costs zero runtime dependencies — which matters, because tripl-mcp
inherits every dependency of this package. Unknown keys and unknown tables are
ignored, so an older CLI keeps working against a file written by a newer one.
--config PATH overrides discovery. A missing default file is fine; a missing
--config path is an error. On POSIX, a config file that holds an api_key and
is readable by other users gets one warning on stderr — chmod 600 it.
Create API keys in the tripl app under Settings → API keys.
Development
cd cli
uv sync
uv run --group dev pytest -q
uv run --group dev ruff check
uv run --group dev ruff format --check
uv run --group dev mypy src
mcp-server resolves this package from ../cli via [tool.uv.sources] rather
than from the published release, so a change here is picked up by
cd mcp-server && uv sync without any install step — and both suites test the
same source. Run both after touching client.py.
Metadata
Release files for tripl 0.2.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tripl-0.2.2.tar.gz | 477.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tripl-0.2.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 834.5 kB
Release files / tripl-0.2.2.tar.gz
| Download URL | tripl-0.2.2.tar.gz |
|---|---|
| Size | 477.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
92849c72f8dad4285715e31822b1697756b2516313a9346ce769d014c68d7d7c
|
|
BLAKE2b-256 checksum How to use checksums |
1ba18aa9b33999617aaf5ad64b056e749ad54a5b11bbc359306fe8b42da6598d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logRelease files / tripl-0.2.2-py3-none-any.whl
| Download URL | tripl-0.2.2-py3-none-any.whl |
|---|---|
| Size | 356.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
afef23b5e8638a432cda490f69f1be47a77039e3c97d5eb15002a739ec9afaad
|
|
BLAKE2b-256 checksum How to use checksums |
beeaedb7404b78b26f4167693928e5cda283d44827c5ab5b4ee94b0983fb611d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency log