Read your Pulse analytics from the terminal.
A single static binary with no runtime to install. Read-only, aggregates-only, and it stores your API key in the operating system keychain rather than in a file.
$ 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 24sbrew 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.
Upgrading from v1.1.0 or earlier? The tap moved from a Homebrew formula to a cask in v1.1.1. Homebrew does not switch you across on its own, so
brew upgradewill stop finding new versions. Run this once:brew uninstall pulse && brew install ciphera-net/tap/pulseEverything else is unchanged, Linux included — the cask carries
linux_amd64andlinux_arm64builds and installs on Homebrew for Linux the same way the formula did.
$ 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.
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.
Pulse enforces a minimum cell size. 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.
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.
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.
| (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.
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.
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.
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.
$ 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
agent 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, and the Cellar (or Caskroom) is writable — so replacing the
file in place works, and then brew list --versions pulse describes a binary that no longer exists
and the next brew upgrade quietly reverts you. 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.
Archives are signed with cosign against
cosign.pub in this 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. cosign v3 adds a --signature has been deprecated warning — the verification it
performs is the same.
About that flag. 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 and a release pipeline is
part of the critical path. --insecure-ignore-tlog=true 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 of the flag is cosign's, not a description of the check.
The honest trade-off: 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 this repository.
- 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. A support answer beginning "just point it at…" is one somebody else can also give.
- No plugin system.
--jsonplus a shell is the extension mechanism.
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.