claude-acc-manager: Architecture
An arc42 description of the system, small enough to live in one file. Durable: this file and docs/adr/ hold decisions and non-trivial knowledge, cite real sources of truth, and never cite research_repos/ (see ADR-0001).
1. Introduction and goals
Requirements overview
cam is a Linux-only CLI/TUI that manages, measures, and swaps Claude Code OAuth subscription accounts:
cam add <name>: register an account by logging in at the source (ADR-0004); tokens are never copied.cam list / status / usage: per-account usage and limits (5h, 7d, model-scoped weeklies, resets).cam switch [<name>] [--strategy best|next-available]: manual or quota-driven swap of the login used by the plainclaudecommand.cam auto [--once|--interval|--threshold|--cooldown]: unattended threshold-driven switching with anti-flap guards.cam upgrade [target]: delegate the upgrade to the installer that owns this cam (uv/pipx); source installs print the manual path.
Explicitly out of scope: macOS/keychain/menubar, Claude Desktop, session-history merging, directory mappings, aliases, export/import, API-key and setup-token accounts (ADR-0005), systemd unit packaging (ADR-0006), Secret Service storage (ADR-0003). Detail in §11.
Quality goals
| Goal | What it means here |
|---|---|
| Credential safety | Secrets never leave the machine except to api.anthropic.com / platform.claude.com; 0600/0700 + atomic writes; never in argv, logs, or error bodies (§8). |
| Measurement fidelity | Decisions consume real quota windows from the account's own endpoint, within a measured request budget rather than guesses. |
| Race correctness | Swaps hold Claude Code's own mkdir locks so a live session's refresh never interleaves with a write (§6). |
| Local-first | No telemetry; the only third-party egress is an opt-out PyPI release check (updates.mode, CAM_DISABLE_UPDATE_CHECK). |
| Minimal supply chain | Two runtime deps (textual, rich), both UI; stdlib for HTTP and CLI parsing (ADR-0002). |
Stakeholders
| Role | Who | Interest |
|---|---|---|
| Maintainer | the solo developer, also the primary user | decisions that stay navigable across context-free sessions; a gate that substitutes for reviewer memory |
| Users | Claude Code subscribers on Linux with multiple OAuth accounts | credential safety, honest quota numbers, an install that doesn't fight claude |
2. Constraints
- Linux only. File-credential model (
~/.claude/.credentials.json); no keychain paths. - OAuth subscription accounts only (ADR-0005).
- Stack locked: Python ≥3.11 + uv; runtime deps
textual+rich; stdliburllib/argparse(ADR-0002). - Strict TDD per commit: coverage ≥95% branch + mutation gate (ADR-0011).
- Package-by-component with import-linter contracts (ADR-0010).
- Usage endpoint budget: ~28–30 req/h/identity for non-first-party clients; every fetch goes through
poll_policy(§6).
3. Context and scope
┌─────────────────────────────┐
cam add ─────────►│ claude (CLAUDE_CONFIG_DIR= │
│ <store>/accounts/<name>/) │◄── interactive OAuth login
└─────────────────────────────┘
cam switch/usage ─► ~/.claude/.credentials.json ◄── read/write under
~/.claude.json (oauthAccount) claude-code's mkdir locks
└── api.anthropic.com ◄── /api/oauth/usage, /api/oauth/profile
platform.claude.com ◄── /v1/oauth/token (refresh)
| Partner | Direction | Interface |
|---|---|---|
<secure-store>/.credentials.json |
we read/write | OAuth blob (claudeAiOauth: accessToken, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier; optional sibling organizationUuid); 0600. <secure-store> resolves CLAUDE_SECURESTORAGE_CONFIG_DIR (defined → verbatim, defined-empty → ~/.claude) then CLAUDE_CONFIG_DIR, default ~/.claude (ADR-0014) |
~/.claude.json |
we splice | top-level oauthAccount identity; ~90 other keys must be preserved; path quirk: ~/.claude/.config.json wins if it exists (legacy) |
api.anthropic.com /api/oauth/usage |
we GET | Authorization: Bearer + anthropic-beta: oauth-2025-04-20 + UA; no Content-Type (ADR-0007) |
api.anthropic.com /api/oauth/profile |
we GET | same headers; identity oracle that classifies which account a credential belongs to |
platform.claude.com /v1/oauth/token |
we POST | RFC 6749 refresh grant, client_id 9d1c250a-e61b-44d9-88ed-5944d1962f5e; Content-Type + UA, no anthropic-beta |
| claude-code process | we coexist with | mkdir locks <secure-store>/.oauth_refresh.lock, <realpath(secure-store)>.lock (60s staleness), ~/.claude.json.lock (10s staleness), <secure-store>/.storage-write (15s staleness, held per credential mutation). Contract verified only on [2.1.144, 2.2.0); mutating ops probe claude --version and refuse outside it (ADR-0015) |
pypi.org /pypi/claude-acc-manager/json |
we GET | info.version for the release check: 2s budget, no credentials, advisory (never gates a command), cached 24h |
Usage response contract (undocumented; ADR-0012): five_hour/seven_day {utilization 0–100, resets_at}; per-model weeklies only via limits[] entries carrying scope.model.display_name; ≤101 tolerated and saturated; unknown keys ignored; extra_usage present on the wire but unparsed. Percentages and reset epochs only; no absolute token counts. OAuth-only (API keys → 401).
Refresh rotation: one-time-use refresh tokens: both rotated tokens persist in one atomic write or the lineage strands (ADR-0009; anthropics/claude-code#31021, #30930).
Our storage (ADR-0003): $XDG_DATA_HOME/claude-acc-manager/: accounts/<name>/ (each a real CLAUDE_CONFIG_DIR holding .credentials.json + .claude.json), registry.json (order, active pointer, enabled flags, quarantined lineages), usage-cache.json (last-good + 429 backoff per account), settings.json (threshold/interval/cooldown/hysteresis/strategy + privacy/updates sections), auto-state.json (cooldown + no-return departure snapshot, under .auto-state.lock), update-check.json (one release-check record: the last outcome, failure cached 24h), .lock (registry flock), .ops.lock (operations flock: add/remove/switch hold it for their whole transaction; readers never take it; ADR-0014). Dirs 0700, files 0600, all writes atomic via shared/fsio.py.
4. Solution strategy
| Problem | Approach | Decision |
|---|---|---|
| Account onboarding without copying tokens | CLAUDE_CONFIG_DIR login-at-source |
ADR-0004 |
| Who refreshes which token | Claude Code owns the active slot; we refresh inactive accounts only, on demand | ADR-0009 |
| Rate-limited, undocumented usage endpoint | Honest UA; per-endpoint headers; flat 429 backoff; last-good while trust_ok |
ADR-0007, ADR-0008 |
| Drifting response shape | Schema-tolerant model of exactly the consumed keys | ADR-0012 |
| Layered structure that stays navigable | Package-by-component + import-linter contracts | ADR-0010 |
| No reviewer memory between sessions | Strict TDD, full gate per commit | ADR-0011 |
| Hermetic CLI tests | Transport-only cli.py; composition root in __main__ |
ADR-0013 |
5. Building blocks
src/claude_acc_manager/
accounts/ # the switching domain
domain/ entities · value_objects · oauth_identity · credential_fields
services/{identity_match, switch_selection}
application/ports.py AccountStorePort · ActiveSlotPort · ClaudeLockPort
OpsLockPort · LoginLauncherPort · AccountDirReaderPort
AccountDirPort · UnclaimedCredentialPort · ClockPort
ClaudeContractPort · SwitchExecutorPort · ActiveIdentityPort
LineageQuarantinePort · DiagnosticsPort
application/use_cases/ add · remove · list · status · switch · set_enabled
quarantine_dead_lineage · collect_accounts_view ·
collect_doctor_report
infrastructure/ file_account_store · active_slot · claude_locks
ops_lock · claude_login_launcher · account_dir_files
account_credential_store · unclaimed_store · path_resolver
iso_system_clock · claude_contract_probe · diagnostics_probe
usage/ # the measurement domain
domain/ usage_snapshot · usage_report · oauth_credential ·
resolved_identity · usage_cache_entry ·
services/{headroom, poll_policy, pace, cache_trust}
application/ports.py UsageApiPort · TokenRefresherPort · IdentityLookupPort
UsageCachePort · CredentialStorePort · ClockPort
HttpTransportPort · ClaudeContractPort · UsageFetchPort
application/use_cases/ fetch_account_usage
infrastructure/ anthropic_oauth · http_transport · file_usage_cache
epoch_system_clock · claude_contract_probe
auto/ # the unattended-switching domain
domain/ auto_event · auto_state · services/{auto_rank, loop_delay}
application/ auto_engine · ports (FreshenPort · AutoStatePort)
application/use_cases/ freshen_target
infrastructure/ file_auto_state
settings/ # persisted preferences
domain/ settings_spec
application/ports.py SettingsPort
application/use_cases/ list_settings · load_settings · set_setting · unset_setting
infrastructure/ file_settings
update/ # the self-update domain
domain/ update_offer · services/{version_order, install_method}
application/ports.py ReleaseFeedPort · UpdateCheckCachePort
UpgradeRunnerPort · HttpGetPort
application/use_cases/ check_update · self_upgrade (+ update_message)
infrastructure/ pypi_release_feed · file_update_cache
subprocess_upgrade_runner
shared/ fsio (atomic private writes) · file_lock · claude_contract
container · severity_ramp
cli/ transport-only dispatch (ADR-0013): parser · dispatch ·
commands · human · json_output · auto_output · context
tui/ textual UI: app · dashboard · account_list · autoview ·
settings_screen · modals · widgets · throbber · visuals ·
formatting · theme · palette
__main__.py composition root (ADR-0013)
Structural rules are ADR-0010; accounts may import usage, never the reverse. update also consumes usage's transport ports (HttpTransportPort · ClockPort) rather than re-opening an HTTP boundary.
6. Runtime views
Fetch one account's usage (cam usage <name>)
- Fresh cache → serve without fetching.
- Stale/absent cache → if the account is inactive and its token is expired, refresh first (ADR-0009);
invalid_grantreturns a permanent quarantine signal without hitting/usage. - GET usage → snapshot → persist cache → answer with a poll plan.
- 429 → arm flat backoff, serve frozen last-good while
trust_okholds (ADR-0008); any other failure freezes last-good without arming the lockout.
Switch transaction
Under .ops.lock (outermost, cam vs cam), then claude-code's .oauth_refresh.lock + ~/.claude.json.lock; every .credentials.json mutation also holds <secure-store>/.storage-write for its write instant (ADR-0014):
- Back up the outgoing credential +
oauthAccountinto the outgoing account's slot; the identity oracle classifies an unattributable credential before anything is overwritten. - Read the target account's stored credential +
oauthAccount. - Write target credentials to
~/.claude/.credentials.json(atomic, 0600). - Splice target
oauthAccountinto~/.claude.json, preserving all other keys (salvage-copy + replace if torn). - Update
registry.json.
Any failure rolls back in reverse. A running Claude Code picks up file-mode credentials on its next message.
Strategy semantics
headroom(account) = 100 − max(utilization of binding windows) over 5h, 7d, and each active model-scoped weekly. best: switch only to a strictly-greater headroom; stay on unmeasurable current. next-available: registry order, skipping headroom ≤ 0. Disabled accounts and quarantined lineages are never candidates.
Auto tick
auto --once: one check → maybe switch → exit (0 switched / 1 error / 2 no action / 3 blocked). auto loop: default 60s interval (min 15s), threshold 90% (50–99.9), cooldown 300s between proactive switches, hysteresis 10% on candidates, SIGTERM-clean. Every fetch respects the poll budget.
7. Deployment view
cam is a single-user local install: no daemon, no listener, no background service.
- Install:
pipx install claude-acc-manageroruv tool install claude-acc-managerputscamonPATH; Python ≥3.11, runtime depstextual+rich(ADR-0002).cam upgradedelegates upgrades back to that installer; a source (git/path) install prints its manual re-install path instead. - Files at runtime (all inside the installing user's account): cam's store under
$XDG_DATA_HOME/claude-acc-manager/(§3 "Our storage"); the live slot~/.claude/.credentials.json+~/.claude.json; lock dirs held only for a write's duration (§6). - Processes: every
camcommand runs in the foreground and exits;cam autois a supervised foreground loop, deliberately not a systemd unit (ADR-0006). - Coexistence: mutating ops probe
claude --versionand refuse outside the verified band[2.1.144, 2.2.0)(ADR-0015).
8. Cross-cutting concepts
Credential-safety guarantees (each test-enforced)
- Egress allowlist on credential-bearing traffic:
api.anthropic.com+platform.claude.comonly; enforced inanthropic_oauth.py's_require_allowed_host, asserted by a unit test; no configurable base URLs. The release check addspypi.orgas the one non-Anthropic egress, a hardcoded URL carrying no credentials (update/infrastructure/pypi_release_feed.py). - No telemetry and no third-party endpoints beyond that opt-out release check (
updates.mode off/CAM_DISABLE_UPDATE_CHECKkill it). - 0600/0700 + atomic writes everywhere via
shared/fsio.py. - Secrets never in argv, logs, exceptions, or persisted error bodies;
AnthropicApiErrorcarries status + RFC 6749 error code only, and redaction tests assert a token-shaped body never reachesstr(exc). - Hermetic tests: injected paths, clocks, transports; nothing touches real
$HOME/$XDG/network. - Refuse to run as root (outside containers).
- Tears surface, never swallowed: a present-but-malformed JSON file raises rather than silently defaults.
- Mutating/interoperating operations (add, switch, refresh, launch) refuse outside the verified claude band
[2.1.144, 2.2.0); probed per call at the narrowest unsafe op, overridable only per run viaCAM_ASSUME_CLAUDE_CONTRACT; read-only commands never probe (ADR-0015).
Identity oracle fails open
The profile GET resolves "which account owns this credential" before a switch overwrites anything; on any error it answers None and the caller treats the credential as unattributable rather than guessing at one.
9. Architecture decisions
| # | Decision |
|---|---|
| 0001 | Split durable decisions from ephemeral build evidence |
| 0002 | Python + uv; stdlib urllib/argparse; runtime deps textual + rich |
| 0003 | Credentials at rest: 0600/0700 under XDG, atomic writes |
| 0004 | Onboarding is login-at-source via CLAUDE_CONFIG_DIR |
| 0005 | OAuth subscription accounts only |
| 0006 | Auto scope: strategies + --once + foreground loop; no systemd |
| 0007 | Honest User-Agent; per-endpoint header sets |
| 0008 | Flat 429 backoff; last-good while trust_ok holds |
| 0009 | Never refresh the active slot; atomic rotation persist; invalid_grant quarantines |
| 0010 | Package-by-component with import-linter contracts |
| 0011 | Strict TDD; per-commit gate; 95% branch + mutmut |
| 0012 | Schema-tolerant usage model; extra_usage deferred |
| 0013 | Transport-only cli.py; __main__ composition root |
| 0014 | Credential writes mirror claude's .storage-write; .ops.lock serializes add/remove/switch |
| 0015 | Mutating ops require the verified claude band [2.1.144, 2.2.0); CAM_ASSUME_CLAUDE_CONTRACT overrides |
10. Quality requirements
The development loop
Strict TDD: red → green → refactor; test + implementation land in one conventional commit (type(scope): imperative, enforced by commit-msg). One logical change per commit; nothing committed red, since the pytest-in-pre-commit gate makes a red commit mechanically impossible (ADR-0011 for the full hook order).
Standing thresholds
Branch coverage ≥95% (enforced) · mutation: zero disqualifying mutants (survived|no tests|suspicious|timeout|segfault|not checked) · xenon --max-absolute B --max-modules B --max-average A · radon cc_min A · pyright strict · bandit -ll · vulture min-confidence 80 · deptry · import-linter contracts · tests hermetic (§8.5), Triple-A, named fakes, test/ mirrors src/ · -m integration opt-in only.
Commit protocol
Conventional commits; scope = component or concern (feat(accounts):, fix(usage):, docs(adr):). Milestone gate measurements go in the closing commit's body, never in a doc (ADR-0001).
11. Risks and technical debt
| Risk | Evidence | Mitigation |
|---|---|---|
/api/oauth/usage is undocumented and can change |
open upstream requests for an official surface (anthropics/claude-code#44328, #32796) | schema-tolerant parsing (ADR-0012); -m integration smoke test re-verifies shape |
| 429 wall | non-first-party budget ~28–30 req/h; the wall does not recover (anthropics/claude-code#31637, #30930) | poll policy constants measured under that regime; flat backoff + last-good (ADR-0008) |
| UA requirement tightened | the UA-bucket behavior could change (anthropics/claude-code#31021) | single UA constant; honest identity (ADR-0007) |
| Refresh-token stranding | one-time-use rotation (anthropics/claude-code#31021, #30930) | atomic dual persist; never refresh active; invalid_grant quarantine (ADR-0009) |
Deferred (not debt; gated on a real consumer)
extra_usage parsing (ADR-0012) · Secret Service storage (ADR-0003) · systemd packaging (ADR-0006). Full list with owners: maintainer/backlog.md → Deferred and watched.
12. Glossary
| Term | Meaning here |
|---|---|
| Active slot | The credential pair claude actually reads: <secure-store>/.credentials.json + oauthAccount in ~/.claude.json (§3) |
| Headroom | 100 − max(utilization) across an account's binding quota windows (§6) |
| Lineage | One OAuth grant plus its rotated descendants; dies permanently on invalid_grant (ADR-0009) |
| Quarantine | Registry flag excluding a dead lineage from switching candidates; the account stays listed and removable |
Last-good / trust_ok |
The frozen cache entry served while fetches fail; trusted until the limiting window's resets_at (ADR-0008) |
| Contract band | The verified claude version range [2.1.144, 2.2.0) that mutating ops refuse to run outside (ADR-0015) |
| mkdir lock | claude-code's proper-lockfile-compatible lock: a directory whose existence is the lock (§6) |
| Ops lock | <store>/.ops.lock, cam's own flock serializing add/remove/switch transactions (ADR-0014) |