SLOPSHOPPER

account-rotator-demo

Recording harness: answers the account-rotator mod's keychain or credentials file, usage, sign-in and store calls with imitated logins.

newtimer
★ 15v0.1.0MITupdated 2026-10-15ivokund/claude-account-rotator/demo/harness
A shopper browsing a rack in a slop shop
README

Claude account rotator

A Claude Code mod for people with more than one Claude account. It shows each account's usage under the prompt, and moves your session to another account before the one in use runs out.

The footer up close as the account in use runs low; the session moves to another account and a notice says so; then the panel switches the footer's top row between bars, pies and braille

<sub>A real session fed imitated accounts (demo/harness), so no real account appears.</sub>

Install

/plugin install account-rotator --marketplace ivokund/claude-account-rotator

Then click the usage in the footer, press + Add an account and approve the sign-in in your browser. The account you are signed in with now is account 1.

The footer

A column per account. The top row is the room it has right now (its 5-hour window, or none once its week is spent), the bottom row its week: green with plenty left, red with little. The percents are the account in use's.

Click it for the panel: each account's usage and reset times, Sign in, Use now and Remove, + Add an account, Rotate now, Rotation, which picks the account it moves to, and Display, which picks the footer's look:

The footer's looks. Glyphs: bars, pies or braille. The account in use: between lines, on a tile, or with the rest dimmed. Counting what is used instead of the room left, without percents, or without glyphs. An account that needs a sign-in, one signing in, and the rows' names under the pointer.

What it does

  • Rotates. Once the account in use is down to 10% of its 5-hour window or 5% of its week, the session moves to another account, and a notice says which and why. Rotation in the panel picks which: by default the one whose 5-hour window resets soonest, so room about to refill is used before it does; else the one with the most room, or the next in the list; or disabled, which leaves switching to you.
  • Recovers. A turn that still hits a usage limit or a rejected sign-in switches accounts and asks Claude to carry on.
  • Shows what is really in use. If a switch doesn't take, the footer and panel go back to the old account, and the rotation stops trying the other one for that session.
  • Follows /login. /login signs in the account the session is on, which after a switch isn't Claude Code's login. The mod checks who each account's sign-in belongs to: one that now holds another account's asks for its own again, and the session moves off it; one signed in as an account not on the list becomes that account.
  • Names Claude Code's login. The panel's second line says which account new sessions start on, and whether it is personal or a Team or Enterprise seat, whose sessions can't switch.
  • Warns. When every account is near its limit, a line above the prompt says when the next one frees up. When an account needs signing in again, the same line says so and signs it in with one click.

Requirements

  • macOS, where Claude Code keeps sign-ins in the keychain, or Linux, where it keeps them in files.
  • Claude Code 2.1.296 or later, in its fullscreen renderer (/tui fullscreen), the only one that passes clicks on: the panel opens with a click.
  • Any Mac terminal: Terminal.app, iTerm2, Ghostty, Warp, VS Code; on Linux, one with mouse reporting. In iTerm2 the profile's Enable mouse reporting must be on, in tmux set -g mouse on, and iTerm2's tmux -CC mode can't run fullscreen.
  • Claude subscription accounts (tested with Max and Team seats). Your /login must be a personal one, Pro or Max: see Team and Enterprise accounts.

Claude Code's own "You've used N% of your session limit" warning can still show for a minute after a switch: the switch lands within about 35 seconds, and warnings Claude Code queued before it still play. No mod can hide it.

Mods are early access in Claude Code, and this one relies on internals a release can change: where sign-ins are stored, and the usage endpoint behind /usage.

Team and Enterprise accounts

The mod moves between personal accounts and Team or Enterprise seats. But Claude Code stops plugins from switching accounts, for the rest of a session, when:

  • the session started on a Team or Enterprise account, or
  • plugins reloaded while one was in use: /reload-plugins, or installing, enabling or disabling a plugin (since 2.1.299).

The mod tells you when this happens. To keep switching:

  • /login with your personal account (Pro or Max) in a new session. New sessions start on Claude Code's login, which the panel's second line names; /login changes it only in a session on it, and in a session the mod moved, signs in that account instead. It doesn't unblock the session it runs in: restart Claude after.
  • Add Team seats from the panel, not /login: + Add an account, and Sign in to renew.
  • Restart Claude when blocked: quit, and run claude --continue or claude --resume to keep your conversation (or plain claude). Each starts on your /login. Not /restart: it stays on the account in use.

The block comes from a built-in security default that stops plugins you install from changing where Claude Code reads its sign-in. It also runs on any machine with managed settings.

Turning the security default off

[!WARNING] This removes a security protection for every plugin and every session on the machine. With it off, a plugin you install can override what an organization's policy sets for Claude Code (its hooks, prompt content, settings, tool and MCP server rules) and change where Claude Code reads sign-ins and settings from. Anthropic's Usage Policy forbids intentionally bypassing restrictions or guardrails built into its products. This is a setting Claude Code offers administrators, but using it so a Team session can switch accounts may count as bypassing. Do it only on a machine you alone control, with plugins you trust. Never do it where an organization manages Claude Code, or where your Team or Enterprise organization's rules forbid it.

Managed settings decide whether the default runs: once they set prependPlugins, it runs only if listed there. The file must be owned by an administrator, since Claude Code ignores this key in a file your account can write. If the file below already exists, it holds your organization's policy: leave it alone. Otherwise:

# macOS
sudo mkdir -p "/Library/Application Support/ClaudeCode"
echo '{ "prependPlugins": [] }' | sudo tee "/Library/Application Support/ClaudeCode/managed-settings.json"

# Linux
sudo mkdir -p /etc/claude-code
echo '{ "prependPlugins": [] }' | sudo tee /etc/claude-code/managed-settings.json

It applies to sessions started after. Delete the file to turn the default back on. Managed settings an organization sends from Anthropic's servers can override it.

Untested: this follows how Claude Code 2.1.299 describes and reads the setting, but it has not been tried end to end, and a release can change it. You use it at your own risk.

How it works

  • Each account you add gets a folder, ~/.claude/accounts/<n>, and Claude Code keeps its sign-in in the keychain under that folder's name (on Linux, in that folder's .credentials.json), as for any config folder. A switch points the session at another account's sign-in.
  • Its only network traffic: each account's usage every 10 minutes, from the endpoint /usage reads, and once per sign-in who it belongs to, from the one Claude Code asks at a sign-in, each with that account's own token. Tokens stay where Claude Code keeps them; the mod stores each account's email, organization, plan and last usage, and a digest of its token, to tell a new sign-in.

Uninstall

Remove each account in the panel, which signs it out, then run claude plugin uninstall account-rotator@claude-account-rotator and delete ~/.claude/accounts.

Terms

As of October 2026:

  • Neither Anthropic's Consumer Terms (October 8, 2025) nor its Commercial Terms (June 17, 2025) forbid one person from holding more than one Claude account. The Consumer Terms forbid sharing an account's login details, so add only your own. A Team or Enterprise seat is also under its organization's rules.
  • Claude Code's legal page says the advertised usage limits of Pro and Max assume ordinary, individual usage. It also says Anthropic may enforce its rules on sign-ins and credentials without notice. The mod signs accounts in only through Claude Code's own claude auth login and keeps no tokens of its own.
  • The Consumer Terms forbid reaching the Services "through automated or non-human means" except with an API key. The mod's usage check every 10 minutes and its "carry on" prompt after a switch run inside Claude Code, as part of your own session, but judge that for yourself.
  • The Usage Policy forbids intentionally bypassing restrictions or guardrails built into Anthropic's products: read the warning in Turning the security default off first.

Terms change; this is not legal advice. Not affiliated with or endorsed by Anthropic.

Develop

claude --plugin-dir .     # a session with the mod loaded from this folder
claude plugin validate .
claude plugin test .

demo/record.mjs records the GIF and demo/looks.mjs draws the footer's looks, both from a real session fed imitated accounts; their headers say what they need.

License

MIT

Source 1 files
hooks/register.ts 276 lines
1import type { EngineInterface, ProcessRunResult, Register, SessionRateLimit } from 'claude-code'
2
3// Answers the account-rotator mod's calls on `$` with imitated logins, so a
4// recording shows the real mod with nobody's accounts in it. The session's own
5// requests stay on whatever login started it: a switch here is the mod's alone.
6// The login in use drains once the recorder creates the file DEMO_DRAIN_FILE
7// names, so it runs low while the camera is on the footer, with no prompt sent.
8// DEMO_IN_USE picks how the footer marks the login in use, and the login
9// DEMO_SIGNED_OUT names starts signed out, for a shot of the footer asking
10// for a sign-in.
11
12/** The mod whose calls are answered; every other plugin's go through. */
13const MOD = 'account-rotator'
14const HOME = '/home/you'
15const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
16const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
17const MINUTE = 60_000
18const HOUR = 60 * MINUTE
19const DAY = 24 * HOUR
20/** The mod's clock runs this many times faster than the wall's, so a switch settles in seconds. */
21const SPEED = 10
22/** The mod's one-minute sync and ten-minute check both run this often. */
23const TICK_MS = 500
24/** How often the recorder's file is looked for. */
25const POLL_MS = 250
26/** How long the imitated browser sign-in takes. */
27const SIGN_IN_MS = 4500
28
29/**
30 * One imitated login: who, percent used of its 5-hour window and its week,
31 * how fast its window fills while it drains (percent a second), and when
32 * each window resets, as an offset from the clock's first reading.
33 */
34type Login = {
35  id: string
36  email: string
37  orgName: string
38  plan: string
39  five: number
40  week: number
41  fillPerSecond: number
42  fiveResets: number
43  weekResets: number
44  isSignedIn: boolean
45}
46
47/** Login 1 runs low within seconds of draining. */
48const logins: Login[] = [
49  { id: '1', email: 'you@example.com', orgName: 'Personal', plan: 'max', five: 60, week: 38, fillPerSecond: 10, fiveResets: 2 * HOUR + 14 * MINUTE, weekResets: 2 * DAY + 5 * HOUR, isSignedIn: true },
50  { id: '2', email: 'you@acme.example', orgName: 'Acme', plan: 'team', five: 6, week: 22, fillPerSecond: 1.5, fiveResets: 4 * HOUR + 41 * MINUTE, weekResets: 5 * DAY + 2 * HOUR, isSignedIn: true },
51  { id: '3', email: 'you@example.com', orgName: 'Side gig', plan: 'pro', five: 41, week: 77, fillPerSecond: 1.5, fiveResets: 1 * HOUR + 3 * MINUTE, weekResets: 3 * DAY + 20 * HOUR, isSignedIn: true },
52]
53/** The login + Add a login signs in, its reset offsets counted from the sign-in. */
54const ADDED = { email: 'you@studio.example', orgName: 'Studio', plan: 'max', five: 0, week: 9, fillPerSecond: 1.5, fiveIn: 5 * HOUR, weekIn: 6 * DAY + 9 * HOUR }
55
56const slotOf = (id: string) => (id === '1' ? '' : `${HOME}/.claude/accounts/${id}`)
57const idOf = (slot: string) => (slot === '' ? '1' : (slot.split('/').pop() ?? ''))
58
59/** The mod's store, kept here: nothing reaches the file every session of the real mod shares. */
60const store = new Map<string, unknown>([
61  [
62    'accounts',
63    logins.map(l => ({ id: l.id, slot: slotOf(l.id), email: l.email, orgId: `org-${l.id}`, orgName: l.orgName, plan: l.plan })),
64  ],
65])
66
67let activeSlot = ''
68/** From the recorder's file until the mod moves off the login it drains. */
69let isDraining = false
70/** Wall-clock start, and the last time the fill was brought up to date. */
71let start: number | undefined
72let filledTo: number | undefined
73
74const fakeNow = (wall: number) => {
75  start ??= wall
76  return start + (wall - start) * SPEED
77}
78const iso = (offset: number) => new Date((start ?? 0) + offset).toISOString()
79/** Every 5-hour reset time handed out: a reading that carries none of them is the real session's. */
80const isImitated = (value: unknown) => {
81  const resetsAt = (value as { fiveHour?: { resetsAt?: unknown } } | null)?.fiveHour?.resetsAt
82  return logins.some(l => iso(l.fiveResets) === resetsAt)
83}
84
85/** Fills the login in use for the time it drained since the last look. */
86function fill(wall: number): void {
87  const elapsed = filledTo === undefined ? 0 : (wall - filledTo) / 1000
88  filledTo = wall
89  const active = logins.find(l => l.id === idOf(activeSlot))
90  if (isDraining && active !== undefined) active.five = Math.min(100, active.five + active.fillPerSecond * elapsed)
91}
92
93const usageOf = (l: Login) => ({
94  five_hour: { utilization: Math.round(l.five), resets_at: iso(l.fiveResets) },
95  seven_day: { utilization: Math.round(l.week), resets_at: iso(l.weekResets) },
96})
97
98/** Who a login's token signs in, as the profile endpoint answers: the organization the store names. */
99const profileOf = (l: Login) => ({
100  account: { uuid: `account-${l.id}`, email: l.email },
101  organization: { uuid: `org-${l.id}`, name: l.orgName, organization_type: `claude_${l.plan}` },
102})
103
104const limitsOf = (l: Login): SessionRateLimit[] => [
105  { kind: 'five_hour', percentUsed: Math.round(l.five), resetsAt: iso(l.fiveResets) },
106  { kind: 'seven_day', percentUsed: Math.round(l.week), resetsAt: iso(l.weekResets) },
107]
108
109const ran = (exitCode: number, stdout: string): ProcessRunResult => ({
110  exitCode,
111  stdout,
112  stderr: '',
113  isStdoutTruncated: false,
114  isStderrTruncated: false,
115})
116
117/** The keychain entry Claude Code keeps a slot's login in, as the mod names it. */
118async function keychainItem(slot: string): Promise<string> {
119  if (slot === '') return 'Claude Code-credentials'
120  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(slot.normalize('NFC')))
121  const hex = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
122  return `Claude Code-credentials-${hex.slice(0, 8)}`
123}
124
125/** A login's sign-in as Claude Code stores it, its token the one the usage answer knows. */
126async function signInOf($: EngineInterface, l: Login): Promise<string> {
127  const now = fakeNow(await $.clock.now())
128  const oauth = { accessToken: `demo-${l.id}`, expiresAt: now + 6 * HOUR, refreshTokenExpiresAt: now + 25 * DAY, subscriptionType: l.plan }
129  return JSON.stringify({ claudeAiOauth: oauth })
130}
131
132async function stored($: EngineInterface, item: string): Promise<ProcessRunResult> {
133  for (const l of logins) {
134    if (!l.isSignedIn || (await keychainItem(slotOf(l.id))) !== item) continue
135    return ran(0, await signInOf($, l))
136  }
137  return ran(44, '')
138}
139
140/** Where there is no keychain (Linux): the signed-in login whose credentials file is `path`. */
141const fileLogin = (path: string) =>
142  logins.find(l => l.isSignedIn && `${slotOf(l.id) || `${HOME}/.claude`}/.credentials.json` === path)
143
144function status(slot: string): string {
145  const l = logins.find(x => x.id === idOf(slot) && x.isSignedIn)
146  if (l === undefined) return JSON.stringify({ loggedIn: false })
147  return JSON.stringify({ loggedIn: true, email: l.email, orgId: `org-${l.id}`, orgName: l.orgName, subscriptionType: l.plan })
148}
149
150export const register: Register = on => {
151  on('session.start', async ($, e, next) => {
152    const inUse = await $.env.get('DEMO_IN_USE')
153    if (inUse !== undefined && inUse !== '') store.set('footer', { active: inUse })
154    const signedOut = await $.env.get('DEMO_SIGNED_OUT')
155    for (const l of logins) if (l.id === signedOut) l.isSignedIn = false
156    const started = await next(e)
157    const trigger = await $.env.get('DEMO_DRAIN_FILE')
158    if (trigger === undefined || trigger === '') return started
159    const poll = $.clock.every(POLL_MS, () => {
160      void $.fs.exists(trigger).then(async exists => {
161        if (!exists || isDraining) return
162        poll.cancel()
163        fill(await $.clock.now())
164        isDraining = true
165      })
166    })
167    return started
168  })
169
170  on('clock.now', async ($, e, next) => {
171    const wall = await next(e)
172    return next.origin.plugin !== MOD || wall.deny !== undefined ? wall : { value: fakeNow(wall.value) }
173  })
174
175  on('clock.every', async ($, e, next) => (next.origin.plugin === MOD ? next({ ...e, ms: TICK_MS }) : next(e)))
176
177  on('env.get', async ($, e, next) => {
178    if (next.origin.plugin !== MOD) return next(e)
179    if (e.name === 'USER') return { value: 'you' }
180    if (e.name === 'HOME') return { value: HOME }
181    if (e.name === 'CLAUDE_SECURESTORAGE_CONFIG_DIR') return { value: activeSlot === '' ? undefined : activeSlot }
182    return next(e)
183  })
184
185  // A switch is the mod's alone: the session keeps the login it started with,
186  // and the drain ends with the login that ran low.
187  on('env.set', async ($, e, next) => {
188    if (next.origin.plugin !== MOD || e.name !== 'CLAUDE_SECURESTORAGE_CONFIG_DIR') return next(e)
189    // The mod sets the slot in use at its start, to learn whether switching is allowed: no switch.
190    if ((e.value ?? '') === activeSlot) return { value: undefined }
191    fill(await $.clock.now())
192    activeSlot = e.value ?? ''
193    isDraining = false
194    return { value: undefined }
195  })
196
197  on('process.run', async ($, e, next) => {
198    if (next.origin.plugin !== MOD) return next(e)
199    const [cmd, sub, verb] = e.argv
200    if (cmd === 'security') return { value: await stored($, e.argv[e.argv.length - 1] ?? '') }
201    if (cmd === 'claude' && sub === 'auth') {
202      const slot = e.init?.env?.CLAUDE_SECURESTORAGE_CONFIG_DIR ?? ''
203      if (verb === 'status') return { value: ran(0, status(slot)) }
204      if (verb === 'logout') {
205        const l = logins.find(x => x.id === idOf(slot))
206        if (l !== undefined) l.isSignedIn = false
207        return { value: ran(0, '') }
208      }
209    }
210    return next(e)
211  })
212
213  // The browser sign-in, imitated: a pause, then the new login is there.
214  on('process.spawn', async function* ($, e, next) {
215    if (next.origin.plugin !== MOD || e.argv[0] !== 'claude' || e.argv[2] !== 'login') return yield* next(e)
216    const id = idOf(e.env?.CLAUDE_SECURESTORAGE_CONFIG_DIR ?? '')
217    yield { stream: 'stdout' as const, text: 'Opening browser to sign in…\n' }
218    await $.clock.sleep(SIGN_IN_MS)
219    const existing = logins.find(l => l.id === id)
220    if (existing !== undefined) existing.isSignedIn = true
221    else {
222      const elapsed = fakeNow(await $.clock.now()) - (start ?? 0)
223      const { fiveIn, weekIn, ...who } = ADDED
224      logins.push({ id, ...who, fiveResets: elapsed + fiveIn, weekResets: elapsed + weekIn, isSignedIn: true })
225    }
226    return { value: { code: 0, signal: null } }
227  })
228
229  on('http.fetch', async ($, e, next) => {
230    if (next.origin.plugin !== MOD || (e.url !== USAGE_URL && e.url !== PROFILE_URL)) return next(e)
231    fill(await $.clock.now())
232    const token = (e.init?.headers?.Authorization ?? '').replace('Bearer ', '')
233    const l = logins.find(x => `demo-${x.id}` === token && x.isSignedIn)
234    if (l === undefined) return { value: { status: 401, ok: false, headers: {}, text: '' } }
235    const body = e.url === PROFILE_URL ? profileOf(l) : usageOf(l)
236    return { value: { status: 200, ok: true, headers: {}, text: JSON.stringify(body) } }
237  })
238
239  // What the session's last response reported, for the mod's check that a switch took.
240  on('session.usage', async ($, e, next) => {
241    const real = await next(e)
242    if (next.origin.plugin !== MOD || real.deny !== undefined) return real
243    fill(await $.clock.now())
244    const active = logins.find(l => l.id === idOf(activeSlot))
245    return { value: { ...real.value, rateLimits: active === undefined ? [] : limitsOf(active) } }
246  })
247
248  on('store.get', async ($, e, next) => (next.origin.plugin === MOD ? { value: store.get(e.key) } : next(e)))
249
250  // The real responses' usage is the starting login's, not the imitated one in use: refused.
251  on('store.set', async ($, e, next) => {
252    if (next.origin.plugin !== MOD) return next(e)
253    if (e.key.startsWith('reading:') && !isImitated(e.value)) return { deny: 'the demo shows imitated usage only' }
254    store.set(e.key, e.value)
255    return { value: undefined }
256  })
257
258  on('store.delete', async ($, e, next) => {
259    if (next.origin.plugin !== MOD) return next(e)
260    store.delete(e.key)
261    return { value: undefined }
262  })
263
264  on('store.keys', async ($, e, next) => (next.origin.plugin === MOD ? { value: [...store.keys()] } : next(e)))
265
266  on('fs.write', async ($, e, next) => (next.origin.plugin === MOD ? { value: undefined } : next(e)))
267
268  on('fs.read', async ($, e, next) => {
269    if (next.origin.plugin !== MOD || !e.path.endsWith('/.credentials.json')) return next(e)
270    const l = fileLogin(e.path)
271    return l === undefined ? { deny: 'no such file' } : { value: await signInOf($, l) }
272  })
273
274  on('ui.notify', async ($, e, next) => (next.origin.plugin === MOD ? { value: { isSent: false, reason: 'refused' } } : next(e)))
275}
276