Read your Pulse Analytics data 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 breakdown <dimension> |
Rank a site's traffic by one dimension |
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 breakdown page --last 7d
ciphera.net · page · 1 Aug – 7 Aug 2026 (UTC)
VALUE VISITORS PAGEVIEWS
/ 1,284 1,901
/pricing 412 498
/blog/opaque-migration 203 211
$ pulse breakdown region --last 30d --limit 5
ciphera.net · region · 1 Aug – 31 Aug 2026 (UTC)
VALUE COUNTRY VISITORS PAGEVIEWS
Brussels BE 340 512
Antwerp BE 118 160breakdown ranks a site's traffic over a range by one dimension — the top pages, referrers,
countries and so on, largest first. It reuses --last/--from/--to and --filter exactly as
stats does, plus --limit (1–100, default 20).
Unlike every other command, breakdown has no privacy floor. Every row comes back with its
real counts, including a row covering fewer than five visitors — there is no — here and nothing
is ever withheld. region rows carry a COUNTRY column, because a region name alone is ambiguous
("Limburg" is a province of both Belgium and the Netherlands); no other dimension does.
Groupable dimensions are a narrower list than filterable ones — some of --filter's dimensions are
too fine-grained to publish as a ranked list, or are free text a visitor typed:
page, entry_page, exit_page, referrer, channel, country, region, browser, os,
device, language, utm_source, utm_medium, utm_campaign.
(city, screen_resolution, timezone, utm_term and utm_content stay filterable but are not
groupable.) An unknown dimension is refused locally, before a request is spent.
A page path, referrer or UTM value is visitor-supplied and can contain anything. Every command's
table and CSV output (not just breakdown's — realtime's top paths go through the same
renderer) escapes any control character, invisible character, or bidi-override/isolate character
as \uXXXX rather than writing it to your terminal raw, and table mode truncates an unusually long
value to keep columns aligned. --csv keeps every value in full (still escaped, and see
Output for the CSV-specific formula guard); --json is the API's own bytes, untouched.
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'A --csv cell whose value would open as a spreadsheet formula — it starts with =, +, -, @,
or a leading tab or carriage return — is written with a leading apostrophe, unless the whole cell
is a plain number (so -5 in a numeric column is untouched): a defence against CSV injection
(CWE-1236) for any visitor-supplied or pasted-in value this CLI exports. --json is never altered
by this or by the control-character escaping above — it is the API's own bytes.
--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.