agctl shows the subscription rate-limit utilization of several Claude accounts at
once — the 5-hour window, the weekly window, the weekly per-model window (Fable), and
usage credits — in one table or one terminal UI, without running claude and without
disturbing a Claude Code session that is already running.
Phase 1 is Claude only. Later phases add other providers and account switching; see Scope.
Account | Org | Plan | 5h | Weekly | Fable (weekly) | Credits | 5h reset | Weekly reset | State
-------------------+------+------+-----+--------+----------------+-------------------------+-----------------+----------------------+-------
[email protected] | Acme | max | 21% | 35% | 56% | n/a | 1h12m (4:15 PM) | 2d22h (Sun 02:00 PM) | ok
[email protected] | Acme | max | 4% | 12% | 30% | $219.56 / $5000.00 (4%) | 2h13m (5:16 PM) | 2d22h (Sun 02:00 PM) | ok
The Credits cell reads n/a when the account has no usage credits, off when they are
disabled, <used> / <limit> (<pct>%) when capped, and <used> / Unlimited when not.
The two reset columns say when each window rolls over as well as how long is left: the
5-hour window in 5h reset, the seven-day all-models window in Weekly reset. Each cell
leads with the countdown and follows it with the absolute local time in parentheses, and
within a column every countdown lines up flush left and every closing parenthesis lines up
flush right, so 2d22h and a shorter countdown in the same column still end at the same
right edge. The absolute time carries as much of the date as it takes to name the day —
nothing for a reset later today (4:15 PM), the weekday for another day this week
(Sun 02:00 PM), the date from a week out (Sep 16 02:00 PM) — and the hour is zero-padded
once a weekday or a date joins it, so every absolute time inside one of those two shapes is
the same width; today's bare clock time keeps the un-padded hour. A reset that has already
passed reads now (11:59 PM), and a window agctl has no reset for is an em dash. A per-model
weekly window other than Fable gets a continuation row of its own, and its reset appears in
Weekly reset.
macOS only in phase 1: account discovery reads the login keychain through security(1).
cargo build --release
./target/release/agctl claude statusThere is no published crate and no installer yet. If you install by hand, install the default-feature binary:
# correct
cargo build --release
install -m 0755 target/release/agctl ~/.local/bin/agctlNever build or install with
--all-features. Thetestingfeature compiles the test seams into the artifact, including overrides for the OAuth token endpoint. A production binary that honoursAGCTL_CLAUDE_TOKEN_URLwould send your refresh token wherever an environment variable pointed it.cargo install --all-featuresandcargo build --release --all-featuresare both wrong for anything you intend to run.scripts/release-gate.shbuilds the release artifact the correct way and proves it carries none of those seams.
# zsh — add to ~/.zshrc, after compinit (the script calls compdef, which
# only exists once the completion system is loaded)
autoload -Uz compinit && compinit
eval "$(agctl completions zsh)"
# or install the file once into a directory you own and put on fpath:
mkdir -p ~/.zfunc && agctl completions zsh > ~/.zfunc/_agctl
fpath=(~/.zfunc $fpath) # before compinit in ~/.zshrc
# bash — add to ~/.bashrc
eval "$(agctl completions bash)"
# fish
agctl completions fish > ~/.config/fish/completions/agctl.fishelvish and powershell are also accepted. The script is generated from the same
clap definition the binary parses, so it never drifts from the real flag set. On
bash older than 4.4 (macOS ships 3.2) the candidates come back sorted alphabetically
rather than in the order the help text lists them; everything else is the same.
Every provider command lives under agctl claude; the one top-level command is
agctl completions, above. --config-dir DIR is global and names agctl's store; it
is accepted before or after the subcommand.
agctl claude status
agctl claude status --json | jq '.rows[] | {id, state, windows}'
agctl claude status --account 8ff4… --account [email protected]
agctl claude status --all --refresh --timeout 30s| flag | effect |
|---|---|
--json |
the report as JSON instead of a table; the shape is fixed by schemas/status.v1.json |
--raw |
include the untouched upstream response body under raw |
--refresh |
refresh expired credentials agctl owns, and bypass the usage cache |
--no-cache |
bypass the usage cache without forcing a token refresh |
--all |
also show the rows hidden by default (stale siblings, foreign items, forgotten services) |
--by-identity |
fold the live credential into the row of the account that owns it, and add a Kind column; table only |
--account <ID> |
limit the report to one account; repeat for several |
--timeout <DUR> |
per-HTTP-request timeout, default 10s (10s, 5m, 2h, or a bare number of seconds) |
<ID> is the account UUID when that is unambiguous, <account-uuid>/<organization-uuid>
when it is not, and the email address when that is unique. A keychain item with no
identity is addressed by its service name.
One address can legitimately appear on two rows — a credential agctl owns and the one
Claude Code is signed in as can be the same account with two independent token pairs. The
owned row says so, with same identity as live in its State column and
"same_identity_as": "live" in --json; the rows stay separate, because each pair
expires, refreshes and can be revoked on its own.
--by-identity collapses that pair into one table row instead, with a Kind column
reading live+owned. The owned row is the one that survives, because it is the one
agctl can refresh, relocate or forget. It is a table flag: --json still emits one
object per credential source, so the two row counts differ under it, and a live row in a
failing state is never folded away.
agctl claude watch
agctl claude watch --interval 10m--interval defaults to 300s and will not go below 60s — agctl declines to poll
an undocumented endpoint faster than that. Keys: q, Esc, Ctrl-C or Ctrl-D quit;
r refreshes now; arrows or j/k move the selection. (Ctrl-C is bound explicitly
because raw mode swallows the terminal's own interrupt.)
Three things about watch that are not visible from the flags:
- A scheduled pass may be served from the 300 s usage cache and make no request at
all.
ralways goes to the wire. watchhas no--timeout; every request in a pass uses the 10 sstatusdefault. An--intervalso large that the schedule arithmetic overflows simply schedules nothing, and onlyrfetches.watchhas no--all. The footer counts the hidden rows and points atagctl claude status --allto see them.
agctl claude login --label work
agctl claude login --manual # paste `code#state` instead of using the loopback
agctl claude login --no-duplicate # refuse if this account is already the live oneThe browser goes to Anthropic's authorize page; the code comes back either to a loopback
listener on 127.0.0.1 or, with --manual, by paste. Only after the exchange returns —
so only once the account and organization are known — does anything reach disk, and it
reaches <config-dir>/claude/<account-uuid>/<organization-uuid>/.credentials.json under
that namespace's lock. A mismatched state fails before the exchange and leaves nothing
behind at all.
Logging the same (account, organization) in twice overwrites, and only after an
interactive confirmation. There is no --yes on login, so a non-interactive re-login
refuses rather than replacing a credential another process may be refreshing.
Logging in as the account Claude Code is already signed in as is allowed, and prints a
one-line notice on standard error saying both sessions stay valid: two independent token
pairs for one account is a supported setup. --no-duplicate refuses that case instead,
exits 1 and writes nothing; its message names the .claude.json the claim came from, so a
stale one can be checked. To change which account Claude Code itself uses, that is
agctl claude use --live <id>, not a second login.
agctl claude accounts list [--all]
agctl claude accounts show <id>
agctl claude accounts remove <id> [--delete-secret] [--yes]
agctl claude accounts relocate <id> [--yes]
agctl claude accounts forget <keychain-service>
agctl claude accounts unforget <keychain-service>remove and relocate mutate a namespace and therefore wait for that namespace's lock
before touching anything; both refuse a row agctl does not own. forget and
unforget flip one flag in agctl's own registry — the keychain item they hide is
never read, never written and never removed. relocate moves a namespace that was
created as _unknown-org into its real organization directory once the organization is
known.
agctl claude import --from keychain --dry-run
agctl claude import --from keychain --claude-config-dir ~/work/.claudeAn import records what is already true and changes nothing else: it reads Claude Code
credential items belonging to other configuration directories, once, to learn who they
belong to, and files them in agctl's registry as read-only rows. No credential is
written, moved or deleted; an account already in the registry is reported and left alone,
so a second import is a no-op. --dry-run prints the plan and writes nothing at all.
--claude-config-dir names a Claude Code configuration directory to scan; repeat it for
several. It is deliberately spelled differently from the global --config-dir, which
always means agctl's own store.
agctl claude doctor
agctl claude doctor --remove-stale ~/.config/agctl/claude/<acct>/<org>/.oauth_refresh.lock --yesThe report covers the keychain preflight, every discovered row with its token expiries,
credentials on this machine that belong to something else and are never read, the
namespace locks and who holds them, the Claude Code locks agctl is holding itself, the
artefacts a Claude Code session leaves behind, the files a failed write leaves behind, and
the four situations that are not failures but are worth knowing about: a stale sibling, a
forgotten service, two rows holding the same credential, and a namespace still called
_unknown-org.
--remove-stale is the only thing in agctl that deletes anything Claude Code
created, and deleting a lock that is not actually stale can corrupt a running Claude Code
session's credential store. It is fenced accordingly. The path must:
- spell a location under
<config-dir>/claude/, or be named by a held-lock record whose process is gone — see below; - not be in
.locks/— those are agctl's own locks, which nothing ever unlinks; - be named
.oauth_refresh.lock,.storage-write, or a legacy<namespace>.lock; - be a directory, reached without following a symbolic link: Claude Code takes every
one of its locks with
mkdirand releases it withrmdir, so a directory is the only shape a lapsed lock has. A regular file at one of those names was written by something else; the report calls it anomalous and nothing removes it; - be older than 60 s;
- show the same modification time in two samples 12 s apart — Claude Code's lock holders heartbeat every five seconds and derive "the holder is alive" from exactly that comparison, so an unchanged mtime across twelve seconds is the evidence that nobody is holding it;
- and be confirmed with
--yes, after the risk has been printed.
Anything else is refused, including a path that satisfies six of the seven. The command takes about twelve seconds because of step 6.
Step 1 has one exception, and it is the only way --remove-stale reaches outside
<config-dir>/claude/. When agctl takes Claude Code's locks itself it records what it
took before taking it, and a crash leaves that record naming directories nothing else will
ever remove. So a path outside the store is accepted when a record in
<config-dir>/claude/held-locks/ names that exact path and the process that wrote it
is gone. A live process, a record naming some other path, and no record at all are each
refused. doctor lists those records, and prints the removal command for the ones that
leaked.
Three sources, and what separates them is who is allowed to write the credential:
- The live keychain item — the credentials the
claudeyou run right now is using. agctl reads it and never refreshes or writes it. Identity comes from the blob's owntokenAccount, or from.claude.jsonfor this row only. - Per-configuration-directory keychain items — a Claude Code credential item named
after some other config directory, recorded by
import --from keychain. Read once, read-only forever. - agctl-owned namespaces —
<config-dir>/claude/<account-uuid>/<organization-uuid>/.credentials.json, created bylogin. These are the only credentials agctl will ever refresh or write.
A keychain item is named after a directory spelling, not after an account, so the same
account can appear under several names and two names can point at one directory. agctl
therefore folds two entries into one row only when their token digests match, never by
path. Two items naming one physical directory but holding different credentials are a
stale sibling of live: real, not actionable, hidden by default and counted in the
footer. A blob that names nobody is identity unknown and stays visible, because logging
in fixes it. An unrecognised Claude Code item is unclaimed and shown; accounts forget
hides it.
By default the table shows Live, Owned, unclaimed and identity unknown rows. Stale
siblings, third-party (claude-switcher:*) items and forgotten services are hidden and
counted; --all shows them.
- agctl writes exactly one class of keychain item, and deletes none. The
security(1)subcommands it issues areshow-keychain-info,find-generic-passwordanddump-keychain— all reads — plusadd-generic-password -U, on standard input, for one case: a namespace agctl created that a Claude Code session has since migrated into the keychain. That item is refreshed in place under Claude Code's own lock protocol, and its name is derived from the account registry, so no other item is nameable as a target. The liveClaude Code-credentialsitem is never written. There is nodelete-generic-passwordcode path at all, and no secret ever appears in a command line: the credential goes tosecurity -iover a pipe. Every write is appended to~/.config/agctl/claude/keychain-writes.jsonl, which records digest prefixes and never token material; the entry is written after the write, and a failure to append is reported rather than rolling the write back, so a process killed between the two leaves a write the log does not name. - agctl never writes under a live Claude Code configuration directory, and never
touches
.claude.json. Every file it creates is under its own configuration directory. - Refresh tokens sit at rest in 0600 files —
~/.config/agctl/claude/<acct>/<org>/.credentials.json, in a directory tree created at 0700. This is the same posture as Claude Code's own plaintext fallback store, which holds the same material in the same shape at the same mode. It is not the keychain, and it is not encrypted: anything running as your user can read it. - The namespace lock lives outside the namespace —
~/.config/agctl/claude/.locks/<acct>.<org>.lock— is created once, and is never unlinked, not even by the command that deletes the namespace it protects.flocklocks an inode, so a lock file that can be deleted and recreated is a lock two processes can hold at the same time. - Claude Code activity in a namespace is detected and the refresh is refused. Before a
refresh POST, agctl checks the write target, takes the lock, re-checks for a Claude
Code session under the lock, and re-checks once more immediately before the rename. Any
surprise at any of those points ends in a refusal. There is no flag that overrides it: a
row reading
claude session detected — refresh refusedis the system working. A namespace a session has migrated into the keychain is the one activity that is not a refusal: agctl refreshes that item instead of the file, and refuses again if the item changes while the refresh is in flight. - A row agctl does not own is never refreshed and, when expired, is not even fetched. Its owner refreshes it; agctl reports.
- agctl removes a lock artefact it did not create in exactly two circumstances.
doctor --remove-stale, fenced by the seven conditions — and the one record-attested exception to the first of them — listed underdoctor; and, as a protocol peer, a stale Claude Code lock inside agctl's own directory tree while refreshing a migrated namespace's keychain item. The second takes twelve seconds of modification-time sampling before it removes anything, stands down if anyclaudeprocess is stopped, and appends the whole decision to the audit log whether it broke the lock or abandoned the attempt. Neither can reach the live~/.claude.
The numbers come from GET https://api.anthropic.com/api/oauth/usage, the endpoint
Claude Code's own /usage command reads. It is undocumented. It is not part of
Anthropic's published API, it carries no compatibility promise, and it may change shape or
disappear in any Claude Code release — which is what
docs/re-verify.md is for.
agctl sends an honest User-Agent: agctl/<version>. It does not pretend to be
Claude Code. A client that lies about who it is cannot be rate-limited, deprecated or
excluded separately from the product it is impersonating, which is bad for both sides. If
Anthropic ever starts refusing the honest agent, AGCTL_CLAUDE_USER_AGENT replaces the
string without waiting for a release.
Reading your own subscription usage with your own credentials is the same operation
/usage performs, but you are responsible for your own use of Anthropic's services under
their terms. agctl reads usage figures and refreshes tokens it owns; it sends no
inference requests and consumes no quota.
Caching and polling. A usage response is cached for 300 s per account, at
<config-dir>/cache/claude/<acct>.<org>.<sha8>.json. Inside that window a repeated
status answers from disk and makes no request. --refresh and --no-cache bypass it.
watch --interval will not go below 60 s. When a fetch fails — a 429, a dead network,
a keychain that went away — the last good numbers are shown with the row marked stale or
rate-limited, rather than a blank cell.
agctl coexists with Claude Code. It does not coexist with another tool that rewrites the same credentials.
- A third-party menu-bar account switcher (for example
claude-account-switcher) works by deleting and recreating the live keychain item on every switch. agctl lists those tools'claude-switcher:*items and never reads or writes them, but it cannot stop the live item from being replaced underneath it. Run one switcher, not two. /logoutinside a Claude Code session that is pointed at an agctl namespace deletes that namespace's credential store. Claude Code's logout clears both the keychain item and the plaintext file. Nothing is corrupted, but that account needs a newagctl claude login.- Two agctl stores with different
--config-dirvalues have independent locks. Logging the same account into both makes two independent holders of one refresh chain, and each will eventually invalidate the other's token. Use one store per account.
| variable | meaning |
|---|---|
AGCTL_CONFIG_DIR |
the environment form of --config-dir. Default: the XDG configuration directory plus agctl, i.e. ~/.config/agctl |
AGCTL_CLAUDE_USER_AGENT |
replaces the agctl/<version> User-Agent on the usage and token endpoints |
AGCTL_CLAUDE_OAUTH_SCOPES |
replaces the space-separated scope set requested at login. A diagnostic: the server grants the same five scopes whatever is asked for |
RUST_LOG |
tracing filter for the diagnostics on stderr. Unset or unparseable means warn. RUST_LOG=agctl=trace is the useful setting; no token material is ever logged at any level |
TZ |
selects the zone the 5h reset and Weekly reset columns are printed in. Unset — or set to something unrecognised — means the system zone (/etc/localtime), and UTC when even that cannot be determined |
The three AGCTL_* names above are the whole AGCTL_* surface agctl defines —
RUST_LOG and TZ it only reads. Every other AGCTL_*
name you may find in the source is a test seam compiled only under the testing feature
and absent from a release build — see Build and scripts/release-gate.sh.
agctl also reads a handful of variables it does not define, because they decide what Claude Code itself would do:
| variable | meaning |
|---|---|
CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR |
read on every run by EnvView::from_process in src/provider/claude/namespace.rs; together they decide which keychain service name agctl rebuilds — see docs/re-verify.md section 1 |
CLAUDE_CODE_OAUTH_TOKEN |
short-circuits Claude Code's own credential lookup; agctl reports that row read-only and never refreshes it |
HOME |
locates the live store |
USER, LOGNAME |
the acct attribute every find-generic-password is keyed on (src/secret/mod.rs); an unexpected value finds nothing rather than erroring |
| code | meaning |
|---|---|
0 |
the run produced a complete, healthy result |
1 |
the run failed outright and produced nothing useful — a bad configuration, an I/O failure, a refused command |
2 |
the run produced output, but at least one shown row is degraded: expired, needs login, rate-limited, stale, keychain-locked, busy, or refused |
Exit 2 is about rows you can see. A row hidden by default cannot change the exit status, because you did not ask about it. A pending credential that was replayed successfully is not a degraded row and exits 0.
The gate is three commands, and --all-features is not optional for the last two — the
test suite fails to compile without it, on purpose:
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo nextest run --all-featuresSee .claude/skills/check/SKILL.md for what each one is guarding and how to read a
failure, and AGENTS.md for the build environment (the whole crate builds with
-C debug-assertions=off -C overflow-checks=off, so debug_assert! is inert and integer
overflow wraps silently).
Unit tests live in a sibling foo_tests.rs beside each foo.rs; the end-to-end suite is
tests/e2e_*.rs, all of it behind #![cfg(feature = "testing")]. Two of those tests are
deliberately slow — one waits out doctor's 12 s stale-sampling interval, one waits out
the 5 s command lock timeout — so the whole suite takes about twelve seconds of wall clock.
No test may touch the real keychain, the real $HOME, or the network.
Before releasing an artifact, run scripts/release-gate.sh: it builds a default-feature
release into a scratch directory and proves the binary contains no test seam.
After a Claude Code upgrade, work through docs/re-verify.md — agctl's correctness depends on four contracts read out of Claude Code's own binary, and an upgrade can change any of them.
| phase | scope |
|---|---|
| 1 (this) | status, watch, login, accounts, import (read-only), doctor — Claude only, macOS only |
| 2 | switching the account Claude Code uses, without running claude |
| 3+ | other providers: Codex, Cursor, Copilot, and the rest |
Apache-2.0. See LICENSE.