This document is the source of truth for the user-facing surface every quantcli *-export-cli should adhere to. Each CLI exists to export personal data from a single upstream service (Cronometer, Liftoff, Withings, …) for use by humans on a terminal and by personal LLM agents calling the CLI as a tool.
The contract exists so that, once you have used one of these CLIs, the others feel like the same tool with a different verb.
Each cell shows whether the section is implemented and how it is attested for that CLI:
- machine — covered by a
compat/bundle that self-tests green inquantcli/common's own CI on every PR. During this partial-adoption window, machine attests that the bundle exists and is green here; the explicit next milestone is the per-exporter consumer-wire PRs that will run the bundle directly in each*-export-cli's own CI. - human — implemented; verified only by review at merge time.
- — — not implemented yet.
| Section | crono-export | liftoff-export | withings-export |
|---|---|---|---|
Repo naming ({service}-export-cli) |
human | human | human |
| Timezone policy | human | human | human |
Date flags (--since / --until) — surface |
machine | machine | machine |
| Date flags — local-midnight semantics | human | human | human |
--format flag surface (documented, rejects unknown) |
human | human | machine |
--format markdown (default) |
human | human | machine |
--format json |
human | human | machine |
--format csv |
— | — | machine |
auth status subcommand |
human | human | human |
prime subcommand |
human | human | human |
Hermetic --help / flag-validation (§7) |
machine | machine | machine |
All implemented sections shipped across all three CLIs (April 25, 2026). The table is per-cell rather than per-row so partial-codec adoption (e.g. crono/liftoff implementing markdown+json but not csv) is visible without losing fidelity. Cells marked "human" are candidates for promotion to machine as the compat library grows and exporters wire it in via compat.Runner.SupportedFormats.
Each CLI lives at github.com/quantcli/{service}-export-cli where {service} is the upstream service in lowercase (crono, liftoff, withings). The binary is {service}-export. Homebrew cask is {service}-export.
Off-by-one-day bugs are the most common timezone failure in tools like these. All three CLIs treat dates the same way to avoid them:
- User-supplied dates parse in
time.Local. Usetime.ParseInLocation("2006-01-02", s, time.Local), never baretime.Parse. Baretime.Parsedefaults to UTC and silently shifts the calendar boundary by the user's offset. - API timestamps that flow into day-bucketing or human-readable output are converted with
.Local()before formatting. Bucketing without local conversion buckets by whatever zone the API returned, which is usually UTC, which is wrong. - JSON output preserves RFC3339 with whatever offset Go's default
time.Timemarshaler emits — usually local. Don't override the marshaler. - CSV / markdown output renders timestamps in the user's local zone.
Every subcommand that selects a window of data accepts:
--since VALUE inclusive lower bound
--until VALUE inclusive upper bound (omit for "now")
VALUE is one of:
| Form | Example | Meaning |
|---|---|---|
| Keyword | today, yesterday |
Local-calendar day |
| Absolute | 2026-04-15 |
Local midnight that calendar day |
| Relative | 7d, 4w, 6m, 1y |
N units before today's local midnight |
Window semantics are half-open [since, until_exclusive) internally, but --until is inclusive of the named day from the user's point of view: the CLI adds 24h to the parsed value before filtering, so --until 2026-04-15 includes all 24 hours of April 15.
Relative durations snap to local midnight, not "this many days before the current instant". --since 30d returns the same window whether you run it at 9am or 11pm.
When neither flag is given, each subcommand picks its own default (typical: 7d for log-style commands, 30d or 90d for sparse commands like workouts/measurements, 1d for dense commands like intraday).
--format markdown default; fitdown-style human-readable
--format json array on stdout, suitable for jq / LLM agents
--format csv spreadsheet-friendly
A single --format flag, not separate --json / --csv flags. markdown is the default because terminals are humans by default; agents pass --format json once.
CLIs MAY implement a subset of these codecs. A CLI MUST implement markdown and json; csv is recommended but optional. The Status table tracks per-CLI implementation per codec, and compat.Runner.SupportedFormats lets the conformance bundle target the declared subset so a partial-codec exporter can adopt the bundle without an immediate --format csv failure. Declaring less than {markdown, json} is non-conforming — markdown is required for human terminal use, json is required for agent/script use, and either alone leaves one of the two use cases unserved.
Output rules:
- stdout: data only, in the requested format.
- stderr: errors and progress messages. Pipelines using
>to redirect stdout do not need2>&1. - Empty result: success with empty output (
[]for JSON, no rows for markdown/CSV), exit code 0. Empty is not an error. - Exit code: 0 success, non-zero only for auth or network failure.
Auth flows differ legitimately across upstreams (env-var basic auth, OAuth2, interactive credential prompts), but two surface elements are required of every CLI:
- An
auth statussubcommand that prints one line summarizing readiness — e.g.logged in as you@example.com (token expires 2026-05-01)ormissing CRONOMETER_PASSWORD. Exit code 0 if usable, non-zero otherwise. - A headless path: where the upstream's auth model permits it, every CLI accepts environment variables that let a fresh container run without an interactive login. The variable names are
{SERVICE}_*(e.g.CRONOMETER_PASSWORD,WITHINGS_REFRESH_TOKEN). Document them inprime.
Where the upstream forbids headless auth (e.g. interactive OAuth consent), auth status still works and the CLI still exposes auth login / auth logout / auth refresh.
Every CLI exposes a prime subcommand that prints a single-screen primer aimed at LLM agents calling the CLI as a tool. Same section structure across all three so an agent that has read one knows where to look in another:
WHAT IT IS one paragraph
I/O CONTRACT stdout/stderr/exit-code/format
AUTH env vars + auth subcommands
DATE FLAGS link to this contract; CLI-specific defaults
SUBCOMMANDS one block per, with output schema sketch
EXAMPLES 3-5 jq recipes for common questions
GOTCHAS non-obvious pitfalls
Prime is short. It is not a man page. If it grows past one terminal screen, something belongs in this contract instead.
A --help invocation and any flag-validation failure that exits non-zero before subcommand work begins must make no network requests. This lets an LLM agent or an offline user introspect the CLI — discover what it does and how to call it — without an authenticated session or a working network. "Before subcommand work" includes flag-parse failures inside subcommands (e.g. liftoff-export workouts list --since lol): cobra parses subcommand flags inside the subcommand layer, but that parse runs before any data fetch, so it is also a §7 case.
The compat/ library pins this down by running the --help path and the flag-parse-failure path under a no-network environment; per-repo wiring of that library is described in §8.
Out of scope: subcommands that do real work (auth status, prime, data subcommands) — by construction they need network access, and this section does not constrain them. --version is not yet specified here; it is a candidate for a future revision once a compat test pins it down. When that revision lands, --version joins this rule — version banners must not dial out (e.g. for update checks) without explicit user opt-in.
Conformance to this contract is verified by compat/, a small black-box Go test library that lives in this repo and is imported by every *-export-cli from its own CI as adoption rolls out per-repo. Today the bundles self-test green in quantcli/common's own CI on every PR; the consumer-wire PRs that import them into each *-export-cli's CI are the next milestone, one per exporter. The current bundles:
compat/dates— pins down §3: that--since/--untilare documented in--help, and that an invalid value exits non-zero with a stderr-only error. The bundle also pins down §7 for both the--helppath and the date-parse-failure path.compat/formats— pins down §4: that--formatis documented, an unknown value exits non-zero with a stderr-only error,--format jsonemits a parseable JSON array,--format csvemits at least a header row, and the default is byte-identical to--format markdown. The bundle additionally pins down §7 for the--formatparse-failure path.
A new exporter is not "in" the family until its CI runs at least the dates bundle green. The formats bundle ships with a Runner.SupportedFormats affordance so partial-codec exporters can adopt it without an immediate --format csv failure: declare the codecs you actually implement and the bundle skips codec-specific subtests for the rest. Each exporter's per-codec cells in the Status table flip from human to machine as it wires the bundle in; codecs not yet implemented stay at — until the writer lands.
Semantic versioning. User-visible bug fix → patch. New subcommand or flag → minor. Removed/renamed flag → major. Releases cut via gh release create against the relevant tag; goreleaser builds binaries for darwin/linux/windows × amd64/arm64 and publishes the cask to quantcli/homebrew-tap.
This contract is a living document. A change here is a change every CLI agrees to make. Open a PR against this repo before changing the surface in any individual CLI.