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…

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.

Add account creates a config directory for the new account and opens a terminal to sign in.Claude Multi-Account: Cost report by account.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.
/plugin install multi-account --marketplace roych28/claude-multi-account ` or from a shell: ` claude plugin install multi-account --marketplace roych28/claude-multi-account ``claudeCode.claudeProcessWrapper to a small launcher in ~/.claude-multi-account/bin.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 dirfolders: 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.
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.
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.plugin/: the Claude Code plugin multi-accountvscode/: the VS Code extension, with the launcher in vscode/binClaude Multi-Account: Switch account for this folderClaude Multi-Account: Cost report by accountClaude Multi-Account: Add accountClaude Multi-Account: Start Claude Code tabs on the folder's accountClaude Multi-Account: RefreshClaude Multi-Account: Enter Pro license keymacOS or Linux, with Claude Code installed. The launcher is a POSIX shell script.
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.
Found a bug or want a feature? Open an issue. If the extension saves you time, you can sponsor its development.
MIT. Not affiliated with Anthropic. Made by Roy Chemo at Kashuv.
hooks/register.tsx 399 lines1import { 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}
399types/index.d.ts 26 lines1export 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