Skip to content

Releases: errhythm/ccswap

v0.35.1

Choose a tag to compare

@errhythm errhythm released this 25 Sep 21:26
14df53c

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 default autoswitch.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

Choose a tag to compare

@errhythm errhythm released this 21 Sep 05:21
cbd3e7b

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 behavior

The 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

Choose a tag to compare

@errhythm errhythm released this 15 Sep 19:34
2f94581

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

Choose a tag to compare

@errhythm errhythm released this 13 Sep 16:06
5222bf5

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

Choose a tag to compare

@errhythm errhythm released this 12 Sep 14:50
4eaa481

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 plain claude. 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 menubar blocks the terminal that started it, so the status item died with the terminal. ccswap menubar --install-service registers it to start at login, with --uninstall-service and --service-status alongside. Logs go to ~/Library/Logs/com.ccswap.menubar.{log,err}.
  • loginExpiresAt on JSON rows, when the stored login records when its refresh token expires. That is the moment the slot needs a fresh /login and ccswap add --slot N, so a script can warn days ahead instead of discovering relogin_required the hard way.
  • usageError and usageRetryAt on rows whose usage is unavailable with nothing else to explain it. usageError names the last fetch failure by kind, such as http-429 or timeout, and usageRetryAt gives 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 switch refuses 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 with invalid_grant on 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 --json and the other --json commands, whose stdout is meant to be one machine-readable object.

Fixed

  • ccswap add refuses 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 ccswap console script. Upstream looks up its own cswap, which does not exist here, so both probes missed and the plist got a virtualenv-internal path that ccswap upgrade then breaks.
  • The launchd job is labelled com.ccswap.menubar, not upstream's com.cswap.menubar.

v0.31.0

Choose a tag to compare

@errhythm errhythm released this 21 Aug 11:12
e5063a3

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: best
    

    Park the cursor on a row and press Enter repeatedly to cycle it. Theme moves here from the root menu; Ctrl+T still 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.threshold was 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.strategy cycles best / consume-first. Exact threshold values (say 87.5) still belong to ccswap 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.view works 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.theme no longer resets your other appearance settings. The loader returned its whole default object when the theme was unreadable, which was invisible while theme was the only field in that section and would have silently reset ui.view alongside 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 list or the menu bar.
  • Suite: 2171 passed, 3 skipped.

v0.30.0

Choose a tag to compare

@errhythm errhythm released this 21 Aug 10:14
1e56077

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's 5h/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.json used 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

Choose a tag to compare

@errhythm errhythm released this 21 Aug 08:40
ce27f66

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_DIR no 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 honors CLAUDE_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 run launches Claude Code with a per-session CLAUDE_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 primaryApiKey is 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_HOME was a module-level mkdtemp() 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 through tmp_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 .enc that 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 through paths.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_DIR naming 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-swap directory 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

Choose a tag to compare

@errhythm errhythm released this 13 Aug 11:48
470be6b

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 ID drops 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.py and switcher.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. glob silently swallows scan errors, so an unreadable directory read as "no orphans"; it now uses iterdir and surfaces the failure.
  • ccswap import narrates 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 say ccswap.

Notes

  • The on-disk layout is unchanged and still uses upstream's claude-swap directory 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

Choose a tag to compare

@errhythm errhythm released this 13 Aug 11:29
e571460

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 on accountId or email and took the first hit — so ccswap codex add mapped a second seat onto the first seat's slot and overwrote its tokens in place while reporting success, ccswap codex status named 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_snapshot read 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 accountId and email together. Anything short of that exact pair is treated as unmanaged: ccswap codex add allocates a new slot instead of claiming an existing one, and ccswap codex switch refuses 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 add rather 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.