Berserk CLI
Command-line tool for interacting with Berserk
The Berserk CLI provides command-line access for querying data, managing clusters, and automation. It pairs especially well with AI coding agents like Claude Code for investigating production issues.
Installation
curl -fsSL https://go.bzrk.dev | bashThis installs the bzrk binary to ~/.local/bin. You can override the install directory with BZRK_INSTALL_DIR:
BZRK_INSTALL_DIR=/usr/local/bin curl -fsSL https://go.bzrk.dev | bashTo install a specific version:
curl -fsSL https://go.bzrk.dev | bash -s v1.0.15Platform support
The CLI is available for Linux (x86_64 and aarch64) and macOS (Apple Silicon).
Getting Started
The quickest path from a fresh install to your first query:
# 1. Verify the binary
bzrk --help
# 2. Create a profile pointing at your gateway, and make it active.
# --database picks the default database for queries; "default" is
# the one every cluster ships with (optional — this is the default).
bzrk profile add dev \
--endpoint https://berserk.dev.example.com \
--database default
bzrk profile use dev # so the commands below need no -P flag
# 3. Sign in (device-code flow)
bzrk login
# 4. See what's there
bzrk database list # databases (admin-only in v1)
bzrk table list # tables in the active database
# 5. Run a query. Always pass --since (or bound it in the query) — there is no default window.
bzrk search "default | count" --since "24h ago"A few notes on the sequence:
- Profile before login.
bzrk loginsigns in an existing profile, so it has to come after step 2 — there's no zero-config login. With no profile it stops and tells you to create one. profile usesets the active profile once, so the rest of the commands need no-P. Pass-P <name>for a one-off against a different profile — see Resolution order.defaultis the catch-all table every cluster ships with; swap in any name frombzrk table list.
The sections below cover profiles — file layout and resolution — and authentication in detail.
Profiles
Profiles let you store named connection configurations so you can switch between environments (local, dev, production) without remembering endpoint URLs. The storage splits two concerns:
- Endpoints — named URLs. Not secret; safe to share.
- Sessions — credentials (bearer + refresh token + expiry) referencing an endpoint by name.
Configuration file
User configuration lives at ~/.config/bzrk/config.toml (following XDG Base Directory). The file mode is enforced to 0600 — the CLI refuses to read it otherwise and tells you the exact chmod 600 <path> to run. bzrk profile add and bzrk login set the mode automatically.
default_session = "dev" # the active profile, used when no -P is passed
# Endpoint glossary — URLs by name, no secrets.
[endpoints.dev]
url = "https://berserk.dev.example.com"
[endpoints.gw-local]
url = "http://localhost:9500"
# A session pairs credentials with an endpoint, by name.
[sessions.dev]
endpoint = "dev"
bearer_token = "abcdef01…"
refresh_token = "0123456789…"
access_token_expires_at = "2026-05-26T14:00:00Z"
[sessions.gw-local]
endpoint = "gw-local"
bearer_token = "…"
refresh_token = "…"Each session has the following fields:
| Field | Required | Description |
|---|---|---|
endpoint | Yes | The name of an [endpoints.X] entry the session points at. |
bearer_token | No | Access token issued by bzrk login. Sent as Authorization: Bearer … on every request. Auto-refreshed before expiry. |
refresh_token | No | Long-lived token used to mint new access tokens. bzrk logout revokes it server-side and clears it locally. |
access_token_expires_at | No | RFC 3339 timestamp written by bzrk login. The CLI refreshes ~30s before this fires. |
username, database | No | Optional metadata. database sets the default database for queries on this profile (overridable per-invocation with --database). |
Client certificates and custom headers
When a gateway sits behind a mutual-TLS (mTLS) edge — one that requires every client to present an X.509 certificate — attach the certificate and any routing headers to the endpoint. These describe how to reach that URL, so every session pointing at the endpoint inherits them.
[endpoints.secure]
url = "https://gateway.internal.example:443"
# Client identity presented for mTLS (PEM). Both are required together.
client_cert_path = "~/.cache/certs/client.pem" # leaf + signing chain
client_key_path = "~/.cache/certs/client-key.pem"
# Verify the server against this CA instead of the system trust store (PEM, optional).
ca_cert_path = "~/.cache/certs/ca.pem"
# TLS server name (SNI / authority) to verify against, when it differs from the
# endpoint host — e.g. an upstream service identity behind a routing proxy (optional).
tls_server_name = "upstream.internal.example"
# Headers added to every gRPC request to this endpoint — e.g. an edge routing tag.
[endpoints.secure.headers]
"x-routing-tag" = "region/prod"| Endpoint field | Description |
|---|---|
client_cert_path | PEM client certificate (leaf + chain) presented for mTLS. Requires client_key_path and an https:// URL. |
client_key_path | PEM private key for client_cert_path. |
ca_cert_path | PEM CA bundle used to verify the server, instead of the system roots. |
tls_server_name | TLS server name (SNI / authority) to verify against when it differs from the endpoint host. |
headers | Table of Name = "Value" pairs attached to every gRPC request sent to this endpoint. |
Paths may start with ~ or $HOME; the CLI expands them to your home directory. The mTLS fields require an https:// endpoint — the CLI refuses to start otherwise. The certificate is re-read on every invocation, so an externally-rotated certificate is picked up by the next command.
bzrk profile add accepts the same flags and persists them into the new [endpoints.X] entry, so you don't have to hand-edit config.toml:
bzrk profile add secure \
--endpoint https://gateway.internal.example:443 \
--client-cert ~/.cache/certs/client.pem \
--client-key ~/.cache/certs/client-key.pem \
--ca-cert ~/.cache/certs/ca.pem \
--tls-server-name upstream.internal.example \
--header "x-routing-tag: region/prod"Every setting can also be supplied (or overridden) per-invocation with a flag — handy for CI or while testing:
bzrk -P secure search "default | take 10" --since "1h ago" \
--client-cert ~/.cache/certs/client.pem \
--client-key ~/.cache/certs/client-key.pem \
--ca-cert ~/.cache/certs/ca.pem \
--tls-server-name upstream.internal.example \
--header "x-routing-tag: region/prod"Flags win over the endpoint's stored values; --header is repeatable and merges over the endpoint's headers (the flag wins on a name clash).
Managing profiles
# List profiles
bzrk profile list
# Add a profile
bzrk profile add dev --endpoint https://berserk.dev.example.com
# Set the active profile (used when no -P is passed)
bzrk profile use gw-local
# Rename
bzrk profile rename dev-gw dev
# Remove
bzrk profile remove old-envAfter adding a profile, run bzrk login to authenticate against it (or set BZRK_BEARER_TOKEN for unattended use). See Authenticating below.
Resolution order
When you run bzrk, the CLI picks a profile by walking these in order:
- Explicit
--endpoint/--bearer-tokenflags override the resolved profile's URL / token. -P <name>— use that profile.BZRK_SESSIONenv var — same.- Otherwise the active profile (
default_session, set bybzrk profile use). - Default endpoint (
http://localhost:9500) if nothing matches.
Migration
If you previously had ~/.config/bzrk/profiles.toml, the first invocation auto-migrates it to config.toml and renames the original to profiles.toml.bak-<ISO>. The migration is idempotent — once config.toml exists, the legacy reader doesn't fire again.
Authenticating
The CLI talks to Berserk through the gateway service — the auth edge that owns OIDC, sessions, and bearer-token issuance. Every CLI invocation (except bzrk login itself) carries a bearer token; without one you'll see Run 'bzrk login' to authenticate. and exit.
bzrk login — RFC 8628 device-code flow
bzrk login signs in the active profile, so create one first. With no resolvable profile it stops with an error telling you to pass --profile <name> or run bzrk profile use <name> first; there is no zero-config login.
Sign in via the device-code flow:
bzrk login # signs in the active profile
bzrk -P dev login # or target a specific profileThe CLI does three things:
- POSTs to
/api/oauth/deviceon your gateway, getting back a short-liveddevice_code+user_code+verification_uri. - Prints the verification URL + user code on stderr so stdout stays clean for piping, and (on a TTY) tries to open the browser as a soft best-effort.
- Polls the gateway every few seconds until you approve in the browser (or until the device code expires — ~10 minutes).
$ bzrk -P dev login
To authenticate, open https://berserk.example.com/oauth/device and enter:
user_code: ABCD-WXYZ
Waiting for approval…
✓ Signed in as alice@example.com (profile: dev)On success the CLI persists (access_token, refresh_token, expires_at) into the active profile in ~/.config/bzrk/config.toml. Access tokens refresh automatically on every command; you only re-run bzrk login when the refresh token expires or you're switching identities.
Where the verification URL points
verification_uri is derived from the gateway's GATEWAY_PUBLIC_BASE_URL. If
you're running locally with port-forward, set that to http://127.0.0.1:9500
so the URL is reachable from your browser.
bzrk auth status — who am I
Prints the resolved identity from the gateway's GET /api/me:
bzrk auth status
# alice@example.com (user_id 019e41d1-…, session expires 2026-05-26T14:00:00Z)bzrk logout — revoke the token
Revokes the refresh token server-side and clears the access / refresh / expiry fields from the active profile. Idempotent.
bzrk logoutScripted / CI use
For non-interactive use (CI, scheduled jobs), set BZRK_BEARER_TOKEN directly — it overrides whatever's in the active profile:
BZRK_BEARER_TOKEN="…" bzrk search "default | take 10"Provision the bearer token via your secret manager. Service-account-style tokens (issued by a logged-in admin, scoped to a single workload) are tracked as a follow-up.
Search
bzrk search "<KQL>" --since "<TIME>" [--until "<TIME>"]Always pass --since, or bound time in the query (| where timestamp > ago(1h)). There is no default window — a scan with no time bound is a MissingTimeFilter error. --until defaults to now.
bzrk search "default | take 10" --since "1h ago"
bzrk search "default | count" --since "24h ago" --no-streamStreaming snapshots
bzrk search on a TTY (and in --agent mode) prints successive snapshots of the same --since/--until window as more of it is scanned. The requested window does not change.
An early table is not the full-window answer. A running count() or sum() is a lower bound. min(), max(), and avg() on an early snapshot are real numbers for the slices scanned so far, not globals.
| How you run it | What you get |
|---|---|
| TTY | Live table that updates as coverage grows |
--agent (or auto-detected in Claude Code / Codex) | Increment headers plus TSV under ~/.cache/bzrk/history/<id>/; each header line ends with the absolute path of its TSV snapshot |
| Pipe / redirect, not an agent | Waits for the last snapshot |
--json / --csv / --tsv / --no-stream | Last snapshot only |
--no-stream is the flag for scripts and for any caller that must not see a provisional table.
Stopping early with --stop-when
For threshold questions ("are there at least 50 errors?"), let the CLI stop itself the moment a completed snapshot decides the answer:
bzrk search "default | where severity_text == 'ERROR' | summarize n=count()" \
--since "7d ago" --stop-when "n >= 50"Existence (rows >= 1 or rows > 0) on a row-producing query that is not already limited and not an aggregate also appends | take 1, so the engine stops scheduling more work. The result is a single row — enough to answer "does a match exist?" If you want to see the matching rows, run without --stop-when or use a column threshold. Already-limited last operators (take, limit, top) and aggregate last operators (count, summarize, make-series) are left alone, as are pipelines that cannot legally be extended (render, otel-log-stats, a trailing ;). Column thresholds (n >= 50) and rows >= 2 do not inject take 1 — those need the scan until the count crosses.
bzrk search "default | where severity_text == 'ERROR'" \
--since "7d ago" --stop-when "rows >= 1"The predicate is <ident> <op> <number> where <ident> is rows (snapshot row count) or a column name (first row's numeric value), and <op> is one of >=, <=, >, <, ==, !=. Because a column predicate reads only the first row, express "any row crosses X" over a multi-row summarize by sorting that column to the top — | summarize n=count() by service | sort by n desc with --stop-when "n > 1000" stops the moment any single service crosses the line. When it holds, the scan is cancelled, a # Stopped Early header names the predicate and (in agent mode) the deciding snapshot's absolute TSV path, and the run records a completed_early outcome. The result is a partial lower bound over the requested window, not the full-window value — exactly what a threshold question needs. Predicates a partial cannot decide (absence, exact min/max/count over the whole window) still need the full run.
For conditions the predicate grammar cannot express, --stop-cmd runs a shell command against each completed snapshot's TSV instead (unix only, mutually exclusive with --stop-when). The command runs via sh -c with the snapshot's absolute TSV path as $1 (also exported as BZRK_SNAPSHOT_TSV, with BZRK_INCREMENT and BZRK_ROWS), and exit codes follow grep: 0 stops the query, 1 keeps scanning, anything else aborts the run as a broken condition.
# Stop as soon as any snapshot mentions OOMKilled
bzrk search "default | where severity_text == 'ERROR' | take 500" \
--since "24h ago" --stop-cmd 'grep -q OOMKilled "$1"'
# Stop once 50 data rows (excluding the header) have arrived
bzrk search "default | where severity_text == 'ERROR' | take 500" \
--since "24h ago" --stop-cmd '[ "$(tail -n +2 "$1" | wc -l)" -ge 50 ]'
# Stop when column 2 of any row crosses 0.99
bzrk search "default | summarize p = max(ratio) by service" \
--since "6h ago" --stop-cmd 'awk -F"\t" "NR>1 && \$2>0.99 {exit 0} END {exit 1}" "$1"'Agents
Do not treat the first increment as the answer. For threshold, existence (rows >= 1), and newest-N (rows >= N) questions, pass --stop-when; for string matches or multi-column conditions, pass --stop-cmd — both run in the foreground with no monitoring loop. Only when the stop decision needs judgment (reading the data, not testing it) monitor the increment headers yourself and stop the query when satisfied; wait for the last snapshot only when a partial cannot decide the question. See AI Agent Setup.