SLOPSHOPPER

Account switcher

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

newpanebandcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · account-switcher
│ ┃ Accounts ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ACTIVE ● account-switcher: Ctrl+Alt+Shift+numpad - opens the account sidebar │ ┃ This session is not signed in with a saved ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ a: add ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ↑↓ move ⏎ switch a add esc close ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /account-switch │ │ ⟨Claude Code's own drawing⟩ Account: no account ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Account: no account
Pane · Accounts
ACTIVE This session is not signed in with a saved account. a: add ↑↓ move ⏎ switch a add esc close
README

Account switcher

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.

Opening the account sidebar, changing Clawd's hat, switching to the Work account and checking /status

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.

Quick start

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.

🐧 Linux

curl -fsSL <repo>/raw/main/install.sh | sh -s -- <repo>.git

🍎 macOS

curl -fsSL <repo>/raw/main/install.sh | sh -s -- <repo>.git

🪟 Windows

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).

Using it

/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 sidebar, with the active account at the top and two others below

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 confirmation shown before /logout

Configuration

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.

  • To turn both off, run /account-switch keys off. /account-switch keys on puts them back.
  • To use a different key, change the ctrl+alt+shift+- entry in the Global block to any key you like. Keep its value, app:toggleDiffPreSession.
  • If you already bound Right or Left in the 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.

How it works

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.

Development

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.

Source 6 files
hooks/register.ts 453 lines
1import 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}
453
hooks/accounts.ts 569 lines
1// 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}
569
hooks/clawd.ts 81 lines
1// 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}
81
hooks/keychain.ts 71 lines
1import 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}
71
hooks/panel.ts 144 lines
1// 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}
144
hooks/platform.ts 85 lines
1// 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