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

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.

<sub>A real session fed imitated accounts (demo/harness), so no real account appears.</sub>
/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.
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:

/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./tui fullscreen), the only one that passes clicks on: the panel opens with a click.set -g mouse on, and iTerm2's tmux -CC mode can't run fullscreen./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.
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:
/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./login: + Add an account, and Sign in to renew.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.
[!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.
~/.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./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.Remove each account in the panel, which signs it out, then run claude plugin uninstall account-rotator@claude-account-rotator and delete ~/.claude/accounts.
As of October 2026:
claude auth login and keeps no tokens of its own.Terms change; this is not legal advice. Not affiliated with or endorsed by Anthropic.
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.
hooks/register.ts 276 lines1import 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