Claude Code mod: shows which Claude account, organization and plan the session uses and how much of the usage limit is left, and holds prompts in a folder…

A Claude Code mod that shows which Claude account you are signed in to: the organization, the email, the plan and how much of your usage limit is left, in one quiet line under the prompt. It also stops you from working in a folder on the wrong account.
<img src="docs/footer.png" alt="The prompt footer with the whoami line: Personal, the account email, Max 20x, and 5h 41%, resets 20:30" width="760">
<a href="#install">Install</a> · <a href="#what-you-see">What you see</a> · <a href="#pin-an-account-to-a-folder">Pin an account</a> · <a href="#settings">Settings</a> · <a href="#what-it-can-reach">What it can reach</a>
With a personal account and one or more work organizations, Claude Code does not show which one the session runs on. You find out from a bill, an empty limit, or an admin. whoami keeps the answer on screen.
Inside Claude Code (2.1.296 or later):
/plugin install whoami --marketplace tornikegomareli/claude-whoami
/reload-plugins
claude plugin marketplace add tornikegomareli/claude-whoami
claude plugin install whoami@claude-whoami
Or load it from a clone for one session:
git clone https://github.com/tornikegomareli/claude-whoami
claude --plugin-dir ./claude-whoami
The installer may say that 2 config options are not set. The defaults are fine, and /config changes them.
Mods are an early-access Claude Code feature, and their API can change between releases.
One line at the bottom of the prompt footer, each part in a soft color from your theme:
| Part | Example | Color |
|---|---|---|
| Account | Acme · me@acme.com, or Personal · me@gmail.com | blue |
| Plan | Max 20x, Pro | orange |
| Usage | 5h 41%, resets 20:30 | green under 50%, yellow from 50%, red from 80% |
5h 41% means you have used 41% of your current 5-hour usage limit, and resets 20:30 is when it starts over. It is the same figure as /usage. The weekly limit joins the line once it is half used: 7d 63%, resets Fri.
If the session runs on an API key, the line says API key from ANTHROPIC_API_KEY and shows no plan or usage.
/whoami prints the account, and the pin that covers the folder, if one does.
Run /whoami pin in a folder to expect the current account there and in its subfolders. If a later session in that folder runs on another account:
<img src="docs/guard.png" alt="A prompt held in a folder pinned to Acme Inc while the session is signed in to a personal account, with the red band above the prompt" width="760">
Run /login to switch, or /whoami ok to continue on the current account for this session.
| Command | Action |
|---|---|
/whoami | Show the account, and the pin that covers this folder |
/whoami pin | Expect the current account in this folder and its subfolders |
/whoami unpin | Remove the pin that covers this folder |
/whoami ok | Continue on the current account for this session |
Slash commands, and prompts from plugins or other sessions, are never held.
Change them in /config (search for whoami), or with /plugin configure whoami@claude-whoami.
| Setting | Default | What it does |
|---|---|---|
fiveHourLimit | true | On: show how much of the 5-hour usage limit is used, and when it resets. |
weeklyLimit | over-half | always: always show the weekly usage limit. over-half: only once it is 50% used. never: do not show it. |
whoami makes no network requests and calls no model.
| It reads or does | Through |
|---|---|
Runs claude auth status --json for the account, organization and plan | $.process.run |
Reads organizationRateLimitTier (the 20x) from ~/.claude.json, or $CLAUDE_CONFIG_DIR/.claude.json | $.fs.read, $.env.get |
| Asks whether the session uses a login or an API key. It reads only the credential's kind, never the credential | $.session.authorize |
Reads the usage-limit figures that /usage shows | session.measure, $.session.usage |
| Keeps your pins (folder and account) between sessions, in its own store under the Claude Code configuration folder | $.store |
| Drops a typed prompt while the folder's pin does not match | prompt.submit |
| Draws the footer line and the red band | ui.render |
claude auth status reports ANTHROPIC_API_KEY even when the session declined that key and uses the login, so whoami asks the session which credential it uses before it trusts that report.
claude plugin validate . prints every hook and call.
claude --plugin-dir . # load it; saving a file reloads it
claude plugin validate .
claude plugin test .
hooks/register.tsx 142 lines1import type { EngineInterface, Register } from 'claude-code'
2import { UNKNOWN, accountOf, parseStatus, pinFor, planOf, usageOf } from './account'
3import type { Account, Limit, Pins, Show } from './account'
4
5const USAGE = '/whoami shows the account. /whoami pin expects it in this folder, /whoami unpin stops that, /whoami ok continues on a wrong one.'
6
7let account: Account = UNKNOWN
8let plan = '' // "Max 20x"
9let limits: readonly Limit[] = []
10let show: Show = { fiveHour: true, weekly: 'over-half' }
11let pin: { dir: string; account: Account } | undefined
12let isAllowed = false // `/whoami ok` was run in this session
13
14const isWrong = () => !!pin && pin.account.id !== account.id && !isAllowed
15
16// The plan's size ("20x") is only in the global config file, beside the login.
17async function rateLimitTier($: EngineInterface) {
18 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (await $.env.get('HOME'))
19 const text = await $.fs.read(`${dir}/.claude.json`).catch(() => '')
20 try {
21 return (JSON.parse(text) as { oauthAccount?: { organizationRateLimitTier?: string } }).oauthAccount?.organizationRateLimitTier
22 } catch {
23 return undefined
24 }
25}
26
27async function pins($: EngineInterface) {
28 return ((await $.store.get('pins')) ?? {}) as Pins
29}
30
31// Reads the account and this folder's pin, then redraws the footer label and the band.
32async function check($: EngineInterface) {
33 // `claude auth status` reports ANTHROPIC_API_KEY even when the session declined it and uses the login.
34 const usesLogin = (await $.session.authorize().catch(() => null))?.kind === 'bearer'
35 const env = usesLogin ? { ANTHROPIC_API_KEY: '' } : undefined
36 const r = await $.process.run(['claude', 'auth', 'status', '--json'], { env, timeoutMs: 10000 }).catch(() => undefined)
37 const status = r && parseStatus(r.stdout)
38 account = status ? accountOf(status) : UNKNOWN
39 plan = usesLogin ? planOf(status?.subscriptionType, await rateLimitTier($)) : ''
40 pin = pinFor(await pins($), await $.session.cwd())
41 // Figures the session had before this mod loaded never raise a `session.measure` of their own.
42 limits = (await $.session.usage().catch(() => undefined))?.rateLimits ?? limits
43 $.ui.invalidate('ui.render')
44}
45
46async function command($: EngineInterface, arg: string) {
47 await check($)
48 const cwd = await $.session.cwd()
49 const all = await pins($)
50 if (arg === 'pin') {
51 await $.store.set('pins', { ...all, [cwd]: account })
52 await check($)
53 return `Pinned ${account.label} to ${cwd} and its subfolders.`
54 }
55 if (arg === 'unpin') {
56 if (!pin) return 'No pin covers this folder.'
57 const { [pin.dir]: _, ...rest } = all
58 const dir = pin.dir
59 await $.store.set('pins', rest)
60 await check($)
61 return `Removed the pin of ${dir}.`
62 }
63 if (arg === 'ok') {
64 isAllowed = true
65 await check($)
66 return `Continuing on ${account.label} for this session.`
67 }
68 if (arg) return USAGE
69 const expected = pin ? `\n${pin.dir} expects ${pin.account.label}.` : ''
70 return `Signed in: ${account.label}.${expected}`
71}
72
73export const register: Register = (on, options) => {
74 show = {
75 fiveHour: options.fiveHourLimit !== false,
76 weekly: options.weeklyLimit === 'always' || options.weeklyLimit === 'never' ? options.weeklyLimit : 'over-half',
77 }
78
79 on('session.start', async ($, e, next) => {
80 await $.command.register({ name: 'whoami', description: `Which Claude account this session uses. ${USAGE}` }).catch(() => {})
81 void check($).catch(() => {})
82 return next(e)
83 })
84
85 on('command.run', { command: 'whoami' }, async ($, e) => ({ text: await command($, e.args.trim()) }))
86
87 // A prompt typed in a folder pinned to another account is dropped; the engine puts it back in the box.
88 on('prompt.submit', async ($, e, next) => {
89 if (e.origin.kind !== 'composer' || e.text.trimStart().startsWith('/')) return next(e)
90 if (isWrong()) await check($) // after /login the account may be right now
91 if (!isWrong()) {
92 void check($).catch(() => {})
93 return next(e)
94 }
95 return { drop: `Signed in to ${account.label}, but this folder expects ${pin?.account.label}. Run /login to switch, or /whoami ok to continue.` }
96 })
97
98 on('session.measure', async ($, e, next) => {
99 if (e.changed.includes('rateLimits')) {
100 limits = e.rateLimits
101 $.ui.invalidate('ui.render')
102 }
103 return next(e)
104 })
105
106 // The footer's mode labels (`focus`), redrawn on the left with the account, plan and usage after them.
107 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
108 const elements = $.ui.resolve(e)
109 if (account === UNKNOWN || !('Box' in elements)) return next(e)
110 const { Box, Text } = elements
111 const usage = usageOf(limits, show)
112 const parts = [
113 { text: account.label, color: 'suggestion' },
114 { text: plan, color: 'claude' },
115 { text: usage.text, color: usage.used >= 80 ? 'error' : usage.used >= 50 ? 'warning' : 'success' },
116 ].filter(p => p.text)
117 return (
118 <Box width="100%" justifyContent="flex-start">
119 <Text>
120 {e.props.modes.length ? <Text dimColor>{e.props.modes.join(' & ')} & </Text> : null}
121 {parts.map((p, i) => <Text key={p.color}>{i ? <Text dimColor> · </Text> : null}<Text color={p.color}>{p.text}</Text></Text>)}
122 </Text>
123 </Box>
124 )
125 })
126
127 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
128 if (!isWrong() || e.props.hasSurvey) return next(e)
129 const elements = $.ui.resolve(e)
130 if (!('Box' in elements)) return next(e)
131 const { Box, Text } = elements
132 return (
133 <Box flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
134 <Text color="error" bold>Wrong Claude account for this folder</Text>
135 <Text>Signed in: {account.label}</Text>
136 <Text>{pin?.dir} expects: {pin?.account.label}</Text>
137 <Text dimColor>Run /login to switch, or /whoami ok to continue on this account.</Text>
138 </Box>
139 )
140 })
141}
142hooks/account.ts 79 lines1// What `claude auth status --json` prints. Fields absent for a login method are left out.
2export type AuthStatus = {
3 loggedIn?: boolean
4 authMethod?: string
5 apiProvider?: string
6 apiKeySource?: string
7 email?: string
8 orgId?: string
9 orgName?: string
10 subscriptionType?: string
11}
12
13// One usage-limit window, as `session.measure` reports it.
14export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
15
16// `id` is what a pin compares; `label` is what the person reads.
17export type Account = { id: string; label: string }
18
19// Folder path to the account expected there.
20export type Pins = Record<string, Account>
21
22export const UNKNOWN: Account = { id: 'unknown', label: 'account unknown' }
23
24export function accountOf(s: AuthStatus): Account {
25 if (s.apiProvider && s.apiProvider !== 'firstParty') return { id: `provider:${s.apiProvider}`, label: s.apiProvider }
26 if (s.authMethod === 'api_key') return { id: 'api-key', label: `API key from ${s.apiKeySource ?? 'unknown source'}` }
27 if (!s.loggedIn) return { id: 'none', label: 'not signed in' }
28 const org = s.email && s.orgName === `${s.email}'s Organization` ? 'Personal' : s.orgName
29 const label = [org, s.email].filter(Boolean).join(' · ')
30 return { id: s.orgId ?? s.email ?? UNKNOWN.id, label: label || UNKNOWN.label }
31}
32
33export function parseStatus(stdout: string): AuthStatus | undefined {
34 try {
35 return JSON.parse(stdout) as AuthStatus
36 } catch {
37 return undefined
38 }
39}
40
41// `max` and `default_claude_max_20x` read as "Max 20x".
42export function planOf(subscriptionType?: string, rateLimitTier?: string) {
43 if (!subscriptionType) return ''
44 const size = rateLimitTier?.match(/_(\d+x)$/)?.[1]
45 return [subscriptionType[0]!.toUpperCase() + subscriptionType.slice(1), size].filter(Boolean).join(' ')
46}
47
48const pad = (n: number) => String(n).padStart(2, '0')
49const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
50
51// Which windows the footer shows: the fiveHourLimit and weeklyLimit settings.
52export type Show = { fiveHour: boolean; weekly: 'always' | 'over-half' | 'never' }
53
54const isShown = (l: Limit, show: Show) =>
55 l.kind === 'five_hour' ? show.fiveHour
56 : l.kind === 'seven_day' && (show.weekly === 'always' || (show.weekly === 'over-half' && l.percentUsed >= 50))
57
58// "5h 42%, resets 18:30 · 7d 63%, resets Fri", and the highest percent shown, for its color.
59export function usageOf(limits: readonly Limit[], show: Show) {
60 const parts: string[] = []
61 let used = 0
62 for (const l of limits) {
63 if (!isShown(l, show)) continue
64 const at = l.resetsAt ? new Date(l.resetsAt) : undefined
65 const resets = at ? `, resets ${l.kind === 'five_hour' ? `${pad(at.getHours())}:${pad(at.getMinutes())}` : DAYS[at.getDay()]}` : ''
66 parts.push(`${l.kind === 'five_hour' ? '5h' : '7d'} ${Math.round(l.percentUsed)}%${resets}`)
67 used = Math.max(used, l.percentUsed)
68 }
69 return { text: parts.join(' · '), used }
70}
71
72// The pin of `dir` or of its nearest parent folder.
73export function pinFor(pins: Pins, dir: string): { dir: string; account: Account } | undefined {
74 for (let d = dir; d; d = d.slice(0, d.lastIndexOf('/'))) {
75 const account = pins[d]
76 if (account) return { dir: d, account }
77 }
78}
79