SLOPSHOPPER

Account Swap

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

newpanespinnercommandtoaststatus
v0.1.0MITupdated 2026-10-05trongtaiz/account-swap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · account-swap
│ ┃ Switch account ✕ › fix the failing auth test and add an audit log call │ ┃ No saved accounts yet. │ ┃ Enter to switch · Esc to close ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /swap │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Switch account
No saved accounts yet. Enter to switch · Esc to close
README

Account Swap

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.
  • The hint line under the prompt lists your accounts with the one in use starred, e.g. swap: 1 work 42% · 2 personal 10%. While a token overrides the login, a yellow swap off (…) row appears under it.
  • Auto-swap: when the active account reaches 90% of its 5-hour or weekly limit, the mod switches to the next saved account that isn't near its limit (at most once every 5 minutes).

Install

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.

Usage

CommandWhat it does
/swapOpen the account picker (digit or Enter switches, Esc closes)
/swap listList saved accounts as text
/swap addSave 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 nextSwitch 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.

Saving logged-in accounts

  1. /login as the first account, then /swap add.
  2. /login as the next account, then /swap add. Repeat for each account.

Saving setup-tokens

  1. Create a token with claude setup-token.
  2. Start Claude Code with 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.
  3. Remove 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.

Security: what this mod stores and writes

  • Every saved account's credentials, in plain text, in Claude Code's plugin store: ~/.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.
  • On a swap it overwrites ~/.claude/.credentials.json and the oauthAccount entry in ~/.claude.json (or the same files under CLAUDE_CONFIG_DIR).
  • It makes no network requests; usage figures come from Claude Code's own rate-limit reports.

/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.

Platform support

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.

Terms of use

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.

Development

claude plugin test .          # run the tests
claude plugin validate --strict .

License

MIT

Source 2 files
hooks/register.tsx 345 lines
1import { 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}
345
types/index.d.ts 10 lines
1// 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