Metadata-Version: 2.5
Name: aiusage-tui
Version: 0.1.0
Summary: One-screen terminal dashboard for the rate limits of the local AI coding agents
Project-URL: Homepage, https://github.com/sharaxlex/aiusage
Project-URL: Issues, https://github.com/sharaxlex/aiusage/issues
Author: sharax
License-Expression: MIT
License-File: LICENSE
Keywords: antigravity,claude,codex,dashboard,rate-limit,tui,usage
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console :: Curses
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Utilities
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# aiusage

[![CI](https://github.com/sharaxlex/aiusage/actions/workflows/ci.yml/badge.svg)](https://github.com/sharaxlex/aiusage/actions/workflows/ci.yml)

**One screen for every AI coding agent's rate limits.** How much of each limit is spent, and
when it resets — for all your Claude accounts, Codex and Antigravity at once, refreshed every
minute.

Pure Python standard library: no dependencies, no services, no telemetry, no network calls
except to the providers' own endpoints.

```
 AI USAGE   peak 89%                                        next 52s   12:18:54

 ╭─ claude work ─────────────────────────── team ╮  ╭─ claude personal ──────────────────────── pro ╮
 │  5h ███████░░░░░░░░░░░░░░░  31%  ↻ 4h01m      │  │  5h ████████████████████░░  89%  ↻ 2h11m      │
 │  wk ████████░░░░░░░░░░░░░░  35%  ↻ 4d5h       │  │  wk ██████████████████░░░░  82%  ↻ 1d4h       │
 ╰───────────────────────────────────────────────╯  ╰───────────────────────────────────────────────╯

 ╭─ codex ───────────────────────────────── team ╮  ╭─ agy ─────────────────────────────────────────╮
 │  wk ████░░░░░░░░░░░░░░░░░░  20%  ↻ 5d11h      │  │  gemini ───────────────────────────────────── │
 ╰───────────────────────────────────────────────╯  │  5h █░░░░░░░░░░░░░░░░░░░░░   6%  ↻ 2h44m      │
                                                    │  wk █░░░░░░░░░░░░░░░░░░░░░   6%  ↻ 4d20h      │
                                                    │  claude/gpt ───────────────────────────────── │
                                                    │  5h ░░░░░░░░░░░░░░░░░░░░░░   0%  ↻ 4h58m      │
                                                    │  wk ░░░░░░░░░░░░░░░░░░░░░░   0%  ↻ 6d22h      │
                                                    ╰───────────────────────────────────────────────╯
 q quit   r refresh   poll 4.1s
```

`5h` is the five-hour window, `wk` the weekly one. Percentages are **consumed**, `↻` is the
time until reset. Green below 70%, yellow from 70%, red from 90%. `peak` in the header is the
hottest number across every agent — the one thing to look at.

Cards are packed shortest-column-first, so a two-line card never gets stretched to match a tall
neighbour, and the grid folds to a single column on a narrow terminal.

## Why

Every agent shows its own limits in its own place, in its own format, and only while you are
inside it. When you split work across three CLIs and two Claude accounts, "which one still has
room?" becomes a question you cannot answer without opening all of them.

Two design consequences worth knowing before you install:

- **The agent list is not hard-coded.** The dashboard scans the machine for logged-in CLIs, so a
  new account appears without editing anything. See [Auto-discovery](#auto-discovery).
- **Polling is deliberately slow.** The Claude usage endpoint throttles hard, per account. The
  refresh windows here come from measurements, not guesses. See [Polling and rate
  limits](#polling-and-rate-limits).

## Requirements

Python 3.9+ and at least one logged-in CLI: [`claude`](https://claude.com/claude-code),
[`codex`](https://developers.openai.com/codex/cli), or `agy` (Antigravity). Anything you are not
logged into simply does not appear.

Linux and macOS. Windows is untested — the curses TUI will not work there, though `--json` and
`--table` should.

## Install

**Single file** — no pip, no venv, no root:

```bash
curl -L https://github.com/sharaxlex/aiusage/releases/latest/download/aiusage \
     -o ~/.local/bin/aiusage && chmod +x ~/.local/bin/aiusage
```

**As a package**, if you have `uv` or `pipx`:

```bash
uv tool install aiusage-tui                                 # from PyPI
uv tool install git+https://github.com/sharaxlex/aiusage.git   # or straight from the repo
```

The distribution is called `aiusage-tui` because plain `aiusage` was already taken on PyPI by an
unrelated project. The command and the import name are both `aiusage`.

**From a clone**, for development:

```bash
git clone https://github.com/sharaxlex/aiusage.git
cd aiusage
make install     # uv tool / pipx / pip --user, whichever is available
make run         # or just run it out of the clone, without installing
```

That is the whole setup. Run it:

```bash
aiusage
```

The first run has no config to read, so it scans the machine, keeps what it found in
`~/.config/aiusage/config.json`, and goes straight to the dashboard — every later run reads
that file and scans nothing. Log into a new profile later and `aiusage init` picks it up; see
[Auto-discovery](#auto-discovery).

If the shell says `aiusage: command not found`, `~/.local/bin` is not on your `PATH`:

```bash
export PATH="$HOME/.local/bin:$PATH"
```

## Usage

```bash
aiusage                      # full-screen TUI, refreshed every minute
aiusage --once               # one snapshot as cards, then exit (good under `watch`)
aiusage --json               # one snapshot as JSON
aiusage --table              # compact table, one line per limit window
aiusage --cols 4             # more cards per row (default 2)
aiusage --once --width 100   # fix the grid width instead of using the terminal's
aiusage --only codex --only agy
aiusage --interval 30        # seconds between screen refreshes
aiusage --force              # refresh now, ignoring the freshness window
aiusage --no-cache           # skip the on-disk cache (debugging; may earn a 429)
aiusage --no-refresh         # never rotate OAuth tokens

aiusage init                 # find agents and append the new ones to the config
aiusage init --yes           # no questions, for scripts and provisioning
aiusage init -n              # dry run: show what would be written
aiusage init --prune         # also drop agents whose files are gone
aiusage init --reset         # rewrite the agent list from the scan

aiusage uninstall            # remove the files the dashboard wrote
```

In the TUI: `q` or `Esc` to quit, `r` or space to refresh now (this bypasses the freshness
window, but not the backoff — see below).

When stdout is not a terminal the TUI is skipped automatically and one snapshot is printed
instead, so piping and `watch` work without extra flags.

## Where the numbers come from

| Agent | Source | Latency |
|---|---|---|
| `claude` | `GET api.anthropic.com/api/oauth/usage`, token from `~/.claude/.credentials.json` | ~0.3 s |
| `claude personal` | the same, from `~/.claude-personal/.credentials.json` — and likewise for any `~/.claude-*` profile | ~0.3 s |
| `codex` | `GET chatgpt.com/backend-api/wham/usage`, token from `~/.codex/auth.json`; on failure, the last `token_count` from a recent rollout log (shown as `from log HH:MM`) | ~0.5 s |
| `agy` | `agy --print /usage` — the CLI itself prints a TSV table of the Gemini and Claude/GPT quotas | ~4–5 s |

Agents are polled in parallel, so the slow one (`agy` shells out to a CLI) never holds up the
fast ones, and the TUI stays responsive while it happens.

## Polling and rate limits

The Claude usage endpoint throttles aggressively, and separately from everything else. Measured
against claude 2.1.239, one IP, two accounts:

- **The limit is per account, not per IP and not per application.** At the same moment from the
  same machine: work token → `429`, personal token → `200` with fresh numbers.
- **It is `usage` specifically.** Same token, same second: `GET /api/oauth/profile` → `200`,
  `GET /api/oauth/usage` → `429`. Authentication and token rotation have nothing to do with it.
- **The floor is roughly two minutes per account.** A call 30 s after a good one is always
  refused. A steady 70 s cadence succeeds about every other time (6 of 11). After a refusal,
  access returns after roughly a minute of silence.
- **Bursts are punished for a long time.** A run of back-to-back requests once earned a `429`
  lasting ~24 minutes, including six-minute pauses inside it.
- **`Retry-After` is useless** — it comes back as `0`. The server will not say when to return.

Claude Code itself (`fetchUtilization` in the binary) makes one request with a 5 s timeout and
**no retries**; on `429` it falls back to a seed value from ordinary API response headers or its
own `~/.claude.json → cachedUsageUtilization`. It saves a new snapshot at most every 5 minutes
and treats a saved one as good for up to an hour. Five-minute staleness is normal by design.

So the dashboard works like this:

- **A freshness window per agent** — 10 minutes for Claude (6 requests per hour per account,
  comfortably inside the measured limits), 2 minutes for Codex and agy. The screen still ticks
  every minute to recompute the countdowns, but only genuinely stale agents hit the network.
- **An on-disk cache** at `~/.cache/aiusage/state.json`, shared by every process, so
  `watch -n60 aiusage --once` does not hammer the API either. Data older than 90 seconds makes
  the card show when it was fetched: `╭─ claude work ──── team · 12:41 ╮`.
- **Exponential backoff** on 429 and 5xx: 5 → 10 → 20 → 30 minutes, or longer if the server did
  send a `Retry-After`. The card does not go blank — it keeps showing the last known numbers
  plus `retry in 4m`. One success clears the backoff.
- **No retries inside a single poll**, exactly like the client: a `429` means back off.

Tune it with `"min_interval"` on an individual agent. To wipe the state entirely,
`rm ~/.cache/aiusage/state.json`.

## OAuth tokens and privacy

The dashboard reads the credential files the CLIs already keep, and talks only to the providers'
own endpoints. There is no telemetry, no analytics, no third-party host in the code — there are
exactly four outbound URLs in the source (two usage endpoints, two token endpoints), all
official:

```
api.anthropic.com/api/oauth/usage        platform.claude.com/v1/oauth/token
chatgpt.com/backend-api/wham/usage       auth.openai.com/oauth/token
```

`agy` is not on that list: it is a local subprocess, and whatever network access it needs is its
own business.

Claude access tokens live 8 hours; Codex has its own lifetime. When one has expired, the
dashboard refreshes it with the stored refresh token and **atomically rewrites the same file the
CLI reads** (`~/.claude*/.credentials.json`, `~/.codex/auth.json`), preserving the format and
`0600` permissions. That is deliberate: if the dashboard kept its own copy, the two would rotate
independently and eventually fight. Concurrent runs are guarded by a lock file.

If you would rather it never wrote to a credentials file, use `aiusage --no-refresh` (or
`"refresh": false` on an agent). Expired tokens then show as
`token expired — run claude to re-authenticate` instead of numbers.

## Uninstall

`aiusage uninstall` lists everything the dashboard has written and removes it after one
question:

```text
$ aiusage uninstall

aiusage wrote these files:

  ~/.config/aiusage/config.json  412 B
  ~/.cache/aiusage/state.json    3.1 KB

Credentials of claude and codex are not touched.

Remove them? [y/N] y

Removed 2 files.

To remove the command itself:
  uv tool uninstall aiusage-tui
```

Add `--yes` to skip the question. That really is the whole footprint — no services, no logs, and
nothing outside those two files except a short-lived
`~/.claude*/.credentials.json.aiusage-lock` held during a token refresh, which `uninstall` also
sweeps up if a killed process left one behind.

Credentials are deliberately out of scope: they belong to the CLIs, and deleting them would log
you out of `claude` or `codex`. Rotated tokens stay in those files, where they belong — removing
the dashboard breaks no login.

The command cannot delete the program it is running from, so it prints the right line for the
way you installed it (`uv tool uninstall aiusage-tui`, `pipx uninstall aiusage-tui`,
`rm ~/.local/bin/aiusage` for the zipapp, or `make uninstall` from a clone).

## Auto-discovery

The agent list is not in the source. The dashboard looks for the files the providers would read
anyway, with no network access:

| Type | Where it looks | What proves a login |
|---|---|---|
| `claude` | `~/.claude*`, `~/.config/claude*`, plus `$CLAUDE_CONFIG_DIR` | a `.credentials.json` inside |
| `codex` | `~/.codex*`, `~/.config/codex*`, plus `$CODEX_HOME` | an `auth.json` inside |
| `agy` | `PATH`, then `~/.gemini/antigravity-cli/bin/agy`, `~/.local/bin/agy`, `/usr/local/bin/agy`, `/opt/homebrew/bin/agy` | an executable file |

Card names come from the directory name: leading dot dropped, `-` and `_` become spaces.
`~/.claude` → `claude`, `~/.claude-personal` → `claude personal`, `~/.codex_backup` →
`codex backup`. Names must be unique, because the name is the cache key, so a clash gets a
disambiguator (`claude work` and `claude work (config)`).

Plan and e-mail are read from the same files, but only ever **printed by `init`**, to tell one
account from another. They are not written to the config: a plan changes with the subscription,
and a name has to stay stable.

Profiles that physically cannot report usage — an API key in the credentials instead of OAuth —
are marked `[-]` and skipped rather than added as a permanently red card. A profile with a dead
refresh token is added, flagged `token expired`; logging in again is enough.

### Re-running init

`init` is meant to be run whenever: log into a new profile, run `aiusage init`, and it appends
the agent. Everything already in the config is left exactly as it is — renamed cards,
`enabled: false`, a custom `min_interval`. Rows are matched by *type and path*, not by name, so
a renamed card is not duplicated, and a re-run with nothing new prints `Nothing to change` and
does not touch the file.

| Command | Effect on the config |
|---|---|
| `aiusage init` | append new agents only |
| `aiusage init --prune` | append new, remove those whose files are gone |
| `aiusage init --reset` | rewrite the agent list from the scan |

`--prune` is for deleted profiles, which otherwise sit in the config forever as a
`no credentials — log in first` card. It only removes rows that lost their evidence of a login;
hidden-but-alive agents are kept, as are agents of a type the dashboard does not know — it will
not judge someone else's provider.

`--reset` means "go back to what is visible on the machine", and everything the rewrite costs is
shown rather than applied silently:

```text
The agent list will be rewritten from the scan:

  [+] claude                    ~/.claude           team
  [+] claude personal           ~/.claude-personal  pro

Dropped:

  [x] claude old profile        ~/.claude-gone      not found on this machine
  [x] claude by hand            ~/work/profile      still works, but the scan cannot find it
  [!] claude at work            ~/.claude           name 'claude at work', enabled: false, min_interval: 900
```

Note the second `[x]`: a profile configured by hand outside the search patterns is alive, but
the scan cannot see it, so `--reset` would drop it. That is why it gets its own reason, and why
`[!]` spells out which manual settings would be lost. `interval`, `columns` and any key this
version does not know about survive a `--reset` — only the agent list is rewritten.

### The first run

With no config on disk, `aiusage` scans, saves what it found, and says so once in the footer:
`saved 3 agents to ~/.config/aiusage/config.json`. Installing and running is therefore the whole
setup — nothing to configure before the dashboard is useful.

Saving is what makes the *next* run cheap and predictable: as long as the config lists agents,
the dashboard does not stat a single extra path, and the file is there to rename a card, hide
one with `enabled: false`, or give one its own `min_interval`. Noticing a new profile and
running `init` is then your call — nothing rescans behind your back.

The first run refuses to save in exactly two cases. An **empty scan** is not written, because
recording "nothing installed" would stop every later run from looking again — you get
`no agents found — run aiusage init` instead. And an **existing config is never touched**,
whatever is in it: a file with no agents in it means someone edited it that way, and
rewriting it is `init`'s job, not a side effect of starting the dashboard. A config directory
that cannot be written to is not an error either — the dashboard just runs off the scan and
says `auto-detected — run aiusage init to save`.

## Configuration

The first run writes one (see above), and `aiusage init` rewrites it. You only need to edit it
by hand to change something the scan cannot know — a card's name, whether it is shown, its
refresh window:

```json
{
  "interval": 60,
  "columns": 2,
  "agents": [
    {"name": "claude",          "type": "claude", "config_dir": "~/.claude"},
    {"name": "claude personal", "type": "claude", "config_dir": "~/.claude-personal"},
    {"name": "codex",           "type": "codex",  "home": "~/.codex"},
    {"name": "agy",             "type": "agy"}
  ]
}
```

Per-agent fields: `enabled: false` hides the card, `refresh: false` forbids token rotation,
`min_interval` overrides the freshness window in seconds. `codex` takes `home`; `agy` takes `bin`
and `timeout`. A third Claude account is one more row with a different `config_dir` — or just log
in and re-run `aiusage init`.

Environment: `AIUSAGE_CONFIG` overrides the config path (default
`~/.config/aiusage/config.json`), `AIUSAGE_CACHE` the cache path (default
`~/.cache/aiusage/state.json`).

## Troubleshooting

| What you see | What it means |
|---|---|
| `no credentials — log in first` | no `.credentials.json` — start that CLI and log in |
| `refresh rejected — run claude to log in` | the refresh token is dead, or `refreshTokenExpiresAt` has passed |
| `from log HH:MM` on codex | the API was unreachable; these numbers come from the last session log |
| `unparsed /usage (...)` on agy | `agy` changed its output format, or is not logged in — check `agy --print /usage` |
| `timeout after 45s` on agy | the CLI did not answer; raise `"timeout"` in the config |
| `rate limited · retry in 4m` | the API refused (429); the last cached numbers are shown, with a countdown to the next attempt |
| `team · 12:41` in a card header | the data is cached, fetched at 12:41 (fresh data shows no time) |
| `saved 3 agents to ~/.config/aiusage/config.json` | the first run froze what it found; later runs read the file |
| `auto-detected — run aiusage init to save` in the footer | running off the scan without saving — usually a config directory that cannot be written to |
| `no agents found — run aiusage init` | nothing logged in was found — log into `claude` or `codex` and retry |
| `[-] api key mode — no usage endpoint` in `init` | the credentials hold an API key, not OAuth: limits are not exposed that way, so the profile is skipped |
| `[x] not found on this machine` in `init --reset/--prune` | the configured profile is gone from disk; the row can go |
| `[x] still works, but the scan cannot find it` | a live profile outside the search patterns: `--reset` would drop it, `--prune` would not |

## Development

```
pyproject.toml    build (hatchling), the `aiusage` entry point, zero dependencies
Makefile          run / test / install / zipapp / clean
tests/            no network: providers are stubbed, discovery runs against a fake $HOME
src/aiusage/
  __main__.py     CLI: flags, the init subcommand, mode selection (TUI / --once / --json)
  config.py       defaults, ~/.config/aiusage/config.json, merging with the scan
  discover.py     scanning $HOME and PATH, names from paths
  initcmd.py      the `aiusage init` wizard: show, ask, write
  uninstall.py    `aiusage uninstall`: list what was written, remove it
  collector.py    parallel polling: what is stale, what is cached, what is backing off
  model.py        Report / Window — the one shape all data takes
  cache.py        last-good values plus backoff, shared by every process
  cards.py        cards: frames, responsive grid, styled segments
  render.py       bars, percentages, time formatting, the --table mode
  tui.py          the curses screen, background polling, the timer
  httpx.py        JSON over HTTPS on the standard library
  credfile.py     the credential files the CLIs keep: atomic rewrite, JWT claims
  providers/
    claude.py     OAuth usage + token rotation
    codex.py      wham/usage + the rollout-log fallback
    agy.py        parsing `agy --print /usage`
```

```bash
make test        # or: PYTHONPATH=src python3 -m unittest discover -s tests
make run         # run from the clone without installing
make zipapp      # build the single-file executable into dist/ (~80 KB)
```

CI runs the tests on 3.9 through 3.14 on Linux plus one macOS job, and separately builds the
wheel, the sdist and the zipapp and runs each of them. The test job installs nothing at all —
if it ever needs to, the "runs on a bare python3" promise is gone.

Across the matrix with `uv`:

```bash
for v in 3.9 3.10 3.11 3.12 3.13 3.14; do
    PYTHONPATH=src uv run --no-project --python $v python -m unittest discover -s tests
done
```

This is a `src/` layout: `import aiusage` does not work from the repository root, so the tests
always exercise what actually ships rather than the directory next door. The single-file build
is possible only because there are no dependencies at all.

### Adding a provider

A provider is a function `fetch(spec) -> Report | list[Report]` that never raises: failures go
into `Report.error`. Register it in `providers/REGISTRY` and add a row to the config.

To make it discoverable too, add a scanner in `discover.py` (`_claude` / `_codex` / `_agy` are
the models) and one line to `discover.LAYOUT`: the spec field holding its path, the default the
provider assumes, and the file that proves a login. That single table is what `init` uses to tell
agents apart and what `--prune` uses to decide whether one is still there. Without it the
provider simply is not discovered — configured by hand, it still works.

## Disclaimer

Unofficial and unaffiliated with Anthropic, OpenAI or Google. It reads local credential files and
calls the providers' own endpoints; it is not built on any documented, stable API, so a provider
can change a response shape and break a card at any time.

## License

[MIT](LICENSE).
