Switch between saved Claude accounts from inside Claude Code, and auto-swap before the active one hits its rate limit.

A Claude Code mod for switching between saved Claude accounts without leaving the session, with optional auto-swap before the active account hits its rate limit.
/swap opens a picker, like /model's, listing your saved accounts and their last-seen usage.swap: 1 work 42% · 2 personal 10%. While a token overrides the login, a yellow swap off (…) row appears under it.claude plugin marketplace add trongtaiz/account-swap
claude plugin install account-swap@account-swap
Then start a new session, or run /reload-plugins in an open one.
| Command | What it does | |
|---|---|---|
/swap | Open the account picker (digit or Enter switches, Esc closes) | |
/swap list | List saved accounts as text | |
/swap add | Save the account you're logged in as now | |
/swap add-token <name> | Save the setup-token in CLAUDE_CODE_OAUTH_TOKEN as account <name> | |
| `/swap use <n\ | email>` | Switch to an account by number or email |
/swap next | Switch to the next saved account | |
/swap remove <n> | Forget a saved account (the live login is untouched) | |
| `/swap auto on\ | off` | Turn auto-swap on or off (on by default) |
A swap takes effect on the next request; no restart is needed.
/login as the first account, then /swap add./login as the next account, then /swap add. Repeat for each account.claude setup-token.CLAUDE_CODE_OAUTH_TOKEN set to it, and run /swap add-token <name>. The token is read from the environment, so it never appears in the transcript.CLAUDE_CODE_OAUTH_TOKEN from your shell profile and restart Claude Code.While CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_AUTH_TOKEN is set, it takes precedence over the login this mod swaps, so switching is off and the line under the prompt reads swap off (…) in yellow.
~/.claude/plugins/store/account-swap_*.json. That includes OAuth refresh tokens and setup-tokens. The file has the same permissions as Claude Code's own ~/.claude/.credentials.json, but it holds a copy for every account, so treat it as a secret: don't sync or back up ~/.claude anywhere you wouldn't put a password.~/.claude/.credentials.json and the oauthAccount entry in ~/.claude.json (or the same files under CLAUDE_CONFIG_DIR)./swap remove <n> deletes an account from the store. Uninstalling the plugin may not delete the store file, so delete it yourself if you stop using the mod.
Tested on Windows. It should work on Linux, which also keeps the login in ~/.claude/.credentials.json. On macOS, Claude Code keeps the login in the Keychain, which this mod can't read or write, so it is unlikely to work there.
Using several accounts to get around per-account rate limits may conflict with Anthropic's terms. Check them for your plan before turning on auto-swap.
claude plugin test . # run the tests
claude plugin validate --strict .
MIT
hooks/register.tsx 345 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4type Engine = EngineInterface
5type Account = {
6 email: string
7 // The whole credentials file as Claude Code wrote it; never parsed.
8 creds: string
9 oauthAccount: unknown
10 // The rate-limit windows last seen while this account was active.
11 usage?: SessionRateLimit[]
12}
13
14const THRESHOLD = 90
15const COOLDOWN_MS = 5 * 60_000
16const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
17const hint = atom({ plugin: 'account-swap', key: 'hint' } as const, '')
18const label = atom({ plugin: 'account-swap', key: 'label' } as const, '')
19const PICKER = 'swap'
20
21async function paths($: Engine) {
22 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
23 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
24 if (dir === undefined && home === undefined) throw new Error('cannot find the home directory')
25 const configDir = dir ?? `${home}/.claude`
26 return {
27 creds: `${configDir}/.credentials.json`,
28 config: dir === undefined ? `${home}/.claude.json` : `${dir}/.claude.json`,
29 }
30}
31
32async function readConfig($: Engine) {
33 return JSON.parse(await $.fs.read((await paths($)).config)) as Record<string, unknown>
34}
35
36function emailOf(config: Record<string, unknown>) {
37 const account = config.oauthAccount as { emailAddress?: string } | undefined
38 return account?.emailAddress
39}
40
41async function readLive($: Engine) {
42 const p = await paths($)
43 const creds = await $.fs.read(p.creds).catch(() => {
44 throw new Error('no live login (run /login first)')
45 })
46 const config = await readConfig($)
47 const email = emailOf(config)
48 if (email === undefined) throw new Error('the live login has no account email in .claude.json')
49 return { creds, oauthAccount: config.oauthAccount, email }
50}
51
52async function loadAccounts($: Engine) {
53 return ((await $.store.get('accounts')) as Account[] | undefined) ?? []
54}
55
56function formatUsage(usage: readonly SessionRateLimit[] | undefined) {
57 return (usage ?? []).map(w => `${LABELS[w.kind] ?? w.kind} ${Math.round(w.percentUsed)}%`).join(' · ')
58}
59
60// Both outrank the /login credential this mod swaps, and Claude Code keeps the
61// credential it read at the first request, so a swap under either would do nothing.
62async function overriddenBy($: Engine) {
63 if (await $.env.get('CLAUDE_CODE_OAUTH_TOKEN')) return 'CLAUDE_CODE_OAUTH_TOKEN'
64 if (await $.env.get('ANTHROPIC_AUTH_TOKEN')) return 'ANTHROPIC_AUTH_TOKEN'
65}
66
67// The hint line under the prompt: every account, the active one starred, each with
68// its fullest window as last seen.
69function hintText(accounts: readonly Account[], active: string | undefined, usage?: readonly SessionRateLimit[]) {
70 if (accounts.length === 0) return ''
71 const rows = accounts.map((a, i) => {
72 const seen = a.email === active ? (usage ?? a.usage) : a.usage
73 const pct = seen?.length ? ` ${Math.round(Math.max(...seen.map(w => w.percentUsed)))}%` : ''
74 const label = `${i + 1} ${a.email}${pct}`
75 return a.email === active ? `*${label}*` : label
76 })
77 return `swap: ${rows.join(' · ')}`
78}
79
80// Everything the mod shows rides the existing hint line: a $.ui.status notice is a
81// block of its own, with rows of spacing around it.
82async function refreshHint($: Engine, email: string | undefined, usage?: readonly SessionRateLimit[]) {
83 const override = await overriddenBy($)
84 const accounts = await loadAccounts($)
85 await update($, hint, () => (override === undefined ? hintText(accounts, email, usage) : ''))
86 await update($, label, () => (override === undefined ? '' : `swap off (${override})`))
87}
88
89async function addLive($: Engine) {
90 const live = await readLive($)
91 const accounts = await loadAccounts($)
92 const i = accounts.findIndex(a => a.email === live.email)
93 const saved: Account = { ...accounts[i], email: live.email, creds: live.creds, oauthAccount: live.oauthAccount }
94 if (i < 0) accounts.push(saved)
95 else accounts[i] = saved
96 await $.store.set('accounts', accounts)
97 return { email: live.email, isNew: i < 0 }
98}
99
100// A setup-token saved the way claude-swap's add-token does: wrapped as a login in
101// Claude Code's credentials JSON (which it re-reads on change), under a stand-in
102// identity, since the token carries no email. Taken from the session's
103// environment so it is never typed into the transcript.
104async function addToken($: Engine, name: string) {
105 const token = await $.env.get('CLAUDE_CODE_OAUTH_TOKEN')
106 if (token === undefined) {
107 throw new Error('start Claude Code with CLAUDE_CODE_OAUTH_TOKEN set to the setup-token, then run this again')
108 }
109 if (!token.startsWith('sk-ant-oat')) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is not a setup-token (sk-ant-oat…)')
110 const email = `${name}@token.local`
111 const saved: Account = {
112 email,
113 creds: JSON.stringify({ claudeAiOauth: { accessToken: token, scopes: ['user:inference'] } }),
114 oauthAccount: { emailAddress: email, accountUuid: '', organizationUuid: null, organizationName: null },
115 }
116 const accounts = await loadAccounts($)
117 const i = accounts.findIndex(a => a.email === email)
118 if (i < 0) accounts.push(saved)
119 else accounts[i] = saved
120 await $.store.set('accounts', accounts)
121 return email
122}
123
124// The live login's refresh token rotates while Claude Code runs, so the outgoing
125// account is re-saved from the live file first: its stored copy may already be dead.
126async function swapTo($: Engine, targetEmail: string) {
127 const override = await overriddenBy($)
128 if (override !== undefined) {
129 throw new Error(`${override} is set and outranks the login this mod swaps; unset it and restart Claude Code`)
130 }
131 const p = await paths($)
132 // No credentials file (a machine that only ever used setup-tokens): nothing to save.
133 const live = (await $.fs.exists(p.creds)) ? await readLive($) : undefined
134 if (live?.email === targetEmail) return `${targetEmail} is already active.`
135 const outgoing = live === undefined ? 'no login' : (await addLive($)).email
136 const target = (await loadAccounts($)).find(a => a.email === targetEmail)
137 if (target === undefined) throw new Error(`no saved account ${targetEmail}`)
138
139 await $.fs.write(p.creds, target.creds)
140 try {
141 const config = await readConfig($)
142 await $.fs.write(p.config, JSON.stringify({ ...config, oauthAccount: target.oauthAccount }, null, 2))
143 } catch (err) {
144 if (live !== undefined) await $.fs.write(p.creds, live.creds)
145 throw err
146 }
147 await $.store.set('lastSwapAt', await $.clock.now())
148 await refreshHint($, targetEmail)
149 return `Swapped ${outgoing} → ${targetEmail}. Takes effect on the next request.`
150}
151
152function isFull(account: Account, now: number) {
153 return (account.usage ?? []).some(
154 w => w.percentUsed >= THRESHOLD && (w.resetsAt === undefined || Date.parse(w.resetsAt) > now),
155 )
156}
157
158function listText(accounts: readonly Account[], active: string | undefined, isAuto: boolean) {
159 if (accounts.length === 0) return 'No saved accounts. Log in, then run /swap add.'
160 const rows = accounts.map((a, i) => {
161 const usage = formatUsage(a.usage)
162 return `${a.email === active ? '*' : ' '} ${i + 1}. ${a.email}${usage ? ` (${usage} last seen)` : ''}`
163 })
164 return [...rows, `auto-swap: ${isAuto ? `on at ${THRESHOLD}%` : 'off'}`].join('\n')
165}
166
167async function runCommand($: Engine, args: string) {
168 const [verb = 'list', arg, ...extra] = args.trim().split(/\s+/).filter(Boolean)
169 const accounts = await loadAccounts($)
170 const active = await readConfig($).then(emailOf, () => undefined)
171
172 switch (verb) {
173 case 'list':
174 return listText(accounts, active, (await $.store.get('auto')) !== false)
175 case 'add': {
176 const { email, isNew } = await addLive($)
177 await refreshHint($, email)
178 return `${isNew ? 'Saved' : 'Updated'} ${email}. Run /login for another account, then /swap add again.`
179 }
180 case 'add-token': {
181 if (arg === undefined || extra.length > 0 || !/^[\w.-]+$/.test(arg)) {
182 throw new Error('usage: /swap add-token <name> (letters, digits, . _ -)')
183 }
184 const email = await addToken($, arg)
185 await refreshHint($, active)
186 return `Saved ${email}. Remove CLAUDE_CODE_OAUTH_TOKEN from your shell profiles and restart Claude Code, then /swap to switch to it.`
187 }
188 case 'use': {
189 const target = accounts[Number(arg) - 1] ?? accounts.find(a => a.email === arg)
190 if (target === undefined) throw new Error(`no saved account "${arg ?? ''}"`)
191 return swapTo($, target.email)
192 }
193 case 'next': {
194 if (accounts.length < 2) throw new Error('save at least two accounts first')
195 const i = accounts.findIndex(a => a.email === active)
196 return swapTo($, accounts[(i + 1) % accounts.length]!.email)
197 }
198 case 'remove': {
199 const target = accounts[Number(arg) - 1]
200 if (target === undefined) throw new Error(`no saved account #${arg ?? ''}`)
201 await $.store.set('accounts', accounts.filter(a => a !== target))
202 await refreshHint($, active)
203 return `Removed ${target.email} (the live login is untouched).`
204 }
205 case 'auto':
206 if (arg !== 'on' && arg !== 'off') throw new Error('usage: /swap auto on|off')
207 await $.store.set('auto', arg === 'on')
208 return `auto-swap ${arg}.`
209 default:
210 throw new Error('usage: /swap [list|add|add-token <name>|use <n|email>|next|remove <n>|auto on|off]')
211 }
212}
213
214async function onUsage($: Engine, usage: readonly SessionRateLimit[]) {
215 // The figures are the overriding credential's, not any saved account's.
216 if ((await overriddenBy($)) !== undefined) return refreshHint($, undefined)
217 const config = await readConfig($)
218 const active = emailOf(config)
219 const accounts = await loadAccounts($)
220 const i = accounts.findIndex(a => a.email === active)
221 const account = accounts[i]
222 if (account === undefined) return refreshHint($, active, usage)
223
224 accounts[i] = { ...account, usage: [...usage] }
225 await $.store.set('accounts', accounts)
226 await refreshHint($, active, usage)
227
228 const now = await $.clock.now()
229 const lastSwapAt = ((await $.store.get('lastSwapAt')) as number | undefined) ?? 0
230 const isOver = usage.some(w => w.percentUsed >= THRESHOLD)
231 if (!isOver || (await $.store.get('auto')) === false || now - lastSwapAt < COOLDOWN_MS) return
232
233 const next = [...accounts.slice(i + 1), ...accounts.slice(0, i)].find(a => !isFull(a, now))
234 if (next === undefined) {
235 await $.store.set('lastSwapAt', now)
236 return $.ui.toast(`swap: ${active} is at ${THRESHOLD}%+ and every saved account is near its limit.`)
237 }
238 $.ui.toast(await swapTo($, next.email))
239}
240
241export const register: Register = on => {
242 on('session.start', async ($, e, next) => {
243 await $.command.register({
244 name: 'swap',
245 description: 'Switch between saved Claude accounts',
246 argumentHint: '[list|add|add-token <name>|use <n|email>|next|remove <n>|auto on|off]',
247 })
248 // 0.1 pinned a status notice; a reload keeps it until someone clears it.
249 $.ui.status(undefined)
250 await refreshHint($, await readConfig($).then(emailOf, () => undefined)).catch(() => undefined)
251 return next(e)
252 })
253
254 on('command.run', { command: 'swap' }, async ($, e) => {
255 try {
256 if (e.args.trim() === '') {
257 const rows = (await loadAccounts($)).length + 3
258 const opened = await $.ui.open({ id: PICKER, title: 'Switch account', focus: true, closeOnEscape: true, holdToasts: true, rows })
259 if (opened.isPlaced) return {}
260 }
261 return { text: await runCommand($, e.args) }
262 } catch (err) {
263 return { text: `swap: ${(err as Error).message}` }
264 }
265 })
266
267 on('session.measure', async ($, e, next) => {
268 if (e.changed.includes('rateLimits') && e.rateLimits.length > 0) {
269 await onUsage($, e.rateLimits).catch(err => $.ui.toast(`swap: ${(err as Error).message}`))
270 }
271 return next(e)
272 })
273
274 // The /swap picker, drawn like /model's: one row per account, the active one
275 // focused, a digit or Enter switches, Esc closes.
276 on('ui.render', { component: 'Pane', requestId: PICKER }, async ($, e) => {
277 const { Box, Button, Text } = $.ui.resolve(e)
278 const accounts = await loadAccounts($)
279 const active = await readConfig($).then(emailOf, () => undefined)
280 const override = await overriddenBy($)
281 const closeThen = (work: () => Promise<string>) => async () => {
282 await $.ui.close({ id: PICKER })
283 $.ui.toast(await work().catch(err => `swap: ${(err as Error).message}`))
284 }
285
286 return (
287 <Box flexDirection="column">
288 {override !== undefined && <Text dimColor>{override} is set: switching is off.</Text>}
289 {accounts.length === 0 && <Text dimColor>No saved accounts yet.</Text>}
290 {accounts.map((a, i) => {
291 const usage = formatUsage(a.usage)
292 const isActive = a.email === active
293 return (
294 <Button
295 key={`account-${i + 1}`}
296 plain
297 {...(i < 9 ? { hotkey: String(i + 1) } : {})}
298 {...(isActive ? { autoFocus: true as const } : {})}
299 label={`${a.email}${usage ? ` · ${usage}` : ''}${isActive ? ' ✔' : ''}`}
300 onPress={closeThen(() => swapTo($, a.email))}
301 />
302 )
303 })}
304 {active !== undefined && !accounts.some(a => a.email === active) && (
305 <Button
306 key="save"
307 plain
308 hotkey="a"
309 label={`Save current login (${active})`}
310 onPress={closeThen(async () => {
311 const { email } = await addLive($)
312 await refreshHint($, email)
313 return `Saved ${email}.`
314 })}
315 />
316 )}
317 <Text dimColor>Enter to switch · Esc to close</Text>
318 </Box>
319 )
320 })
321
322 // The line under the prompt: the engine's hint line, which `next` hands back for
323 // the engine to keep drawing live, then, only while a token overrides the login,
324 // a yellow row saying swap is off (the engine draws its node first wherever it
325 // sits, and refuses it under a Box that sets a width).
326 // The account list rides the hint's tail only while idle, so "esc to
327 // interrupt" is never crowded out.
328 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
329 const list = await read($, hint)
330 const off = await read($, label)
331 const isIdle = !e.props.isDraft && !e.props.isWorking
332 const engine = await next(list !== '' && isIdle ? { ...e, props: { ...e.props, tail: list } } : e)
333 if (off === '') return engine
334 const { Box, Text } = $.ui.resolve(e)
335 return (
336 <Box flexDirection="column">
337 {engine}
338 <Text color="yellow" bold>
339 {off}
340 </Text>
341 </Box>
342 )
343 })
344}
345types/index.d.ts 10 lines1// The account list drawn on the hint line under the prompt; '' draws nothing.
2export type Hint = string
3
4declare module 'claude-code' {
5 interface PluginState {
6 // label: the row under the hint line while a token overrides the login; '' draws nothing.
7 'account-swap': { hint: Hint; label: Hint }
8 }
9}
10