statable
Statable analytics from the command line: a terminal, a script, or CI.
Every endpoint of the Stats API v1 — all thirty-five of them, reading and writing. A test fails if the API grows an endpoint the CLI has no command for.
statable reading a live site: visitors now, a month against the one before it, a weekly series, the top pages, a CI threshold check, and JSON through jq
Real output from the public demo site,
statable.com/share/03D3Cfb9eA. Rebuild
the recording with asciinema rec -c demo/demo.sh demo/demo.cast and
agg demo/demo.cast demo/statable.gif.
Install
macOS and Linux, with Homebrew
brew install key-arg/tap/statable
Anywhere with a Go toolchain
go install github.com/key-arg/statable-cli/cmd/statable@latest
Windows, with winget
winget install KeyArg.Statable
Windows, with Scoop
scoop bucket add statable https://github.com/key-arg/scoop-bucket
scoop install statable
mise
mise use -g "ubi:key-arg/statable-cli[exe=statable]"
Docker
docker run --rm -e STATABLE_API_KEY docker.io/statable/statable query \
--metric visitors,pageviews --range 7d
The image is linux/amd64 and linux/arm64, built on distroless from the same
binary the release signs, and it carries nothing else: no shell, no package
manager. There is no keyring inside a container and no home directory to write
to, so the key comes from STATABLE_API_KEY and the CLI does not prompt.
The exe is not optional. ubi looks inside the archive for a file named after
the project, and the binary is statable rather than statable-cli; without
it the install fails with "could not find any files matching [statable-cli*]".
By hand. Download the archive for your platform from the
releases page and put the
binary on your PATH. Each release also carries checksums.txt, signed
keylessly, so you can check what you downloaded against who built it:
cosign verify-blob checksums.txt \
--certificate checksums.txt.pem \
--signature checksums.txt.sig \
--certificate-identity-regexp 'https://github\.com/key-arg/statable-cli/\.github/workflows/.+' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
sha256sum --check checksums.txt --ignore-missing
Arch (not yet). An AUR package is built by the release pipeline and will be
published as statable-bin, but it does not exist yet: AUR account
registration is closed while they deal with a wave of automated signups, so
there is no maintainer account to publish it from. Until then, Arch and Omarchy
users want go install or the archive above. This line will say yay -S statable-bin when that is true and not before.
There is deliberately no curl … | sh installer. Piping a URL into a shell
asks you to run whatever the server sends today, and it is the single loudest
complaint the Omarchy project has had to answer for.
Completions and man pages
The Homebrew cask and the AUR package install both. Installing by hand, they
are in the archive under completions/ and manpages/. go install builds
only the binary, so generate the completion yourself:
statable completion zsh > "${fpath[1]}/_statable"
statable completion bash > /etc/bash_completion.d/statable
statable completion fish > ~/.config/fish/completions/statable.fish
Use
statable auth login # prompts on a terminal, prints instructions anywhere else
statable sites # what this key can read
statable sites use example.com # remember a default
statable now # visitors active right now
statable stats --range 30d --compare previous
statable series --by day --range 30d
statable top pages
statable top countries --range month
top takes a short name (pages, sources, countries, browsers, goals, …)
or a full dimension such as visit:utm_campaign.
Two of those dimensions return no comparison at all, whatever --compare says:
codes and goals. The rows come back without previous figures rather than
with zeroes.
The rest of the surface:
statable props # custom property keys, and the event each belongs to
statable goals # the conversion goals defined on the site
statable snippet # the install tag, ready to paste
statable funnels # saved funnel definitions
statable funnel 45 --range 7d # run one, step by step
statable subscription # the plan state of the key's owner
goals lists the definitions; top goals reports how they performed. Both
goals and snippet are served from behind the API's write guard even though
reading them needs only the read scope, so on a deployment with writes switched
off they answer write_disabled — the command says that rather than letting a
404 read as a missing site.
Changing things
The CLI covers the whole API, not only the reading half.
statable sites create https://example.com
statable sites edit --timezone Europe/Kyiv
statable goals create --name Signup --event Signup
statable funnels create --name Checkout --step page:/pricing --step event:Signup
statable keys create ci --scope read
statable settings set countries --block RU --block BY
Three rules apply to everything that writes.
Deletes and rotations ask first. Without a terminal they need --yes
rather than waiting on a question nobody can answer, which is the same rule
this program follows everywhere about input.
PUT means replace. Every settings set and every edit replaces the whole
thing; read the current value first if you mean to add to it. An empty
replacement has to be spelled --clear, because doing it by accident empties a
blocklist.
A minted secret is shown once. keys create and keys rotate print it
alone, so --json | jq -r .token captures it. The server never returns it
again.
A funnel's conversion rate is cumulative: it is measured against everyone who entered, not against the step before. Dropoff is the opposite and counts against the previous step alone.
For CI:
statable check --metric visitors --range 7d --min 100
statable check --metric bounce_rate --max 60
The same two checks in a pipeline that has Docker but no Go toolchain:
docker run --rm -e STATABLE_API_KEY docker.io/statable/statable \
check --metric visitors --range 7d --min 100
The exit code is the answer: 0 within bounds, 3 outside them, and the usual 1 or 2 when the check could not run at all. That third code is the point — a pipeline can tell "traffic dropped" apart from "the tool is broken".
For anything the short commands do not cover:
statable query --metric visitors,pageviews --range 7d
statable query --metric visitors --dimension time:day --filter country=US
statable query --metric visitors --dimension event:props:plan --filter event=Signup
The escape hatch reaches every endpoint, including ones no command wraps, and prints the response exactly as the server sent it:
statable api call GET /sites
statable api call POST /query --var site_id=123 --var metrics='["visitors"]' --var date_range=7d
--var values are parsed as JSON, so --var limit=25 sends a number and
--var metrics='["visitors"]' sends an array. --raw-var always sends a
string, which is how you send the literal text 25. Guessing between the two
is the ambiguity these flags exist to remove. --allow-errors prints the body
of a 4xx or 5xx instead of refusing to show it, while the exit code still tells
the truth.
The CLI can also teach its own surface, reading the API's own description:
statable api search # a table of contents
statable api search funnels
statable api describe /query # what it takes, required fields first
That document is cached for a day, so exploring costs nothing against the hourly budget.
Filters read like the question they ask. = is, != is not, ~ contains,
!~ does not contain. Commas inside one filter are OR; separate --filter
flags are AND, which is exactly how the API models them.
statable top pages --filter country=US,DE --filter page~/blog
Design rules
These are enforced by tests, not by convention. Breaking one is a release blocker.
Print and exit. There is no full-screen interface. If a capability existed only inside a TUI, it would be a bug for every CI job, every pipe and every agent.
Machine output cannot be corrupted. When --format is json or csv, the
writer that human-facing text goes to is io.Discard. A forgotten Println
writes nothing rather than breaking a payload.
stdout is the payload. Notes, warnings and progress go to stderr in every
format, so statable ... --json | jq is always valid input. Help is prose, so
in a machine format it goes to stderr too, and a bare invocation is a usage
error rather than help on stdout and a success code.
A chart is a reading aid, not data. The sparkline in series appears only
on a terminal, for the same reason the table header does: in a pipe it would be
a last line no parser expects. The scale starts at zero, so a flat week looks
flat rather than dramatic.
Geographic breakdowns answer twice. A person sees "United States"; a script
gets US, because the code is exactly what the matching filter accepts, so a
breakdown value drops straight into the next query.
Every format carries the same content. A command never reaches for JSON
directly; it emits a record or a table and the writer encodes it. Asking for
CSV and silently receiving JSON is a broken contract for anything piping into
cut.
Two things deliberately sit outside the payload. A failure is always a JSON envelope in a machine format, because an error is not tabular. And facts about how to read the numbers — the period the server resolved, that a window overlaps imported data, that more rows exist — go to stderr in every format, because they are not rows and would not survive being made into one.
Nothing blocks where nobody can answer. How the process was invoked is
resolved once, and every interactivity decision is a pure function of that.
Without a terminal, auth login prints what to do next and exits 1 instead of
waiting for input forever.
Exit codes mean something.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Action required; next_steps names the fix |
| 2 | Failure |
| 3 | statable check ran and the metric was outside its bounds |
| 64 | The command line itself was malformed |
| 130 | Interrupted |
Code 64 covers every mistake caught before a request exists: an unknown metric, a filter with no operator, a range that is not a range, a limit outside what the API accepts. Nothing was sent anywhere, so repeating the command unchanged will fail identically. That is the distinction 2 exists to make impossible to confuse: a typo is not an outage.
Errors are branchable. Failures carry a stable code. Prose may be
reworded at any time; codes may not.
{
"status": "action_required",
"error": "no API key is configured",
"issues": [{"code": "NOT_AUTHENTICATED", "message": "no API key is configured"}],
"next_steps": ["statable auth login"]
}
The site listing is cached, authenticated, and repairs itself. The API needs a numeric
site id and people name sites by domain, so every reading command used to list
the sites first and spend two calls an hour where one would do. The listing is
kept for a day in a 0600 file, carrying a MAC computed under the key and the
server address. That is not decoration: the file maps a site name to a site
id, so anything that could rewrite it could point a name at someone else's
property and have the command report on it with exit 0 and no warning. A value
merely derived from the key would sit in the same file and could be copied
across; a MAC cannot be produced without the key, so a rewritten file is simply
a miss. Nothing is cached at all when there is no home directory, because the
fallback location is world-writable.
When the server disagrees with a cached id — a site renamed or recreated — the
cache is dropped, the listing refreshed and the call retried once. statable sites always asks the server, which makes it the repair anyone can run by
hand.
Server text can never forge a line. A note carries a site name, a funnel name, a path — text this program did not write. Notes and warnings sanitise what they print, so a funnel named with an embedded newline cannot add a second line an operator would read as real, and an escape sequence in a name cannot colour the rest of the session.
Credentials never degrade silently. The key goes to the system keyring. If
the keyring is unavailable, the command refuses and names --insecure-storage
rather than quietly writing the key in the clear. statable auth status always
reports which source the active key came from.
On Windows the keyring is the Credential Manager, which does protect the key.
The --insecure-storage file does not: Windows maps a file mode onto the
read-only attribute and nothing else, so a file written with 0600 is readable
by every account on the machine. The command says so when it writes one, rather
than leaving you to believe in permissions the operating system ignores.
Resolution order: --key, then STATABLE_API_KEY, then the keyring, then a
0600 file. The credential is resolved on first use, so statable version
never waits on a locked keyring, and every keyring call is bounded and
cancellable.
The environment variable is suggested before --key everywhere, because a key
on the command line is visible in ps and in shell history, and the headless
path is exactly where that matters.
A project file cannot redirect the API. .statable.yml may set site and
period — the default range when --range is not given — and nothing else. It lives in a repository, so it is written by
whoever can open a pull request; a file that could set the API host or a token
would be a supply-chain hole. Refused keys are reported on stderr rather than
ignored.
Environment
| Variable | Effect |
|---|---|
STATABLE_API_KEY | The key to use |
STATABLE_API_URL | Override the API base URL. Environment only, never the project file |
STATABLE_CONFIG_DIR | Where credentials and settings.json live. Defaults to $XDG_CONFIG_HOME/statable |
STATABLE_FORMAT | Default output format |
STATABLE_AGENT | Declare an agent harness so the CLI never prompts |
NO_COLOR, FORCE_COLOR, CLICOLOR, CLICOLOR_FORCE | Standard colour control |
STATABLE_NO_INPUT | Never prompt, the same as --no-input |
BROWSER | Consulted when deciding whether a URL could reach a human |
Develop
go test ./...
go test -race ./...
The truth table in internal/execctx checks all 768 combinations of format and
invocation state on every run. If a change makes the CLI prompt in CI or emit
prose into a JSON payload, that suite fails.
Many of the other tests exist because they caught something, and those name the defect they guard against in their comment, so a change that reintroduces it fails with an explanation rather than a diff.
The suite is checked by mutation: the source is deliberately broken in a few
dozen places and the suite has to notice. Three rounds of that have found
tests that could not fail — a keyring test whose fake ignored the delay it set
up, a truth table with no positive case that an all-false implementation
satisfied, a timing assertion that no real machine could ever trip.
After a release
The command reference at statable.com/docs/cli/commands
is generated from the command tree, not written. Regenerate it from the tag
people can install, not from main:
git checkout v0.3.0
go run ./cmd/gen-docs \
-web ../statable.com-docs/docs/cli/commands.md \
-manifest ../statable.com-docs/hooks/cli-commands.json \
-version v0.3.0 "$(mktemp -d)"
The docs build refuses a page that disagrees with the manifest written beside it, so a section lost by hand fails there rather than going live.
Release notes are in CHANGELOG.md. To report a vulnerability, see SECURITY.md.
Statable
Docs · Stats API · Blog · Status
Cookieless web analytics hosted in the EU, by Key Arg B.V. Privacy: statable.com/privacy · Terms: statable.com/terms