SLOPSHOPPER

whoami

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…

newbandspinnercommandpromptprocess
v0.1.0MITupdated 2026-10-12tornikegomareli/claude-whoami
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · whoami
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /whoami ⎿ whoami: Signed in: account unknown. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

whoami

Release License: MIT Claude Code 2.1.296+

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>

Why

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.

Install

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.

What you see

One line at the bottom of the prompt footer, each part in a soft color from your theme:

PartExampleColor
AccountAcme · me@acme.com, or Personal · me@gmail.comblue
PlanMax 20x, Proorange
Usage5h 41%, resets 20:30green 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.

Pin an account to a folder

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:

  • A red band above the prompt names both accounts.
  • Prompts you type are held, and stay in the box.

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

CommandAction
/whoamiShow the account, and the pin that covers this folder
/whoami pinExpect the current account in this folder and its subfolders
/whoami unpinRemove the pin that covers this folder
/whoami okContinue on the current account for this session

Slash commands, and prompts from plugins or other sessions, are never held.

Settings

Change them in /config (search for whoami), or with /plugin configure whoami@claude-whoami.

SettingDefaultWhat it does
fiveHourLimittrueOn: show how much of the 5-hour usage limit is used, and when it resets.
weeklyLimitover-halfalways: always show the weekly usage limit. over-half: only once it is 50% used. never: do not show it.

What it can reach

whoami makes no network requests and calls no model.

It reads or doesThrough
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 showssession.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 matchprompt.submit
Draws the footer line and the red bandui.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.

Develop

claude --plugin-dir .   # load it; saving a file reloads it
claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.tsx 142 lines
1import 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}
142
hooks/account.ts 79 lines
1// 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