A local daemon and CLI. It periodically checks subscription usage for Claude, ChatGPT, Grok, Cursor, OpenCode Go, Devin, codex2api, and sub2api, and gathers the remaining quota and reset time of every window into one terminal view.
When you subscribe to several AI coding tools, each one shows usage in its own web dashboard, with different windows (5 hours, weekly, monthly) and units (percent, tokens, dollars). With Ullage, one command tells you how much is left, when it comes back, and which account is about to run out.
💡 Credentials stay in the platform credential store by default, and the configuration never contains secrets. By default the daemon talks only over a private local control channel and listens on no TCP port.
The usual way:
Sign in to each provider's dashboard one by one. Check the 5-hour and weekly quota for Claude, the weekly quota for ChatGPT, the monthly quota and dollar spend for Cursor. Repeat for every account.
With Ullage:
One box per subscription. Each row is one window: the remaining share, the time until reset, and a ten-cell bar. An exhausted window shows used up.
- Eight providers, multiple accounts: add several accounts for the same provider; credentials and snapshots are isolated per account.
- Background probing: the daemon queries each account on a schedule (every 300 seconds by default).
showandtuiread saved snapshots and never call the provider. - Terminal board:
ullage tuishows bars and reset countdowns. As the terminal narrows, fields drop in order, so a phone-sized terminal still shows when quota comes back. - Credential safety: credentials live in macOS Keychain, Windows Credential Manager, or Linux Secret Service. Account labels, authorization URLs, and similar personal values are redacted by default.
- Scriptable: JSON output, plus an optional HTTP API that clients reach with a device token after a one-time pairing code.
The command is ullage. The crates.io package name is ullage-cli.
Homebrew (macOS and Linux)
brew install dualface/tap/ullageLinux needs Homebrew on Linux first.
The formula installs the GitHub Release binary for the current OS and CPU,
then runs ullage daemon install and ullage daemon start.
WinGet (Windows)
winget install Dualface.UllageThe installer copies ullage.exe to %LOCALAPPDATA%\Ullage, adds that
directory to the user PATH, then runs ullage daemon install and
ullage daemon start. Open a new terminal after install so PATH updates.
The GitHub Release zip is still a portable copy that does not register the
task.
GitHub Releases
Download the archive for your OS from Releases.
From git (Rust 1.85 or newer)
cargo install --git https://github.com/dualface/ullage-cli --locked ullage-cliFrom this workspace
cargo build --release -p ullage-cliThe binary is target/release/ullage. Place it on your PATH if you want the
user-level service commands to find it at a stable location.
-
Sign in to an account. The command walks you through choosing a provider and authorizing (see "Authentication" below):
ullage auth login
-
Show usage for all accounts:
ullage show --all
-
Open the terminal board. Press
vto switch the layout andqto quit:ullage tui
Homebrew and WinGet installs set up and start the daemon for you. For other install methods, run ullage daemon install first (see "Daemon lifecycle" below).
Kander is a multi-agent kanban orchestrator. The executor and reviewer agents it schedules (Claude Code, Codex, Cursor, Grok, Devin, OpenCode, and others) each draw on a different subscription.
- Check quota before dispatching: glance at
ullage tuiand send large cards to agents with quota to spare. - Stay clear of window limits: when a 5-hour window is nearly used up, read the reset countdown and decide whether to wait or switch agents.
- All accounts on one screen: multiple accounts for the same provider are shown separately, with no dashboard sign-ins.
The rest of this README is the full reference.
Interactive login needs a terminal (stdin and stderr). It selects a provider, creates or reuses an account, prints the authorization URL, waits for the callback value the provider asks for (or polls a device-code flow), verifies the stored credential, then asks for an account label:
ullage auth loginYou do not need the internal account ID. Each provider says what to paste:
- Claude: the full callback URL or
code#state - ChatGPT: the
codequery value - Grok: device-code flow; nothing to paste. Opens a page and finishes on its own
- Cursor: browser sign-in; nothing to paste. Opens a page and finishes on its
own.
--method api-tokenkeeps the older path: create a User API Key at cursor.com/dashboard and type it without echo - OpenCode: the OpenCode Go API key issued at opencode.ai/auth
- Devin: the code the Devin page shows after sign-in (or
code#state).--method api-tokenaccepts a Devin API key instead - codex2api: the gateway base URL, admin key, and the upstream account id or
email, separated by spaces (
base_url admin_key upstream_ref) - sub2api:
base_url admin_key upstream_ref— the gateway base URL (HTTPS or loopback HTTP), the admin API key from the gateway settings, and the upstream account id or name — typed without echo as one line
After login, ullage show --all looks like this:
$ ullage show --all
==== claude - max_20x ====
5h remains 91% - 3h - [-#########]
weekly used up ------* [----------]
fable remains 25% ------* [-------###]
==== chatgpt - pro ====
weekly used up ----*** [----------]Default paths:
| Platform | Config | State | Control |
|---|---|---|---|
| Linux | $XDG_CONFIG_HOME/ullage/config.json or ~/.config/ullage/config.json |
$XDG_STATE_HOME/ullage/state.json or ~/.local/state/ullage/state.json; paired devices in devices.json beside that file |
$XDG_RUNTIME_DIR/ullage/control.sock |
| macOS | ~/Library/Application Support/Ullage/config.json |
~/Library/Application Support/Ullage/state.json; paired devices in devices.json beside that file |
$TMPDIR/ullage-<uid>/control.sock |
| Windows | %APPDATA%\Ullage\config.json |
%LOCALAPPDATA%\Ullage\state.json; paired devices in devices.json beside that file |
\\.\pipe\ullage-<user-scope> |
Overrides: ULLAGE_CONFIG_FILE, ULLAGE_STATE_FILE, ULLAGE_CONTROL_SOCKET
(Unix), ULLAGE_CONTROL_PIPE (Windows), ULLAGE_TUI_STATE_FILE (the TUI's
remembered layout, tui.json beside the state file by default).
Optional file-credential directory, used only when credentials.file_fallback
is true and the native store is unavailable: Linux
$XDG_DATA_HOME/ullage/credentials or ~/.local/share/ullage/credentials;
macOS ~/Library/Application Support/Ullage/credentials; Windows
%LOCALAPPDATA%\Ullage\credentials.
Version 1 JSON. Secret material is not part of the schema. A missing file loads built-in provider endpoints and an empty account list.
Rejected on load:
- unknown fields
- duplicate account IDs
- zero timings
- symbolic links
- files larger than 1 MiB
{
"version": 1,
"daemon": {
"maximum_concurrency": 8,
"default_provider_concurrency": 2,
"provider_limits": []
},
"providers": {},
"credentials": {
"file_fallback": false
},
"http": {
"enabled": false,
"bind": "127.0.0.1:7878",
"allowed_origins": [],
"probe_min_interval_seconds": 60
},
"accounts": [
{
"id": "claude-work",
"provider": "claude",
"label": "work",
"enabled": true,
"interval_seconds": 300,
"timeout_seconds": 30,
"jitter_seconds": 5,
"backoff_initial_seconds": 30,
"backoff_maximum_seconds": 1800
}
]
}provider must be one of claude, chatgpt, grok, cursor, opencode,
devin, codex2api, or sub2api. Provider OAuth and billing endpoints are
compiled in and cannot be redirected here; the codex2api and sub2api gateways
are the exception — their base URL arrives with the credential pasted at
login.
credentials.file_fallback is off by default. Ullage then uses only the
platform credential store (macOS Keychain, Windows Credential Manager, or Linux
Secret Service). On a machine without Secret Service, authentication fails with
a distinct error; re-run with --diagnose to see that this host has no Secret
Service and that file fallback is enabled by setting
credentials.file_fallback to true.
When the switch is on, Ullage still prefers the native store if that backend is available. Only when the native backend reports itself unavailable does Ullage create the platform file-credential directory above.
- Credentials in that directory are stored as plaintext. Any process that can read the current user's files can read them.
- The directory is created private (
0700/ current-user DACL). - A permission check failure stops the daemon instead of silently downgrading.
- Old configuration files that omit the
credentialsobject still load with the switch off.
http.enabled is false by default. While it is off the daemon does not listen
on any TCP port.
When enabled, http.bind accepts auto:<port> or one explicit loopback,
Tailscale, or private LAN address:
| Value | Behavior |
|---|---|
auto:7878 |
Discover every eligible local address and listen on each |
<tailscale-ipv4>:7878 |
Single Tailscale address |
<lan-ipv4>:7878 |
Single private LAN address |
Wildcard, link-local, multicast, and public addresses refuse to start and name
http.bind.
In auto mode, startup logs one http.bind listening <addr> (<class>) line
per listener. If no Tailscale or LAN address is initially available, discovery
retries for up to 60 seconds and then starts with loopback. A failed
non-loopback bind emits a warning and is skipped. If HTTP setup fails
outright — including a failed loopback bind — the daemon keeps running and
serves the control socket only; the error is reported on the daemon's stderr,
which a CLI-launched daemon captures in its per-user daemon-error-*.log.
Authentication, account mutation, and workspace control stay on the private control socket. The HTTP server is a query and pairing surface only.
| Method | Path | Notes |
|---|---|---|
POST |
/v1/pair |
Pairing; no Bearer token |
GET |
/v1/status |
|
GET |
/v1/providers |
|
GET |
/v1/accounts |
|
GET |
/v1/accounts/{id} |
|
GET |
/v1/usage[?account={id}] |
Cached snapshots of every account, or of one; does not contact providers |
GET |
/v1/usage[?account={id}]&metric={display-name} |
Optional keep-list filter |
POST |
/v1/accounts/{id}/probe[?wait=true|false] |
Rate-limited by http.probe_min_interval_seconds (default 60) |
account is optional on /v1/usage; an empty or repeated account returns
400 bad_request. wait defaults to true: the probe runs to completion and
the response is 200 with its result. wait=false starts the probe and
returns 202 at once. Every route except /v1/pair also accepts
diagnose=0|1 (see Errors). A query key the route does not accept,
a repeated key, or a malformed value returns 400 bad_request.
Repeat metric= to keep the union of several display names. This one-shot list
is a keep list, unlike the persisted per-account metrics field, which names
rows to hide.
- Names are trimmed. Matching is then exact, ASCII case-insensitive, and ignores the window a row belongs to. Duplicate names are merged.
- Only display rows are filtered. Hidden bookkeeping such as
limit_reachedstill reaches the client, so a reached limit stays visible. - A valid but unknown name returns
200with no visible measurements. - An empty or whitespace-only name, a name over 128 characters, more than 64
names, or a name with control or bidirectional formatting characters returns
400 invalid_metric. A value that is not valid UTF-8 after percent-decoding returns400 bad_request. metricis accepted on/v1/usageonly; other routes reject it as400 bad_request.- Probe requests for the same account within the minimum interval return
429 rate_limitedwithRetry-Afterset to the remaining seconds (at least 1), in bothwaitmodes. Only existing accounts are rate-limited; a probe for an unknown account returns404.
Requests are checked in this order: Host, then OPTIONS, then
/v1/pair, then the Bearer token, then the body, then the route, method, and
query. So:
- A request with a missing or disallowed
Hostgets403before anything else, includingOPTIONSand pairing. OPTIONSneeds no token. It returns204for a known path and404for an unknown one.- Every method on
/v1/pairskips the token check; onlyPOSTis allowed. - Every other request requires
Authorization: Bearer <device_token>. The scheme is case-sensitive. A failure returns401withWWW-Authenticate: Bearer. - The token is checked before route matching, so an unauthenticated request to
an unknown path or with the wrong method gets
401, not404or405.
Pairing request (Content-Type: application/json, no query string):
{"pair_code":"ABC-DEF","device_name":"client-host"}The response is {"device_id":"...","device_token":"...","device_name":"..."}:
the device ID, the sanitized name, and a 256-bit base64url device token (43
characters, no padding). That response is the only time the raw token is
exposed. A name that is empty after sanitizing becomes unknown; a sanitized
name over 64 characters returns 400 bad_request.
Pairing code rules:
- Six characters from
23456789ABCDEFGHJKMNPQRSTVWXYZ - Case-insensitive on input
- Hyphen allowed only in the displayed position, or omitted entirely
- Expires after 300 seconds
- Succeeds once; generating a new code replaces the pending one and resets its failure count
- Invalidated after five failed validations; a code that cannot be parsed counts as a failure
- One attempt per source IP per second; excess attempts return
429withRetry-After. The attempt is counted before the body is validated, so a malformed request also uses up the slot. The limiter tracks at most 4096 addresses at a time; when it is full, new addresses get429.
devices.json sits beside the state file with current-user-only access
(0600 / protected DACL). Revoked devices stay in the file, marked with
revoked_at, and can no longer authenticate. Each record contains:
- 12-character device ID
- sanitized name
- SHA-256 token hash
- creation time
- last-seen time
It never contains the raw token. Authentication hashes the presented token and
compares it with every record in constant time without returning early; only
an active record can match.
Last-seen writes are limited to once per device per 60 seconds. A corrupt or
unsafe device file refuses daemon startup and is never repaired in place. The
legacy http-token file is ignored and is not deleted automatically.
Accepted Host values: 127.0.0.1:<port>, localhost:<port>, and the actual
listener addresses. Any IPv6 listener additionally enables [::1]:<port>.
Matching ignores case; a Host without a port is rejected.
http.allowed_origins is empty by default:
- Matching origins are echoed with
Vary: Origin, on error responses as well. A preflight also getsAccess-Control-Allow-Methods: GET, POST, OPTIONSandAccess-Control-Allow-Headers: Authorization, Content-Type. - Unmatched origins get no CORS headers.
*is rejected as a configured origin, both when the configuration loads and when the server binds.- The server never returns
Access-Control-Allow-Origin: *orAccess-Control-Allow-Credentials: true.
Every response carries Cache-Control: no-store,
X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer. Each
listener serves at most 256 connections; further connections are closed at
once. Request headers must arrive within 10 seconds.
Remote access may use a direct Tailscale or LAN address, or an SSH tunnel. Ullage does not offer TLS. Tailscale traffic is encrypted by WireGuard, but LAN traffic and its device token are plaintext.
Response bodies stay sanitized. ?diagnose=1 adds a diagnostic field to
provider errors from /v1/usage and probe only; every other response is
unchanged. Request bodies larger than 1 MiB, pairing bodies larger than 4 KiB,
or bodies that take longer than 10 seconds to read are rejected without
affecting other connections.
Errors use one of two body shapes. Errors raised by the HTTP layer are
{"error":"<kind>"}. Errors returned by the daemon are
{"version":...,"request_id":"...","kind":"...","detail":...}, plus
diagnostic when requested. A protocol version mismatch is
{"version":...,"request_id":"...","error":"protocol_mismatch","supported_version":...}.
| Condition | Status |
|---|---|
Missing or disallowed Host |
403 forbidden |
| Missing or invalid Bearer token | 401 unauthorized with WWW-Authenticate: Bearer |
| Unknown route | 404 not_found |
| Wrong method | 405 method_not_allowed with Allow |
| Bad path encoding or query | 400 bad_request |
| Bad metric name | 400 invalid_metric |
| Protocol version mismatch | 400 protocol_mismatch |
| Unsupported command | 400 |
| Body over the size limit | 413 payload_too_large |
| Body read timeout | 408 request_timeout |
| Probe cooldown | 429 rate_limited with Retry-After |
AccountNotFound, AccountSelectorNotFound, unknown account or provider |
404 |
AuthenticationInvalid |
409 |
Provider RateLimited |
429 with Retry-After (the provider's value, or 1) |
Timeout |
504 |
Storage, device store failure, or any other daemon error |
500 |
Pairing uses 403, 405, 408, 413, 429, 500 storage,
401 pair_code_invalid, and 400 bad_request. Pairing returns
400 bad_request for a query string, a Content-Type other than
application/json, a body that does not parse, or a name that is too long.
Detached process. The command returns once the daemon is ready:
ullage daemon runUser-level service (current login session only; no system Administrator service):
ullage daemon install
ullage daemon start
ullage daemon status
ullage daemon stop
ullage daemon uninstallbrew install and brew upgrade run ullage daemon install and
ullage daemon start themselves so the LaunchAgent (or systemd user unit)
pins the current Cellar keg path. winget install Dualface.Ullage does
the same pair from the Inno installer [Run] entries so the current-user
scheduled task pins %LOCALAPPDATA%\Ullage\ullage.exe.
install registers the startup entry and starts the daemon; when the service
is already installed it stops the running daemon and rewrites the entry first.
uninstall stops the daemon and removes only the startup entry. Configuration,
credentials, snapshots, and logs remain. status reports a live daemon when
the authenticated local endpoint is reachable; otherwise it distinguishes
installed-but-stopped from not-installed. Live table output includes
CREDENTIAL_BACKEND (linux_secret_service, macos_keychain,
windows_credential_manager, file_fallback, or other_platform). JSON uses
the same identifiers on payload.credential_backend.
Linux uses a systemd user unit, macOS a LaunchAgent, and Windows a current-user
Task Scheduler task. See docs/architecture.md for platform path and permission
details.
Use the private local control channel to pair and administer HTTP API clients:
ullage device pair
ullage device list
ullage device revoke <device-id>device pair prints a one-use code and its expiry, then tells you to enter the
HTTP API address and code in the client. The code expires after 300 seconds;
creating another code immediately invalidates the previous one. device list
shows only the device ID, name, creation time, and last-seen time. It never
prints device tokens or token hashes. device revoke takes effect immediately
and does not prompt for confirmation.
ullage provider list
ullage account add claude --label work
ullage account list
ullage account show <account-id>
ullage account enable <account-id>
ullage account disable <account-id>
ullage account label <account-id> [label]
ullage account metrics <account-id> [metric]...
ullage account remove <account-id>The same provider may have multiple accounts. Credentials and snapshots are
isolated per account. account label renames an account in place; omit the
label to clear it. Interactive login also asks for a label after a successful
authentication.
account list shows a METRICS column and account show a METRICS line,
both reading - when nothing is stored. account metrics replaces the stored
hide list; omit every name to clear it. The stored names are hidden from that
account's readable summary, so --metric and HTTP metric= stay the one-shot
keep list, naming rows to show. Names match by exact, case-insensitive display
name and ignore the window a row belongs to. An invalid name exits 64
without contacting the daemon.
ullage probe <account-id>
ullage probe <account-id> --no-wait
ullage show <account-id>
ullage show <account-id> --metric usage
ullage show --all
ullage show --all --no-metric-filter
ullage tui
ullage tui --verticalprobe queries the provider and persists a snapshot. show reads persisted
snapshots and does not call the provider.
tui reads all persisted snapshots into an alternate-screen view. Each
subscription is a rounded box with provider plan account embedded in the
top border, a blank row between bands, and rows that read exactly as show
does plus the wait until each window resets:
╭─ claude max_20x personal ────────────╮
│ 5h remains 91% 3h05m ┄━━━━━━━━━ │
│ weekly used up ◦◉◉◉◉◉◉ ┄┄┄┄┄┄┄┄┄┄ │
│ fable remains 25% ◆ 12d ◆ ┄┄┄┄┄┄┄━━━ │
╰────────────────────────────────────────╯A window whose name every row of a box repeats loses it: Cursor reports all
of its windows as monthly-…, so the box shows auto and Codex. The name
stays whenever dropping it would leave a row with nothing of its own.
The bar is ten cells (━ left, ┄ spent) with no brackets, colored by how
much is left: red at or under 10%, yellow at or under 25%, green otherwise.
The countdown has three forms: under a day the exact wait is spelled out
(3h05m, 12m, <1m); within a week each remaining day lights one of seven
dots (◦◦◦◦◦◉◉). A partial day appears before the whole-day dots: ○ for
less than 2 hours, ◔ from 2 to less than 6 hours, ◕ from 6 through 16
hours, and ◉ above 16 hours. For example, 26 hours appears as
◦◦◦◦◦◔◉. Beyond a week the rounded-up day count sits centered between two
diamonds (◆ 23d ◆), still seven cells wide.
Every box shares one set of columns, measured across the whole screen, so a reading under a short window name lines up with the one under a long name on another provider's box. The boxes are as wide as those columns need, up to seventy-two cells; a terminal narrower than that gives what it has, and the rows give up their widest fields in turn.
Boxes are ordered by provider, then by account, ignoring case. They flow from
left to right and wrap onto new rows; a row holding a single box keeps the
box's own width and centers it rather than stretching it across the terminal.
v switches to one box per row and back, and the choice is remembered for the
next run; --vertical forces one box per row for a single run without
changing what is saved. The setting lives in tui.json beside the state file,
and losing that file only costs the remembered layout.
As the terminal narrows, every row of a box gives up its widest field first:
the ten-cell bar shrinks to four cells (┄┄━━), then the verb goes, then the
small bar, then the countdown, leaving the window and its reading. The
countdown outlives the bar on purpose, so a phone-sized terminal still says
when the quota comes back. The frame, rules, and diamonds are East Asian
ambiguous glyphs — one cell wide to this program, but a terminal set to a CJK
locale may render them two cells wide and misalign the box; the blocks of the
card bars and the partial-day ○ are ambiguous too. The countdown dots
◉, ◦, ◔, and ◕ are neutral width and stay one cell in either locale.
The view reads the snapshots again every two minutes, which catches each of
the daemon's five-minute probe rounds without polling it for nothing. The
status line counts down the wait to that next read: one circle filled by
quarter, ○ ◔ ◑ ◕ ●, beside the wait in whole seconds (2m00s, 0m45s).
The circle is the coarse shape of the wait — a quarter every thirty seconds —
and the seconds carry the rest. The line sits in the middle of the terminal
whatever the boxes do — a centered band and a full-width row put it in the
same place — with a blank row between it and the boxes, and a blank row above
the boxes at the top of the screen; a terminal too short for both blank rows
spends its rows on the cards and the hints first, giving up the top margin,
then the gap, and the status line last. A refresh the daemon cannot answer
shows as a circle that empties and stays there: the readings stay put and the
next interval tries again.
The view also scrolls: the mouse wheel moves three rows, PgUp and PgDn move half a screen, Up/Down (or k
and j) move one row, and Home/End jump to the ends. The status line
also carries the keys and the position, giving up the wait in words first,
then the position, the layout hint and the scroll keys, and the circle last.
Press q, Q, Esc, or Ctrl+C to exit; the terminal, including mouse
capture, is restored on the way out.
--metric <display-name> keeps only the summary rows whose display name matches
exactly, ignoring case and the window a row belongs to; repeat the flag to keep
the union of several names. --no-metric-filter ignores the account's stored
filter for one invocation. Without either flag, the readable summary hides the
rows each account's stored account.metrics list names: show --all follows
each account's own list, and probe applies the stored list of the account it
queried. An invalid metric name exits 64 without contacting the daemon. Both
flags affect the readable summary only: --raw and JSON output keep every
measurement.
When either filter leaves no row to show but the account still has
summarizable metrics, the account heading stays, a
! no rows match the metric filter: <names> line names the filter's names, and
the stale, limit, and partial notices still print; the raw table is not used
as a fallback. An account with no summarizable measurements still falls back to
raw output as before.
Table output defaults to a readable summary: one line per usable measurement,
with the window, the metric, how much quota is left, when the window resets,
and a ten-cell progress bar as the last column. A row with nothing left reads
used up rather than remains 0%, which looks like a measurement that came
back empty, and a row under half a percent reads remains <1% rather than
rounding down to that same zero. The reset column always occupies seven
characters: under a day it shows whole hours between dashes (- 3h -), up to
a week it counts the remaining days as stars after dash padding (-******),
and beyond a week it puts the day count between stars (* 23d *), so the
column never shifts and the wait is readable at a glance.
Two kinds of provider bookkeeping never become summary rows. The status booleans
allowed, limit_reached, has_credits, unlimited, on_demand_enabled, and
enabled are hidden as rows, but never as state: a reached limit becomes a
! limit reached line, unmetered credits become a credits unlimited row, and
a switched-off feature is marked (off) on the amount it applies to, or given
its own disabled row when the provider reported no such amount. Cursor's
total_spend, included_spend, and bonus_spend are excluded from the
mapping unconditionally — they are a dollar ledger next to the quota rows,
not remaining quota.
Both kinds still appear in the raw table, which you reach with --raw or,
without it, when no measurement survives the mapping: the account block then
falls back to the raw table with a note, and still carries the stale, limit,
and partial notices.
==== claude - pro ====
5h usage remains 97% - 3h - [##########]
weekly usage remains 89% --***** [-#########]
Weekly Opus usage remains 89% --***** [-#########]
Global output flags: --output table|json|pretty-json, --color auto|always|never,
--raw, and --reveal. --color defaults to auto, which colors table stdout
when it is a terminal and NO_COLOR is unset or empty. JSON and pretty-json
output is never colored. --raw replaces the summary with the untranslated
provider table (WINDOW, MEASUREMENT, USED, LIMIT, UNIT, RESETS_AT);
it affects table output only and is a no-op for --output json and
--output pretty-json. Without --reveal, account labels, auth URIs, flow IDs,
and similar personal values are replaced with [redacted]. --raw does not
change what is redacted. Error details stay redacted even with --reveal.
--diagnose (or ULLAGE_DIAGNOSE=1) shows sanitized partial-failure scope and
category on show and probe. On authentication and probe command failures it
also asks the daemon to attach the provider's own error text. Default error
output is still a stable kind. Without that opt-in, a diagnostic on the daemon
response is rejected as invalid.
Successful JSON is a tagged ControlResult. Compact
ullage --output json show <account> looks like:
{
"result": "snapshots",
"payload": [
{
"account_id": "claude-work",
"usage": {
"outcome": "complete",
"data": {
"provider": "claude",
"account_label": "[redacted]",
"plan": "pro",
"subscription_expires_at": null,
"observed_at": "2026-08-27T12:00:00Z",
"windows": [
{
"window": { "kind": "five_hours" },
"resets_at": "2026-08-27T17:00:00Z",
"measurements": [
{
"name": "tokens",
"used": 12.0,
"limit": 100.0,
"unit": { "kind": "tokens" }
}
]
}
]
}
},
"last_success_at": "2026-08-27T12:00:00Z",
"stale": false,
"last_error": null,
"last_error_at": null,
"metrics": []
}
]
}| Field | Rule |
|---|---|
window.kind |
five_hours, weekly, monthly, or {"kind":"other","id":"...","label":"..."} |
| Missing 5h or weekly window | Omitted; never filled with synthetic zeros |
unit.kind |
requests, tokens, percent, credits, {"kind":"currency","code":"..."}, or {"kind":"other","id":"...","label":"..."} |
limit |
Always present; null when the vendor reports no cap |
resets_at, plan, account_label |
null when unknown. A non-null account_label is [redacted] without --reveal |
subscription_expires_at |
null when the provider has no expiry |
"outcome":"partial" |
Includes failures, a list of {"scope":"...","message":"..."}. Both values are [redacted] without --diagnose. CLI exit status 2 means partial success |
last_error |
null, a string (network, authentication_invalid, protocol_incompatible, unsupported_capability, timeout, cancelled, provider_not_found, account_not_found, storage), or {"rate_limited":{"retry_after_seconds":<n or null>}} |
metrics |
Always present. The account's stored list of display names hidden from the readable summary; it does not filter the JSON |
probe returns {"result":"probe","payload":{"account_id":"...","usage":...,"metrics":[...]}}
with the same usage shape. Other commands use other result tags, such as
daemon_status, providers, accounts, account, auth_challenge,
auth_state, workspaces, and workspace.
One command prints JSON that is not a ControlResult. When the daemon is not
running, daemon status prints {"service":"installed","status":"stopped"} (or
"not_installed") and exits 0.
JSON and pretty-json always carry this raw ControlResult. --raw does not
change their structure or their bytes, so parsers built on this schema keep
working whether or not the flag is passed.
Same tagged shape. Device list payloads contain no token or token-hash field.
| Command | Result |
|---|---|
device pair |
{"result":"pair_code","payload":{"code":"ABC-DEF","expires_at":"..."}} |
device list |
{"result":"devices","payload":[{"id":"...","name":"...","created_at":"...","last_seen_at":"..."}]} |
device revoke (success) |
{"result":"ack"} |
Errors are written to stderr, with one exception: when an auth command reports
the credentials invalid, it prints a normal {"result":"auth_state",...} result
on stdout and exits 3. Its reason is [redacted] without --diagnose.
| Output | Shape |
|---|---|
| Table, runtime error | error: <kind>, then the message and a hint: line when present |
| Table, parse error | clap's own message (see below) |
| JSON / pretty-json | { "status": "error", "error": { "kind": "usage", "message": "..." } } |
When the running daemon is older than the CLI, a
warning: the running daemon (...) is older than this CLI (...) notice comes
first on stderr, ahead of the error or the JSON envelope.
Example runtime error:
{ "status": "error", "error": { "kind": "timeout" } }messagecarries parse-error text when present.hintcarries static guidance for selected kinds:daemon_unavailable,provider_registry_error,account_not_found,account_selector_not_found,invalid_account_metrics,invalid_control_socket, andusagefor rejected control characters. Table output prints the same text as ahint:line.detailcarries the provider's own error text on authentication and probe failures, only with--diagnose. Table output prints it as adetail:line.- Omitted fields are not serialized.
- JSON never includes ANSI color sequences.
Parse mistakes print clap's own message, sanitized so they never echo terminal
control characters. In JSON output they become kind usage with that text in
message.
- Missing subcommands show that layer's full help.
- Unknown flags, missing arguments, and invalid enum values include the parameter name and, when available, a did-you-mean suggestion or the allowed values.
Arguments that contain disallowed control characters are rejected with kind
usage, no message, and a static hint. The hint names the option for
recognized value options such as --method or --account, and uses a generic
message for positional arguments and other flags.
| Situation | Exit | Destination |
|---|---|---|
--help, -h, help, --version |
0 |
stdout |
| Success | 0 |
stdout |
| Other failure | 1 |
stderr |
probe or show with any partial snapshot |
2 |
stdout |
| Credentials invalid | 3 |
stderr, or stdout for an auth_state result |
| Network failure | 4 |
stderr |
| Daemon unavailable | 5 |
stderr |
| Protocol mismatch with the daemon | 6 |
stderr |
| Parse and usage mistakes, invalid metric names | 64 |
stderr |
This release has no live-credential tests. cargo test never sends user
credentials or paid model requests to vendors. If a later suite adds live
smoke, it must be explicit opt-in, must not log account usage numbers, and
must not send paid model requests.
docs/architecture.md— crate graph, storage, hosting, and security boundariesdocs/development.md— provider extension, vendor DTO compatibility, security, and pre-release checks
Report vulnerabilities to dualface@gmail.com. See SECURITY.md.
MIT. See LICENSE.
Other projects by dualface, the author of Ullage:
- Kander: rule-driven multi-agent kanban orchestration, with independent review and delivery gates.
- ste-zh: an agent skill that makes agents report results in Chinese by ASD-STE100 principles: conclusion first, fixed status words, explicit verification state.
- QuickTUI: a full terminal for any coding agent, on your phone. Self-hosted, direct connection. Free for a single host.
