# Ullage: Every AI Subscription's Remaining Quota on One Screen Languages: [English](README.md) · [简体中文](README-CN.md) 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. --- ## Why Ullage? **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:** ullage tui showing quota boxes for several subscriptions 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`. --- ## Key Features - **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). `show` and `tui` read saved snapshots and never call the provider. - **Terminal board**: `ullage tui` shows 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. --- ## Install The command is `ullage`. The crates.io package name is `ullage-cli`. **Homebrew** (macOS and Linux) ```sh brew install dualface/tap/ullage ``` Linux needs [Homebrew on Linux](https://docs.brew.sh/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) ```powershell winget install Dualface.Ullage ``` The 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](https://github.com/dualface/ullage-cli/releases). **From git** (Rust 1.85 or newer) ```sh cargo install --git https://github.com/dualface/ullage-cli --locked ullage-cli ``` **From this workspace** ```sh cargo build --release -p ullage-cli ``` The 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. ## Quick Start 1. Sign in to an account. The command walks you through choosing a provider and authorizing (see "Authentication" below): ```sh ullage auth login ``` 2. Show usage for all accounts: ```sh ullage show --all ``` 3. Open the terminal board. Press `v` to switch the layout and `q` to quit: ```sh 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). --- ## Working with Kander [Kander](https://github.com/dualface/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 tui` and 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. ## Authentication 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: ```sh ullage auth login ``` You do not need the internal account ID. Each provider says what to paste: - Claude: the full callback URL or `code#state` - ChatGPT: the `code` query 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-token` keeps 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: browser sign-in; nothing to paste. `--method api-token` accepts 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: ```console $ ullage show --all ==== claude - max_20x ==== 5h remains 91% - 3h - [-#########] weekly used up ------* [----------] fable remains 25% ------* [-------###] ==== chatgpt - pro ==== weekly used up ----*** [----------] ``` ## Data paths 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-/control.sock` | | Windows | `%APPDATA%\Ullage\config.json` | `%LOCALAPPDATA%\Ullage\state.json`; paired devices in `devices.json` beside that file | `\\.\pipe\ullage-` | 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`. ## Configuration 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 ```json { "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 `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 `credentials` object still load with the switch off. ### HTTP bind `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:` or one explicit loopback, Tailscale, or private LAN address: | Value | Behavior | | ----- | -------- | | `auto:7878` | Discover every eligible local address and listen on each | | `:7878` | Single Tailscale address | | `: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 ()` 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`. ## HTTP API Authentication, account mutation, and workspace control stay on the private control socket. The HTTP server is a query and pairing surface only. ### Routes | 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](#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_reached` still reaches the client, so a reached limit stays visible. - A valid but unknown name returns `200` with 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 returns `400 bad_request`. - `metric` is accepted on `/v1/usage` only; other routes reject it as `400 bad_request`. - Probe requests for the same account within the minimum interval return `429 rate_limited` with `Retry-After` set to the remaining seconds (at least 1), in both `wait` modes. Only existing accounts are rate-limited; a probe for an unknown account returns `404`. ### Authentication and pairing 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 `Host` gets `403` before anything else, including `OPTIONS` and pairing. - `OPTIONS` needs no token. It returns `204` for a known path and `404` for an unknown one. - Every method on `/v1/pair` skips the token check; only `POST` is allowed. - Every other request requires `Authorization: Bearer `. The scheme is case-sensitive. A failure returns `401` with `WWW-Authenticate: Bearer`. - The token is checked before route matching, so an unauthenticated request to an unknown path or with the wrong method gets `401`, not `404` or `405`. Pairing request (`Content-Type: application/json`, no query string): ```json {"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 `429` with `Retry-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 get `429`. ### Device records `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. ### Host, CORS, and transport Accepted `Host` values: `127.0.0.1:`, `localhost:`, and the actual listener addresses. Any IPv6 listener additionally enables `[::1]:`. 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 gets `Access-Control-Allow-Methods: GET, POST, OPTIONS` and `Access-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: *` or `Access-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. ### Errors 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":""}`. 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. ## Daemon lifecycle Detached process. The command returns once the daemon is ready: ```sh ullage daemon run ``` User-level service (current login session only; no system Administrator service): ```sh ullage daemon install ullage daemon start ullage daemon status ullage daemon stop ullage daemon uninstall ``` `brew 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. ## Devices Use the private local control channel to pair and administer HTTP API clients: ```sh ullage device pair ullage device list ullage device revoke ``` `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. ## Accounts ```sh ullage provider list ullage account add claude --label work ullage account list ullage account show ullage account enable ullage account disable ullage account label [label] ullage account metrics [metric]... ullage account remove ``` 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. ## Probe and show ```sh ullage probe ullage probe --no-wait ullage show ullage show --metric usage ullage show --all ullage show --all --no-metric-filter ullage tui ullage tui --vertical ``` `probe` 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: ```console ╭─ 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 ` 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: ` 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. ```text ==== 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. ## JSON schema Successful JSON is a tagged `ControlResult`. Compact `ullage --output json show ` looks like: ```json { "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": [] } ] } ``` ### Usage fields | 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":}}` | | `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. ### Device commands 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 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: `, 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: ```json { "status": "error", "error": { "kind": "timeout" } } ``` - `message` carries parse-error text when present. - `hint` carries static guidance for selected kinds: `daemon_unavailable`, `provider_registry_error`, `account_not_found`, `account_selector_not_found`, `invalid_account_metrics`, `invalid_control_socket`, and `usage` for rejected control characters. Table output prints the same text as a `hint:` line. - `detail` carries the provider's own error text on authentication and probe failures, only with `--diagnose`. Table output prints it as a `detail:` 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 | ## Live credentials 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. ## Documentation - `docs/architecture.md` — crate graph, storage, hosting, and security boundaries - `docs/development.md` — provider extension, vendor DTO compatibility, security, and pre-release checks ## Security Report vulnerabilities to dualface@gmail.com. See [`SECURITY.md`](SECURITY.md). ## License MIT. See [`LICENSE`](LICENSE). --- ## More Projects by the Author Other projects by [dualface](https://github.com/dualface), the author of Ullage: - [Kander](https://github.com/dualface/kander): rule-driven multi-agent kanban orchestration, with independent review and delivery gates. - [ste-zh](https://github.com/dualface/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](https://quicktui.ai/): a full terminal for any coding agent, on your phone. Self-hosted, direct connection. Free for a single host.