Releases: errhythm/ccswap
Release list
v0.35.1
Fixes
- Auto-switch poll lines now show each configured quota window for the active account, e.g.
60% used [5h 41% · 7d 60%] (switch at 96%). With the defaultautoswitch.windows=both, the percentage is the higher of the two windows, and the old line didn't say which one. After a switch it could look like a stale 5h value. The switch decision and JSON output are unchanged. (#8, fixes #7, thanks @GiorgoLazaridis)
v0.35.0 — Scope auto-switching to one usage window
Scope auto-switching to one usage window
ccswap auto weighs both of Claude's account-wide windows: the rolling 5-hour session and the 7-day quota. When two accounts are each pinned by a different window, neither clears the hysteresis margin against the other, and auto correctly refuses to move. That is the right call when both windows matter. It is the wrong one if you are deliberately running your weekly quota all the way down and only care about the session window.
autoswitch.windows picks which windows bind the decision:
ccswap config set autoswitch.windows 5h # session window only, ignore weekly
ccswap config set autoswitch.windows 7d # weekly only, ignore the session window
ccswap config set autoswitch.windows both # default, today's behaviorThe scoping reaches the auto engine's decision and stops there. Polling cadence keeps the full 5h/7d view, so the ignored window never goes stale in the background, and ccswap list, ccswap watch, the TUI and the manual switch --strategy modes keep reading and showing both numbers. The default is unchanged in every path.
Thanks to @leosp1983 for the feature (#6).
Keeping the setting inside the auto engine
Two paths let the new selection escape into surfaces it was never meant to touch, both fixed before release.
The shared usage collector threaded the selection into the 429-stale trust bound. Narrowing the window set only ever removes reset candidates, so a stale snapshot stayed trusted past the reset that should have invalidated it. That collector also backs ccswap list, the TUI, the menu bar and switch --strategy, so with autoswitch.windows=7d a snapshot whose 5h window had already rolled over would keep serving 5h=95% to all of them for up to the full trust ceiling, and switch --strategy best would refuse an account that had actually recovered. Whether cached data is still fresh is objective, so the trust bound now keeps the full view, matching what the cadence planner already does.
The TUI's auto panel ranked candidates on the account-wide default while the engine ranked on the scoped view. With autoswitch.windows=5h and accounts at 5h=20%, 7d=95% and 5h=50%, 7d=30%, the panel displayed the first as the most used and ranked the second ahead of it, then watched auto switch to the one it had just shown as worst. The panel now derives the same selection the engine does.
Full changelog: v0.34.0...v0.35.0
v0.34.0 — Codex accounts in the menu bar
Codex accounts in the menu bar
The menu bar shell only ever drove the Claude switcher. Codex accounts could be switched from ccswap codex switch and from the TUI, but the one surface meant to sit open all day ignored them.
The menu now carries a Codex submenu: one row per managed account with its usage, click to switch, rotate-to-next, and add / disable / remove. Rows reuse the same renderer as the Claude rows, which already understood the Codex-only fields, so a row reads like:
2 you@example.com 5h 0% (4h 59m) · Weekly 96% (ahead) (4d 18h) · Resets 1 (expires 20d 4h) · Credits none
Settings gains an Auto-switch Codex accounts toggle that hosts CodexAutoSwitchEngine the same way the existing toggle hosts the Claude engine. The two are independent, and the row goes inert when no Codex auth store is present rather than showing a ticked box next to an engine that never started.
The Codex submenu offers only rotate-to-next, because CodexAccountSwitcher.switch() discards its strategy argument — there is no "best" or "next available" to offer. The menu bar title stays Claude-driven.
Refresh behavior
Both providers are snapshotted in the same worker pass, behind the existing single in-flight guard, each with its own snapshot source and network gate. One provider failing never discards the other's last-good snapshot, and enabling Claude auto-switch no longer has any way to freeze Codex usage at a stale value.
Codex active-account detection compares the resolved account number rather than reacting to any auth.json write. Since the refresh pass now covers both providers, refreshing on every write would have made each unrelated Codex token refresh pull a Claude usage API fetch along with it — the opposite of what this tool is for.
The usage log's de-duplication key is namespaced by provider. Slot 1 exists on both sides and is two different accounts.
Packaging
PyPI metadata is expanded and the project URLs point at the renamed repository. The README leads with the dashboard, features, and quick start.
Full changelog: v0.33.0...v0.34.0
v0.33.0
Codex credits and account allowances
Codex usage responses carry a credits balance and a per-user spend_control.individual_limit, both of which ccswap previously discarded. They now show up as separate resources in the CLI, TUI, and menu bar, and in ccswap codex usage --json. Credit-only responses that carry no rate-limit windows are accepted.
Thanks to @JOhugo6 for the feature (#3).
Hardened Codex usage parsing
The usage endpoint is a third-party JSON shape ccswap does not control, and several fields reached datetime and float conversions unguarded. A millisecond epoch where seconds were expected, a NaN, or an oversized number would raise out of fetch_codex_usage and take down ccswap codex usage and the menu bar refresh loop instead of degrading to "usage unavailable". All numeric and timestamp conversions now degrade to a missing field.
Partial responses are also no longer accepted as healthy. Previously any fragment that normalized at all counted as success, so a degraded response could overwrite a good stored usage row. A response must now carry a real quota window, real credit state, or a real spend allowance; banked resets or message estimates alone are rejected.
Docs
The README named upstream's command as ccswap. Upstream realiti4/claude-swap ships cswap; this fork ships ccswap.
Full changelog: v0.32.0...v0.33.0
v0.32.0
A sync with upstream realiti4/claude-swap through 7187ce8, 33 commits. Most of it lands on session mode, where running an account as both the default login and a session could leave one copy of the token stale. That case is now handled instead of warned about.
Added
ccswap run 2 --require-session. Without it, asking to run an account that is already the default login quietly launches plainclaude. With it, that fast path refuses instead, so a script cannot think it got an isolated session when it got the default one.- The macOS menu bar can run as a launchd LaunchAgent.
ccswap menubarblocks the terminal that started it, so the status item died with the terminal.ccswap menubar --install-serviceregisters it to start at login, with--uninstall-serviceand--service-statusalongside. Logs go to~/Library/Logs/com.ccswap.menubar.{log,err}. loginExpiresAton JSON rows, when the stored login records when its refresh token expires. That is the moment the slot needs a fresh/loginandccswap add --slot N, so a script can warn days ahead instead of discoveringrelogin_requiredthe hard way.usageErrorandusageRetryAton rows whose usage is unavailable with nothing else to explain it.usageErrornames the last fetch failure by kind, such ashttp-429ortimeout, andusageRetryAtgives the next attempt while the cache is backing off.
Changed
- An exited session's credential is adopted into the account's backup before a switch or usage check reads that backup. A session refreshes its own copy of the token, so the backup went stale the moment the session rotated it.
ccswap switchrefuses to move the default login onto an account with a live session whose stored backup has already fallen behind. Activating a consumed generation could only fail withinvalid_granton its first refresh, so this is now an error rather than a warning you find out about later.- A live session's usage is read with the session's own credential and never refreshed. A read the server refuses shows as token expired and is not requested again until the session renews the credential itself.
- The token-persist warning goes to stderr. It ran inside
ccswap list --jsonand the other--jsoncommands, whose stdout is meant to be one machine-readable object.
Fixed
ccswap addrefuses a credential whose owner is not the account being added, instead of storing it under the wrong slot.- Session records whose PID has been recycled are ignored. A dead session's PID reassigned to an unrelated process read as a live session.
- An unreadable backup no longer produces a dead-token verdict. Not being able to read the file is not evidence the token is dead.
- A symlinked profile's Keychain entry is read under its target path.
- The menu bar stops leaking callbacks. Its rumps callback registry was never purged on rebuild, so memory grew without bound.
- The menu bar reports an interpreter that cannot draw the status item, gating on the interpreter build rather than its version, and requires macOS 26.
Fork-specific
- The LaunchAgent resolves the
ccswapconsole script. Upstream looks up its owncswap, which does not exist here, so both probes missed and the plist got a virtualenv-internal path thatccswap upgradethen breaks. - The launchd job is labelled
com.ccswap.menubar, not upstream'scom.cswap.menubar.
v0.31.0
The dashboard has a Settings screen, and the per-provider view removed in 0.30.0 is back — as a saved preference this time, not a mode you have to remember you are in.
Added
-
Settings… in the dashboard menu, holding four rows. Selecting a row cycles its value in place, so the menu stays one level deep and nothing is buried:
Theme: dark Dashboard view: Combined Auto-switch threshold: 90% Auto-switch strategy: bestPark the cursor on a row and press Enter repeatedly to cycle it. Theme moves here from the root menu;
Ctrl+Tstill cycles it from anywhere. -
ui.view— Combined, Claude Code, or Codex. Combined is the 0.30 view with both providers stacked; the other two filter the dashboard, the switch screen and the watch screen to one provider, keeping its section header so it is always clear what you are looking at. The choice persists between sessions. -
Two auto-switch settings are now reachable from the TUI.
autoswitch.thresholdwas already drawn as a tick on every usage bar but could only be changed by editing config; it now cycles a 75/80/85/90/95 ladder.autoswitch.strategycyclesbest/consume-first. Exact threshold values (say 87.5) still belong toccswap config set autoswitch.threshold— a value set that way is shown as-is and cycles up from where it is, never quietly snapped to a round number. -
ccswap config get/set/list ui.viewworks too. The view is an ordinary settings key, so both surfaces read and write the same file; nothing in the CLI needed changing to gain it.
Fixed
- An unrecognised
ui.themeno longer resets your other appearance settings. The loader returned its whole default object when the theme was unreadable, which was invisible whilethemewas the only field in that section and would have silently resetui.viewalongside it.
Notes
- Filtering is a display filter over data that is already being collected for both providers, not a return to the old one-provider-at-a-time fetching. Switching views is immediate and the hidden provider stays current, so flipping back shows fresh numbers rather than a reload.
- The Disable/enable and Remove menus stay unfiltered on purpose. The view preference governs what the dashboard shows, not which accounts you can administer — an account should never go missing from a destructive menu because of a display setting.
- No change to stored data, credentials, or on-disk layout, and none to
ccswap listor the menu bar. - Suite: 2171 passed, 3 skipped.
v0.30.0
The interactive dashboard now shows both providers at once. Until now the TUI could only show one at a time: a "Provider: …" submenu flipped a mode flag, rebuilt the data source and blanked the view, so you saw Claude Code or Codex and had to remember which. ccswap list printed both, but as a one-shot table whose Codex half carried no usage at all.
Added
- One dashboard, both providers, in labelled sections. Each provider's active account renders full-size with its usage bars; its other accounts sit under it as one-line minis. Codex's own window set (
Weekly, banked resets) reads naturally next to Claude's5h/7d,$$spend and per-model scoped windows — the bar renderer only ever drew the windows an account actually has, so both shapes were already supported. - Actions route to the row you are on. Switch, add, remove and disable/enable all dispatch to the provider of the highlighted account. The provider is carried on the row itself and resolved through a single lookup, so it cannot be lost or defaulted between what you see and what happens. The "Provider: …" mode toggle is gone; nothing in the dashboard is in a mode any more.
- A divider between the Claude Code and Codex groups on the switch and watch screens, drawn by the same helper the dashboard headers use so the two surfaces cannot drift. It is a non-selectable row — the cursor skips it, including when wrapping past either end of the list.
- Auto-switch stays per-provider and now asks which one to drive, opening directly when only one provider has accounts.
Fixed
- An empty provider no longer blanks the whole panel. The zero-accounts branch was a single early return covering the entire view, so once a second provider existed, having no Codex accounts would have taken your Claude accounts down with it. Empty providers are now simply hidden — a Claude-only install looks exactly as it did.
- A provider whose snapshot fails no longer sticks on "loading…" forever. A malformed Codex
sequence.jsonused to leave the panel permanently in its loading state. The failing provider now shows its error inside its own section and the other keeps painting; a Codex failure can neither blank nor delay the Claude half, since each provider refreshes on its own worker. - Usage refreshes no longer interfere across providers. Refresh state is per-provider throughout: previously-shared flags meant a slow Codex pass could suppress Claude's fetch lane and silently stop its usage from updating, and the auto-switch view could leave the other provider with no fetcher at all.
- Rows are identified by provider and slot. Both providers number their slots from 1, so single-number keys crossed rows — the wrong account's row could flash on a refresh, and the cursor could open on the wrong provider's active account.
Notes
- No change to stored data, credentials, or on-disk layout, and none to
ccswap list,ccswap list --json, or the menu bar — this release is confined to the TUI. The account read model is untouched, which is what keeps a Codex row from ever being mistaken for a Claude one. - Version follows the semver convention adopted in 0.28.0: minor, for a feature plus a user-visible behavior change.
- Suite: 2161 passed, 3 skipped.
v0.29.0
Syncs two commits from upstream realiti4/claude-swap through 3c3f2b8, plus the macOS test fixes that landed here after 0.28.0.
Fixed
- A custom
CLAUDE_CONFIG_DIRno longer reads another account's token. The active-credential read resolved its Keychain item from the fixed"Claude Code"service name, while the identity read one layer up honorsCLAUDE_CONFIG_DIR— so under a custom profile the store reported one account's identity against a different account's credential, silently, for every consumer of that read. It now resolves the item the way Claude Code does for the same environment (session.keychain_service_name, the derivation the delete, session-read and capture paths already share). This is not a corner case here:ccswap runlaunches Claude Code with a per-sessionCLAUDE_CONFIG_DIR, which is exactly the custom-profile shape. Thanks to @jamesvillarrubia upstream (#237). - The managed-key Keychain read is now default-profile-only. Same item, gated rather than redirected — there is no pinned derivation for Claude's managed-key service name under a custom profile, and capture already refuses it for that reason. A custom profile's own
primaryApiKeyis still read from its own config, so a managed key there is still found. - The test suite stopped leaking a temp directory per worker per run.
_ISOLATED_HOMEwas a module-levelmkdtemp()with no cleanup, reached once per process — so pytest-xdist made one per worker and nothing ever removed them (37,948 stray dirs measured on one upstream host). It is now allocated throughtmp_path_factory, inside pytest's basetemp, where pytest's own retention reclaims it. Thanks to @codeslake upstream (#267). - The three tests that only ever ran on Linux now run everywhere. Noted as a known failure in 0.28.0's release notes: each assumed the Linux shape of something the platform picks (a backup
.encthat macOS never writes because the bytes go to the Keychain; the XDG backup root that macOS does not use). Nothing was wrong with the code they cover — the tests now pin the backend they need and resolve the backup root throughpaths.get_backup_root()instead of naming a layout.
Notes
- No behavior change for a default-profile install: the resolved Keychain item is the same unsuffixed one, and an explicit
CLAUDE_CONFIG_DIRnaming the default profile tries the hashed item first and then falls back to the unsuffixed one. - The on-disk layout is unchanged and still uses upstream's
claude-swapdirectory names (~/.claude-swap-backup,$XDG_DATA_HOME/claude-swap), so existing installs need no migration. - Suite on macOS: 2146 passed, 3 skipped, 0 failed — the three macOS failures called out in 0.28.0 are gone.
Credits
Upstream work by @realiti4 and contributors.
v0.28.0
Syncs ten commits from upstream realiti4/claude-swap through 9f62506, on top of the Codex fixes in 0.27.0.
Added
ccswap unclaimed— lists stashed credential entries with the slot they came from and why they were stashed.ccswap unclaimed --purge IDdrops one (deletes its bytes; recover with/login+ccswap add). The command came in with the upstream stash work and had never been documented here.
Fixed
- Never consume a stale refresh token, never quarantine an account over one that was. The largest piece of the sync — a rework of token handling across
session.pyandswitcher.py. - An auth-status probe that times out is indeterminate, not invalid. A slow probe used to be read as a bad token; it is now answered from local artifacts instead of being assumed either way. Thanks to @jhutchins upstream.
- The stash orphan detector no longer fails open.
globsilently swallows scan errors, so an unreadable directory read as "no orphans"; it now usesiterdirand surfaces the failure. ccswap importnarrates the strike clear on a forced overwrite instead of clearing it silently.- A switch no longer sends you to "re-add" when the backup is merely behind a locked Keychain. Upstream centralized the target-credential check, which now distinguishes an unreadable-right-now backup from a missing one — a re-add in that state burns the slot's stored grant for nothing.
- User-visible messages named the wrong binary. Text merged from upstream told you to run
cswap …, which is not what this fork installs. The messages you can actually be shown now sayccswap.
Notes
- The on-disk layout is unchanged and still uses upstream's
claude-swapdirectory names (~/.claude-swap-backup,$XDG_DATA_HOME/claude-swap), so existing installs need no migration. - From this release on, versions follow semver properly: patch for fixes, minor for features and behavior changes.
- Suite: 2135 passed, 3 skipped. Three upstream tests fail on macOS — they assume the Linux
.enc/XDG layout and fail identically on a pristine upstream checkout; upstream CI only runs the full suite on Linux and Windows.
Credits
Upstream work by @realiti4 and contributors.
v0.27.0
Fixed
- Codex: two seats in the same ChatGPT Team workspace no longer collapse onto one slot. Team seats share a
chatgpt_account_id, and account lookup matched onaccountIdoremailand took the first hit — soccswap codex addmapped a second seat onto the first seat's slot and overwrote its tokens in place while reporting success,ccswap codex statusnamed the wrong seat as active, switching to the shadowed seat was a silent no-op, and switching away wrote the live login into the wrong slot's credential file. Stored logins were destroyed with no prompt. Reported, diagnosed and fixed by @JOhugo6 in #2, with eight regression tests that fail against 0.26.0. - Codex: a renamed login is no longer refresh-rotated while it is live. The stricter identity above means a login whose stored email has drifted matches no slot, so
accounts_snapshotread it as inactive; a 401 from its stored backup then reached_refresh_inactive_auth, which consumed the refresh token Codex was still holding and logged that session out. The guard is now the token itself, not the identity match.
Behavior
- A Codex login is identified by its
accountIdandemailtogether. Anything short of that exact pair is treated as unmanaged:ccswap codex addallocates a new slot instead of claiming an existing one, andccswap codex switchrefuses through the existing unmanaged-login guard rather than writing a login into a slot that may belong to someone else. - Consequence: the same email across two Team workspaces now keeps separate slots, and a login whose email changed needs a fresh
ccswap codex addrather than being adopted by its old slot. Failing toward a new slot is the safe direction — the alternative silently overwrote credentials.
Credits
Thanks to @JOhugo6 for a report that arrived with a reproduction, a root-cause trace to the exact call sites, and tests that bind the fix — including the mirrored collision their own first two revisions left standing.