Source of truth:
soma-work#574. This document is the implementation contract.
Claude Code stores its OAuth credentials in two places on a developer machine:
- macOS Keychain, service name
Claude Code-credentials. - A fallback file at
~/.claude/.credentials.json.
Tokens rotate frequently (new refresh_token on every /v1/oauth/token call),
quotas (5h / 7d / 7d_sonnet) are exposed only through an undocumented OAuth usage endpoint,
and developers often juggle multiple accounts (personal, team). The existing
single-slot cauth tool in 2lab-ai/agent-island solves part of this but is buried inside
a larger project. This crate is the standalone, multi-slot successor.
- F1 — Byte-identical roundtrip between the Keychain blob and
~/.claude/.credentials.json. - F2 — Query
GET api.anthropic.com/api/oauth/usageand show5h %,7d %,7d_sonnet %,resets_at. 401 auto-retries once after a refresh. - F3 — Proactive refresh when
expires_at < now + 7h, handlingrefresh_tokenrotation, with atomic multi-store writes. - F4 — Multi-slot registry: register N accounts, switch which one Claude Code sees via
claude-token use. - Self-contained binary — One
cargo installgets you a single binary. No Python, no Homebrew tap needed.
crates.iopublish, cargo-dist release binaries,doctorcommand, real-Keychain macOS CI — deferred to PR2.- API-key (
sk-ant-...) management — out of scope entirely. - Slack / Discord notifications — out of scope.
CLIENT_ID = 9d1c250a-e61b-44d9-88ed-5944d1962f5eTOKEN_ENDPOINT = https://platform.claude.com/v1/oauth/tokenUSAGE_ENDPOINT = https://api.anthropic.com/api/oauth/usageDEFAULT_SCOPE = "user:profile user:inference user:sessions:claude_code user:mcp_servers"- Usage request must include header
anthropic-beta: oauth-2025-04-20.
| Purpose | macOS | Linux |
|---|---|---|
| Config (slot catalog) | ~/Library/Application Support/ai.2lab.claude-token-cli/slots.json |
~/.config/claude-token-cli/slots.json |
| Data (journal, keys, locks) | same as config_dir | ~/.local/share/claude-token-cli/ |
| Claude Code credentials | ~/.claude/.credentials.json |
same |
| Legacy migration source | ~/.claude/claude-token-cli.json → renamed .moved on first run |
same |
Resolution via directories::ProjectDirs::from("ai", "2lab", "claude-token-cli").
{
"version": 1,
"active": "work",
"slots": [
{
"name": "work",
"email": "z@2lab.ai",
"plan": "team",
"rate_limit_tier": "tier4",
"last_expires_at": "2026-04-18T12:34:56Z",
"last_five_hour_percent": 42.1,
"last_seven_day_percent": 10.8,
"last_seven_day_sonnet_percent": 7.5,
"last_resets_at": "2026-04-18T17:00:00Z"
}
]
}versionmust be1; unknown versions → hard error with migration hint.- Slot
nameregex:^[a-z0-9][a-z0-9_-]{0,31}$. Reserved names:all, anything matching#\d+. - Credentials themselves live in Keychain (macOS) or in the catalog under
slots[].creds(Linux plaintext, file mode0600).
Applies to refresh and use on the active slot. Ordered so that disk is the
commit point observed by Claude Code.
- Acquire
fd_lockexclusive on${data_dir}/.state.lock. - Write journal
${data_dir}/journal/<slot>.pending.jsonviatempfile → fsync(fd) → rename → fsync(parent).- Journal entry:
{ op, slot, prev_hash, new_hash, op_id, timestamp }. - Hashes =
HMAC-SHA256(per-install key, canonical credentials JSON bytes).
- Journal entry:
- Write Keychain (
security add-generic-password -U -a <acct> -s <svc> -w <payload>). - Write disk
~/.claude/.credentials.jsonvia the same tempfile pattern. - Delete journal file.
- Release lock.
For each journal/*.pending.json:
disk_hash |
keychain_hash |
Action |
|---|---|---|
new |
new |
Stale journal, delete. |
prev |
new |
Resume step 4 (write disk, delete journal). |
prev |
prev |
Resume step 3 (write Keychain, then disk, delete). |
| anything vs. empty | — | Restore Keychain from disk (disk is authoritative when active). |
| otherwise | — | replay conflict hard error, bail, require operator. |
Goal: a crash / SIGKILL during any step leaves recoverable state, never a split-brain where Claude Code sees a token that the Keychain has invalidated.
- Canonical account name / service (for Claude Code's consumption): resolved via
cauth'sresolve_claude_keychain_account_name(service =Claude Code-credentials, account = whatever existing entry uses, fallback to$USER). - Only the active slot lives at that canonical location.
- Inactive slots live under service
claude-token-cli::<slot-slug>(same account). Claude Code never sees them. use <name>swap (journaled):- (a) archive current active →
claude-token-cli::<current_name>; - (b) restore target ←
claude-token-cli::<target_name>to canonical and disk; - (c) update
catalog.active, clear journals.
- (a) archive current active →
- On Linux, no Keychain: all inactive slots live in the catalog file (plaintext,
0600).
- All token fields are
secrecy::SecretString. ManualDebugimpls redact to[REDACTED]. .expose_secret()is allowed only incredentials.rsandoauth.rs. Elsewhere:#[deny(clippy::disallowed_methods)]or equivalent lint.- Errors that carry token material wrap the string in a
RedactedStringnewtype thatDisplays as[REDACTED]. - Defense-in-depth regex scrubber strips
sk-ant-[A-Za-z0-9_-]+andgho_[A-Za-z0-9]+from errorDisplayoutput. Test asserts norefresh_token/access_tokenleaks on any refresh / usage failure path.
- Exclusive
fd_lockon${data_dir}/daemon.lock. Refuse to start if held. - Main loop:
crossbeam::select! { recv(shutdown_rx) -> _ => break, recv(crossbeam::channel::tick(interval)) -> _ => refresh_all(), }
interval=Duration::from_secs(refresh_interval_minutes * 60). Default 30 min; overridable via envCLAUDE_TOKEN_REFRESH_INTERVAL_MINUTES.shutdown_rxfed by asignal-hookhandler forSIGINT/SIGTERM. SIGTERM during sleep exits in milliseconds.- Per slot: refresh iff
expires_at < now + 7h. Sequential, with a 0–2 s jitter between slots.
signal-hookinstallsSIGINT/SIGTERMhandlers that setSHUTDOWN: AtomicBooland fan-out toshutdown_rx.- Critical sections (steps 2–5 of the two-store protocol) check
SHUTDOWNat entry and then run to completion (< 100 ms). io::ErrorKind::StorageFullmaps to a dedicatedCliError::StorageFullvariant. On ENOSPC we do not touch the Keychain — the disk write failed before Keychain was consistent with it.
claude-token add [--from keychain|file] [--path <file>] [--name <slug>]
claude-token (= claude-token list)
claude-token list [--format pretty|json] [--debug] [--detail] [--no-usage]
claude-token use <name|#N>
claude-token refresh [<name>|--all] [--force]
claude-token usage [<name>] [--format pretty|json]
claude-token daemon
claude-token export <name|#N> [--path <file>]
claude-token remove <name|#N> [--yes]
Global flags: --format {pretty,json}, -v (info), -vv (debug).
export— copy a slot's stored credentials to a file (default~/.claude/.credentials.json). Read-only against the catalog and keychain: no journal entry, no active-slot swap, no write to canonical. For the active slot the source of truth is the canonical keychain entry (§8), soexportreads canonical first and falls back to the slot archive only if canonical is missing. For inactive slots it reads the archive atclaude-token-cli::<slot>.remove— delete a slot. Archive blob atclaude-token-cli::<slot>is always dropped. When the removed slot is active, the canonical keychain entry and~/.claude/.credentials.jsonare also cleaned so Claude Code is not left with orphan bytes whose archive companion no longer exists. The critical section holds thestate_lockfrom §7 to prevent interleaving with an in-flightuseorrefresh.listflags:--debug— dump every resolved path, catalog entry, and raw keychain/disk payload byte-for-byte. Tokens are not redacted — intended for local inspection only.--detail— show the Opus-only weekly bucket as an extra7d sonnetcolumn.--no-usage— skip the per-slot/api/oauth/usageround-trip (offline / rate-limited environments).
trait KeychainStore { fn read; fn write; fn delete; fn list }with two impls:MacSecurityCli(cfg(target_os = "macos")).InMemoryFake— always available; used by all unit / integration tests so ubuntu CI exercises the same logic.
tests/roundtrip.rs— parse → serialize → parse idempotence + byte-identical storage.tests/journal_replay.rs— crash between any two steps → replay reaches a consistent state (each of the five table rows in §7).tests/keychain_swap.rs—usecrash between archive / restore → replay completes swap.tests/refresh_mock.rs—wiremockcases: happy, rotatedrefresh_token, 400, 401, 500. Assert errorDisplaydoes not contain the refresh token.tests/usage_mock.rs—wiremockcases: 200, 401 → refresh → retry succeeds, 401 → refresh fails. Leak assertion on all error paths.tests/redaction.rs—RedactedStringDebug/Display+ provenance scrubber.
- Matrix:
ubuntu-latest,macos-14. - Rust stable pinned in
rust-toolchain.toml(1.78+). - Steps:
cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo test --all-targets,cargo audit(warn-only),cargo deny check(warn-only). - Real-Keychain integration test is deferred to PR2.
.
├── Cargo.toml
├── rust-toolchain.toml
├── LICENSE-MIT · LICENSE-APACHE · README.md · SPEC.md · .gitignore
├── .github/workflows/ci.yml
├── src/
│ ├── main.rs # clap + tracing + dispatch + shutdown wiring
│ ├── lib.rs # pub re-exports
│ ├── config.rs # ProjectDirs + legacy migration + path helpers
│ ├── slot.rs # SlotCatalog, name validation
│ ├── credentials.rs # Credentials struct (SecretString fields, flatten extra)
│ ├── redact.rs # RedactedString newtype + scrubber
│ ├── journal.rs # JournalEntry + HMAC key + write + replay
│ ├── keychain.rs # trait KeychainStore + MacSecurityCli + InMemoryFake
│ ├── oauth.rs # refresh + usage clients, reqwest blocking + rustls
│ ├── format.rs # KST+UTC+relative, tabled renderer
│ ├── signal.rs # signal-hook SIGINT/SIGTERM -> shutdown channel
│ └── commands/
│ ├── mod.rs
│ ├── add.rs · list.rs · use_.rs · refresh.rs · usage.rs · daemon.rs
│ ├── export.rs · remove.rs
│ └── replay.rs
└── tests/
├── roundtrip.rs · journal_replay.rs · keychain_swap.rs
└── refresh_mock.rs · usage_mock.rs · redaction.rs
Runtime: clap (derive), reqwest (blocking, rustls-tls), serde, serde_json (preserve_order),
chrono, chrono-tz, tempfile, fd-lock, thiserror, anyhow, tabled, secrecy,
tracing, tracing-subscriber, directories, signal-hook, crossbeam-channel, sha2,
hmac, hex, regex, base64, rand.
Dev: wiremock, tokio (macros, rt-multi-thread), assert_cmd, predicates, tempfile.
Dual MIT / Apache-2.0.
2lab-ai/agent-island
cauth/src/main.rs
commit a6ca08c28ffe311760ac18bb759279253a5c6e3a.
Key functions to port:
refresh_claude_credentials_always(L1758–1804)default_refresh_client(L2586–2635)default_usage_client(L2637–2665)- Keychain read / write via
securityCLI (L1811–1862) - Scope constants (L22–25)