Mod: run each Claude Code session on its own Claude subscription account, and switch live from a sidebar (Ctrl+Alt+Shift+numpad -) or /account-switch

A Claude Code mod for people with more than one Claude subscription. Each session can run on a different account, and you can switch a session live without logging out. Two terminals open side by side can be on your work and personal accounts at the same time.

It needs Claude Code 2.1.287 or later, the first release that loads mods. It has been tested on Linux with 2.1.295. The macOS and Windows code paths have unit tests but have not been run on either system.
Install it, start Claude Code, and run /login once for each account you want to use. After that, press Ctrl+Alt+Shift+numpad minus or run /account-switch to open the sidebar.
The one-liners below install from where the repository is hosted. Replace <repo> with its address, such as https://github.com/you/account-switcher. From a clone, run ./install.sh or .\install.ps1 in the repository folder instead.
curl -fsSL <repo>/raw/main/install.sh | sh -s -- <repo>.git
curl -fsSL <repo>/raw/main/install.sh | sh -s -- <repo>.git
In PowerShell:
& ([scriptblock]::Create((irm <repo>/raw/main/install.ps1))) -Source <repo>.git
Each script checks your Claude Code version, adds this repository as a plugin marketplace and installs the mod. To remove it, run the same script with --uninstall (-Uninstall on Windows).
/login signs in to an account and saves it. A new login never replaces the one other sessions are using: the mod files it as an account of its own, and the session you ran /login in switches to it.
Open the sidebar with Ctrl+Alt+Shift+numpad minus, with /account-switch, or by clicking the "Account:" line above the prompt.
| Key | In the sidebar | | :- | :- | | Up, Down | Move between accounts | | Enter | Switch this session to the selected account | | Right, Left | Change the hat Clawd wears for that account | | a | Add an account (runs /login) | | Esc | Close |

The account at the top, under ACTIVE, is the one this session uses. Every other session keeps its own. Clawd sits beside each account, and his hat tells them apart at a glance. There are 13 to choose from, and each account remembers its own.
The same things work as commands, which is handy in claude -p where there is no sidebar:
/account-switch work switch by label, email, or the start of either
/account-switch 2 switch by position in the list
/account-switch list
/account-switch rename sam@acme.example Work
/account-switch hat work crown
/account-switch forget client
Don't use /logout to change accounts. Claude Code revokes the login on Anthropic's side when you log out, so every session using that account is signed out and you would have to sign in to it again. The mod asks before letting /logout go ahead.

The shortcut and the hat keys live in ~/.claude/keybindings.json, which the mod edits the first time it runs. It binds Ctrl+Alt+Shift+- to open the sidebar, and Right and Left in mod panes to change hats.
/account-switch keys off. /account-switch keys on puts them back.ctrl+alt+shift+- entry in the Global block to any key you like. Keep its value, app:toggleDiffPreSession.Pane context, the mod leaves your binding alone. Hats can then still be set with /account-switch hat.The numpad minus works in terminals that report modifier keys on it: kitty, WezTerm, Ghostty, foot, iTerm2 with the kitty keyboard protocol, and xterm with modifyOtherKeys. Claude Code can't tell numpad minus from the main minus key, so Ctrl+Alt+Shift with the main minus opens the sidebar too. In a terminal that reports neither, rebind it as above.
CLAUDE_CONFIG_DIR is respected if you keep Claude Code's files somewhere other than ~/.claude.
Claude Code reads its login from the directory named by CLAUDE_SECURESTORAGE_CONFIG_DIR, or from ~/.claude when that is unset. The mod gives each account a directory of its own under ~/.claude/accounts/ and points each session's variable at the account it should use. Token refreshes then land in that account's own copy, and a second session on another account never sees them. The login in ~/.claude itself is the default account, which sessions start on and which claude without the mod keeps using.
On Linux and Windows each account's login is a .credentials.json in its directory, readable only by you, the same way Claude Code stores its own. On macOS, where Claude Code keeps logins in the Keychain, each account is a Keychain item named after its directory.
There is one catch. ~/.claude.json holds a single "signed in as" record that every session shares, and /status reads it. So /status first points that record at the session's own account and waits about a second and a half for Claude Code to re-read the file. Requests never depend on that record, because they use each session's own login.
A session remembers its account across claude --resume.
cd mods/account-switcher && claude plugin test # unit tests, no session or network
scripts/integration-test.sh [path-to-claude] # two real sessions against a fake API
The integration test runs two Claude Code sessions side by side on one throwaway home directory. One stays on the default account and the other switches away and back. A fake API records which token each session sent, and both sessions run /status. It needs Python with pyte.
hooks/register.ts 453 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 type Account,
4 AccountStore,
5 bindsSwitchKey,
6 describe,
7 displayName,
8 FileCredentials,
9 hasHatBinding,
10 hasOldBinding,
11 hasSwitchBinding,
12 type Io,
13 matchAccount,
14 parseObject,
15 resolvePaths,
16 SWITCH_ACTION,
17 SWITCH_KEY_LABEL,
18 withoutSwitchBinding,
19 withSwitchBinding,
20} from './accounts.ts'
21import { cycleHat, HATS } from './clawd.ts'
22import { detectStore, KeychainCredentials } from './keychain.ts'
23import { panel, panelRows, useKey } from './panel.ts'
24import { type Commands, commandsFor, detectPlatform, homeDir, joiner, type Platform } from './platform.ts'
25
26const PANE = 'accounts'
27const PANE_COLUMNS = 52
28// Claude Code re-reads .claude.json about every half second; /status waits this long.
29const CONFIG_SETTLE_MS = 1500
30const SESSIONS_KEY = 'sessions'
31const KEYS_OFF_KEY = 'keys-off'
32
33const HELP = [
34 'Usage:',
35 ` /account-switch open the account sidebar (or press ${SWITCH_KEY_LABEL})`,
36 ' /account-switch <name|number> switch this session to a saved account',
37 ' /account-switch list',
38 ' /account-switch add sign in to another account (same as /login)',
39 ' /account-switch rename <name> <label>',
40 ` /account-switch hat <name> <hat> one of: ${HATS.map(h => h.id).join(', ')}`,
41 ' /account-switch forget <name>',
42 ` /account-switch keys on|off add or remove the ${SWITCH_KEY_LABEL} and hat keys`,
43].join('\n')
44
45let accounts: Account[] = []
46let current: Account | undefined
47let focused = ''
48let notice = ''
49
50interface Env {
51 platform: Platform
52 commands: Commands
53 join: (...parts: string[]) => string
54}
55
56async function environment($: EngineInterface): Promise<Env> {
57 const platform = detectPlatform({ appData: await $.env.get('APPDATA'), userProfile: await $.env.get('USERPROFILE') })
58 return { platform, commands: commandsFor(platform), join: joiner(platform) }
59}
60
61async function readText($: EngineInterface, env: Env, path: string): Promise<string | null> {
62 if (!(await $.fs.exists(path))) return null
63 try {
64 return String(await $.fs.read(path))
65 } catch {
66 // $.fs.read stops at 4 MiB and a long-lived ~/.claude.json can be larger.
67 const r = await $.process.run(env.commands.read(path))
68 if (r.exitCode !== 0) throw new Error(`could not read ${path}: ${r.stderr.trim()}`)
69 return r.stdout
70 }
71}
72
73async function writeText($: EngineInterface, env: Env, path: string, text: string): Promise<void> {
74 const r = await $.process.run(env.commands.writeSecret(path), { stdin: text })
75 if (r.exitCode !== 0) throw new Error(`could not write ${path}: ${r.stderr.trim() || `exit ${r.exitCode}`}`)
76}
77
78function makeIo($: EngineInterface, env: Env, accountsDir: string): Io {
79 return {
80 read: path => readText($, env, path),
81 writeSecret: (path, text) => writeText($, env, path, text),
82 list: async dir => ((await $.fs.exists(dir)) ? (await $.fs.list(dir)).map(e => e.name) : []),
83 removeTree: async path => {
84 // Only ever delete inside the accounts directory.
85 if (!path.startsWith(accountsDir) || path === accountsDir) throw new Error(`refusing to delete ${path}`)
86 await $.process.run(env.commands.removeTree(path))
87 },
88 mkdir: async path => (await $.process.run(env.commands.mkdir(path))).exitCode === 0,
89 rmdir: async path => {
90 await $.process.run(env.commands.rmdir(path))
91 },
92 mtime: async path => ((await $.fs.exists(path)) ? (await $.fs.stat(path)).mtimeMs : null),
93 sleep: ms => $.clock.sleep(ms),
94 now: () => $.clock.now(),
95 }
96}
97
98async function store($: EngineInterface): Promise<AccountStore> {
99 const env = await environment($)
100 const home = homeDir(env.platform, { home: await $.env.get('HOME'), userProfile: await $.env.get('USERPROFILE') })
101 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
102 const legacy = await $.fs.exists(env.join(configDir || env.join(home, '.claude'), '.config.json'))
103 const paths = resolvePaths(home, configDir, legacy, env.join)
104 const io = makeIo($, env, paths.accountsDir)
105 const files = new FileCredentials(io, env.join)
106 const keychain = await macKeychain($, paths.configDir)
107 return new AccountStore(io, paths, await detectStore(files, keychain, paths.configDir))
108}
109
110/** The Keychain store on macOS, where `security` exists; undefined elsewhere. */
111async function macKeychain($: EngineInterface, defaultDir: string): Promise<KeychainCredentials | undefined> {
112 if (await $.env.get('APPDATA')) return undefined
113 const probe = await $.process.run(['sh', '-c', 'command -v security && uname -s']).catch(() => undefined)
114 if (!probe || probe.exitCode !== 0 || !probe.stdout.includes('Darwin')) return undefined
115 const user = (await $.env.get('USER')) || 'claude-code-user'
116 return new KeychainCredentials(
117 async (args, stdin) => {
118 const r = await $.process.run(['security', ...args], stdin === undefined ? {} : { stdin })
119 return { exitCode: r.exitCode, stdout: r.stdout }
120 },
121 /^[a-zA-Z0-9._-]+$/.test(user) ? user : 'claude-code-user',
122 defaultDir,
123 )
124}
125
126async function refresh($: EngineInterface): Promise<AccountStore> {
127 const s = await store($)
128 accounts = await s.list()
129 current = await s.forSession(await $.env.get('CLAUDE_SECURESTORAGE_CONFIG_DIR'))
130 $.ui.invalidate('ui.render')
131 return s
132}
133
134/** Points this session's credential storage, and the shared account details, at `account`. */
135async function useAccount($: EngineInterface, s: AccountStore, account: Account): Promise<void> {
136 await s.shareFromHome(account)
137 await $.env.set('CLAUDE_SECURESTORAGE_CONFIG_DIR', account.isHome ? undefined : account.dir)
138 await $.env.set('CLAUDE_CODE_ORGANIZATION_UUID', account.oauthAccount.organizationUuid)
139 await s.pointConfigAt(account.oauthAccount)
140 await rememberSession($, account)
141 await refresh($)
142}
143
144async function pointConfigAtSession($: EngineInterface): Promise<void> {
145 const s = await refresh($)
146 if (!current) return
147 await s.pointConfigAt(current.oauthAccount, true)
148 await $.clock.sleep(CONFIG_SETTLE_MS)
149}
150
151async function rememberSession($: EngineInterface, account: Account): Promise<void> {
152 const id = await $.session.id()
153 const saved = ((await $.store.get(SESSIONS_KEY)) as Record<string, string> | undefined) ?? {}
154 const next = Object.fromEntries(Object.entries({ ...saved, [id]: account.dir }).slice(-200))
155 await $.store.set(SESSIONS_KEY, next)
156}
157
158async function restoreSession($: EngineInterface, s: AccountStore): Promise<void> {
159 if (await $.env.get('CLAUDE_SECURESTORAGE_CONFIG_DIR')) return
160 const id = await $.session.id()
161 const saved = ((await $.store.get(SESSIONS_KEY)) as Record<string, string> | undefined) ?? {}
162 const account = saved[id] ? (await s.list()).find(a => a.dir === saved[id]) : undefined
163 if (account && !account.isHome) await useAccount($, s, account)
164}
165
166async function keybindingsPath($: EngineInterface, s: AccountStore): Promise<string> {
167 return (await environment($)).join(s.configDir, 'keybindings.json')
168}
169
170async function installKeys($: EngineInterface, s: AccountStore, on: boolean): Promise<string> {
171 const env = await environment($)
172 const path = await keybindingsPath($, s)
173 const file = parseObject(await readText($, env, path), path) ?? {}
174 if (on && bindsSwitchKey(file) && !hasSwitchBinding(file)) {
175 return `${SWITCH_KEY_LABEL} already has a binding in keybindings.json, so it was left alone.`
176 }
177 await writeText($, env, path, JSON.stringify(on ? withSwitchBinding(file) : withoutSwitchBinding(file), null, 2))
178 await $.store.set(KEYS_OFF_KEY, !on)
179 return on
180 ? `${SWITCH_KEY_LABEL} opens the account sidebar, and Right and Left in it change Clawd's hat.`
181 : `Removed the ${SWITCH_KEY_LABEL} shortcut and the hat keys.`
182}
183
184/** Installs the shortcuts the first time, and upgrades the ones earlier versions used. */
185async function ensureKeys($: EngineInterface, s: AccountStore): Promise<void> {
186 if (await $.store.get(KEYS_OFF_KEY)) return
187 const env = await environment($)
188 const path = await keybindingsPath($, s)
189 const file = parseObject(await readText($, env, path), path) ?? {}
190 if (hasOldBinding(file)) {
191 await writeText($, env, path, JSON.stringify(withSwitchBinding(file), null, 2))
192 $.ui.log(`The account sidebar moved to ${SWITCH_KEY_LABEL}, and Ctrl+Shift+- is undo again.`)
193 return
194 }
195 if (hasSwitchBinding(file) && !hasHatBinding(file)) {
196 await writeText($, env, path, JSON.stringify(withSwitchBinding(file), null, 2))
197 return
198 }
199 if (bindsSwitchKey(file)) return
200 $.ui.log(await installKeys($, s, true))
201}
202
203function nameOf(account: Account | undefined): string {
204 return account ? displayName(account) : 'no account'
205}
206
207function formatList(list: Account[], active: Account | undefined): string {
208 if (list.length === 0) return 'No accounts yet. Run /login to sign in.'
209 return list
210 .map((a, i) => {
211 const flags = [a.dir === active?.dir ? 'this session' : '', a.isHome ? 'default' : '', a.needsLogin ? 'needs /login' : '']
212 .filter(Boolean)
213 .join(', ')
214 const detail = describe(a)
215 return `${i + 1}. ${displayName(a)}${detail ? ` ${detail}` : ''}${flags ? ` (${flags})` : ''}`
216 })
217 .join('\n')
218}
219
220async function switchTo($: EngineInterface, query: string): Promise<string> {
221 const s = await refresh($)
222 const match = matchAccount(accounts, query)
223 if (match.kind === 'none') return `No account matches "${query}".\n${formatList(accounts, current)}`
224 if (match.kind === 'ambiguous') return `"${query}" matches more than one account: ${match.candidates.map(displayName).join(', ')}`
225 if (match.account.dir === current?.dir) return `This session already uses ${displayName(match.account)}.`
226 await useAccount($, s, match.account)
227 const warning = match.account.needsLogin ? ' Its login has expired, so run /login to sign in to it again.' : ''
228 return `This session now uses ${displayName(match.account)}. Other sessions keep their own account.${warning}`
229}
230
231async function setHat($: EngineInterface, account: Account, hat: string): Promise<void> {
232 const s = await store($)
233 await s.setHat(account, hat)
234 accounts = accounts.map(a => (a.dir === account.dir ? { ...a, hat } : a))
235 if (current?.dir === account.dir) current = { ...current, hat }
236 $.ui.invalidate('ui.render')
237}
238
239async function runCommand($: EngineInterface, args: string): Promise<string | null> {
240 const [verb = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
241 const s = await refresh($)
242
243 switch (verb.toLowerCase()) {
244 case '':
245 return null
246 case 'help':
247 return HELP
248 case 'list':
249 case 'ls':
250 return formatList(accounts, current)
251 case 'add':
252 case 'login':
253 return 'Run /login and sign in. The new account is added and this session switches to it.'
254 case 'rename': {
255 const [query, ...label] = rest
256 if (!query || label.length === 0) return 'Usage: /account-switch rename <name> <label>'
257 const match = matchAccount(accounts, query)
258 if (match.kind !== 'found') return `No single account matches "${query}".`
259 await s.rename(match.account, label.join(' '))
260 await refresh($)
261 return `Renamed ${displayName(match.account)} to ${label.join(' ').trim()}.`
262 }
263 case 'hat': {
264 const [query, hatId] = rest
265 const hat = HATS.find(h => h.id === hatId?.toLowerCase())
266 if (!query || !hat) return `Usage: /account-switch hat <name> <${HATS.map(h => h.id).join('|')}>`
267 const match = matchAccount(accounts, query)
268 if (match.kind !== 'found') return `No single account matches "${query}".`
269 await setHat($, match.account, hat.id)
270 return `${displayName(match.account)}'s Clawd now wears: ${hat.name}.`
271 }
272 case 'forget':
273 case 'remove':
274 case 'rm': {
275 const query = rest.join(' ')
276 if (!query) return 'Usage: /account-switch forget <name>'
277 const match = matchAccount(accounts, query)
278 if (match.kind !== 'found') return `No single account matches "${query}".`
279 if (match.account.isHome) return `${displayName(match.account)} is the default login in ~/.claude. Use /logout from a session on it to remove it.`
280 if (match.account.dir === current?.dir) return `This session uses ${displayName(match.account)}. Switch to another account first.`
281 await s.forget(match.account)
282 await refresh($)
283 return `Forgot ${displayName(match.account)}. Its login stays valid on Anthropic's side until it expires.`
284 }
285 case 'keys':
286 return installKeys($, s, rest[0]?.toLowerCase() !== 'off')
287 case 'use':
288 case 'switch':
289 return rest.length ? switchTo($, rest.join(' ')) : HELP
290 default:
291 return switchTo($, args)
292 }
293}
294
295async function togglePane($: EngineInterface): Promise<void> {
296 if ((await $.ui.panes()).some(p => p.id === PANE)) {
297 await $.ui.close({ id: PANE })
298 return
299 }
300 notice = ''
301 await refresh($)
302 focused = current ? useKey(current) : ''
303 await $.ui.open({ id: PANE, title: 'Accounts', focus: true, closeOnEscape: true, columns: PANE_COLUMNS, rows: panelRows({ accounts, current }) })
304}
305
306export const register: Register = on => {
307 on('session.start', async ($, e, next) => {
308 try {
309 const s = await store($)
310 await s.learnHome()
311 await restoreSession($, s)
312 await refresh($)
313 if (e.isInteractive) await ensureKeys($, s)
314 } catch (error) {
315 $.ui.log(`account-switcher could not start: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
316 }
317 await $.command.register({
318 name: 'account-switch',
319 description: 'Switch this session between Claude subscription accounts',
320 argumentHint: '[name|list|add|rename|hat|forget|keys]',
321 immediate: true,
322 })
323 return next(e)
324 })
325
326 on('command.run', { command: 'account-switch' }, async ($, e) => {
327 try {
328 const text = await runCommand($, e.args)
329 if (text !== null) return { text }
330 } catch (error) {
331 return { text: `Account switch failed: ${error instanceof Error ? error.message : String(error)}` }
332 }
333 if ((await $.session.surfaces()).length === 0) return { text: formatList(accounts, current) }
334 await togglePane($)
335 return {}
336 })
337
338 // .claude.json holds one account for every session, and /status reads it, so
339 // point it at this session's account first and give Claude Code time to re-read it.
340 on('command.run', { command: 'status' }, async ($, e, next) => {
341 await pointConfigAtSession($)
342 return next(e)
343 }).catch(async ($, e, next) => next(e))
344
345 // A new login goes into a scratch directory so it never overwrites the login this
346 // session or any other is using, then becomes an account of its own.
347 on('command.run', { command: 'login' }, async ($, e, next) => {
348 const s = await refresh($)
349 const before = current
350 await s.clearPending()
351 await $.env.set('CLAUDE_SECURESTORAGE_CONFIG_DIR', s.pendingDir)
352
353 let result
354 try {
355 result = await next(e)
356 } finally {
357 const added = await s.adoptPending(await s.configAccount())
358 const target = added ?? before
359 if (target) await useAccount($, s, target)
360 else await $.env.set('CLAUDE_SECURESTORAGE_CONFIG_DIR', undefined)
361 if (added) $.ui.log(`This session now uses ${nameOf(added)}. Switch back any time with ${SWITCH_KEY_LABEL} or /account-switch.`)
362 }
363 return result
364 }).catch(async ($, e, next) => next(e))
365
366 // /logout revokes the login on Anthropic's side, which signs out every session on it.
367 on('command.run', { command: 'logout' }, async ($, e, next) => {
368 const s = await refresh($)
369 const leaving = current
370 if (!leaving) return next(e)
371
372 let answer = 'Log out'
373 try {
374 answer = await $.ui.ask(
375 `Logging out revokes ${nameOf(leaving)}'s login, so every session using it is signed out and you would have to sign in again. To use a different account in this session, use /account-switch instead. Log out anyway?`,
376 { header: 'Logout', options: ['Log out', 'Cancel'] },
377 )
378 } catch {
379 // No one to ask, as in claude -p.
380 }
381 if (answer !== 'Log out') return { text: 'Logout cancelled.' }
382
383 const result = await next(e)
384 await s.forget(leaving)
385 const home = (await s.list()).find(a => a.isHome)
386 if (home && !leaving.isHome) await useAccount($, s, home)
387 else await refresh($)
388 return result
389 }).catch(async ($, e, next) => next(e))
390
391 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
392 const result = await next(e)
393 if (!result.deny && e.element) {
394 focused = e.element
395 $.ui.invalidate('ui.render')
396 }
397 return result
398 }).catch(async ($, e, next) => next(e))
399
400 // Right and Left arrive as page scrolls (see HAT_KEYS); over an account they change its hat.
401 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
402 const account = accounts.find(a => focused === useKey(a))
403 const isPageKey = e.origin.kind === 'person' && !e.pointer && Math.abs(e.by) >= e.bodyRows
404 if (!account || !isPageKey) return next(e)
405 await setHat($, account, cycleHat(account.hat, e.by > 0 ? 1 : -1).id)
406 return { deny: 'changed the hat instead' }
407 }).catch(async ($, e, next) => next(e))
408
409 // The account line above the prompt. Clicking it, or the shortcut, opens the sidebar.
410 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
411 if (e.props.hasSurvey) return next(e)
412 const theirs = await next(e)
413 const { Box, Button } = $.ui.resolve(e)
414 const plan = current?.plan ? ` · ${current.plan.charAt(0).toUpperCase()}${current.plan.slice(1)}` : ''
415 const line = Button({
416 key: 'account-switch',
417 label: `Account: ${nameOf(current)}${plan}`,
418 plain: true,
419 dimColor: true,
420 action: SWITCH_ACTION,
421 onPress: () => togglePane($),
422 })
423 return theirs ? Box({ flexDirection: 'column', children: [theirs, line] }) : line
424 })
425
426 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
427 if (e.requestId !== PANE) return next(e)
428 const { Box, Text, Button } = $.ui.resolve(e)
429 return panel(
430 { Box, Text, Button },
431 { accounts, current, focused, notice, width: e.props.bodyColumns },
432 {
433 choose: account => async () => {
434 notice = await switchTo($, account.isHome ? 'home' : account.key).catch(
435 error => `Account switch failed: ${error instanceof Error ? error.message : String(error)}`,
436 )
437 if (notice.startsWith('This session')) {
438 await $.ui.close({ id: PANE })
439 $.ui.log(notice)
440 notice = ''
441 return
442 }
443 $.ui.invalidate('ui.render')
444 },
445 add: async () => {
446 await $.ui.close({ id: PANE })
447 await $.prompt.fill({ text: '/login' })
448 },
449 },
450 )
451 })
452}
453hooks/accounts.ts 569 lines1// Per-session Claude subscription accounts.
2//
3// Each saved account owns a directory, ~/.claude/accounts/<key>/, holding its own
4// .credentials.json. A session uses one by pointing CLAUDE_SECURESTORAGE_CONFIG_DIR
5// at that directory, so two sessions can run on two accounts and each token refresh
6// lands in the right file. The login in ~/.claude itself is the "home" account,
7// which sessions use by default and which `claude` outside the mod keeps using.
8
9export interface Io {
10 read(path: string): Promise<string | null>
11 /** Atomic write with a 0600 file and 0700 parent directories. */
12 writeSecret(path: string, text: string): Promise<void>
13 /** Names of the entries in a directory, or none if it is missing. */
14 list(dir: string): Promise<string[]>
15 removeTree(path: string): Promise<void>
16 /** Creates a directory, resolving false if it already exists. */
17 mkdir(path: string): Promise<boolean>
18 rmdir(path: string): Promise<void>
19 mtime(path: string): Promise<number | null>
20 sleep(ms: number): Promise<void>
21 now(): Promise<number>
22}
23
24/**
25 * Where an account's login lives. Linux and Windows keep it in `<dir>/.credentials.json`;
26 * macOS keeps it in a Keychain item whose name Claude Code derives from `dir`.
27 */
28export interface CredentialStore {
29 read(dir: string): Promise<Record<string, unknown> | null>
30 write(dir: string, creds: Record<string, unknown>): Promise<void>
31 remove(dir: string): Promise<void>
32}
33
34export class FileCredentials implements CredentialStore {
35 constructor(
36 private readonly io: Io,
37 private readonly join: Join,
38 ) {}
39
40 async read(dir: string): Promise<Record<string, unknown> | null> {
41 const path = this.join(dir, CREDENTIALS)
42 try {
43 return parseObject(await this.io.read(path), path)
44 } catch {
45 return null
46 }
47 }
48
49 async write(dir: string, creds: Record<string, unknown>): Promise<void> {
50 await this.io.writeSecret(this.join(dir, CREDENTIALS), JSON.stringify(creds, null, 2))
51 }
52
53 async remove(): Promise<void> {
54 // The file goes with its directory.
55 }
56}
57
58export type Join = (...parts: string[]) => string
59
60export const posixJoin: Join = (...parts) => parts.join('/')
61
62export interface Paths {
63 configDir: string
64 globalConfig: string
65 accountsDir: string
66 join: Join
67}
68
69export interface OauthAccount {
70 accountUuid: string
71 emailAddress?: string
72 organizationUuid?: string
73 organizationName?: string
74 [key: string]: unknown
75}
76
77export interface Account {
78 key: string
79 label?: string
80 /** Id of the hat Clawd wears beside this account. */
81 hat?: string
82 oauthAccount: OauthAccount
83 /** Directory holding this account's .credentials.json. */
84 dir: string
85 isHome: boolean
86 plan?: string
87 needsLogin: boolean
88}
89
90export type Match = { kind: 'found'; account: Account } | { kind: 'none' } | { kind: 'ambiguous'; candidates: Account[] }
91
92export const HOME_KEY = 'home'
93export const CREDENTIALS = '.credentials.json'
94const META = 'account.json'
95const PENDING = '.pending'
96
97// Keys in .credentials.json that belong to the signed-in account. The rest, such as
98// MCP server logins, are shared from the home account into the others.
99export const ACCOUNT_CREDENTIAL_KEYS = ['claudeAiOauth', 'organizationUuid', 'trustedDeviceToken', 'designOauth']
100
101// Per-account caches Claude Code drops from .claude.json when the account changes.
102export const ACCOUNT_CACHE_KEYS = [
103 'additionalModelOptionsCache',
104 'additionalModelOptionsAnsweredAt',
105 'additionalModelCostsCache',
106 'modelAccessCache',
107 'orgModelDefaultCache',
108 'cachedArtifactRoster',
109 'artifactRosterDenied',
110 'lastSeenOrgDefaultUpdatedAt',
111 'clientDataCache',
112 'clientDataCacheSlots',
113 'autoCompactWindowsCache',
114 'cachedUsageUtilization',
115 'metricsStatusCache',
116 'metricsStatusCacheByPrincipal',
117 'githubWebConnectionStatusCache',
118 'startupPrefetchedAt',
119]
120
121// Claude Code treats its own locks as stale after 15s and refreshes live ones well
122// before that. The wait stays under the 10s hook time limit.
123const LOCK_STALE_MS = 20_000
124const LOCK_RETRY_MS = 100
125const LOCK_ATTEMPTS = 50
126
127export function resolvePaths(home: string, configDirEnv: string | undefined, legacyConfigExists: boolean, join: Join = posixJoin): Paths {
128 const configDir = configDirEnv || join(home, '.claude')
129 const globalConfig = legacyConfigExists
130 ? join(configDir, '.config.json')
131 : configDirEnv
132 ? join(configDirEnv, '.claude.json')
133 : join(home, '.claude.json')
134 return { configDir, globalConfig, accountsDir: join(configDir, 'accounts'), join }
135}
136
137export function accountKey(oauth: OauthAccount): string {
138 return `${oauth.accountUuid}_${oauth.organizationUuid ?? 'personal'}`.replace(/[^A-Za-z0-9_-]/g, '-')
139}
140
141export function displayName(account: Pick<Account, 'label' | 'oauthAccount' | 'key'>): string {
142 return account.label ?? account.oauthAccount.emailAddress ?? account.key
143}
144
145export function describe(account: Account): string {
146 const parts: string[] = []
147 if (account.label && account.oauthAccount.emailAddress) parts.push(account.oauthAccount.emailAddress)
148 if (account.plan) parts.push(account.plan.charAt(0).toUpperCase() + account.plan.slice(1))
149 if (account.oauthAccount.organizationName) parts.push(account.oauthAccount.organizationName)
150 return parts.join(' · ')
151}
152
153/** Matches by 1-based position, exact name, then case-insensitive prefix of label or email. */
154export function matchAccount(accounts: Account[], query: string): Match {
155 const q = query.trim().toLowerCase()
156 if (!q) return { kind: 'none' }
157
158 const index = Number(q)
159 if (Number.isInteger(index) && index >= 1 && index <= accounts.length) {
160 return { kind: 'found', account: accounts[index - 1] }
161 }
162
163 const names = (a: Account) =>
164 [a.label, a.oauthAccount.emailAddress, a.isHome ? HOME_KEY : a.key]
165 .filter((n): n is string => !!n)
166 .map(n => n.toLowerCase())
167
168 const exact = accounts.filter(a => names(a).includes(q))
169 if (exact.length === 1) return { kind: 'found', account: exact[0] }
170
171 const prefixed = accounts.filter(a => names(a).some(n => n.startsWith(q)))
172 if (prefixed.length === 1) return { kind: 'found', account: prefixed[0] }
173 if (prefixed.length > 1) return { kind: 'ambiguous', candidates: prefixed }
174 return { kind: 'none' }
175}
176
177export function applyAccountConfig(config: Record<string, unknown>, oauth: OauthAccount): Record<string, unknown> {
178 const next = { ...config }
179 const current = config.oauthAccount as OauthAccount | undefined
180 if (!current || accountKey(current) !== accountKey(oauth)) {
181 for (const key of ACCOUNT_CACHE_KEYS) delete next[key]
182 }
183 next.oauthAccount = oauth
184 return next
185}
186
187export function splitCredentials(creds: Record<string, unknown>): { account: Record<string, unknown>; shared: Record<string, unknown> } {
188 const account: Record<string, unknown> = {}
189 const shared: Record<string, unknown> = {}
190 for (const [key, value] of Object.entries(creds)) {
191 if (ACCOUNT_CREDENTIAL_KEYS.includes(key)) account[key] = value
192 else shared[key] = value
193 }
194 return { account, shared }
195}
196
197// The shortcut is a Button bound to an engine action that nothing else handles
198// while the newer /diff mod is enabled. Claude Code reads numpad minus as "-", so
199// Ctrl+Alt+Shift+numpad minus arrives as ctrl+alt+shift+- from terminals that
200// report modifiers on it (kitty protocol, xterm modifyOtherKeys, and keypad mode).
201export const SWITCH_ACTION = 'app:toggleDiffPreSession'
202export const SWITCH_KEY = 'ctrl+alt+shift+-'
203export const SWITCH_KEY_LABEL = 'Ctrl+Alt+Shift+numpad -'
204
205// A mod can't read Right and Left in its pane, so they are bound to page scrolls,
206// which the mod refuses and turns into changing the focused account's hat.
207export const HAT_KEYS: Record<string, string> = { right: 'pane:pageDown', left: 'pane:pageUp' }
208
209// What version 0.2 installed, removed on upgrade so undo gets its key back.
210const OLD_SWITCH_KEYS = ['ctrl+shift+-', 'ctrl+_']
211const OLD_UNDO_FALLBACK = 'ctrl+x ctrl+u'
212
213interface BindingBlock {
214 context: string
215 bindings: Record<string, string | null>
216}
217
218function bindingBlocks(file: Record<string, unknown>): BindingBlock[] {
219 return Array.isArray(file.bindings) ? (file.bindings as BindingBlock[]) : []
220}
221
222/** Whether the keybindings file already binds the switch key to anything. */
223export function bindsSwitchKey(file: Record<string, unknown>): boolean {
224 return bindingBlocks(file).some(b => SWITCH_KEY in (b.bindings ?? {}))
225}
226
227/** Whether the switch key is bound to the sidebar. */
228export function hasSwitchBinding(file: Record<string, unknown>): boolean {
229 return bindingBlocks(file).some(b => b.context === 'Global' && b.bindings?.[SWITCH_KEY] === SWITCH_ACTION)
230}
231
232export function hasOldBinding(file: Record<string, unknown>): boolean {
233 return bindingBlocks(file).some(b => b.context === 'Global' && OLD_SWITCH_KEYS.some(k => b.bindings?.[k] === SWITCH_ACTION))
234}
235
236/** Drops the bindings an older version of the mod installed. */
237export function withoutOldBinding(file: Record<string, unknown>): Record<string, unknown> {
238 const blocks = bindingBlocks(file)
239 .map(b => {
240 const bindings = { ...b.bindings }
241 for (const key of OLD_SWITCH_KEYS) {
242 if (b.context === 'Global' && bindings[key] === SWITCH_ACTION) delete bindings[key]
243 if (b.context === 'Chat' && bindings[key] === null) delete bindings[key]
244 }
245 if (b.context === 'Chat' && bindings[OLD_UNDO_FALLBACK] === 'chat:undo') delete bindings[OLD_UNDO_FALLBACK]
246 return { ...b, bindings }
247 })
248 .filter(b => Object.keys(b.bindings).length > 0)
249 return { ...file, bindings: blocks }
250}
251
252/** Whether Right and Left in a pane already reach the mod as hat changes. */
253export function hasHatBinding(file: Record<string, unknown>): boolean {
254 const pane = bindingBlocks(file).find(b => b.context === 'Pane')
255 return Object.entries(HAT_KEYS).every(([key, action]) => pane?.bindings?.[key] === action)
256}
257
258/** Adds the sidebar shortcut, and the hat keys unless the user bound Right or Left in panes themselves. */
259export function withSwitchBinding(file: Record<string, unknown>): Record<string, unknown> {
260 const blocks = bindingBlocks(withoutOldBinding(file)).map(b => ({ ...b, bindings: { ...b.bindings } }))
261 const block = (context: string) => {
262 let found = blocks.find(b => b.context === context)
263 if (!found) {
264 found = { context, bindings: {} }
265 blocks.push(found)
266 }
267 return found
268 }
269 block('Global').bindings[SWITCH_KEY] = SWITCH_ACTION
270 const pane = block('Pane')
271 for (const [key, action] of Object.entries(HAT_KEYS)) {
272 if (!(key in pane.bindings)) pane.bindings[key] = action
273 }
274 return { ...file, bindings: blocks }
275}
276
277export function withoutSwitchBinding(file: Record<string, unknown>): Record<string, unknown> {
278 const blocks = bindingBlocks(withoutOldBinding(file))
279 .map(b => {
280 const bindings = { ...b.bindings }
281 if (b.context === 'Global' && bindings[SWITCH_KEY] === SWITCH_ACTION) delete bindings[SWITCH_KEY]
282 if (b.context === 'Pane') {
283 for (const [key, action] of Object.entries(HAT_KEYS)) if (bindings[key] === action) delete bindings[key]
284 }
285 return { ...b, bindings }
286 })
287 .filter(b => Object.keys(b.bindings).length > 0)
288 return { ...file, bindings: blocks }
289}
290
291interface Tokens {
292 refreshToken?: string
293 subscriptionType?: string
294 refreshTokenExpiresAt?: number
295}
296
297function tokensOf(creds: Record<string, unknown> | null): Tokens | undefined {
298 const value = creds?.claudeAiOauth
299 if (value === null || typeof value !== 'object') return undefined
300 return value as Tokens
301}
302
303export function parseObject(text: string | null, path: string): Record<string, unknown> | null {
304 if (text === null || text.trim() === '') return null
305 const value: unknown = JSON.parse(text)
306 if (value === null || typeof value !== 'object' || Array.isArray(value)) {
307 throw new Error(`${path} does not hold a JSON object`)
308 }
309 return value as Record<string, unknown>
310}
311
312function sameJson(a: unknown, b: unknown): boolean {
313 return JSON.stringify(a) === JSON.stringify(b)
314}
315
316interface Meta {
317 oauthAccount: OauthAccount
318 label?: string
319 hat?: string
320}
321
322function metaOf(value: Record<string, unknown> | null): Meta | undefined {
323 const oauth = value?.oauthAccount as OauthAccount | undefined
324 if (!oauth?.accountUuid) return undefined
325 return {
326 oauthAccount: oauth,
327 ...(typeof value?.label === 'string' ? { label: value.label } : {}),
328 ...(typeof value?.hat === 'string' ? { hat: value.hat } : {}),
329 }
330}
331
332export class AccountStore {
333 constructor(
334 private readonly io: Io,
335 private readonly paths: Paths,
336 private readonly creds: CredentialStore,
337 ) {}
338
339 get pendingDir(): string {
340 return this.join(this.paths.accountsDir, PENDING)
341 }
342
343 private get join(): Join {
344 return this.paths.join
345 }
346
347 private get homeMetaPath(): string {
348 return this.join(this.paths.accountsDir, 'home.json')
349 }
350
351 private dirFor(key: string): string {
352 return this.join(this.paths.accountsDir, key)
353 }
354
355 get configDir(): string {
356 return this.paths.configDir
357 }
358
359 /** Every account with a login: the home account first, then saved ones by name. */
360 async list(): Promise<Account[]> {
361 const now = await this.io.now()
362 const accounts: Account[] = []
363
364 const home = await this.homeAccount(now)
365 if (home) accounts.push(home)
366
367 const saved: Account[] = []
368 for (const name of await this.io.list(this.paths.accountsDir)) {
369 if (name.startsWith('.') || name.endsWith('.json')) continue
370 const dir = this.dirFor(name)
371 const metaPath = this.join(dir, META)
372 try {
373 const meta = metaOf(parseObject(await this.io.read(metaPath), metaPath))
374 if (meta) saved.push(await this.hydrate({ key: name, ...meta }, dir, false, now))
375 } catch {
376 // A corrupt entry should not hide the others.
377 }
378 }
379 saved.sort((a, b) => displayName(a).localeCompare(displayName(b)))
380 return [...accounts, ...saved]
381 }
382
383 /** The account a session uses, from its CLAUDE_SECURESTORAGE_CONFIG_DIR. */
384 async forSession(storageDir: string | undefined): Promise<Account | undefined> {
385 const dir = storageDir || this.paths.configDir
386 return (await this.list()).find(a => a.dir === dir)
387 }
388
389 /**
390 * Records who the home account is the first time the mod runs. Afterwards
391 * .claude.json follows whichever session switched last, so it can't be trusted.
392 */
393 async learnHome(): Promise<void> {
394 if (await this.homeMeta()) return
395 const creds = await this.creds.read(this.paths.configDir)
396 if (!tokensOf(creds)?.refreshToken) return
397 const oauth = await this.configAccount()
398 if (oauth) await this.rememberHome(oauth)
399 }
400
401 /**
402 * Files the login that `/login` just wrote into the pending directory. It replaces
403 * the home login when there is none or it is the same account, and otherwise
404 * becomes a saved account of its own. Returns the account, or undefined if the
405 * login did not finish.
406 */
407 async adoptPending(oauth: OauthAccount | undefined): Promise<Account | undefined> {
408 const pending = await this.creds.read(this.pendingDir)
409 if (!oauth || !pending || !tokensOf(pending)?.refreshToken) {
410 await this.clearPending()
411 return undefined
412 }
413
414 const key = accountKey(oauth)
415 const home = await this.homeMeta()
416 const homeCreds = await this.creds.read(this.paths.configDir)
417 const intoHome = !tokensOf(homeCreds)?.refreshToken || (home && accountKey(home.oauthAccount) === key)
418
419 if (intoHome) {
420 await this.withLock(this.join(this.paths.configDir, '.storage-write'), async () => {
421 const live = (await this.creds.read(this.paths.configDir)) ?? {}
422 await this.creds.write(this.paths.configDir, { ...splitCredentials(live).shared, ...splitCredentials(pending).account })
423 })
424 await this.rememberHome(oauth)
425 } else {
426 const dir = this.dirFor(key)
427 const metaPath = this.join(dir, META)
428 const existing = metaOf(parseObject(await this.io.read(metaPath), metaPath))
429 await this.creds.write(dir, { ...splitCredentials(homeCreds ?? {}).shared, ...splitCredentials(pending).account })
430 await this.writeMeta(dir, { ...existing, oauthAccount: oauth })
431 }
432
433 await this.clearPending()
434 const target = intoHome ? this.paths.configDir : this.dirFor(key)
435 return (await this.list()).find(a => a.dir === target)
436 }
437
438 async clearPending(): Promise<void> {
439 await this.creds.remove(this.pendingDir)
440 await this.io.removeTree(this.pendingDir)
441 }
442
443 /** Copies MCP logins and other shared entries from the home account where the account has none. */
444 async shareFromHome(account: Account): Promise<void> {
445 if (account.isHome) return
446 const { shared } = splitCredentials((await this.creds.read(this.paths.configDir)) ?? {})
447 await this.withLock(this.join(account.dir, '.storage-write'), async () => {
448 const creds = (await this.creds.read(account.dir)) ?? {}
449 const missing = Object.fromEntries(Object.entries(shared).filter(([k]) => !(k in creds)))
450 if (Object.keys(missing).length === 0) return
451 await this.creds.write(account.dir, { ...creds, ...missing })
452 })
453 }
454
455 async rename(account: Account, label: string): Promise<void> {
456 const trimmed = label.trim()
457 await this.updateMeta(account, meta => {
458 const { label: _old, ...rest } = meta
459 return trimmed ? { ...rest, label: trimmed } : rest
460 })
461 }
462
463 async setHat(account: Account, hat: string): Promise<void> {
464 await this.updateMeta(account, meta => ({ ...meta, hat }))
465 }
466
467 async forget(account: Account): Promise<void> {
468 if (account.isHome) {
469 await this.io.removeTree(this.homeMetaPath)
470 return
471 }
472 await this.creds.remove(account.dir)
473 await this.io.removeTree(account.dir)
474 }
475
476 /**
477 * Points .claude.json's account details at `oauth`. /status reads them, and the
478 * file is shared by every session, so each session points it at its own account
479 * when it needs them right. With `touch`, the file is rewritten even when it
480 * already matches, because a running session only re-reads it when its mtime is
481 * newer than the copy it holds. Resolves true if it wrote.
482 */
483 async pointConfigAt(oauth: OauthAccount, touch = false): Promise<boolean> {
484 return this.withLock(this.paths.globalConfig, async () => {
485 const config = parseObject(await this.io.read(this.paths.globalConfig), this.paths.globalConfig) ?? {}
486 const next = applyAccountConfig(config, oauth)
487 if (!touch && sameJson(config, next)) return false
488 await this.io.writeSecret(this.paths.globalConfig, JSON.stringify(next, null, 2))
489 return true
490 })
491 }
492
493 async configAccount(): Promise<OauthAccount | undefined> {
494 const config = parseObject(await this.io.read(this.paths.globalConfig), this.paths.globalConfig)
495 const oauth = config?.oauthAccount as OauthAccount | undefined
496 return oauth?.accountUuid ? oauth : undefined
497 }
498
499 async configValue(key: string): Promise<unknown> {
500 return parseObject(await this.io.read(this.paths.globalConfig), this.paths.globalConfig)?.[key]
501 }
502
503 private async rememberHome(oauth: OauthAccount): Promise<void> {
504 const home = await this.homeMeta()
505 const same = home && accountKey(home.oauthAccount) === accountKey(oauth)
506 await this.writeMeta(null, same ? { ...home, oauthAccount: oauth } : { oauthAccount: oauth })
507 }
508
509 private async updateMeta(account: Account, change: (meta: Meta) => Meta): Promise<void> {
510 if (account.isHome) {
511 const home = await this.homeMeta()
512 if (home) await this.writeMeta(null, change(home))
513 return
514 }
515 const path = this.join(account.dir, META)
516 const meta = metaOf(parseObject(await this.io.read(path), path)) ?? { oauthAccount: account.oauthAccount }
517 await this.writeMeta(account.dir, change(meta))
518 }
519
520 /** Writes a saved account's metadata, or the home account's when `dir` is null. */
521 private async writeMeta(dir: string | null, meta: Meta): Promise<void> {
522 const path = dir === null ? this.homeMetaPath : this.join(dir, META)
523 await this.io.writeSecret(path, JSON.stringify(meta, null, 2))
524 }
525
526 private async homeMeta(): Promise<Meta | undefined> {
527 return metaOf(parseObject(await this.io.read(this.homeMetaPath), this.homeMetaPath))
528 }
529
530 private async homeAccount(now: number): Promise<Account | undefined> {
531 const home = await this.homeMeta()
532 if (!home) return undefined
533 const account = await this.hydrate({ key: HOME_KEY, ...home }, this.paths.configDir, true, now)
534 return account.needsLogin && !account.plan ? undefined : account
535 }
536
537 private async hydrate(meta: Meta & { key: string }, dir: string, isHome: boolean, now: number): Promise<Account> {
538 const tokens = tokensOf(await this.creds.read(dir))
539 const expired = typeof tokens?.refreshTokenExpiresAt === 'number' && tokens.refreshTokenExpiresAt < now
540 return {
541 ...meta,
542 dir,
543 isHome,
544 ...(tokens?.subscriptionType ? { plan: tokens.subscriptionType } : {}),
545 needsLogin: !tokens?.refreshToken || expired,
546 }
547 }
548
549 private async withLock<T>(target: string, work: () => Promise<T>): Promise<T> {
550 const lockDir = `${target}.lock`
551 for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
552 if (await this.io.mkdir(lockDir)) {
553 try {
554 return await work()
555 } finally {
556 await this.io.rmdir(lockDir)
557 }
558 }
559 const mtime = await this.io.mtime(lockDir)
560 if (mtime !== null && (await this.io.now()) - mtime > LOCK_STALE_MS) {
561 await this.io.rmdir(lockDir)
562 continue
563 }
564 await this.io.sleep(LOCK_RETRY_MS)
565 }
566 throw new Error(`timed out waiting for ${lockDir}`)
567 }
568}
569hooks/clawd.ts 81 lines1// Clawd, the Claude Code mascot as its welcome banner draws him, with a hat row
2// on top. Every row is nine cells wide. A hat can also swap his face row, which
3// is how sunglasses sit over his eyes.
4
5export interface Segment {
6 text: string
7 colour: string
8 background?: string
9}
10
11export interface Hat {
12 id: string
13 name: string
14 /** The row above Clawd's head. */
15 top: Segment[]
16 /** Replaces Clawd's face row when set. */
17 face?: Segment[]
18}
19
20export const CLAWD_COLOUR = 'rgb(215,119,87)'
21export const CLAWD_ROWS = [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ ']
22
23const BLANK: Segment[] = [{ text: ' ', colour: 'text' }]
24const solid = (text: string, colour: string): Segment[] => [{ text, colour }]
25const SHADES = 'rgb(30,30,36)'
26
27export const HATS: Hat[] = [
28 { id: 'none', name: 'No hat', top: BLANK },
29 { id: 'top', name: 'Top hat', top: solid(' ▗███▖ ', 'rgb(90,90,100)') },
30 {
31 // A cone in pink, blue and yellow stripes.
32 id: 'party',
33 name: 'Party hat',
34 top: [
35 { text: ' ', colour: 'text' },
36 { text: '▗', colour: 'rgb(232,121,249)' },
37 { text: '▟', colour: 'rgb(96,165,250)' },
38 { text: '█', colour: 'rgb(250,204,21)' },
39 { text: '▙', colour: 'rgb(232,121,249)' },
40 { text: '▖', colour: 'rgb(96,165,250)' },
41 { text: ' ', colour: 'text' },
42 ],
43 },
44 { id: 'crown', name: 'Crown', top: solid(' ▙▟▙▟▙ ', 'rgb(250,204,21)') },
45 { id: 'beanie', name: 'Beanie', top: solid(' ▗▟█▙▖ ', 'rgb(96,165,250)') },
46 { id: 'cap', name: 'Cap', top: solid(' ▗███▄▄ ', 'rgb(239,68,68)') },
47 { id: 'wizard', name: 'Wizard hat', top: solid(' ▗█▖ ', 'rgb(139,92,246)') },
48 { id: 'chef', name: 'Chef hat', top: solid(' ▟███▙ ', 'rgb(245,245,245)') },
49 { id: 'cowboy', name: 'Cowboy hat', top: solid(' ▄▄▟█▙▄▄ ', 'rgb(180,83,9)') },
50 { id: 'halo', name: 'Halo', top: solid(' ▁▁▁▁▁ ', 'rgb(253,224,71)') },
51 { id: 'bow', name: 'Bow', top: solid(' ▚▄▞ ', 'rgb(244,114,182)') },
52 { id: 'headphones', name: 'Headphones', top: solid(' ▗▛▀▀▀▜▖ ', 'rgb(34,197,94)') },
53 {
54 id: 'sunglasses',
55 name: 'Sunglasses',
56 top: BLANK,
57 // Dark lenses over the lower half of his face row, where his eyes are, with
58 // his face showing above them and a bridge between.
59 face: [
60 { text: ' ▐', colour: CLAWD_COLOUR },
61 { text: '▄▄▄▄▄', colour: SHADES, background: CLAWD_COLOUR },
62 { text: '▌ ', colour: CLAWD_COLOUR },
63 ],
64 },
65]
66
67export function hatById(id: string | undefined): Hat {
68 return HATS.find(h => h.id === id) ?? HATS[0]
69}
70
71/** The hat `step` places along from `id`, wrapping at either end. */
72export function cycleHat(id: string | undefined, step: number): Hat {
73 const index = HATS.indexOf(hatById(id))
74 return HATS[(((index + step) % HATS.length) + HATS.length) % HATS.length]
75}
76
77/** Clawd's four rows wearing `hat`, each as coloured segments. */
78export function clawdRows(hat: Hat): Segment[][] {
79 return [hat.top, hat.face ?? solid(CLAWD_ROWS[0], CLAWD_COLOUR), solid(CLAWD_ROWS[1], CLAWD_COLOUR), solid(CLAWD_ROWS[2], CLAWD_COLOUR)]
80}
81hooks/keychain.ts 71 lines1import type { CredentialStore } from './accounts.ts'
2
3/**
4 * Picks the store Claude Code is using: the credentials file when one exists, as
5 * on Linux and Windows and on recent macOS builds, else the macOS Keychain.
6 */
7export async function detectStore(
8 files: CredentialStore,
9 keychain: CredentialStore | undefined,
10 defaultDir: string,
11): Promise<CredentialStore> {
12 if (!keychain || (await files.read(defaultDir))) return files
13 return (await keychain.read(defaultDir)) ? keychain : files
14}
15
16/** Runs `security` and returns its exit code and output. */
17export type Security = (args: string[], stdin?: string) => Promise<{ exitCode: number; stdout: string }>
18
19const SERVICE = 'Claude Code-credentials'
20const NOT_FOUND = 44
21
22async function sha256Hex(text: string): Promise<string> {
23 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
24 return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
25}
26
27/**
28 * The Keychain item Claude Code reads for a credential directory: the plain
29 * service name for the default directory, and otherwise the name followed by the
30 * first eight hex digits of the directory's SHA-256.
31 */
32export async function serviceFor(dir: string, defaultDir: string): Promise<string> {
33 if (dir === defaultDir) return SERVICE
34 return `${SERVICE}-${(await sha256Hex(dir.normalize('NFC'))).slice(0, 8)}`
35}
36
37/** macOS: each account's login is a generic password in the login Keychain. */
38export class KeychainCredentials implements CredentialStore {
39 constructor(
40 private readonly security: Security,
41 private readonly user: string,
42 private readonly defaultDir: string,
43 ) {}
44
45 async read(dir: string): Promise<Record<string, unknown> | null> {
46 const service = await serviceFor(dir, this.defaultDir)
47 const r = await this.security(['find-generic-password', '-a', this.user, '-w', '-s', service])
48 if (r.exitCode === NOT_FOUND || r.exitCode !== 0) return null
49 try {
50 const value: unknown = JSON.parse(r.stdout.trim())
51 return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
52 } catch {
53 return null
54 }
55 }
56
57 async write(dir: string, creds: Record<string, unknown>): Promise<void> {
58 const service = await serviceFor(dir, this.defaultDir)
59 const hex = [...new TextEncoder().encode(JSON.stringify(creds))].map(b => b.toString(16).padStart(2, '0')).join('')
60 // Passing the secret on stdin through `security -i` keeps it out of the process list.
61 const line = `add-generic-password -U -a "${this.user}" -s "${service}" -X "${hex}"\n`
62 const r = await this.security(['-i'], line)
63 if (r.exitCode !== 0) throw new Error(`could not save the login to the Keychain (security exited ${r.exitCode})`)
64 }
65
66 async remove(dir: string): Promise<void> {
67 const service = await serviceFor(dir, this.defaultDir)
68 await this.security(['delete-generic-password', '-a', this.user, '-s', service])
69 }
70}
71hooks/panel.ts 144 lines1// The account sidebar: the session's account in a box at the top, the others
2// below, each with Clawd on the right wearing that account's hat.
3import { type Account, displayName } from './accounts.ts'
4import { clawdRows, hatById, type Segment } from './clawd.ts'
5
6type Element = (props: object) => unknown
7
8export interface PanelElements {
9 Box: Element
10 Text: Element
11 Button: Element
12}
13
14export interface PanelState {
15 accounts: Account[]
16 current: Account | undefined
17 /** Key of the control holding the keyboard, as `ui.focus` named it. */
18 focused: string
19 notice: string
20 width: number
21}
22
23export interface PanelActions {
24 choose(account: Account): () => Promise<void>
25 add(): Promise<void>
26}
27
28export const useKey = (account: Account) => `use-${account.key}`
29
30function plan(account: Account): string {
31 return account.plan ? account.plan.charAt(0).toUpperCase() + account.plan.slice(1) : ''
32}
33
34function segmentLine({ Box, Text }: PanelElements, segments: Segment[]): unknown {
35 return Box({
36 flexDirection: 'row',
37 children: segments.map(s => Text({ color: s.colour, ...(s.background ? { backgroundColor: s.background } : {}), children: [s.text] })),
38 })
39}
40
41function clawd(el: PanelElements, account: Account): unknown {
42 return el.Box({ flexDirection: 'column', flexShrink: 0, children: clawdRows(hatById(account.hat)).map(row => segmentLine(el, row)) })
43}
44
45/** The rows of text beside Clawd: email and plan, then organisation, then any warning. */
46function details(el: PanelElements, account: Account, dim: boolean): unknown[] {
47 const { Text } = el
48 return [
49 Text({ dimColor: dim, wrap: 'truncate-end', children: [[account.oauthAccount.emailAddress, plan(account)].filter(Boolean).join(' · ')] }),
50 Text({ dimColor: dim, wrap: 'truncate-end', children: [account.oauthAccount.organizationName ?? ' '] }),
51 ...(account.needsLogin ? [Text({ color: 'warning', wrap: 'truncate-end', children: ['sign in again with /login'] })] : []),
52 ]
53}
54
55export function panel(el: PanelElements, state: PanelState, actions: PanelActions): unknown {
56 const { Box, Text, Button } = el
57 const blank = () => Text({ children: [' '] })
58 const isFocused = (a: Account) => state.focused === useKey(a)
59 // The terminal inverts the focused Button's text, so each row's Button holds
60 // only its marker and the row draws its own highlight around it.
61 const select = (a: Account, marker: unknown, autoFocus: boolean) =>
62 Button({ key: useKey(a), plain: true, onPress: actions.choose(a), ...(autoFocus ? { autoFocus: true as const } : {}), children: [marker] })
63
64 const mine = state.current
65 const others = state.accounts.filter(a => a.dir !== mine?.dir)
66 const addFocused = state.focused === 'add'
67 const focusedAccount = state.accounts.find(isFocused)
68
69 const active = mine
70 ? Box({
71 flexDirection: 'row',
72 justifyContent: 'space-between',
73 borderStyle: 'round',
74 borderColor: isFocused(mine) ? 'claude' : 'success',
75 paddingX: 2,
76 marginTop: 1,
77 children: [
78 Box({ flexDirection: 'column', flexShrink: 1, children: [
79 Box({ flexDirection: 'row', children: [
80 select(mine, Text({ children: [''] }), true),
81 Text({ bold: true, wrap: 'truncate-end', children: [displayName(mine)] }),
82 Text({ color: 'success', children: [' ● active'] }),
83 ] }),
84 ...details(el, mine, true),
85 ] }),
86 clawd(el, mine),
87 ],
88 })
89 : Text({ dimColor: true, wrap: 'wrap', children: ['This session is not signed in with a saved account.'] })
90
91 const rows = others.map((a, i) => {
92 const f = isFocused(a)
93 return Box({
94 key: `row-${a.key}`,
95 flexDirection: 'row',
96 justifyContent: 'space-between',
97 marginTop: 1,
98 children: [
99 Box({ flexDirection: 'row', flexShrink: 1, children: [
100 select(a, Text({ color: f ? 'claude' : 'subtle', children: [f ? '┃' : '│'] }), !mine && i === 0),
101 Box({ flexDirection: 'column', flexShrink: 1, paddingLeft: 1, children: [
102 Text({ bold: true, color: f ? 'claude' : 'text', wrap: 'truncate-end', children: [displayName(a)] }),
103 ...details(el, a, !f),
104 ] }),
105 ] }),
106 Box({ paddingRight: 2, flexShrink: 0, children: [clawd(el, a)] }),
107 ],
108 })
109 })
110
111 return Box({
112 flexDirection: 'column',
113 width: state.width,
114 paddingX: 2,
115 paddingY: 1,
116 children: [
117 Text({ dimColor: true, bold: true, children: ['ACTIVE'] }),
118 active,
119 ...(others.length ? [blank(), Text({ dimColor: true, bold: true, children: ['SWITCH TO'] }), ...rows] : []),
120 blank(),
121 Box({ flexDirection: 'row', children: [
122 Text({ color: addFocused ? 'claude' : 'subtle', children: [addFocused ? '┃ ' : ' '] }),
123 Button({
124 key: 'add',
125 plain: true,
126 hotkey: 'a',
127 onPress: () => actions.add(),
128 children: [Text({ color: addFocused ? 'claude' : 'suggestion', bold: addFocused, children: ['+ Add an account'] })],
129 }),
130 ] }),
131 blank(),
132 Text({ dimColor: true, wrap: 'truncate-end', children: [focusedAccount ? `${hatById(focusedAccount.hat).name} · ←→ to change hat` : ' '] }),
133 Text({ dimColor: true, wrap: 'wrap', children: ['↑↓ move ⏎ switch a add esc close'] }),
134 ...(state.notice ? [blank(), Text({ color: 'warning', wrap: 'wrap', children: [state.notice] })] : []),
135 ],
136 })
137}
138
139/** Rows the panel needs, so an inline pane opens tall enough to show every account. */
140export function panelRows(state: Pick<PanelState, 'accounts' | 'current'>): number {
141 const others = state.accounts.filter(a => a.dir !== state.current?.dir)
142 return 2 + 1 + 6 + (others.length ? 2 + others.length * 5 : 0) + 6
143}
144hooks/platform.ts 85 lines1// The shell commands behind each file operation, per platform. Linux and macOS use
2// POSIX tools; Windows uses PowerShell, which every supported Windows ships.
3
4export type Platform = 'posix' | 'windows'
5
6export interface Commands {
7 /** Writes stdin to the path atomically, owner-only, creating parent directories. */
8 writeSecret(path: string): string[]
9 removeTree(path: string): string[]
10 /** Exits 0 only if it created the directory. */
11 mkdir(path: string): string[]
12 rmdir(path: string): string[]
13 read(path: string): string[]
14}
15
16// Writes go through a temp file and rename so readers never see half a file,
17// and through readlink so a symlinked ~/.claude.json keeps its link.
18const POSIX_WRITE = [
19 'set -e',
20 'umask 077',
21 't=$1',
22 'if [ -L "$t" ]; then t=$(readlink -f -- "$t"); fi',
23 'mkdir -p -- "$(dirname -- "$t")"',
24 'tmp="$t.tmp.$$"',
25 'cat > "$tmp"',
26 'mv -f -- "$tmp" "$t"',
27].join('\n')
28
29// The path arrives as an argument, never inside the script, so no quoting is needed.
30// The file inherits the user profile's ACL, which only that user and SYSTEM can read.
31const WINDOWS_WRITE = [
32 '$t = $args[0]',
33 '$item = Get-Item -LiteralPath $t -ErrorAction SilentlyContinue',
34 'if ($item -and $item.LinkType) { $t = $item.Target }',
35 '$dir = Split-Path -Parent $t',
36 'New-Item -ItemType Directory -Force -Path $dir | Out-Null',
37 '$tmp = "$t.tmp.$PID"',
38 '$text = [Console]::In.ReadToEnd()',
39 '[IO.File]::WriteAllText($tmp, $text, (New-Object Text.UTF8Encoding $false))',
40 'Move-Item -LiteralPath $tmp -Destination $t -Force',
41].join('; ')
42
43const POWERSHELL = ['powershell', '-NoProfile', '-NonInteractive', '-Command']
44
45const posix: Commands = {
46 writeSecret: path => ['sh', '-c', POSIX_WRITE, 'sh', path],
47 removeTree: path => ['rm', '-rf', '--', path],
48 mkdir: path => ['mkdir', '--', path],
49 rmdir: path => ['rmdir', '--', path],
50 read: path => ['cat', '--', path],
51}
52
53const windows: Commands = {
54 writeSecret: path => [...POWERSHELL, WINDOWS_WRITE, path],
55 removeTree: path => [...POWERSHELL, 'Remove-Item -LiteralPath $args[0] -Recurse -Force -ErrorAction SilentlyContinue', path],
56 // New-Item fails when the directory exists, which is what the lock relies on.
57 mkdir: path => [...POWERSHELL, 'New-Item -ItemType Directory -Path $args[0] -ErrorAction Stop | Out-Null', path],
58 rmdir: path => [...POWERSHELL, 'Remove-Item -LiteralPath $args[0] -Force -ErrorAction SilentlyContinue', path],
59 read: path => [...POWERSHELL, 'Get-Content -LiteralPath $args[0] -Raw -Encoding UTF8', path],
60}
61
62export function commandsFor(platform: Platform): Commands {
63 return platform === 'windows' ? windows : posix
64}
65
66/** Windows always sets APPDATA and USERPROFILE; Linux and macOS set neither. */
67export function detectPlatform(env: { appData?: string; userProfile?: string }): Platform {
68 return env.appData && env.userProfile ? 'windows' : 'posix'
69}
70
71/**
72 * The home directory Claude Code itself uses: USERPROFILE on Windows, HOME
73 * elsewhere. Kept in its native spelling, since Claude Code names a credential
74 * directory's Keychain item and lock files after the exact string.
75 */
76export function homeDir(platform: Platform, env: { home?: string; userProfile?: string }): string {
77 return (platform === 'windows' ? env.userProfile || env.home : env.home) ?? ''
78}
79
80/** Joins path parts with the platform's separator. */
81export function joiner(platform: Platform): (...parts: string[]) => string {
82 const sep = platform === 'windows' ? '\\' : '/'
83 return (...parts) => parts.map((p, i) => (i === 0 ? p.replace(/[\\/]+$/, '') : p.replace(/^[\\/]+|[\\/]+$/g, ''))).join(sep)
84}
85