@bitskiff/cli

bitskiff is the management surface for a Bitskiff project (docs/design/cli.md 1). Install it, point it at a project, and do everything the console will eventually do: create and rotate keys, read and change limits and settings, list and close sessions, and read usage and the audit log. It is not a host—it opens no session, joins none, holds no seat, and speaks no transport; it calls the control plane over HTTP and exits.

Install

npm i -g @bitskiff/cli

The verbs below work against a running api—point bitskiff at any endpoint with --endpoint.

Sign in

The credential is an admin key, mrc_ak_..., organization-scoped with an optional project allowlist (cli.md 2.1). bitskiff login reads it from stdin, or prompts without echo on a TTY—never as an argument, because a key in --key is a key in the shell history and in ps.

$ bitskiff login <<< "$MRC_ADMIN_KEY"
bitskiff: stored mrc_ak_a7k2m9xq...jy2k in the config file and verified it against proj_7f3

With no project resolved yet, bitskiff login stores the key and says plainly that it did not verify it, on stderr:

bitskiff: stored mrc_ak_a7k2m9xq...jy2k in the config file; it was not verified, because no project is resolved

MRC_ADMIN_KEY overrides the stored credential, which is what lets CI run without a login step (cli.md 2.2, D-780). Project scope resolves the same way, highest rank first: --project proj_... on the command, MRC_PROJECT, or the config file --

bitskiff config set project proj_7f3

bitskiff config set endpoint <url> and bitskiff config get [field] work the same way and mask adminKey like everywhere else; there is no bitskiff config set admin-key—storing a credential is always bitskiff login.

First commands

bitskiff whoami

Prints which endpoint, project and credential this shell resolved and from which rank, then what GET /v1/whoami answers (cli.md 2.4). The rows are the answer this verb promises, so they print first and always; the api half is best effort—an unset endpoint or a call that fails prints one line saying so instead of the body, and the command still exits 0:

$ bitskiff whoami
endpoint    https://api.example.test  (MRC_ENDPOINT)
project     proj_7f3  (MRC_PROJECT)
org         org_1  (MRC_ORG)
credential  mrc_ak_a7k2m9xq...jy2k  (MRC_ADMIN_KEY)

engineUrl  wss://engine.example
region     eu
env        production
apiId      a_eu

With no endpoint resolved, or a route the api side has not built yet:

$ bitskiff whoami
endpoint    (not set)
project     (not set)
org         (not set)
credential  (not set)

bitskiff: no endpoint resolved, so there was nothing to call

whoami does not yet echo the caller's own identity (the organization or roles behind the key): that block waits on api-identity-routes and is not built.

bitskiff sessions list

$ bitskiff sessions list
{"sessions":[{"sessionId":"s_1","state":"live","participants":[]}],"cursor":"c2"}
bitskiff: cursor: c2

The next page's cursor prints on stderr, never mixed into the answer on stdout; --cursor c2 starts there, and --all follows every page to the end.

bitskiff keys create

$ bitskiff keys create --type publishable --label demo --origin https://a.example \
    --origin https://b.example --quota concurrent_sessions=10 \
    --quota session_opens_per_minute=5
{"keyId":"key_1","type":"publishable","orgId":"org_1","projectId":"proj_7f3","projects":[],
 "masked":"mrc_pk_a7k2m9xq...jy2k","secret":"mrc_pk_a7k2m9xqw4tz8bn3p6rs5vd1fg0hjy2k",
 "status":"active","label":"demo","origins":[],"permissions":[], ...}
bitskiff: this key string will not be shown again (credentials.md 3.1)

(Reformatted here for legibility—the CLI prints one line.) The full key string is on stdout exactly once, in the create and rotate responses; every other verb prints the masked form, mrc_pk_a7k2m9xq...jy2k.

A secret key takes a permission list or a preset, never both: bitskiff keys create --type secret --preset host-only for a device that only hosts, or repeated --permission sessions:open --permission usage:read to name the list yourself (cli.md 3.2). Any key takes --limit <row>=<value> repeated and --expires 90d (a duration, a time, or epoch ms); a blank row inherits the project's value and no --expires takes the route's default (credentials.md 3). bitskiff keys revoke <keyId> --reason manual|compromise is required and there is no default—manual drains (every session it opened keeps running until it ends on its own) and compromise cuts (every session it opened ends now); on a TTY, leaving --reason off asks instead of refusing.

bitskiff tokens

$ BITSKIFF_SECRET_KEY=mrc_sk_... bitskiff tokens mint --subject user_42 --ttl 120
{"tokenId":"tok_9f2","token":"mrc_at_a7k2m9xqw4tz8bn3p6rs5vd1fg0hjy2k","expiresAt":1789000000000}
bitskiff: this token will not be shown again (credentials.md 4)

$ BITSKIFF_SECRET_KEY=mrc_sk_... bitskiff tokens list --subject user_42
{"tokens":[{"tokenId":"tok_9f2","subject":"user_42","permissions":["sessions:open"]}]}

$ BITSKIFF_SECRET_KEY=mrc_sk_... bitskiff tokens revoke tok_9f2
bitskiff: the sessions this token opened keep running (credentials.md 4)

mint takes --permission (the session words: sessions:open, sessions:read, sessions:manage, sessions:join; the default is sessions:open), --limit <row>=<value> repeated, --ttl and, on a sessions:join token, --session <sessionId> to bind it to one session. list filters by --subject or --key and never carries a clear string. revoke ends one token by id; the sessions it opened keep running, which is bitskiff sessions work.

This is the one verb family that does not send the stored organization key: the routes take a secret key holding tokens:mint, so they read BITSKIFF_SECRET_KEY from the environment and refuse naming it when that is not set. It is a developer's smoke test of the mint (credentials.md 4), not the integration, which is the same REST call made from the developer's own backend.

bitskiff limits

The bare noun is the read verb—there is no bitskiff limits get (cli.md 3.3):

$ bitskiff limits
{"maxConcurrentSessions":5,"concurrentSessions":1,"bytesPerSecond":16384}

bitskiff usage

bitskiff usage --month 2026-02 asks GET /v1/project/usage for that whole UTC month; with no --month, no --from and no --to, the window defaults to the current UTC month. --from/--to (with --by day|month) work the same way as --month.

$ bitskiff usage --month 2026-02 --by month
{"rows":[{"bucketStart":"1769904000000","meters":{"peak.participants":4,"session_ms.attached":600000}}]}

The route serves the frozen usage_daily and usage_monthly tables (metering.md 4.2, 8), so a day appears once its usage-daily job has run and an empty answer means nothing is frozen for that range yet. --live polls GET /v1/project/limits every 5 seconds for the current counters instead of a historical query.

Output and exit codes

stdout is the answer and nothing else; progress, notices and every error go to stderr. Format follows the destination—a table on a TTY, JSON otherwise—and --format=json|table overrides either way. Under JSON, the api's response body is printed byte for byte: no envelope, no renamed fields (cli.md 4).

The exit code is a function of the error registry's http column, condensed to one class per code (cli.md 5):

ExitClass
0success
1unclassified failure
2usage error (bad flag, unresolved project or endpoint)—nothing was sent
3unauthenticated (401)
4forbidden (403)
5not found (404)
6conflict or gone (409, 410)
7limited (429)—the only class worth retrying, and only when the body's retryable is true
8invalid request (400, 426)
9the platform or the network failed (500, 503, transport)

Above 9 is the shell's: 128 + signal, and 130 for SIGINT, which is how sessions tail and usage --live exit when a person stops them. A limit refusal prints the catalogue id and how to raise it:

bitskiff: Refused: concurrent_sessions (project proj_7f3) is at 25 of 25.
  retry after 12s  (Retry-After: 12)
  raise it with: bitskiff limits set concurrent_sessions <value> --project proj_7f3
  requestId req_9k2m4x

Everything else

The full verb table, the config file's precedence rules, paging, id and key formatting, and bitskiff sessions tail's poll contract are docs/design/cli.md.