Skip to main content

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:

  1. command-line flag — --url / --base-url, --api-key
  2. environment — TRIPL_BASE_URL, TRIPL_API_KEY
  3. 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.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tripl 0.2.3
File Size Uploaded
tripl-0.2.3.tar.gz 477.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tripl 0.2.3
File Interpreter ABI Platform
tripl-0.2.3-py3-none-any.whl Python 3 none any Details

Total release size: 834.6 kB

Release files / tripl-0.2.3.tar.gz

Download URL tripl-0.2.3.tar.gz
Size 477.8 kB
Tags Source
SHA-256 checksum
How to use checksums
0830fba3db6035e8392c8b67d546dd130f76f81911f4da23b0b160eb3f970977
BLAKE2b-256 checksum
How to use checksums
d9faa33df8f22cad4fb8fde14b3244b8bde196d8948d74c5cfe54f2437e3f352
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

Release files / tripl-0.2.3-py3-none-any.whl

Download URL tripl-0.2.3-py3-none-any.whl
Size 356.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3a4ef1f2ae4506764c874919c6d3b44447669287c04b2494de2c78c79edaeafe
BLAKE2b-256 checksum
How to use checksums
c72d2f78fd36dd3e25d404331cb00c685c84bec32bd846d36962f6173355b358
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

Release history Release notifications | RSS feed

This release

0.2.3 This release

2 release files

0.2.2

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page