CLI
Read your Pulse analytics from the terminal.
pulse is a single static binary that reads your analytics from the command line. It talks to the same public read API as everything else in this section — read-only, aggregates-only — so anything true of the API is true here too, including the n≥5 privacy floor.
It is public and open source: ciphera-net/pulse-cli, Apache-2.0.
$ pulse stats --last 7d
ciphera.net · 1 Aug – 7 Aug 2026 (UTC)
Visitors 1,284
Pageviews 3,401
Bounce rate 62.4%
Avg duration 1m 47s
Avg scroll depth 59.7%
Avg visible time 24sInstall
brew install ciphera-net/tap/pulse # macOS and Linux
go install github.com/ciphera-net/pulse-cli/cmd/pulse@latestOr download a signed archive from releases — macOS, Linux and Windows, amd64 and arm64. See Verifying a release.
Warning
Upgrading from v1.1.0 or earlier? The tap moved from a Homebrew formula to a cask in v1.1.1, and Homebrew does not switch you across on its own — brew upgrade will stop finding new versions. Run brew uninstall pulse && brew install ciphera-net/tap/pulse once. Everything else is unchanged, Linux included.
Getting started
Create a key first — see Creating a key on the API page. Then:
$ pulse auth login
Paste your API key (create one at Settings → Organization → API Keys):
› ••••••••••••••••••••••••••••••••••••
✓ Stored key "production" (…chju) in the macOS Keychain.
Organization 2c1f74ec-… · all sites · expires 2026-11-05 (89 days)
$ pulse sites ls
SLUG DOMAIN TIMEZONE LAST EVENT
ciphera-net ciphera.net UTC 2 min ago
id-ciphera-net id.ciphera.net Europe/Brussels 1 hr ago
pulse-ciphera-net pulse.ciphera.net UTC 4 min ago
$ pulse sites use ciphera.net
✓ Default site set to ciphera.net.sites use accepts a slug, a domain, or an id — whichever you remember.
Commands
pulse auth login · logout · status | Manage the stored key |
pulse sites ls · use <site> | List sites, set the default |
pulse stats | Aggregate metrics over a range |
pulse realtime | Visitors active right now |
pulse export daily · pages | Bulk CSV or JSON |
pulse upgrade [--check] | Install the newest release |
Every command takes --site to override the default and --profile to switch between stored keys.
Reading a withheld result
The CLI inherits the API's n≥5 privacy floor. Any filter engages it, and a slice covering fewer than five visitors is withheld — including a genuine zero.
$ pulse stats --last 7d --filter country==BE
ciphera.net · 1 Aug – 7 Aug 2026 (UTC)
Visitors —
Pageviews —
...
Withheld: this slice covers fewer than 5 visitors, so every metric is reported as —.
That means "fewer than 5, possibly none" — it does not mean zero.— is not zero and not an error. A one-visitor slice and an empty slice return byte-identical responses, on purpose: if "withheld" meant "between 1 and 4" while a real zero came back as 0, walking a dimension's values would reveal exactly which ones have a live cohort.
In --json the metrics are null and meta.suppressed is true. Never render that as 0.
Filtering
Repeatable, one flag per constraint:
pulse stats --last 30d --filter country==BE
pulse stats --last 30d --filter country==BE --filter country==NL # either country
pulse stats --last 30d --filter country==BE --filter browser!=FirefoxRepeating one dimension widens the query (OR); different dimensions narrow it (AND). A key may combine at most two dimensions — a third is refused, because narrowing that far describes individuals rather than populations. The CLI catches it before spending a request.
Available dimensions: page, referrer, channel, country, region, city, browser, os, device, screen_resolution, language, timezone, utm_source, utm_medium, utm_campaign, utm_term, utm_content.
realtime accepts no filters, permanently: a filtered five-minute window describes one person's current session.
Ranges
pulse stats --last 7d # 7d · 30d · month · year
pulse stats --from 2026-08-01 --to 2026-08-07Relative periods are resolved by the server, in the site's timezone, and the CLI prints the range the server actually queried. That is why --last today is refused rather than guessed: the dashboard knows more periods than the API publishes, and every published one is a 24-month commitment.
export needs explicit dates, so pulse export daily --last 7d asks the server to resolve the period first (one extra quota unit) rather than computing it against your own clock.
Output
| (default) | Aligned table for reading |
--json | Exactly the API response, unmodified — pipe it to jq |
--csv | Spreadsheet-friendly |
Colour, progress and warnings go to stderr, and colour turns itself off when stdout is not a terminal. So this is always clean:
pulse export daily --last 30d > month.csv
pulse stats --last 7d --json | jq '.data.visitors'--json returns the API's own bytes rather than a re-encoding. That keeps the CLI usable as a debugging tool for the API, and stops it from becoming a second, subtly different contract — v1 is additive-only, so an older CLI meeting a newer field is expected, and passthrough means the field still reaches you.
Exit codes
0 | Success | |
1 | Unexpected or server error | server_error |
2 | Bad usage | invalid_request |
3 | Not authenticated | unauthorized |
4 | Not found or out of scope | not_found |
5 | Rate limited | rate_limited |
Locally-detected failures use the same code the API would have: a missing credential is 3 whether the CLI noticed or the server did.
pulse auth status >/dev/null 2>&1 || { [ $? -eq 3 ] && pulse auth login; }On a 429 the CLI honours Retry-After and retries once, noting it on stderr. Only a second failure exits 5.
Output that could not be written is a failure, not a success. A full disk, a quota, or a ulimit -f cap exits 1 and names the stream and the reason on stderr, so pulse export daily > week.csv can never leave you a truncated file and a 0. A closed pipe is not a failure — pulse sites ls | head -3 is the pipeline working, and it stays quiet.
Credentials
pulse auth login writes to the macOS Keychain, libsecret (Linux), or the Windows Credential Manager. This tool never writes a key to a file — not as a fallback, not on a keychain error.
Warning
For CI, where no keychain exists, export PULSE_API_KEY. It takes precedence over the keychain, so an explicit export always wins, and pulse auth status tells you which one is in use.
~/.config/pulse/config.toml holds preferences only — default site, profiles. Never a credential.
Upgrading
$ pulse upgrade --check
Update available: v1.0.0 → v1.1.0
https://github.com/ciphera-net/pulse-cli/releases/tag/v1.1.0
Run `pulse upgrade` to install it.
$ pulse upgrade
Downloading pulse_1.1.0_darwin_arm64.tar.gz (2.8 MB)…
Verifying the release signature…
✓ pulse v1.1.0 installed at /usr/local/bin/pulse.The archive comes from GitHub's public release feed: no API key is sent, and no Pulse quota is spent. Its cosign signature is checked against the key compiled into the binary you are already running — not one fetched at the same time as the archive — and a signature that does not verify means nothing is written at all.
--check exits 0 whether or not an update exists — it is meant for a cron job, a Makefile or an unattended loop, and all of those treat a non-zero exit as something to escalate. An available upgrade is news, not a failure. Only a check that could not be performed exits non-zero. --json gives it to you as an object:
$ pulse upgrade --check --json
{"current":"v1.0.0","latest":"v1.1.0","update_available":true,"install_method":"binary",
"path":"/usr/local/bin/pulse","release_url":"…","action":"checked"}A pulse installed by a package manager is left alone. Homebrew and go install each keep their own record of what version they put there, so pulse upgrade detects both Homebrew layouts, cask and formula, and prints what to run instead:
$ pulse upgrade
pulse v1.0.0 → v1.1.0 is available; installed via Homebrew, so run: brew upgrade pulseIf the install directory is not writable, the command says so, names the path, and stops. It never uses sudo — and never suggests you do.
Verifying a release
Archives are signed with cosign against cosign.pub in the repository.
cosign verify-blob \
--key https://raw.githubusercontent.com/ciphera-net/pulse-cli/main/cosign.pub \
--signature pulse_1.0.0_darwin_arm64.tar.gz.sig \
--insecure-ignore-tlog=true \
pulse_1.0.0_darwin_arm64.tar.gzchecksums.txt is published alongside and is itself signed.
Works on both current cosign majors. Releases are signed with cosign v2.4.1, and the command above was checked against v2.4.1 and v3.1.2: both print Verified OK for a good archive and both fail on a modified one.
Note
About --insecure-ignore-tlog. We sign with our own key and do not publish to the Sigstore transparency log, which is operated in the US — Ciphera's stack is deliberately EU/CH-based. The flag tells cosign not to look for a log entry that was never created; the signature is still checked against our published key, and a modified archive still fails. The name is cosign's, not a description of the check. Without a transparency log there is no public append-only record of everything this key has signed, so verification rests on trusting cosign.pub from the repository.
What the CLI deliberately does not do
- No write operations. The API is read-only and the CLI must not imply otherwise.
- No live tail.
pulse realtimereturns a count, once. Streaming reads as per-visitor tracking, which is the line Pulse does not cross. - No configurable API host.
- No plugin system.
--jsonplus a shell is the extension mechanism.
Licence
Apache-2.0. © Ciphera BV.
Wire types live in ciphera-net/pulse-api-go, shared with the server so a contract mismatch is a compile error.