SLOPSHOPPER

multi-account

Run each project on its own Claude account: shows the account, cost and limits above the prompt, switches the account per folder, and hands the next session…

newbandcommandtoaststatusprompt
v0.2.0MITupdated 2026-10-15roych28/claude-multi-account/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · multi-account
› fix the failing auth test and add an audit log call ⏺ 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 › /account ⎿ multi-account: Account: default (not signed in) ⎿ multi-account: Other accounts: none signed in ⎿ multi-account: Session cost $0.42 · 5-hour limit 31% ⎿ multi-account: Switch with /account <name>, or /account auto for the default. default not signed in Session cost $0.42 · 5-hour limit 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
default not signed in Session cost $0.42 · 5-hour limit 31%
README

Claude Multi-Account

Run every project on the right Claude account, and always know which one you are on.

Freelancers and consultants often hold a Claude account per client, plus a personal one. Claude Code signs in once per config directory, so it is easy to spend a client's quota on your own project, or lose track of what a session costs. Claude Multi-Account puts the account, the usage and the switch where you can see them.

Status bar

Features

  • The account in the status bar. The account this folder runs on, the session's tokens and cost, and the highest rate-limit usage. The item turns amber at 90% of a limit.
  • Switch in one click. Hover the item: the account and its email come first, then a button for every other account. The choice is saved per folder, so each project keeps its own account and you can work on several at once.
  • Add accounts from VS Code. Add account creates a config directory for the new account and opens a terminal to sign in.
  • Pick up where you stopped. After a switch, start a new conversation right where you are: it receives the last request, the last answer and the path of the previous transcript, so the work continues on the new account without repeating yourself.
  • Real token counts. Tokens are read from the session transcript: input, output, cache reads and cache writes, for the whole session, resumed sessions included.
  • Cost report by account. What each account, and each folder under it, cost in a month. One account per client means one line per client on your invoice. Open it from the hover or with Claude Multi-Account: Cost report by account.

Pro

Pro adds the full history of the cost report, beyond the current and the previous month, and CSV export for invoices. Every Pro feature is free during the launch. When Pro goes on sale, Claude Multi-Account: Enter Pro license key unlocks it on your machine.

Getting started

  1. Install this extension and the companion Claude Code plugin. At the prompt of a Claude Code session: `` /plugin install multi-account --marketplace roych28/claude-multi-account ` or from a shell: ` claude plugin install multi-account --marketplace roych28/claude-multi-account ``
  2. When the extension asks, let it start Claude Code tabs on each folder's account. It sets claudeCode.claudeProcessWrapper to a small launcher in ~/.claude-multi-account/bin.
  3. Run Claude Multi-Account: Add account for each client and sign in.
  4. Open a project, click the account in the status bar, and pick the account it should run on.

How it works

Every Claude account lives in its own config directory, which Claude Code reads from CLAUDE_CONFIG_DIR. Two plain-text files in ~/.claude-multi-account hold the setup:

  • accounts: one account per line, name<TAB>config dir
  • folders: one rule per line, folder<TAB>account. The longest matching folder wins, and a * line is the default.

The launcher reads these files and starts Claude Code on the right account. The companion plugin reports the account, cost and limits of each session, records each session's cost in ~/.claude-multi-account/ledger, and writes the handoff note the next session reads. The cost is Claude Code's own figure at API prices; on a Pro or Max plan it measures the work done, not what the plan charged.

In the terminal

The same rules work for claude in any terminal. Add this to ~/.zshrc or ~/.bashrc:

claude() { ~/.claude-multi-account/bin/cma exec claude "$@"; }

Inside a session, /account shows the account, cost and limits, and /account <name> switches the folder. The plugin also draws the account and its switch buttons above the prompt.

Testing

  • cd vscode && npm test: unit tests for the rules, tokens, cost report and license.
  • cd vscode && npm run test:integration: runs the extension inside a real VS Code against a throwaway setup.
  • claude plugin test ./plugin: the Claude Code plugin's tests.
  • scripts/check-accounts: a live check on the accounts of this machine. For each signed-in account it runs one short session through the launcher and checks that it ran on that account, was reported by the plugin and landed in the cost ledger. Then it moves a folder between two accounts and checks the second one picks up where the first stopped. It uses a temporary setup, never your own rules, and costs a few cents per account.

Repository layout

  • plugin/: the Claude Code plugin multi-account
  • vscode/: the VS Code extension, with the launcher in vscode/bin

Commands

  • Claude Multi-Account: Switch account for this folder
  • Claude Multi-Account: Cost report by account
  • Claude Multi-Account: Add account
  • Claude Multi-Account: Start Claude Code tabs on the folder's account
  • Claude Multi-Account: Refresh
  • Claude Multi-Account: Enter Pro license key

Requirements

macOS or Linux, with Claude Code installed. The launcher is a POSIX shell script.

Privacy

Everything stays on your machine. The extension reads Claude Code's local config and transcripts and writes only to ~/.claude-multi-account. The one network call is to the license server, with your key, when you enter a Pro license key.

Support

Found a bug or want a feature? Open an issue. If the extension saves you time, you can sponsor its development.

License

MIT. Not affiliated with Anthropic. Made by Roy Chemo at Kashuv.

Source 2 files
hooks/register.tsx 399 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ClaudeMultiAccountAccount, ClaudeMultiAccountInfo, ClaudeMultiAccountUsage } from '../types'
5
6const info = atom({ plugin: 'multi-account', key: 'info' } as const, null)
7const usage = atom({ plugin: 'multi-account', key: 'usage' } as const, { limits: [] })
8const nextAccount = atom({ plugin: 'multi-account', key: 'next' } as const, null)
9const handoff = atom({ plugin: 'multi-account', key: 'handoff' } as const, null)
10const run = atom({ plugin: 'multi-account', key: 'run' } as const, null)
11
12const HANDOFF_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000
13const NOT_SIGNED_IN = 'not signed in'
14const LIMIT_NAMES: Record<string, string> = { five_hour: '5-hour limit', seven_day: 'Weekly limit' }
15
16type Handoff = {
17  sessionId: string
18  account: string
19  at: number
20  transcript: string
21  lastRequest: string
22  lastAnswer: string
23  summary?: string
24}
25
26type Rule = { path: string; account: string }
27
28async function home($: EngineInterface): Promise<string> {
29  return (await $.env.get('HOME')) ?? ''
30}
31
32async function base($: EngineInterface): Promise<string> {
33  return (await $.env.get('CLAUDE_MULTI_ACCOUNT_HOME')) || `${await home($)}/.claude-multi-account`
34}
35
36function expand(p: string, h: string): string {
37  return (p === '~' || p.startsWith('~/') ? h + p.slice(1) : p).replace(/\/+$/, '')
38}
39
40async function readLines($: EngineInterface, path: string): Promise<string[][]> {
41  try {
42    return (await $.fs.read(path))
43      .split('\n')
44      .map(l => l.trim())
45      .filter(l => l !== '' && !l.startsWith('#'))
46      .map(l => l.split('\t').map(f => f.trim()))
47  } catch {
48    return []
49  }
50}
51
52async function emailOf($: EngineInterface, dir: string, h: string): Promise<string> {
53  const file = dir === `${h}/.claude` ? `${h}/.claude.json` : `${dir}/.claude.json`
54  try {
55    const parsed = JSON.parse(await $.fs.read(file)) as { oauthAccount?: { emailAddress?: string } }
56    return parsed.oauthAccount?.emailAddress ?? NOT_SIGNED_IN
57  } catch {
58    return NOT_SIGNED_IN
59  }
60}
61
62async function accounts($: EngineInterface): Promise<ClaudeMultiAccountAccount[]> {
63  const h = await home($)
64  const rows = await readLines($, `${await base($)}/accounts`)
65  const list = rows.filter(r => r[0] && r[1]).map(r => ({ name: r[0]!, dir: expand(r[1]!, h) }))
66  const all = list.length > 0 ? list : [{ name: 'default', dir: `${h}/.claude` }]
67  const out: ClaudeMultiAccountAccount[] = []
68  for (const a of all) out.push({ ...a, email: await emailOf($, a.dir, h) })
69  return out
70}
71
72async function rules($: EngineInterface): Promise<Rule[]> {
73  const h = await home($)
74  const rows = await readLines($, `${await base($)}/folders`)
75  return rows.filter(r => r[0] && r[1]).map(r => ({ path: r[0] === '*' ? '*' : expand(r[0]!, h), account: r[1]! }))
76}
77
78export function accountFor(folder: string, list: Rule[]): string | null {
79  let best: Rule | null = null
80  for (const r of list) {
81    if (r.path === '*') {
82      if (!best) best = r
83      continue
84    }
85    const matches = folder === r.path || folder.startsWith(`${r.path}/`)
86    if (matches && (!best || best.path === '*' || r.path.length > best.path.length)) best = r
87  }
88  return best?.account ?? null
89}
90
91async function folderRoot($: EngineInterface, cwd: string): Promise<string> {
92  try {
93    const ran = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
94    return ran.exitCode === 0 && ran.stdout.trim() ? ran.stdout.trim() : cwd
95  } catch {
96    return cwd
97  }
98}
99
100function keyOf(path: string): string {
101  return path.replace(/[^a-zA-Z0-9]/g, '-')
102}
103
104async function loadInfo($: EngineInterface, cwd: string): Promise<ClaudeMultiAccountInfo> {
105  const h = await home($)
106  const list = await accounts($)
107  const dir = expand((await $.env.get('CLAUDE_CONFIG_DIR')) || `${h}/.claude`, h)
108  const current = list.find(a => a.dir === dir) ?? {
109    name: dir.split('/').pop() ?? dir,
110    dir,
111    email: await emailOf($, dir, h),
112  }
113  const sessionId = await $.session.id()
114  return {
115    current,
116    others: list.filter(a => a.dir !== current.dir && a.email !== NOT_SIGNED_IN),
117    folder: await folderRoot($, cwd),
118    transcript: `${dir}/projects/${keyOf(cwd)}/${sessionId}.jsonl`,
119  }
120}
121
122function clip(text: string, max: number): string {
123  return text.length > max ? `${text.slice(0, max)} [...]` : text
124}
125
126async function handoffPath($: EngineInterface, folder: string): Promise<string> {
127  return `${await base($)}/handoff/${keyOf(folder)}.json`
128}
129
130async function readHandoff($: EngineInterface, folder: string): Promise<Handoff | null> {
131  try {
132    return JSON.parse(await $.fs.read(await handoffPath($, folder))) as Handoff
133  } catch {
134    return null
135  }
136}
137
138async function writeHandoff($: EngineInterface, summary?: string): Promise<void> {
139  const i = await read($, info)
140  if (!i) return
141  const sessionId = await $.session.id()
142  const messages = await $.session.messages()
143  const lastRequest = [...messages].reverse().find(m => m.role === 'user' && m.text.trim() !== '')
144  const lastAnswer = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim() !== '')
145  const previous = await readHandoff($, i.folder)
146  const keptSummary = summary ?? (previous?.sessionId === sessionId ? previous.summary : undefined)
147  const note: Handoff = {
148    sessionId,
149    account: i.current.name,
150    at: await $.clock.now(),
151    transcript: i.transcript,
152    lastRequest: clip(lastRequest?.text ?? '', 2000),
153    lastAnswer: clip(lastAnswer?.text ?? '', 3000),
154    ...(keptSummary ? { summary: keptSummary } : {}),
155  }
156  await $.fs.write(await handoffPath($, i.folder), JSON.stringify(note, null, 2))
157}
158
159async function writeStatus($: EngineInterface): Promise<void> {
160  const i = await read($, info)
161  if (!i) return
162  const sessionId = await $.session.id()
163  const status = {
164    sessionId,
165    cwd: await $.session.cwd(),
166    folder: i.folder,
167    transcript: i.transcript,
168    account: { name: i.current.name, email: i.current.email },
169    others: i.others.map(o => ({ name: o.name, email: o.email })),
170    usage: await read($, usage),
171    next: await read($, nextAccount),
172    updatedAt: await $.clock.now(),
173  }
174  await $.fs.write(`${await base($)}/sessions/${sessionId}.json`, JSON.stringify(status))
175  await writeLedger($, sessionId, i, status.usage, status.updatedAt)
176}
177
178// One ledger entry per run of a session, so a session resumed on another account is billed to each account
179// for its own part. The cost counts from the run's start, whether or not Claude Code restores it on resume.
180async function writeLedger(
181  $: EngineInterface,
182  sessionId: string,
183  i: ClaudeMultiAccountInfo,
184  u: ClaudeMultiAccountUsage,
185  now: number,
186): Promise<void> {
187  const r = await read($, run)
188  if (!r || u.usd === undefined) return
189  const entry = {
190    sessionId,
191    account: i.current.name,
192    email: i.current.email,
193    folder: i.folder,
194    startedAt: r.startedAt,
195    updatedAt: now,
196    usd: Math.max(0, u.usd - r.startUsd),
197  }
198  await $.fs.write(`${await base($)}/ledger/${sessionId}-${r.startedAt}.json`, JSON.stringify(entry))
199}
200
201function handoffPrompt(note: Handoff): string {
202  return [
203    '# Where the previous session in this folder stopped',
204    `Session ${note.sessionId} on the ${note.account} account, last active ${new Date(note.at).toISOString()}.`,
205    'If the person continues earlier work, pick up from here without asking them to repeat it; if they start something new, ignore this.',
206    `Full transcript (JSONL, read its tail for more detail): ${note.transcript}`,
207    '',
208    'Last request from the person:',
209    note.lastRequest || '(none)',
210    '',
211    'Last answer:',
212    note.lastAnswer || '(none)',
213    ...(note.summary ? ['', 'Handoff summary written when the account was switched:', note.summary] : []),
214  ].join('\n')
215}
216
217const SUMMARY_PROMPT =
218  'The person is moving this work to another Claude account; a fresh session will continue from a note. ' +
219  'Write that handoff note in the language of the conversation, under 300 words: the goal, what is done ' +
220  '(with full file paths and commits), what is in progress, the exact next step, and open questions. ' +
221  'No secrets, keys or tokens. Reply with the note only.'
222
223async function pin($: EngineInterface, folder: string, name: string): Promise<void> {
224  const path = `${await base($)}/folders`
225  let lines: string[] = []
226  try {
227    lines = (await $.fs.read(path)).split('\n').filter(l => l.trim() !== '')
228  } catch {}
229  const h = await home($)
230  const kept = lines.filter(l => {
231    const first = l.split('\t')[0]?.trim() ?? ''
232    return l.trim().startsWith('#') || first === '*' || expand(first, h) !== folder
233  })
234  if (name !== 'auto') kept.push(`${folder}\t${name}`)
235  await $.fs.write(path, kept.join('\n') + '\n')
236}
237
238async function switchTo($: EngineInterface, name: string): Promise<string> {
239  const i = await read($, info)
240  if (!i) return 'Not ready yet.'
241  const list = await accounts($)
242  if (name !== 'auto' && !list.some(a => a.name === name)) {
243    return `Unknown account: ${name}. Known: ${list.map(a => a.name).join(', ')}, or auto.`
244  }
245  await pin($, i.folder, name)
246  const next = accountFor(i.folder, await rules($))
247  await update($, nextAccount, () => next)
248
249  const fork = await $.model.fork({ prompt: SUMMARY_PROMPT })
250  let note = ''
251  if (fork.isAnswered) {
252    await writeHandoff($, fork.text.trim())
253  } else {
254    await writeHandoff($)
255    note = `\nNo handoff summary (${fork.reason}): the next session gets the last request, the last answer and the transcript path.`
256    $.ui.toast(`Handoff summary not written (${fork.reason})`)
257  }
258  await writeStatus($)
259  return (
260    `${i.folder} now runs on ${next ?? 'the default account'}.${note}\n` +
261    'To continue from here: open a new Claude tab, or exit and run claude --continue in this folder.'
262  )
263}
264
265function usageLine(u: ClaudeMultiAccountUsage): string {
266  const cost = `Session cost ${u.usd === undefined ? 'unknown' : `$${u.usd.toFixed(2)}`}`
267  return [cost, ...u.limits.map(l => `${LIMIT_NAMES[l.kind] ?? l.kind} ${l.percentUsed}%`)].join('  ·  ')
268}
269
270function statusText(i: ClaudeMultiAccountInfo, u: ClaudeMultiAccountUsage, next: string | null): string {
271  const lines = [
272    `Account: ${i.current.name} (${i.current.email})`,
273    `Other accounts: ${i.others.map(o => `${o.name} (${o.email})`).join(', ') || 'none signed in'}`,
274    usageLine(u),
275  ]
276  if (next && next !== i.current.name) lines.push(`The next session in ${i.folder} runs on ${next}.`)
277  lines.push('Switch with /account <name>, or /account auto for the default.')
278  return lines.join('\n')
279}
280
281export const register: Register = on => {
282  on('session.start', async ($, e, next) => {
283    const loaded = await loadInfo($, e.cwd)
284    await update($, info, () => loaded)
285    await update($, nextAccount, () => null)
286
287    const now = await $.session.usage()
288    await update($, usage, () => ({
289      usd: now.cost?.usd,
290      limits: now.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed })),
291    }))
292    const startedAt = await $.clock.now()
293    await update($, run, () => ({ startedAt, startUsd: now.cost?.usd ?? 0 }))
294
295    const names = (await accounts($)).map(a => a.name)
296    await $.command.register({
297      name: 'account',
298      description: 'Show the Claude account, cost and limits; /account <name> switches this folder',
299      argumentHint: `[${[...names, 'auto'].join('|')}]`,
300    })
301
302    const isFresh = (await $.session.messages()).length === 0
303    const note = await readHandoff($, loaded.folder)
304    const sessionId = await $.session.id()
305    const isRecent = note !== null && (await $.clock.now()) - note.at < HANDOFF_MAX_AGE_MS
306    if (isFresh && note && isRecent && note.sessionId !== sessionId) {
307      await update($, handoff, () => handoffPrompt(note))
308    }
309    await writeStatus($)
310
311    if ((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-vscode') {
312      const text = `Claude account: ${loaded.current.name} (${loaded.current.email})`
313      $.ui.status(text)
314      try {
315        await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } })
316      } catch (err) {
317        $.ui.toast(`${text}. The notice could not be added: ${String(err)}`)
318      }
319    }
320
321    return next(e)
322  })
323
324  on('prompt.submit', async ($, e, next) => {
325    if (e.command || e.isMeta) return next(e)
326    const text = await read($, handoff)
327    if (!text) return next(e)
328    await update($, handoff, () => null)
329    return next({ ...e, context: [...(e.context ?? []), text] })
330  })
331
332  on('session.measure', async ($, e, next) => {
333    await update($, usage, () => ({
334      usd: e.cost?.usd,
335      limits: e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed })),
336    }))
337    await writeStatus($)
338    return next(e)
339  })
340
341  on('turn.complete', async ($, e, next) => {
342    const done = await next(e)
343    if (e.agentId === undefined) {
344      try {
345        await writeHandoff($)
346      } catch (err) {
347        $.ui.toast(`Handoff note not saved: ${String(err)}`)
348      }
349    }
350    await writeStatus($)
351    return done
352  })
353
354  on('command.run', { command: 'account' }, async ($, e) => {
355    const name = e.args.trim()
356    if (name) return { text: await switchTo($, name) }
357    const i = (await read($, info)) ?? (await loadInfo($, await $.session.cwd()))
358    return { text: statusText(i, await read($, usage), await read($, nextAccount)) }
359  })
360
361  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
362    if (e.props.hasSurvey) return next(e)
363    if ((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-vscode') return next(e)
364    const i = await read($, info)
365    if (!i) return next(e)
366    const { Box, Text, Button } = $.ui.resolve(e)
367    const u = await read($, usage)
368    const pending = await read($, nextAccount)
369
370    return (
371      <Box flexDirection="column">
372        <Box flexDirection="row">
373          <Text bold>{i.current.name}</Text>
374          <Text dimColor>  {i.current.email}</Text>
375        </Box>
376        {i.others.length > 0 && (
377          <Box flexDirection="row">
378            <Text dimColor>Switch to  </Text>
379            {i.others.map(o => (
380              <Button
381                key={`switch-${o.name}`}
382                label={o.name}
383                onPress={async () => {
384                  const result = await switchTo($, o.name)
385                  $.ui.toast(result.split('\n')[0] ?? result)
386                }}
387              />
388            ))}
389          </Box>
390        )}
391        {pending && pending !== i.current.name && (
392          <Text color="yellow">The next session in this folder runs on {pending}</Text>
393        )}
394        <Text dimColor>{usageLine(u)}</Text>
395      </Box>
396    )
397  })
398}
399
types/index.d.ts 26 lines
1export type ClaudeMultiAccountAccount = { name: string; dir: string; email: string }
2
3export type ClaudeMultiAccountInfo = {
4  current: ClaudeMultiAccountAccount
5  others: ClaudeMultiAccountAccount[]
6  folder: string
7  transcript: string
8}
9
10export type ClaudeMultiAccountUsage = {
11  usd?: number
12  limits: { kind: string; percentUsed: number }[]
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'multi-account': {
18      info: ClaudeMultiAccountInfo | null
19      usage: ClaudeMultiAccountUsage
20      next: string | null
21      handoff: string | null
22      run: { startedAt: number; startUsd: number } | null
23    }
24  }
25}
26