SLOPSHOPPER

rice-session-accounts

Choose an Anthropic OAuth account by email and tier for this session; resumable setup on macOS, Linux and WSL, with the machine's configuration preserved

newpaneguardcommandtoaststatus
v0.2.0MITupdated 2026-10-16moabualruz/rice-session-accounts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rice-session-accounts
│ ┃ Choose account ✕ › fix the failing auth test and add an audit log call │ ┃ Choose an account for this session │ ┃ Other sessions keep their accounts. Escape ⏺ Read(src/auth.ts) │ ┃ closes this list. ⎿ Read 6 lines │ ┃ Account (Up/Down, Enter): * login | Unknown ⏺ Update(src/auth.ts) │ ┃ * login | Unknown | Unknown | current sessio ⎿ Added 2 lines, removed 1 line │ ┃ [ Cancel ] ⏺ 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 │ │ › /rice-account │ ⎿ rice-session-accounts: Choose an account in the account list. Th │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Choose account
Choose an account for this session Other sessions keep their accounts. Escape closes this list. Account (Up/Down, Enter): * login | Unknown | Unknown | curr * login | Unknown | Unknown | current session [ Cancel ]
README

rice-session-accounts

Save several Anthropic OAuth accounts and choose which one a Claude Code session uses. /rice-account opens an account picker showing each saved name, email and plan. Choose a row, or use /rice-account personal-live, to switch the current session from its next request, including subagents and claude -p processes it starts afterward. Other running sessions keep their accounts.

The supported account kinds are the built-in login, separate claudeai browser sign-ins, and claudeai-token setup tokens. Model and provider configuration belongs to rice-multi-models.

The plugin is named rice-session-accounts. Its command is /rice-account. It supports macOS and Linux, including WSL. Install Node.js 22 or newer and make node available in the environment where Claude runs. The setup helper and the Linux credential backend use it.

Version 0.2.0 was verified with native Claude Code 2.1.296 on WSL and 2.1.300 on macOS. See verification details for the checks and their scope.

Install

Run this command in your terminal:

claude plugin install rice-session-accounts --marketplace moabualruz/rice-mods

This adds the Rice marketplace if necessary and installs the plugin. Start a new Claude Code session, approve the plugin's requested capabilities when prompted, and enter:

/rice-account

You should see the built-in login account, which restores the credentials this session started with. Choose an account row to switch, or press Escape to close the picker. Installing the plugin does not sign you into another account.

The picker shows the selected account, its cached email and its subscription tier, such as Pro, Max (5x), Team or Enterprise. Opening the picker reads local metadata without making an Anthropic request. Unknown means the metadata is unavailable. Not signed in means no saved native login was found. Run /rice-account check <name> to verify a credential with Anthropic. Accounts added with a setup token may not have local email or plan metadata.

In a headless session, /rice-account returns a text list. Use /rice-account list whenever you want the text version explicitly.

If you already have the plugin, update it instead:

claude plugin marketplace update rice-mods
claude plugin update rice-session-accounts@rice-mods

Restart Claude Code after updating so the session loads the new plugin code.

Add your personal Claude account

  1. In Claude Code, save the account name:
   /rice-account add personal-live

The omitted kind defaults to claudeai, a separate claude.ai sign-in. The command prints one terminal command with this installation's exact paths.

  1. Copy that generated command into a terminal on the same machine. For WSL, use a terminal inside the same WSL distribution. The helper starts Claude's browser sign-in using a separate account folder, waits for the login to finish, and checks the saved credential. Choose the intended account in the browser. Follow the printed browser instructions if WSL cannot open it. If the saved account is already signed in, the helper checks its local login status and skips the browser login. The check in the next step contacts the Anthropic.
  1. When setup succeeds, return to Claude Code:
   /rice-account check personal-live
   /rice-account personal-live

The check reports the signed-in account. The switch takes effect on this session's next request. /rice-account marks the selected account with this session.

Browser sign-in is the only interactive part of a normal claudeai setup. The helper chooses the credential backend for your operating system. You do not need to construct CLAUDE_CONFIG_DIR paths or run macOS keychain commands yourself.

If you stopped partway through, resume without removing the saved account:

/rice-account setup personal-live

This prints the terminal setup command again. Repeating /rice-account add personal-live also resumes setup for the saved account. To sign in again even when the helper finds a valid login, append --login to the generated terminal command.

Account kinds

For another browser sign-in, use the claudeai flow above. To use a setup token, add a claudeai-token account and run the terminal command it prints:

/rice-account add personal-token claudeai-token

The helper runs claude setup-token, then asks you to copy the generated token into a hidden prompt. Enter the token in that terminal prompt.

KindExample in Claude CodeTerminal setup
login/rice-account loginBuilt in. Restore the credentials this session started with.
claudeai/rice-account add personal-liveA separate claude.ai browser sign-in. The mod renews its expiring login.
claudeai-token/rice-account add personal-token claudeai-tokenGenerate a setup token and store it through the helper's hidden prompt.

Account names may contain letters, digits, dots, underscores and hyphens, up to 40 characters. Start the name with a letter or digit. login is reserved for the built-in account that restores this process's starting credentials.

The full add syntax is:

/rice-account add <name> [kind] [key=value ...]

Use label= for an account label. A claudeai-token account also accepts service= to choose its OS keyring service name. Enter actual setup tokens only at the terminal helper's hidden prompt. For an existing account, use /rice-account setup <name> to repeat its setup. Use another name when adding an account with a different kind or configuration.

Daily commands

CommandResult
/rice-accountOpen the account picker with saved names, emails, plans and this session's selection. Return a text list when a picker cannot be displayed.
/rice-account listShow the text list of saved accounts and their cached identity metadata.
/rice-account <name>Switch the current session to a saved account.
/rice-account loginRestore the credential environment this session started with.
/rice-account check [name]Check the named sign-in or setup token, or the current account when omitted. Saved OAuth tokens use Anthropic's profile endpoint when their permissions allow it.
/rice-account usageShow the current account's reported five-hour and weekly usage windows. Some setup tokens cannot access this information.
/rice-account setup <name>Print the terminal command to complete or repeat an account's setup.
/rice-account remove <name>Remove a saved account's configuration. The command explains any remaining credential cleanup.

Independent sessions launched from your usual terminal keep their normal login. Subagents and child Claude processes started by a switched session inherit its selected account. /rice-account login restores the credential environment the current process started with, including an inherited account for a child process. A selection survives plugin reload and /clear in the current process. Changing an account does not alter already-running independent sessions or children.

Linux and WSL credentials

  • A claudeai account uses Claude Code's own isolated login folder. On Linux and WSL, Claude stores that login in the folder's native .credentials.json file. On macOS, Claude uses the keychain item associated with that folder.
  • Setup tokens entered into this plugin's helper use the macOS Keychain or Linux Secret Service through secret-tool. The plugin does not create a separate plaintext token store.
  • Linux claudeai browser login does not require secret-tool. Setup-token accounts do require secret-tool, a running Secret Service implementation and access to an unlocked collection in the same user session. Installing the executable alone does not provide a working keyring.

If a Linux setup-token account reports that secret-tool is missing, install the package that provides it for your distribution. Debian and Ubuntu use libsecret-tools:

sudo apt-get install libsecret-tools

In a headless WSL environment, use the claudeai browser login, or configure a working user D-Bus and Secret Service before adding setup-token accounts. The helper reports missing prerequisites before prompting for a token.

Troubleshooting

SymptomWhat to do
/rice-account is unknownConfirm rice-session-accounts@rice-mods is installed and enabled, then restart Claude Code.
/rice-account add personal-live asks for a kind, or repeating it says to remove the account firstThe session has an older plugin version. Update the plugin and restart Claude Code, then run /rice-account setup personal-live. Keep the saved account.
/usr/bin/security fails with ENOENT on WSL or LinuxThe session has the macOS-only credential code from an older version. Update and restart, then repeat setup or check. Linux uses its native credential file or Secret Service.
The account is saved but not signed inRun /rice-account setup <name>, execute its generated command in a terminal on that machine, then check the account.
Browser login used the wrong account, or a login can no longer renewRun /rice-account setup <name> again. Append --login to the generated terminal command to force a fresh sign-in.
A copied setup command no longer exists after a plugin updateRun /rice-account setup <name> again to get the current installed path.
node is missing or too oldInstall Node.js 22 or newer on the machine running the helper, then rerun its command. WSL needs its own Linux Node installation.
An older provider account is missing from the pickerThis mod now switches Anthropic OAuth accounts only. Legacy provider entries are retained in configuration but cannot be selected here. Configure providers in rice-multi-models.

Files and session behavior

The default config directory is ~/.claude. When Claude Code starts with CLAUDE_CONFIG_DIR, paths below are relative to that directory instead.

  • rice-session-accounts/accounts.json contains account names, kinds and credential references.
  • rice-session-accounts/logins/<name>/ is each claudeai account's isolated native Claude config directory.
  • The rice-session-accounts Keychain or Secret Service service stores named setup tokens by account name, unless the account specifies service=.

The isolated folders hold only the additional account logins. Switching accounts does not change the current session's config directory, settings, installed plugins, skills, hooks, MCP servers or project files. The plugin changes Claude's OAuth credential environment inside the current process and leaves the machine's shared Claude login in place. Legacy provider entries in accounts.json are preserved when saving supported accounts.

Development checks

Run these from the repository root. The terminal helper tests use Node's built-in test runner; plugin tests use Claude Code's native mod test runner.

npm run check

No dependency installation is needed. This also runs an isolated native process proof for subagents, both process APIs, Bash, claude -p, sibling sessions and a fresh session. Credentials and model replies in that proof are synthetic; the processes and configuration inheritance are real. It does not use your accounts or install the plugin.

Adapted from simplecore-inc/claude-mods (MIT); see THIRD_PARTY_NOTICES.md.

License

MIT, see LICENSE. The adapted simplecore-inc/claude-mods code keeps its own MIT notice in LICENSES/.

Source 14 files
hooks/register.ts 500 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ActiveAccount, EnvSnapshot } from '../types'
5import { loginEmail, materialFor } from './accounts'
6import type { Machine } from './accounts'
7import { ADD_OPTIONS, addAccount, defaultConfig, describeAccount, KINDS, LOGIN_NAME, parseConfig, pathsFor, removeAccount, serializeConfig } from './config'
8import type { AccountConfig, Config, Kind } from './config'
9import { MANAGED, planEnv } from './env'
10import type { Managed, Originals } from './env'
11import type { EnvName, Io } from './io'
12import { message } from './io'
13import { readLogin } from './credentials'
14import { setupText, shellQuote } from './setup'
15import { accountIdentity } from './identity'
16import { ACCOUNT_PICKER_ID, openAccountPicker, renderAccountPicker } from './picker'
17import type { AccountRow } from './picker'
18import { registerDescendantAuth } from './descendants'
19import { EXPIRY_MARGIN_MS, oauthInit, parseProfile, parseUsage, PROFILE_URL, USAGE_URL } from './oauth'
20
21const PLUGIN = 'rice-session-accounts'
22const activeAtom = atom({ plugin: 'rice-session-accounts', key: 'active' } as const, null)
23const originalsAtom = atom({ plugin: 'rice-session-accounts', key: 'originals' } as const, null)
24
25/**
26 * The module's copy of the session state. `$.state` survives a reload and a
27 * module variable survives a /clear (which empties the state while this
28 * process keeps the account it switched to); each puts the other back.
29 */
30let mirror: { active: ActiveAccount | null; originals: EnvSnapshot | null } = { active: null, originals: null }
31let renewal: Timer | undefined
32let expiresAt: number | undefined
33let knownNames: string[] = [LOGIN_NAME]
34
35// Claude 2.1.296 follows `$` only into helpers declared in this module.
36async function syncDescendantAuth($: EngineInterface): Promise<void> {
37  const token = await $.env.get('CLAUDE_CODE_OAUTH_TOKEN')
38  const current = await $.env.get('RICE_SESSION_ACCOUNTS_OAUTH_RELAY')
39  if (current !== (token || undefined)) await $.env.set('RICE_SESSION_ACCOUNTS_OAUTH_RELAY', token || undefined)
40}
41
42async function variable($: EngineInterface, name: EnvName): Promise<string | undefined> {
43  switch (name) {
44    case 'HOME':
45      return $.env.get('HOME')
46    case 'USER':
47      return $.env.get('USER')
48    case 'CLAUDE_CONFIG_DIR':
49      return $.env.get('CLAUDE_CONFIG_DIR')
50    case 'CLAUDE_SECURESTORAGE_CONFIG_DIR':
51      return $.env.get('CLAUDE_SECURESTORAGE_CONFIG_DIR')
52  }
53}
54
55async function getManaged($: EngineInterface, name: Managed): Promise<string | undefined> {
56  switch (name) {
57    case 'ANTHROPIC_API_KEY':
58      return $.env.get('ANTHROPIC_API_KEY')
59    case 'ANTHROPIC_AUTH_TOKEN':
60      return $.env.get('ANTHROPIC_AUTH_TOKEN')
61    case 'CLAUDE_CODE_OAUTH_TOKEN':
62      return $.env.get('CLAUDE_CODE_OAUTH_TOKEN')
63    case 'ANTHROPIC_BASE_URL':
64      return $.env.get('ANTHROPIC_BASE_URL')
65    case 'CLAUDE_CODE_USE_BEDROCK':
66      return $.env.get('CLAUDE_CODE_USE_BEDROCK')
67    case 'CLAUDE_CODE_USE_VERTEX':
68      return $.env.get('CLAUDE_CODE_USE_VERTEX')
69    case 'CLAUDE_CODE_USE_FOUNDRY':
70      return $.env.get('CLAUDE_CODE_USE_FOUNDRY')
71    case 'CLAUDE_CODE_USE_ANTHROPIC_AWS':
72      return $.env.get('CLAUDE_CODE_USE_ANTHROPIC_AWS')
73    case 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD':
74      return $.env.get('CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD')
75    case 'CLAUDE_CODE_USE_MANTLE':
76      return $.env.get('CLAUDE_CODE_USE_MANTLE')
77    case 'CLAUDE_CODE_USE_GATEWAY':
78      return $.env.get('CLAUDE_CODE_USE_GATEWAY')
79    case 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR':
80      return $.env.get('CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR')
81    case 'CLAUDE_CODE_GATEWAY_TOKEN_FILE_DESCRIPTOR':
82      return $.env.get('CLAUDE_CODE_GATEWAY_TOKEN_FILE_DESCRIPTOR')
83    case 'CLAUDE_BG_AUTH_SNAPSHOT_PATH':
84      return $.env.get('CLAUDE_BG_AUTH_SNAPSHOT_PATH')
85    case 'CLAUDE_CODE_HOST_CREDS_FILE':
86      return $.env.get('CLAUDE_CODE_HOST_CREDS_FILE')
87    case 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST':
88      return $.env.get('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST')
89    case 'CLAUDE_CODE_HOST_AUTH_ENV_VAR':
90      return $.env.get('CLAUDE_CODE_HOST_AUTH_ENV_VAR')
91    case 'AWS_PROFILE':
92      return $.env.get('AWS_PROFILE')
93    case 'AWS_REGION':
94      return $.env.get('AWS_REGION')
95    case 'AWS_BEARER_TOKEN_BEDROCK':
96      return $.env.get('AWS_BEARER_TOKEN_BEDROCK')
97    case 'ANTHROPIC_BEDROCK_BASE_URL':
98      return $.env.get('ANTHROPIC_BEDROCK_BASE_URL')
99    case 'ANTHROPIC_VERTEX_PROJECT_ID':
100      return $.env.get('ANTHROPIC_VERTEX_PROJECT_ID')
101    case 'CLOUD_ML_REGION':
102      return $.env.get('CLOUD_ML_REGION')
103    case 'ANTHROPIC_VERTEX_BASE_URL':
104      return $.env.get('ANTHROPIC_VERTEX_BASE_URL')
105    case 'ANTHROPIC_FOUNDRY_RESOURCE':
106      return $.env.get('ANTHROPIC_FOUNDRY_RESOURCE')
107    case 'ANTHROPIC_FOUNDRY_BASE_URL':
108      return $.env.get('ANTHROPIC_FOUNDRY_BASE_URL')
109    case 'ANTHROPIC_FOUNDRY_API_KEY':
110      return $.env.get('ANTHROPIC_FOUNDRY_API_KEY')
111  }
112}
113
114async function setManaged($: EngineInterface, name: Managed, value: string | undefined): Promise<void> {
115  switch (name) {
116    case 'ANTHROPIC_API_KEY':
117      return $.env.set('ANTHROPIC_API_KEY', value)
118    case 'ANTHROPIC_AUTH_TOKEN':
119      return $.env.set('ANTHROPIC_AUTH_TOKEN', value)
120    case 'CLAUDE_CODE_OAUTH_TOKEN':
121      return $.env.set('CLAUDE_CODE_OAUTH_TOKEN', value)
122    case 'ANTHROPIC_BASE_URL':
123      return $.env.set('ANTHROPIC_BASE_URL', value)
124    case 'CLAUDE_CODE_USE_BEDROCK':
125      return $.env.set('CLAUDE_CODE_USE_BEDROCK', value)
126    case 'CLAUDE_CODE_USE_VERTEX':
127      return $.env.set('CLAUDE_CODE_USE_VERTEX', value)
128    case 'CLAUDE_CODE_USE_FOUNDRY':
129      return $.env.set('CLAUDE_CODE_USE_FOUNDRY', value)
130    case 'CLAUDE_CODE_USE_ANTHROPIC_AWS':
131      return $.env.set('CLAUDE_CODE_USE_ANTHROPIC_AWS', value)
132    case 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD':
133      return $.env.set('CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD', value)
134    case 'CLAUDE_CODE_USE_MANTLE':
135      return $.env.set('CLAUDE_CODE_USE_MANTLE', value)
136    case 'CLAUDE_CODE_USE_GATEWAY':
137      return $.env.set('CLAUDE_CODE_USE_GATEWAY', value)
138    case 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR':
139      return $.env.set('CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', value)
140    case 'CLAUDE_CODE_GATEWAY_TOKEN_FILE_DESCRIPTOR':
141      return $.env.set('CLAUDE_CODE_GATEWAY_TOKEN_FILE_DESCRIPTOR', value)
142    case 'CLAUDE_BG_AUTH_SNAPSHOT_PATH':
143      return $.env.set('CLAUDE_BG_AUTH_SNAPSHOT_PATH', value)
144    case 'CLAUDE_CODE_HOST_CREDS_FILE':
145      return $.env.set('CLAUDE_CODE_HOST_CREDS_FILE', value)
146    case 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST':
147      return $.env.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', value)
148    case 'CLAUDE_CODE_HOST_AUTH_ENV_VAR':
149      return $.env.set('CLAUDE_CODE_HOST_AUTH_ENV_VAR', value)
150    case 'AWS_PROFILE':
151      return $.env.set('AWS_PROFILE', value)
152    case 'AWS_REGION':
153      return $.env.set('AWS_REGION', value)
154    case 'AWS_BEARER_TOKEN_BEDROCK':
155      return $.env.set('AWS_BEARER_TOKEN_BEDROCK', value)
156    case 'ANTHROPIC_BEDROCK_BASE_URL':
157      return $.env.set('ANTHROPIC_BEDROCK_BASE_URL', value)
158    case 'ANTHROPIC_VERTEX_PROJECT_ID':
159      return $.env.set('ANTHROPIC_VERTEX_PROJECT_ID', value)
160    case 'CLOUD_ML_REGION':
161      return $.env.set('CLOUD_ML_REGION', value)
162    case 'ANTHROPIC_VERTEX_BASE_URL':
163      return $.env.set('ANTHROPIC_VERTEX_BASE_URL', value)
164    case 'ANTHROPIC_FOUNDRY_RESOURCE':
165      return $.env.set('ANTHROPIC_FOUNDRY_RESOURCE', value)
166    case 'ANTHROPIC_FOUNDRY_BASE_URL':
167      return $.env.set('ANTHROPIC_FOUNDRY_BASE_URL', value)
168    case 'ANTHROPIC_FOUNDRY_API_KEY':
169      return $.env.set('ANTHROPIC_FOUNDRY_API_KEY', value)
170  }
171}
172
173function machineIo($: EngineInterface): Io {
174  return {
175    run: (argv, init) => $.process.run(argv, init),
176    read: path => $.fs.read(path),
177    write: (path, text) => $.fs.write(path, text),
178    exists: path => $.fs.exists(path),
179    stat: path => $.fs.stat(path),
180    env: name => variable($, name),
181    now: () => $.clock.now(),
182    sleep: ms => $.clock.sleep(ms),
183    fetch: (url, init) => $.http.fetch(url, init),
184    log: text => $.ui.log(`${PLUGIN}: ${text}`, { to: 'debug' }),
185  }
186}
187
188async function machineOf($: EngineInterface): Promise<Machine> {
189  const configDir = await variable($, 'CLAUDE_CONFIG_DIR')
190  const home = (await variable($, 'HOME')) ?? ''
191
192  return {
193    claudeDir: configDir || `${home}/.claude`,
194    user: await variable($, 'USER'),
195    storage: { configDir: configDir || undefined, secureStorageDir: await variable($, 'CLAUDE_SECURESTORAGE_CONFIG_DIR') },
196  }
197}
198
199async function loadConfig($: EngineInterface): Promise<{ config: Config; problems: string[] }> {
200  const paths = pathsFor((await machineOf($)).claudeDir)
201  if (!(await $.fs.exists(paths.config))) return { config: defaultConfig(), problems: [] }
202  const loaded = parseConfig(await $.fs.read(paths.config))
203  knownNames = Object.entries(loaded.config.accounts).filter(([, account]) => (KINDS as readonly string[]).includes(account.kind)).map(([name]) => name)
204
205  return loaded
206}
207
208async function saveConfig($: EngineInterface, config: Config): Promise<void> {
209  const paths = pathsFor((await machineOf($)).claudeDir)
210  await $.fs.write(paths.config, serializeConfig(config))
211  knownNames = Object.entries(config.accounts).filter(([, account]) => (KINDS as readonly string[]).includes(account.kind)).map(([name]) => name)
212}
213
214/** The managed variables as this process started, taken once and kept across reloads and /clear. */
215async function originalsOf($: EngineInterface): Promise<Originals> {
216  const kept = (await read($, originalsAtom)) ?? mirror.originals
217  if (kept) {
218    if (!(await read($, originalsAtom))) await update($, originalsAtom, () => kept)
219
220    return kept as Originals
221  }
222  const taken = {} as Originals
223  for (const name of MANAGED) taken[name] = (await getManaged($, name)) ?? null
224  mirror = { ...mirror, originals: taken }
225  await update($, originalsAtom, () => taken)
226
227  return taken
228}
229
230function statusText(name: string, account: AccountConfig): string | undefined {
231  return name === LOGIN_NAME ? undefined : `account ${name}${account.label ? ` (${account.label})` : ''}`
232}
233
234function scheduleRenewal($: EngineInterface, name: string, now: number): void {
235  renewal?.cancel()
236  renewal = undefined
237  if (expiresAt === undefined) return
238  const wait = Math.max(30_000, expiresAt - EXPIRY_MARGIN_MS - now)
239  renewal = $.clock.after(wait, () => {
240    void renewIfActive($, name)
241  })
242}
243
244async function renewIfActive($: EngineInterface, name: string): Promise<void> {
245  if (mirror.active?.name !== name) return
246  await switchTo($, name, true).catch((error: unknown) => {
247    $.ui.log(`${PLUGIN}: could not renew ${name}: ${message(error)}`, { to: 'debug' })
248    $.ui.toast(`${PLUGIN}: could not renew ${name}: ${message(error)}`)
249  })
250}
251
252/** Puts this process on an account: its secret read, the managed variables set, the state and status updated. */
253async function switchTo($: EngineInterface, name: string, isQuiet = false): Promise<string> {
254  const { config, problems } = await loadConfig($)
255  const account = config.accounts[name]
256  if (!account) {
257    const extra = problems.length > 0 ? ` (accounts.json: ${problems.join('; ')})` : ''
258
259    throw new Error(`No account named ${name}. Saved: ${Object.keys(config.accounts).join(', ')}${extra}`)
260  }
261  if (!(KINDS as readonly string[]).includes(account.kind)) throw new Error('Rice Accounts switches Anthropic OAuth accounts only. Configure provider accounts in rice-multi-models. This saved entry was kept.')
262  const machine = await machineOf($)
263  const originals = await originalsOf($)
264  const material = await materialFor(machineIo($), name, account, pathsFor(machine.claudeDir), machine)
265  const plan = planEnv(account, material.secret, originals)
266  for (const variableName of MANAGED) {
267    if ((await getManaged($, variableName)) !== plan[variableName]) await setManaged($, variableName, plan[variableName])
268  }
269  await syncDescendantAuth($)
270  const now = await $.clock.now()
271  const active: ActiveAccount | null = name === LOGIN_NAME ? null : { name, switchedAt: now }
272  mirror = { ...mirror, active }
273  await update($, activeAtom, () => active)
274  expiresAt = material.expiresAt
275  scheduleRenewal($, name, now)
276  $.ui.status(statusText(name, account))
277  if (isQuiet) return ''
278  const email = account.kind === 'claudeai' ? await loginEmail(machineIo($), account.loginDir) : undefined
279
280  return name === LOGIN_NAME
281    ? "This session is back on this machine's /login. Other sessions were not touched."
282    : `This session now uses ${name} (${account.kind}${email ? `, ${email}` : ''}) from its next request, for its subagents and any claude -p it starts. Other sessions keep their own account.`
283}
284
285/** After a reload (state kept) or a /clear (module kept), the account this process holds is put back in both. */
286async function restore($: EngineInterface): Promise<void> {
287  await originalsOf($)
288  const kept = (await read($, activeAtom)) ?? mirror.active
289  if (!kept) return
290  if (!(await read($, activeAtom))) await update($, activeAtom, () => kept)
291  await switchTo($, kept.name, true).catch((error: unknown) => {
292    $.ui.toast(`${PLUGIN}: could not keep ${kept.name}: ${message(error)}`)
293  })
294}
295
296function formatLimits(limits: { label: string; percent: number; resetsAt?: string }[]): string {
297  if (limits.length === 0) return 'no usage windows reported'
298
299  return limits
300    .map(limit => {
301      const at = limit.resetsAt ? new Date(limit.resetsAt) : undefined
302      const when = at && !Number.isNaN(at.getTime()) ? `, resets ${at.toLocaleString()}` : ''
303
304      return `${limit.label} ${Math.round(limit.percent)}%${when}`
305    })
306    .join('\n')
307}
308
309async function usageText($: EngineInterface): Promise<string> {
310  const name = mirror.active?.name ?? LOGIN_NAME
311  const { config } = await loadConfig($)
312  const account = config.accounts[name]
313  if (!account) return `No account named ${name}.`
314  if (!(KINDS as readonly string[]).includes(account.kind)) return 'Rice Accounts supports Anthropic OAuth accounts only. This provider entry is retained for compatibility; use rice-multi-models for providers.'
315  let init
316  if (account.kind === 'login') {
317    const handle = (await $.session.authorize())?.handle
318    if (!handle) return 'This session has no Anthropic credential to look usage up with.'
319    init = oauthInit({ handle })
320  } else if (account.kind === 'claudeai' || account.kind === 'claudeai-token') {
321    const machine = await machineOf($)
322    const material = await materialFor(machineIo($), name, account, pathsFor(machine.claudeDir), machine)
323    if (!material.secret) return `${name} has no token.`
324    init = oauthInit({ token: material.secret })
325  } else {
326    return `${name} is a ${account.kind} account: its limits are on the provider's own console.`
327  }
328  const response = await $.http.fetch(USAGE_URL, init)
329  if (!response.ok) return `${name}: the usage lookup answered HTTP ${response.status}${account.kind === 'claudeai-token' ? ' (setup tokens may not reach it)' : ''}.`
330
331  return `${name} usage:\n${formatLimits(parseUsage(JSON.parse(response.text)))}`
332}
333
334async function checkText($: EngineInterface, name: string): Promise<string> {
335  const { config } = await loadConfig($)
336  const account = config.accounts[name]
337  if (!account) return `No account named ${name}.`
338  if (!(KINDS as readonly string[]).includes(account.kind)) return 'Rice Accounts supports Anthropic OAuth accounts only. This provider entry is retained; use rice-multi-models for providers.'
339  const machine = await machineOf($)
340  if (account.kind === 'login') {
341    const stored = await readLogin(machineIo($), { storage: machine.storage, defaultConfigDir: machine.claudeDir, user: machine.user })
342    if (!stored) return 'This machine has no saved Claude OAuth login. Run claude auth login in a terminal.'
343    const response = await $.http.fetch(PROFILE_URL, oauthInit({ token: stored.claudeAiOauth.accessToken }))
344    if (!response.ok) return `login: the profile lookup answered HTTP ${response.status}.`
345    return `login: signed in as ${parseProfile(response.text).email}.`
346  }
347  const material = await materialFor(machineIo($), name, account, pathsFor(machine.claudeDir), machine)
348  if (account.kind === 'claudeai' || account.kind === 'claudeai-token') {
349    const response = await $.http.fetch(PROFILE_URL, oauthInit({ token: material.secret ?? '' }))
350    if (!response.ok) return `${name}: token found; the profile lookup answered HTTP ${response.status}.`
351    const owner = parseProfile(response.text)
352
353    return `${name}: signed in as ${owner.email}${owner.organizationName ? ` (${owner.organizationName})` : ''}.`
354  }
355  return `${name}: OAuth account is ready.`
356}
357
358async function listText($: EngineInterface): Promise<string> {
359  const { config, problems } = await loadConfig($)
360  const rows = await accountRows($)
361  const lines = rows.map(row => `${row.selected ? '*' : ' '} ${describeAccount(row.name, config.accounts[row.name]!)} | ${row.email} | ${row.tier}${row.selected ? ' [this session]' : ''}`)
362  const notes = problems.length > 0 ? `\nSkipped in accounts.json: ${problems.join('; ')}` : ''
363
364  return `${lines.join('\n')}${notes}\n\n/rice-account <name> switches this session only. /rice-account add <name> [kind] saves another (default: claudeai). /rice-account setup <name> resumes setup.`
365}
366
367async function accountRows($: EngineInterface): Promise<AccountRow[]> {
368  const { config } = await loadConfig($)
369  const machine = await machineOf($)
370  const io = machineIo($)
371  const current = mirror.active?.name ?? LOGIN_NAME
372  return Promise.all(Object.entries(config.accounts).filter(([, account]) => (KINDS as readonly string[]).includes(account.kind)).map(async ([name, account]) => ({
373    name, kind: account.kind, selected: name === current, ...await accountIdentity(io, account, machine),
374  })))
375}
376
377function parseOptions(words: readonly string[]): Record<string, string> {
378  const options: Record<string, string> = {}
379  for (const word of words) {
380    const at = word.indexOf('=')
381    if (at <= 0 || at === word.length - 1) throw new Error('Expected key=value for each option; credentials are entered in the terminal helper.')
382    const key = word.slice(0, at)
383    if (!(ADD_OPTIONS as readonly string[]).includes(key)) throw new Error(`Unknown option: ${key}`)
384    if (options[key] !== undefined) throw new Error(`Repeated option: ${key}`)
385    options[key] = word.slice(at + 1)
386  }
387
388  return options
389}
390
391async function runCommand($: EngineInterface, args: string): Promise<string> {
392  const words = args.trim().split(/\s+/).filter(Boolean)
393  const [first, ...rest] = words
394  try {
395    if (!first) {
396      if (await openAccountPicker(() => $.ui.open({ id: ACCOUNT_PICKER_ID, title: 'Choose account', focus: true, closeOnEscape: true }))) return 'Choose an account in the account list. This changes only this session.'
397      return await listText($)
398    }
399    if (first === 'list') return await listText($)
400    if (first === 'usage') return await usageText($)
401    if (first === 'check') return await checkText($, rest[0] ?? mirror.active?.name ?? LOGIN_NAME)
402    if (first === 'default') return await switchTo($, (await loadConfig($)).config.default)
403    if (first === 'setup') {
404      const name = rest[0]
405      const { config } = await loadConfig($)
406      if (!name || !config.accounts[name] || rest.length !== 1) return 'Usage: /rice-account setup <saved name>'
407      if (!(KINDS as readonly string[]).includes(config.accounts[name]!.kind)) return 'Rice Accounts supports Anthropic OAuth accounts only. This provider entry is retained; use rice-multi-models for providers.'
408      if (name === LOGIN_NAME) return 'The built-in login uses this machine\'s Claude Code authentication. Run claude auth login in a terminal.'
409      return setupText($.plugin.root, (await machineOf($)).claudeDir, name)
410    }
411    if (first === 'add') {
412      const [name, word, ...tail] = rest
413      const { config } = await loadConfig($)
414      const kind = word && !word.includes('=') ? word : config.accounts[name ?? '']?.kind ?? 'claudeai'
415      const more = word?.includes('=') ? [word, ...tail] : tail
416      if (!name || kind === 'login' || !(KINDS as readonly string[]).includes(kind)) {
417        return `Usage: /rice-account add <name> [kind] [key=value ...]\nDefault kind: claudeai\nKinds: ${KINDS.filter(each => each !== 'login').join(', ')}\nOptions: label=, service= (setup tokens only). Configure provider accounts in rice-multi-models.`
418      }
419      const machine = await machineOf($)
420      const added = addAccount(config, name, kind as Kind, parseOptions(more), pathsFor(machine.claudeDir))
421      if (added.config !== config) await saveConfig($, added.config)
422
423      return setupText($.plugin.root, machine.claudeDir, name, !config.accounts[name])
424    }
425    if (first === 'remove') {
426      const name = rest[0]
427      if (!name) return 'Usage: /rice-account remove <name>'
428      if (mirror.active?.name === name) return `${name} is this session's account; /rice-account login first.`
429      const { config } = await loadConfig($)
430      const account = config.accounts[name]
431      if (config.legacy && Object.hasOwn(config.legacy, name) || account && !(KINDS as readonly string[]).includes(account.kind)) return 'This provider entry is retained. Rice Accounts manages OAuth accounts only.'
432      await saveConfig($, removeAccount(config, name))
433      const cleanup =
434        account?.kind === 'claudeai'
435          ? `\nIts native login is retained. To sign it out in a terminal: env -u CLAUDECODE CLAUDE_CONFIG_DIR=${shellQuote(account.loginDir ?? '')} CLAUDE_SECURESTORAGE_CONFIG_DIR=${shellQuote(account.loginDir ?? '')} claude auth logout`
436          : account?.secret
437            ? '\nIts credential is retained in the OS keyring. Remove it with your OS credential manager when no account uses it.'
438            : ''
439
440      return `Removed ${name} from accounts.json.${cleanup}`
441    }
442    if (first === 'set-default') {
443      const name = rest[0]
444      const { config } = await loadConfig($)
445      if (!name || !config.accounts[name] || !(KINDS as readonly string[]).includes(config.accounts[name]!.kind) || rest.length !== 1) return 'This legacy command expects a saved OAuth account name. Use /rice-account <name> to switch this session.'
446      await saveConfig($, { ...config, default: name })
447
448      return `The legacy /rice-account default target is ${name}. Use /rice-account ${name} to switch this session. New sessions still start on this machine's normal Claude login.`
449    }
450
451    if (rest.length > 0) return 'Usage: /rice-account <saved name> or /rice-account setup <saved name>'
452    return await switchTo($, first)
453  } catch (error) {
454    return message(error).replace(/^(?:rice-session-accounts:\s*)+/, '')
455  }
456}
457
458export const register: Register = on => {
459  registerDescendantAuth(on)
460  on('ui.render', { component: 'Pane' }, ($, e, next) => {
461    if (e.requestId !== ACCOUNT_PICKER_ID) return next(e)
462    return renderAccountPicker($.ui.resolve(e), () => accountRows($), name => switchTo($, name), {
463      invalidate: () => $.ui.invalidate('ui.render'),
464      log: text => $.ui.log(text),
465      close: () => $.ui.close({ id: ACCOUNT_PICKER_ID }),
466    })
467  })
468  on('session.start', async ($, e, next) => {
469    const started = await next(e)
470    await $.command.register({
471      name: 'rice-account',
472      description: 'Choose a Claude OAuth account by email and tier, or add, set up and check accounts',
473      argumentHint: '[<name> | list | setup <name> | check [name] | add <name> [kind] | remove <name>]',
474    })
475    await restore($)
476    await syncDescendantAuth($)
477
478    return started
479  })
480
481  on('turn.start', async ($, e, next) => {
482    if (mirror.active && !(await read($, activeAtom))) await restore($)
483    if (mirror.active && expiresAt !== undefined && expiresAt - EXPIRY_MARGIN_MS <= (await $.clock.now())) await renewIfActive($, mirror.active.name)
484
485    return next(e)
486  })
487
488  on('command.run', { command: 'rice-account' }, async ($, e) => ({ text: await runCommand($, e.args) }))
489
490  on('prompt.autocomplete', async ($, e, next) => {
491    const found = await next(e)
492    if (!e.text.startsWith('/rice-account ') || e.start <= '/rice-account'.length) return found
493    const rows = knownNames
494      .filter(name => name.startsWith(e.token) && name !== e.token)
495      .map(name => ({ text: name, description: 'switch this session to it' }))
496
497    return { suggestions: [...found.suggestions, ...rows] }
498  })
499}
500
hooks/accounts.ts 88 lines
1import type { AccountConfig, Paths } from './config'
2import { readLogin, readSecret, writeLogin } from './credentials'
3import type { LoginLocation } from './credentials'
4import type { Io } from './io'
5import { isSameGrant } from './keychain'
6import type { Credential, StorageVariables } from './keychain'
7import { withLock } from './lock'
8import { AnthropicError, applyRefresh, needsRefresh, refreshInit, TOKEN_URL } from './oauth'
9
10/** Where Claude Code keeps this machine's own login. */
11export type Machine = { claudeDir: string; user: string | undefined; storage: StorageVariables }
12
13/** What a switch needs from an account's credential store; the secret stays in memory only. */
14export type Material = { secret?: string; expiresAt?: number }
15
16/** The secret, and when it expires for a login that does, read from its platform store. */
17export async function materialFor(io: Io, name: string, account: AccountConfig, paths: Paths, machine: Machine): Promise<Material> {
18  if (account.kind === 'claudeai') {
19    const credential = await freshLogin(io, name, account, paths, machine)
20
21    return { secret: credential.claudeAiOauth.accessToken, expiresAt: credential.claudeAiOauth.expiresAt }
22  }
23  if (!account.secret) return {}
24  const text = await readSecret(io, account.secret)
25  if (text === null || text.trim() === '') {
26    throw new Error(
27      `${name} has no stored secret yet: /rice-account setup ${name}`,
28    )
29  }
30
31  return { secret: text.trim() }
32}
33
34async function machineLogin(io: Io, machine: Machine): Promise<Credential | null> {
35  return readLogin(io, { storage: machine.storage, defaultConfigDir: machine.claudeDir, user: machine.user })
36}
37
38/**
39 * The isolated login of a claudeai account, renewed when it expires within
40 * five minutes. Its folder is signed in only for this mod, so renewing it
41 * (the server rotates the refresh token) spends nobody else's grant; the lock
42 * and the read inside it keep two sessions from renewing it at once.
43 */
44export async function freshLogin(io: Io, name: string, account: AccountConfig, paths: Paths, machine: Machine): Promise<Credential> {
45  if (!account.loginDir) throw new Error(`${name} has no loginDir`)
46  const location: LoginLocation = {
47    storage: { configDir: account.loginDir, secureStorageDir: account.loginDir },
48    defaultConfigDir: machine.claudeDir,
49    user: machine.user,
50  }
51  const load = async (): Promise<Credential> => {
52    const value = await readLogin(io, location)
53    if (value === null) throw new Error(`${name} is not signed in yet: /rice-account setup ${name}`)
54
55    return value
56  }
57  const first = await load()
58  if (!needsRefresh(first, await io.now())) return first
59
60  return withLock(io, paths.lock(name), { staleMs: 60_000, tries: 20 }, async () => {
61    const current = await load()
62    if (!needsRefresh(current, await io.now())) return current
63    const own = await machineLogin(io, machine)
64    if (isSameGrant(current, own)) throw new Error(`${name} holds this machine's own /login; switch to "login" instead`)
65    const response = await io.fetch(TOKEN_URL, refreshInit(current))
66    if (!response.ok) {
67      throw new AnthropicError(`the ${name} login could not be renewed (HTTP ${response.status}); sign it in again: /rice-account setup ${name}`, response.status)
68    }
69    const fresh = applyRefresh(current, response.text, await io.now())
70    const saved = await writeLogin(io, location, fresh, current)
71    io.log(`renewed the ${name} login`)
72
73    return saved
74  })
75}
76
77/** The email an isolated login folder records (not secret), for the listing. */
78export async function loginEmail(io: Io, loginDir: string | undefined): Promise<string | undefined> {
79  if (!loginDir) return undefined
80  try {
81    const raw = JSON.parse(await io.read(`${loginDir}/.claude.json`)) as { oauthAccount?: { emailAddress?: unknown } }
82
83    return typeof raw.oauthAccount?.emailAddress === 'string' ? raw.oauthAccount.emailAddress : undefined
84  } catch {
85    return undefined
86  }
87}
88
hooks/config.ts 230 lines
1/**
2 * The saved accounts: a JSON file of names, kinds and where each one's secret
3 * lives. This file holds no credentials.
4 */
5
6export const KINDS = ['login', 'claudeai', 'claudeai-token'] as const
7// Read legacy provider records so ordinary OAuth edits preserve them verbatim.
8const STORED_KINDS = [...KINDS, 'api-key', 'gateway', 'bedrock', 'vertex', 'foundry'] as const
9export type Kind = (typeof STORED_KINDS)[number]
10
11/** The keychain service a secret saved by name lives under, its account being the saved account's name. */
12export const SECRET_SERVICE = 'rice-session-accounts'
13
14export type SecretRef = { service: string; account: string }
15
16export type AccountConfig = {
17  kind: Kind
18  label?: string
19  /** claudeai: the config folder an isolated `claude auth login` signed in. */
20  loginDir?: string
21  /** claudeai-token, api-key, gateway, and optionally bedrock (bearer token) and foundry (API key). */
22  secret?: SecretRef
23  /** gateway: required. bedrock, vertex, foundry: optional endpoint override. */
24  baseUrl?: string
25  awsProfile?: string
26  region?: string
27  projectId?: string
28  resource?: string
29  /** Model ids this account calls by another name (a Bedrock inference profile, say). */
30  models?: Record<string, string>
31}
32
33export type Config = { default: string; accounts: Record<string, AccountConfig>; legacy?: Record<string, unknown> }
34
35export const LOGIN_NAME = 'login'
36
37export function defaultConfig(): Config {
38  return { default: LOGIN_NAME, accounts: { [LOGIN_NAME]: { kind: 'login', label: "this machine's /login" } } }
39}
40
41export type Paths = { base: string; config: string; loginDir: (name: string) => string; lock: (name: string) => string }
42
43export function pathsFor(claudeDir: string): Paths {
44  const base = `${claudeDir.replace(/\/+$/, '')}/rice-session-accounts`
45
46  return {
47    base,
48    config: `${base}/accounts.json`,
49    loginDir: name => `${base}/logins/${name}`,
50    lock: name => `${base}/locks/${name}.lock`,
51  }
52}
53
54/** Names that are safe in a keychain account, a folder and a command line. */
55export function isAccountName(name: string): boolean {
56  return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/.test(name)
57}
58
59function isKind(value: unknown): value is Kind {
60  return typeof value === 'string' && (STORED_KINDS as readonly string[]).includes(value)
61}
62
63function stringField(raw: Record<string, unknown>, key: string): string | undefined {
64  return typeof raw[key] === 'string' && raw[key] !== '' ? (raw[key] as string) : undefined
65}
66
67/** Checks one account; the problems name what is missing for its kind. */
68export function accountProblems(name: string, account: AccountConfig): string[] {
69  const problems: string[] = []
70  if (!isAccountName(name)) problems.push(`"${name}" is not a usable name (letters, digits, . _ -, up to 40)`)
71  const needsSecret = account.kind === 'claudeai-token' || account.kind === 'api-key' || account.kind === 'gateway'
72  if (needsSecret && !account.secret) problems.push(`${name}: a ${account.kind} account needs "secret"`)
73  if (account.kind === 'claudeai' && !account.loginDir) problems.push(`${name}: a claudeai account needs "loginDir"`)
74  if (account.kind === 'gateway' && !account.baseUrl) problems.push(`${name}: a gateway account needs "baseUrl"`)
75  if (account.kind === 'vertex' && !account.projectId) problems.push(`${name}: a vertex account needs "projectId"`)
76  if (account.kind === 'foundry' && !account.resource && !account.baseUrl) problems.push(`${name}: a foundry account needs "resource" or "baseUrl"`)
77
78  return problems
79}
80
81/** The file's accounts, the bad ones left out and said; a file that does not parse is all problem. */
82export function parseConfig(text: string): { config: Config; problems: string[] } {
83  const config = defaultConfig()
84  const problems: string[] = []
85  let raw: unknown
86  try {
87    raw = JSON.parse(text)
88  } catch (error) {
89    return { config, problems: [`accounts.json does not parse: ${error instanceof Error ? error.message : String(error)}`] }
90  }
91  const top = (raw ?? {}) as { default?: unknown; accounts?: unknown }
92  if (top.accounts && typeof top.accounts === 'object') {
93    for (const [name, value] of Object.entries(top.accounts as Record<string, unknown>)) {
94      const entry = (value ?? {}) as Record<string, unknown>
95      if (!isKind(entry.kind)) {
96        problems.push(`${name}: unknown kind ${JSON.stringify(entry.kind)} (one of ${KINDS.join(', ')})`)
97        continue
98      }
99      if (!(KINDS as readonly string[]).includes(entry.kind)) {
100        config.legacy ??= Object.create(null) as Record<string, unknown>
101        config.legacy[name] = value
102      }
103      const secret = entry.secret as { service?: unknown; account?: unknown } | undefined
104      const models = entry.models && typeof entry.models === 'object' ? (entry.models as Record<string, unknown>) : undefined
105      const account: AccountConfig = {
106        kind: entry.kind,
107        label: stringField(entry, 'label'),
108        loginDir: stringField(entry, 'loginDir'),
109        secret: typeof secret?.service === 'string' && typeof secret.account === 'string' ? { service: secret.service, account: secret.account } : undefined,
110        baseUrl: stringField(entry, 'baseUrl'),
111        awsProfile: stringField(entry, 'awsProfile'),
112        region: stringField(entry, 'region'),
113        projectId: stringField(entry, 'projectId'),
114        resource: stringField(entry, 'resource'),
115        models: models
116          ? Object.fromEntries(Object.entries(models).filter((pair): pair is [string, string] => typeof pair[1] === 'string'))
117          : undefined,
118      }
119      const found = accountProblems(name, account)
120      if (found.length > 0) {
121        problems.push(...found)
122        continue
123      }
124      config.accounts[name] = account
125    }
126  }
127  if (typeof top.default === 'string') {
128    if (config.accounts[top.default]) config.default = top.default
129    else problems.push(`default names no saved account: ${top.default}`)
130  }
131
132  return { config, problems }
133}
134
135export function serializeConfig(config: Config): string {
136  return `${JSON.stringify({ default: config.default, accounts: { ...config.accounts, ...config.legacy } }, null, 2)}\n`
137}
138
139/** The options `/rice-account add` takes, as `key=value` words. */
140export const ADD_OPTIONS = ['label', 'service'] as const
141
142/**
143 * A new account and what the person still does once, in a terminal, before
144 * switching to it. Nothing secret is asked here: a secret is typed into the
145 * keychain prompt, never into the session.
146 */
147export function addAccount(
148  config: Config,
149  name: string,
150  kind: Kind,
151  options: Record<string, string>,
152  paths: Paths,
153): { config: Config; steps: string[] } {
154  if (!isAccountName(name)) throw new Error(`"${name}" is not a usable name (letters, digits, . _ -, up to 40)`)
155  if (!(KINDS as readonly string[]).includes(kind)) throw new Error('Rice Accounts supports Anthropic OAuth accounts only. Use rice-multi-models for provider accounts.')
156  if (config.legacy && Object.hasOwn(config.legacy, name)) throw new Error(`${name} is a preserved provider entry. Use another name for an OAuth account.`)
157  for (const option of Object.keys(options)) {
158    if (!(ADD_OPTIONS as readonly string[]).includes(option)) throw new Error(`Unknown option: ${option}`)
159  }
160  const allowed: Record<Kind, readonly string[]> = {
161    login: [], claudeai: ['label'], 'claudeai-token': ['label', 'service'], 'api-key': ['label', 'service'],
162    gateway: ['label', 'service', 'base_url'], bedrock: ['label', 'aws_profile', 'region', 'base_url', 'secret', 'service'],
163    vertex: ['label', 'project', 'region', 'base_url'], foundry: ['label', 'resource', 'base_url', 'secret', 'service'],
164  }
165  for (const option of Object.keys(options)) {
166    if (!allowed[kind].includes(option)) throw new Error(`Option ${option} does not apply to ${kind}`)
167  }
168  if (options.secret && !((kind === 'bedrock' && options.secret === 'bearer') || (kind === 'foundry' && options.secret === 'key'))) {
169    throw new Error('Use secret=bearer for Bedrock or secret=key for Foundry.')
170  }
171  if (options.service && (kind === 'bedrock' || kind === 'foundry') && !options.secret) throw new Error('service= requires secret=bearer for Bedrock or secret=key for Foundry.')
172  const existing = config.accounts[name]
173  if (existing) {
174    if (existing.kind !== kind || Object.keys(options).length > 0) {
175      throw new Error(`${name} is already saved as ${existing.kind}; use /rice-account setup ${name} to finish it. Existing settings were kept.`)
176    }
177    return { config, steps: [] }
178  }
179  const service = options.service ?? SECRET_SERVICE
180  const secret: SecretRef = { service, account: name }
181  const storeSecret = (what: string) => `The terminal setup helper will ask for the ${what} without echoing it.`
182  const account: AccountConfig = { kind, label: options.label }
183  const steps: string[] = []
184  switch (kind) {
185    case 'login':
186      throw new Error("the machine's /login is saved already as \"login\"")
187    case 'claudeai': {
188      account.loginDir = paths.loginDir(name)
189      steps.push('The terminal setup helper signs in this isolated account using Claude Code.')
190      break
191    }
192    case 'claudeai-token':
193      account.secret = secret
194      steps.push('claude setup-token   # sign in as that account; it prints a one-year token')
195      steps.push(storeSecret('token'))
196      break
197    default:
198      throw new Error('Rice Accounts supports Anthropic OAuth accounts only.')
199  }
200  const problems = accountProblems(name, account)
201  if (problems.length > 0) throw new Error(problems.join('; '))
202
203  return { config: { ...config, accounts: { ...config.accounts, [name]: account } }, steps: [...steps, `then: /rice-account ${name}`] }
204}
205
206/** The config without an account; the default goes back to the machine login when it named it. */
207export function removeAccount(config: Config, name: string): Config {
208  if (name === LOGIN_NAME) throw new Error("the machine's /login cannot be removed")
209  if (!config.accounts[name]) throw new Error(`no account named ${name}`)
210  const accounts = { ...config.accounts }
211  delete accounts[name]
212
213  return { ...config, default: config.default === name ? LOGIN_NAME : config.default, accounts }
214}
215
216export function describeAccount(name: string, account: AccountConfig): string {
217  const where =
218    account.kind === 'bedrock'
219      ? [account.awsProfile && `profile ${account.awsProfile}`, account.region].filter(Boolean).join(', ')
220      : account.kind === 'vertex'
221        ? [account.projectId, account.region].filter(Boolean).join(', ')
222        : account.kind === 'foundry'
223          ? (account.resource ?? account.baseUrl ?? '')
224          : account.kind === 'gateway'
225            ? (account.baseUrl ?? '')
226            : ''
227
228  return `${name}  ${account.kind}${account.label ? `  "${account.label}"` : ''}${where ? `  (${where})` : ''}`
229}
230
hooks/env.ts 96 lines
1import type { AccountConfig } from './config'
2
3/**
4 * The environment variables a switch owns. Claude Code reads them on every
5 * request, so setting them in this process changes this session's account
6 * from its next request, its subagents' included, and every child it starts
7 * after (a `claude -p` too); other sessions are other processes.
8 */
9export const MANAGED = [
10  'ANTHROPIC_API_KEY',
11  'ANTHROPIC_AUTH_TOKEN',
12  'CLAUDE_CODE_OAUTH_TOKEN',
13  'ANTHROPIC_BASE_URL',
14  'CLAUDE_CODE_USE_BEDROCK',
15  'CLAUDE_CODE_USE_VERTEX',
16  'CLAUDE_CODE_USE_FOUNDRY',
17  'CLAUDE_CODE_USE_ANTHROPIC_AWS',
18  'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
19  'CLAUDE_CODE_USE_MANTLE',
20  'CLAUDE_CODE_USE_GATEWAY',
21  'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
22  'CLAUDE_CODE_GATEWAY_TOKEN_FILE_DESCRIPTOR',
23  'CLAUDE_BG_AUTH_SNAPSHOT_PATH',
24  'CLAUDE_CODE_HOST_CREDS_FILE',
25  'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
26  'CLAUDE_CODE_HOST_AUTH_ENV_VAR',
27  'AWS_PROFILE',
28  'AWS_REGION',
29  'AWS_BEARER_TOKEN_BEDROCK',
30  'ANTHROPIC_BEDROCK_BASE_URL',
31  'ANTHROPIC_VERTEX_PROJECT_ID',
32  'CLOUD_ML_REGION',
33  'ANTHROPIC_VERTEX_BASE_URL',
34  'ANTHROPIC_FOUNDRY_RESOURCE',
35  'ANTHROPIC_FOUNDRY_BASE_URL',
36  'ANTHROPIC_FOUNDRY_API_KEY',
37] as const
38
39export type Managed = (typeof MANAGED)[number]
40
41/** Each managed variable as the process started: a value, or null for unset. */
42export type Originals = Record<Managed, string | null>
43
44/** What a switch sets: a value, or undefined to unset. */
45export type EnvPlan = Record<Managed, string | undefined>
46
47/** The variables that pick a credential or a provider; a switch to any other account clears them first. */
48const AUTH: readonly Managed[] = [
49  'ANTHROPIC_API_KEY',
50  'ANTHROPIC_AUTH_TOKEN',
51  'CLAUDE_CODE_OAUTH_TOKEN',
52  'CLAUDE_CODE_USE_BEDROCK',
53  'CLAUDE_CODE_USE_VERTEX',
54  'CLAUDE_CODE_USE_FOUNDRY',
55  'CLAUDE_CODE_USE_ANTHROPIC_AWS',
56  'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
57  'CLAUDE_CODE_USE_MANTLE',
58  'CLAUDE_CODE_USE_GATEWAY',
59  'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
60  'CLAUDE_CODE_GATEWAY_TOKEN_FILE_DESCRIPTOR',
61  'CLAUDE_BG_AUTH_SNAPSHOT_PATH',
62  'CLAUDE_CODE_HOST_CREDS_FILE',
63  'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
64  'CLAUDE_CODE_HOST_AUTH_ENV_VAR',
65  'AWS_BEARER_TOKEN_BEDROCK',
66  'ANTHROPIC_FOUNDRY_API_KEY',
67]
68
69/**
70 * The environment for an account. `secret` is the account's token or key
71 * (a claude.ai access token, an API key, a gateway token), absent for kinds
72 * that use another tool's credentials. The machine login restores the
73 * process's own values; any other kind starts from them with every
74 * credential and provider switch cleared, then sets its own.
75 */
76export function planEnv(account: AccountConfig, secret: string | undefined, originals: Originals): EnvPlan {
77  const plan = Object.fromEntries(MANAGED.map(name => [name, originals[name] ?? undefined])) as EnvPlan
78  if (account.kind === 'login') return plan
79  for (const name of AUTH) plan[name] = undefined
80  switch (account.kind) {
81    case 'claudeai':
82    case 'claudeai-token':
83      plan.CLAUDE_CODE_OAUTH_TOKEN = secret
84      break
85    default:
86      throw new Error('Rice Accounts supports Anthropic OAuth accounts only.')
87  }
88
89  return plan
90}
91
92/** The names a plan changes from what the process holds now, for the switch to set. */
93export function changedNames(plan: EnvPlan, current: Record<Managed, string | undefined>): Managed[] {
94  return MANAGED.filter(name => plan[name] !== current[name])
95}
96
hooks/io.ts 27 lines
1// Adapted from simplecore-inc/claude-mods mods/accounts/hooks/io.ts at 722763c
2// (MIT, Copyright (c) 2026 SimpleCORE Inc., see LICENSES/simplecore-claude-mods.MIT.txt).
3// Modified: trimmed to what rice-session-accounts uses; no store or listing.
4import type { FsStat, HttpInit, HttpResponse, ProcessRunInit, ProcessRunResult } from 'claude-code'
5
6/** The environment variables the modules read, each read by its literal name in register.ts. */
7export type EnvName = 'HOME' | 'USER' | 'CLAUDE_CONFIG_DIR' | 'CLAUDE_SECURESTORAGE_CONFIG_DIR'
8
9/** What the modules reach the machine through; register.ts builds it over `$`. */
10export type Io = {
11  run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
12  read: (path: string) => Promise<string>
13  write: (path: string, text: string) => Promise<void>
14  exists: (path: string) => Promise<boolean>
15  stat: (path: string) => Promise<FsStat>
16  env: (name: EnvName) => Promise<string | undefined>
17  now: () => Promise<number>
18  sleep: (ms: number) => Promise<void>
19  fetch: (url: string, init?: HttpInit) => Promise<HttpResponse>
20  /** A line for the debug log; never a secret. */
21  log: (text: string) => void
22}
23
24export function message(error: unknown): string {
25  return error instanceof Error ? error.message : String(error)
26}
27
hooks/credentials.ts 164 lines
1import type { SecretRef } from './config'
2import type { Io } from './io'
3import { keychainAccountName, liveServiceName, parseCredential, readItem, writeItem } from './keychain'
4import type { Credential, StorageVariables } from './keychain'
5
6export type HostPlatform = 'darwin' | 'linux'
7export type LoginLocation = { storage: StorageVariables; defaultConfigDir: string; user?: string }
8
9const platforms = new WeakMap<Io, Promise<HostPlatform>>()
10
11/** Detect the execution host, including Linux inside WSL, once per Io instance. */
12export function hostPlatform(io: Io): Promise<HostPlatform> {
13  const known = platforms.get(io)
14  if (known) return known
15  const pending = (async (): Promise<HostPlatform> => {
16    const result = await io.run(['/usr/bin/uname', '-s'], { timeoutMs: 10_000 })
17      .catch(() => { throw new Error('Cannot detect the host OS: /usr/bin/uname -s must be available.') })
18    if (result.exitCode !== 0) throw new Error('Cannot detect the host OS: /usr/bin/uname -s failed.')
19    if (result.stdout.trim() === 'Darwin') return 'darwin'
20    if (result.stdout.trim() === 'Linux') return 'linux'
21    throw new Error('Account storage supports macOS and Linux (including WSL).')
22  })()
23  platforms.set(io, pending)
24  return pending
25}
26
27/** Native Linux secure storage is independent of CLAUDE_CONFIG_DIR when overridden. */
28export function nativeCredentialPath(location: LoginLocation, home: string | undefined): string {
29  const { secureStorageDir, configDir } = location.storage
30  const folder = secureStorageDir === ''
31    ? home ? `${home}/.claude` : undefined
32    : secureStorageDir ?? configDir ?? location.defaultConfigDir
33  if (!folder || !folder.startsWith('/') || folder.includes('\0')) throw new Error('The native credential directory must be an absolute path; HOME is required for an empty secure-storage override.')
34
35  return `${folder.normalize('NFC').replace(/\/+$/, '')}/.credentials.json`
36}
37
38/**
39 * Node 22+ supplies the no-follow, file mode and atomic rename primitives the
40 * mod file API does not expose. Paths and static code are argv; secrets use
41 * stdin/stdout only. Exit 43 means a valid document without OAuth; exit 44
42 * means only ENOENT, never an unreadable store.
43 */
44const NATIVE_FILE = String.raw`
45const fs = require('node:fs');
46const crypto = require('node:crypto');
47const [operation, path] = process.argv.slice(1);
48const flags = fs.constants;
49const limit = 1024 * 1024;
50let temporary;
51function fail(code) { process.exitCode = code; throw new Error('credential-operation-refused'); }
52function read() {
53  let fd;
54  try {
55    fd = fs.openSync(path, flags.O_RDONLY | flags.O_NOFOLLOW | flags.O_NONBLOCK);
56    const stat = fs.fstatSync(fd);
57    if (!stat.isFile() || stat.size > limit) fail(46);
58    const bytes = Buffer.alloc(limit + 1);
59    let size = 0;
60    while (size < bytes.length) {
61      const count = fs.readSync(fd, bytes, size, bytes.length - size, null);
62      if (count === 0) break;
63      size += count;
64    }
65    if (size > limit) fail(46);
66    const value = JSON.parse(bytes.toString('utf8', 0, size));
67    if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(46);
68    const oauth = value?.claudeAiOauth;
69    if (oauth === undefined || oauth === null) fail(43);
70    if (typeof oauth?.accessToken !== 'string' || typeof oauth.refreshToken !== 'string' || !Number.isFinite(oauth.expiresAt)) fail(46);
71    return value;
72  } finally { if (fd !== undefined) fs.closeSync(fd); }
73}
74try {
75  if (Number(process.versions.node.split('.')[0]) < 22) fail(50);
76  if (!path?.startsWith('/') || path.includes('\0')) fail(46);
77  let document = read();
78  if (operation === 'write') {
79    const input = JSON.parse(fs.readFileSync(0, 'utf8'));
80    if (document.claudeAiOauth.refreshToken !== input.expectedRefreshToken) fail(47);
81    const oauth = input.oauth;
82    if (typeof oauth?.accessToken !== 'string' || typeof oauth.refreshToken !== 'string' || !Number.isFinite(oauth.expiresAt)) fail(46);
83    document = { ...document, claudeAiOauth: { ...document.claudeAiOauth, accessToken: oauth.accessToken, refreshToken: oauth.refreshToken, expiresAt: oauth.expiresAt } };
84    const encoded = JSON.stringify(document);
85    if (Buffer.byteLength(encoded) > limit) fail(46);
86    temporary = path + '.rice-' + process.pid + '-' + crypto.randomUUID();
87    const fd = fs.openSync(temporary, flags.O_WRONLY | flags.O_CREAT | flags.O_EXCL | flags.O_NOFOLLOW, 0o600);
88    try { fs.writeFileSync(fd, encoded, 'utf8'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
89    fs.renameSync(temporary, path);
90    temporary = undefined;
91  } else if (operation !== 'read') fail(46);
92  process.stdout.write(JSON.stringify(document));
93} catch (error) {
94  if (!process.exitCode) process.exitCode = error?.code === 'ENOENT' ? 44 : error?.code === 'ELOOP' ? 45 : 46;
95} finally {
96  if (temporary) { try { fs.unlinkSync(temporary); } catch {} }
97}
98`
99
100function credential(text: string): Credential | null {
101  try {
102    const document: unknown = JSON.parse(text)
103    if (document === null || typeof document !== 'object' || Array.isArray(document)) throw new Error('invalid document')
104    const oauth = (document as { claudeAiOauth?: unknown }).claudeAiOauth
105    if (oauth === undefined || oauth === null) return null
106    const value = parseCredential(text)
107    if (!Number.isFinite(value.claudeAiOauth.expiresAt)) throw new Error('missing expiry')
108    return value
109  } catch {
110    throw new Error('The credential store contains no valid Claude OAuth login. Run account setup again.')
111  }
112}
113
114async function nativeFile(io: Io, operation: 'read' | 'write', path: string, stdin?: string): Promise<Credential | null> {
115  const result = await io.run(['node', '-e', NATIVE_FILE, operation, path], { stdin, timeoutMs: 15_000 })
116    .catch(() => { throw new Error('Linux account storage requires Node.js 22 or later on PATH. Install it, then run account setup again.') })
117  if ((result.exitCode === 43 || result.exitCode === 44) && operation === 'read') return null
118  if (result.exitCode === 50) throw new Error('Linux account storage requires Node.js 22 or later on PATH.')
119  if (result.exitCode === 45) throw new Error('Refused a symbolic link at the native credential file. Run account setup with its own login directory.')
120  if (result.exitCode === 47) throw new Error('The login changed while it was renewed. Retry the account switch to read the newer login.')
121  if (result.exitCode !== 0 || result.isStdoutTruncated) throw new Error(`Cannot ${operation} the native Claude credential file. Check its permissions and run account setup again.`)
122
123  return credential(result.stdout)
124}
125
126export async function readLogin(io: Io, location: LoginLocation): Promise<Credential | null> {
127  if (await hostPlatform(io) === 'darwin') {
128    const service = await liveServiceName(location.storage, location.defaultConfigDir)
129    const text = await readItem(io, service, keychainAccountName(location.user))
130    return text === null ? null : credential(text)
131  }
132
133  return nativeFile(io, 'read', nativeCredentialPath(location, await io.env('HOME')))
134}
135
136/** Persist a rotated grant without replacing unrelated native credential fields. */
137export async function writeLogin(io: Io, location: LoginLocation, fresh: Credential, previous: Credential): Promise<Credential> {
138  if (await hostPlatform(io) === 'darwin') {
139    const service = await liveServiceName(location.storage, location.defaultConfigDir)
140    await writeItem(io, service, keychainAccountName(location.user), JSON.stringify(fresh))
141    return fresh
142  }
143  const saved = await nativeFile(io, 'write', nativeCredentialPath(location, await io.env('HOME')), JSON.stringify({
144    expectedRefreshToken: previous.claudeAiOauth.refreshToken,
145    oauth: {
146      accessToken: fresh.claudeAiOauth.accessToken,
147      refreshToken: fresh.claudeAiOauth.refreshToken,
148      expiresAt: fresh.claudeAiOauth.expiresAt,
149    },
150  }))
151  if (saved === null) throw new Error('The native credential file disappeared during renewal. Run account setup again.')
152  return saved
153}
154
155/** Named keys and tokens use the OS keyring, separate from native OAuth files. */
156export async function readSecret(io: Io, reference: SecretRef): Promise<string | null> {
157  if (await hostPlatform(io) === 'darwin') return readItem(io, reference.service, reference.account)
158  const result = await io.run(['secret-tool', 'lookup', 'service', reference.service, 'account', reference.account], { timeoutMs: 15_000 })
159    .catch(() => { throw new Error('Named account secrets on Linux require secret-tool (libsecret) and an unlocked Secret Service keyring. Install or enable them, then run account setup again.') })
160  if (result.exitCode === 1 && result.stderr.trim() === '') return null
161  if (result.exitCode !== 0 || result.isStdoutTruncated) throw new Error('Cannot read the Linux account keyring. Check that secret-tool (libsecret) and an unlocked Secret Service keyring are available, then run account setup again.')
162  return result.stdout.replace(/\n$/, '')
163}
164
hooks/setup.ts 13 lines
1/** POSIX quoting for a command copied into Bash, zsh or fish. */
2export function shellQuote(value: string): string {
3  return `'${value.replace(/'/g, `'\\''`)}'`
4}
5
6export function setupCommand(pluginRoot: string, configDir: string, name: string): string {
7  return `node ${shellQuote(`${pluginRoot.replace(/\/+$/, '')}/scripts/account-setup.mjs`)} --config-dir ${shellQuote(configDir)} ${shellQuote(name)}`
8}
9
10export function setupText(pluginRoot: string, configDir: string, name: string, saved = false): string {
11  return `${saved ? `Saved ${name}.` : `${name} is saved.`} Finish or resume setup in a terminal:\n${setupCommand(pluginRoot, configDir, name)}\n\nThe helper checks prerequisites, opens the provider login when needed, and verifies setup.\nThen return here: /rice-account ${name}\nTo show this command again: /rice-account setup ${name}`
12}
13
hooks/identity.ts 93 lines
1import type { Machine } from './accounts'
2import type { AccountConfig } from './config'
3import { readLogin } from './credentials'
4import type { LoginLocation } from './credentials'
5import type { Io } from './io'
6import type { Credential } from './keychain'
7
8export type AccountIdentity = { email: string; tier: string }
9
10const UNKNOWN: AccountIdentity = { email: 'Unknown', tier: 'Unknown' }
11const UNSIGNED: AccountIdentity = { email: 'Not signed in', tier: 'Unknown' }
12const PLANS: Record<string, string> = {
13  free: 'Free', pro: 'Pro', max: 'Max', team: 'Team', enterprise: 'Enterprise',
14}
15
16function plan(credential: Credential): string {
17  const { subscriptionType, rateLimitTier } = credential.claudeAiOauth
18  if (typeof subscriptionType !== 'string') return 'Unknown'
19  const subscription = subscriptionType.trim().toLowerCase()
20  if (subscription === 'max') {
21    if (rateLimitTier === 'default_claude_max_5x') return 'Max (5x)'
22    if (rateLimitTier === 'default_claude_max_20x') return 'Max (20x)'
23  }
24
25  return Object.hasOwn(PLANS, subscription) ? PLANS[subscription] ?? 'Unknown' : 'Unknown'
26}
27
28/** Native account metadata only. Never infer an identity from a saved label. */
29async function emailAt(io: Io, path: string): Promise<string | undefined> {
30  try {
31    const raw: unknown = JSON.parse(await io.read(path))
32    const email = (raw as { oauthAccount?: { emailAddress?: unknown } } | null)?.oauthAccount?.emailAddress
33    if (typeof email !== 'string') return undefined
34    const value = email.trim()
35
36    return value.length > 0 && value.length <= 320 && !/[\u0000-\u001f\u007f]/.test(value) ? value : undefined
37  } catch {
38    return undefined
39  }
40}
41
42function metadataPath(folder: string): string {
43  return `${folder.replace(/\/+$/, '')}/.claude.json`
44}
45
46/**
47 * Read cached email and plan for account rows without refreshing the login or
48 * making provider requests. Credential bytes are never returned or logged.
49 */
50export async function accountIdentity(io: Io, account: AccountConfig, machine: Machine): Promise<AccountIdentity> {
51  if (account.kind !== 'login' && account.kind !== 'claudeai') {
52    const provider = {
53      'claudeai-token': 'Unknown', 'api-key': 'API', gateway: 'Gateway',
54      bedrock: 'AWS Bedrock', vertex: 'Google Vertex', foundry: 'Azure Foundry',
55    }
56
57    return { email: 'Not available', tier: provider[account.kind] }
58  }
59  if (account.kind === 'claudeai' && !account.loginDir) return { ...UNSIGNED }
60  const location: LoginLocation = {
61    storage: account.kind === 'claudeai'
62      ? { configDir: account.loginDir, secureStorageDir: account.loginDir }
63      : machine.storage,
64    defaultConfigDir: machine.claudeDir,
65    user: machine.user,
66  }
67  let credential: Credential | null
68  try {
69    credential = await readLogin(io, location)
70  } catch {
71    return { ...UNKNOWN }
72  }
73  if (credential === null) return { ...UNSIGNED }
74
75  const paths: string[] = []
76  if (account.kind === 'claudeai') {
77    paths.push(metadataPath(account.loginDir!))
78  } else if (machine.storage.configDir) {
79    paths.push(metadataPath(machine.storage.configDir))
80  } else {
81    const home = await io.env('HOME').catch(() => undefined)
82    if (home) paths.push(metadataPath(home))
83    const fallback = metadataPath(machine.claudeDir)
84    if (!paths.includes(fallback)) paths.push(fallback)
85  }
86  for (const path of paths) {
87    const email = await emailAt(io, path)
88    if (email) return { email, tier: plan(credential) }
89  }
90
91  return { email: 'Unknown', tier: plan(credential) }
92}
93
hooks/picker.tsx 83 lines
1import type { Elements, RenderElement, RenderSurface, UiOpenResult } from 'claude-code'
2
3export type AccountRow = { name: string; kind: string; email: string; tier: string; selected: boolean }
4export type PickerControls = { invalidate: () => void; log: (text: string) => void; close: () => Promise<void> }
5
6export const ACCOUNT_PICKER_ID = 'rice-account-picker'
7let error: string | undefined
8let selecting = false
9
10function message(error: unknown): string {
11  return error instanceof Error ? error.message : String(error)
12}
13
14function label(row: AccountRow): string {
15  return `${row.selected ? '* ' : ''}${row.name} | ${row.email || 'email unavailable'} | ${row.tier || 'tier unavailable'}${row.selected ? ' | current session' : ''}`
16}
17
18/** Opens a native picker when this session has a surface that places panes. */
19export async function openAccountPicker(open: () => Promise<UiOpenResult>): Promise<boolean> {
20  error = undefined
21  const opened = await open()
22  return opened.isPlaced
23}
24
25/** The switch callback succeeds with its status text, or throws to keep the picker open. */
26export async function renderAccountPicker(
27  elements: Elements[RenderSurface],
28  rows: () => Promise<AccountRow[]>,
29  select: (name: string) => Promise<string>,
30  controls: PickerControls,
31): Promise<RenderElement> {
32  const { Box, Text, Button } = elements
33  const Select = 'Select' in elements ? elements.Select : undefined
34  let accounts: AccountRow[] = []
35  let loadError: string | undefined
36  try {
37    accounts = await rows()
38  } catch (caught) {
39    loadError = message(caught)
40  }
41
42  const choose = async (name: string): Promise<void> => {
43    if (selecting) return
44    selecting = true
45    error = undefined
46    controls.invalidate()
47    try {
48      const result = await select(name)
49      if (result) controls.log(result)
50      await controls.close()
51    } catch (caught) {
52      error = message(caught)
53    } finally {
54      selecting = false
55      controls.invalidate()
56    }
57  }
58
59  return (
60    <Box flexDirection="column">
61      <Text>Choose an account for this session</Text>
62      <Text dimColor>Other sessions keep their accounts. Escape closes this list.</Text>
63      {accounts.length > 0 && Select && (
64        <Select
65          key="account-picker-select"
66          label="Account (Up/Down, Enter)"
67          options={accounts.map(row => ({ value: row.name, label: label(row) }))}
68          value={accounts.find(row => row.selected)?.name ?? accounts[0]?.name}
69          autoFocus
70          onSelect={choose}
71        />
72      )}
73      {accounts.map(row => (
74        <Button key={`account:${row.name}`} plain label={label(row)} onPress={() => choose(row.name)} />
75      ))}
76      {accounts.length === 0 && !loadError && <Text>No saved accounts are available.</Text>}
77      {selecting && <Text dimColor>Switching account...</Text>}
78      {(error || loadError) && <Text color="red">{error || loadError}</Text>}
79      <Button key="account-picker-close" role="dismiss" label="Cancel" onPress={controls.close} />
80    </Box>
81  )
82}
83
hooks/descendants.ts 44 lines
1import type { EngineInterface, On } from 'claude-code'
2
3/** Native child-environment filtering removes CLAUDE_CODE_OAUTH_TOKEN. */
4const BASH_AUTH = 'export CLAUDE_CODE_OAUTH_TOKEN="${RICE_SESSION_ACCOUNTS_OAUTH_RELAY-}"; unset RICE_SESSION_ACCOUNTS_OAUTH_RELAY;\n'
5
6/** Derived process state only. No credential bytes enter commands or files. */
7export async function syncDescendantAuth($: EngineInterface): Promise<void> {
8  const token = await $.env.get('CLAUDE_CODE_OAUTH_TOKEN')
9  const current = await $.env.get('RICE_SESSION_ACCOUNTS_OAUTH_RELAY')
10  if (current !== (token || undefined)) await $.env.set('RICE_SESSION_ACCOUNTS_OAUTH_RELAY', token || undefined)
11}
12
13/** The caller's explicit environment has precedence over the inherited token. */
14async function childEnvironment($: EngineInterface, explicit: Record<string, string> | undefined): Promise<Record<string, string> | undefined> {
15  const token = await $.env.get('CLAUDE_CODE_OAUTH_TOKEN')
16  return token ? { CLAUDE_CODE_OAUTH_TOKEN: token, ...explicit } : explicit
17}
18
19/**
20 * Keep native child filtering intact, then restore the selected session token
21 * through each supported child surface. No listener or transport is involved.
22 */
23export function registerDescendantAuth(on: On): void {
24  on('process.run', async ($, e, next) => {
25    const env = await childEnvironment($, e.init?.env)
26    return next(env === e.init?.env ? e : { ...e, init: { ...e.init, env } })
27  }).catch((_host, e, next) => next.called ? next(e) : { deny: 'Account authentication could not be prepared for the child process. Retry the account switch.' })
28
29  on('process.spawn', async function* ($, e, next) {
30    const env = await childEnvironment($, e.env)
31    return yield* next(env === e.env ? e : { ...e, env })
32  }).catch(async function* (_host, e, next) {
33    if (next.called) return yield* next(e)
34    return { deny: 'Account authentication could not be prepared for the streamed child process. Retry the account switch.' }
35  })
36
37  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
38    const token = await $.env.get('CLAUDE_CODE_OAUTH_TOKEN')
39    if (!token) return next(e)
40    await syncDescendantAuth($)
41    return next({ ...e, command: BASH_AUTH + e.command })
42  }).catch((_host, e, next) => next.called ? next(e) : { deny: 'Account authentication could not be prepared for Bash. Retry the account switch.' })
43}
44
hooks/oauth.ts 120 lines
1// Adapted from simplecore-inc/claude-mods mods/accounts/hooks/anthropic.ts at 722763c
2// (MIT, Copyright (c) 2026 SimpleCORE Inc., see LICENSES/simplecore-claude-mods.MIT.txt).
3// Modified: kept the endpoint constants, refresh, usage and profile parsing; pane-specific
4// readings, spend and healing removed; LimitView declared here.
5import type { HttpInit } from 'claude-code'
6
7import type { Credential } from './keychain'
8
9export const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
10export const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
11export const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
12const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
13const OAUTH_BETA = 'oauth-2025-04-20'
14/** Refresh this long before the access token expires. */
15export const EXPIRY_MARGIN_MS = 5 * 60 * 1000
16
17export class AnthropicError extends Error {
18  constructor(
19    message: string,
20    readonly status?: number,
21  ) {
22    super(message)
23  }
24}
25
26/** One usage window: `5h`, `wk`, or a model's own weekly limit. */
27export type LimitView = { label: string; percent: number; resetsAt?: string }
28
29type RawLimit = {
30  kind?: string
31  percent?: number
32  resets_at?: string | null
33  scope?: { model?: { display_name?: string | null } | null } | null
34}
35
36type RawWindow = { utilization?: number | null; resets_at?: string | null } | null
37
38type RawUsage = { limits?: RawLimit[]; five_hour?: RawWindow; seven_day?: RawWindow }
39
40function labelOf(limit: RawLimit): string {
41  if (limit.kind === 'session') return '5h'
42  if (limit.kind === 'weekly_all') return 'wk'
43
44  return limit.scope?.model?.display_name ?? limit.kind ?? '?'
45}
46
47/** The usage endpoint's body as windows. */
48export function parseUsage(body: unknown): LimitView[] {
49  const raw = (body ?? {}) as RawUsage
50  if (Array.isArray(raw.limits) && raw.limits.length > 0) {
51    return raw.limits
52      .filter(limit => typeof limit.percent === 'number')
53      .map(limit => ({ label: labelOf(limit), percent: limit.percent as number, resetsAt: limit.resets_at ?? undefined }))
54  }
55  const windows: [string, RawWindow | undefined][] = [
56    ['5h', raw.five_hour],
57    ['wk', raw.seven_day],
58  ]
59
60  return windows.flatMap(([label, window]) =>
61    typeof window?.utilization === 'number' ? [{ label, percent: window.utilization, resetsAt: window.resets_at ?? undefined }] : [],
62  )
63}
64
65/** A usage or profile request: with a bearer token, or with the session's own credential handle. */
66export function oauthInit(auth: { token: string } | { handle: string }): HttpInit {
67  if ('handle' in auth) return { headers: { 'anthropic-beta': OAUTH_BETA }, auth: auth.handle }
68
69  return { headers: { 'anthropic-beta': OAUTH_BETA, Authorization: `Bearer ${auth.token}` } }
70}
71
72/** Whether a token expires within `marginMs`: refreshed before it is used. */
73export function needsRefresh(credential: Credential, now: number, marginMs = EXPIRY_MARGIN_MS): boolean {
74  return credential.claudeAiOauth.expiresAt - marginMs <= now
75}
76
77export function refreshInit(credential: Credential): HttpInit {
78  const oauth = credential.claudeAiOauth
79
80  return {
81    method: 'POST',
82    headers: { 'Content-Type': 'application/json' },
83    body: JSON.stringify({
84      grant_type: 'refresh_token',
85      refresh_token: oauth.refreshToken,
86      client_id: CLIENT_ID,
87      scope: (oauth.scopes ?? []).join(' '),
88    }),
89  }
90}
91
92/** The credential after a refresh; the server rotates the refresh token, so it must be saved. */
93export function applyRefresh(credential: Credential, responseText: string, now: number): Credential {
94  const body = JSON.parse(responseText) as { access_token?: string; refresh_token?: string; expires_in?: number }
95  if (typeof body.access_token !== 'string' || typeof body.expires_in !== 'number') {
96    throw new AnthropicError('the token refresh returned no access token')
97  }
98  const oauth = credential.claudeAiOauth
99
100  return {
101    ...credential,
102    claudeAiOauth: {
103      ...oauth,
104      accessToken: body.access_token,
105      refreshToken: body.refresh_token ?? oauth.refreshToken,
106      expiresAt: now + body.expires_in * 1000,
107    },
108  }
109}
110
111/** Whose a token is, as the profile endpoint answers. */
112export type TokenOwner = { email: string; organizationName?: string }
113
114export function parseProfile(text: string): TokenOwner {
115  const body = JSON.parse(text) as { account?: { email?: string }; organization?: { name?: string } }
116  if (typeof body.account?.email !== 'string') throw new AnthropicError('the profile endpoint named no account')
117
118  return { email: body.account.email, organizationName: body.organization?.name }
119}
120
hooks/keychain.ts 112 lines
1// Adapted from simplecore-inc/claude-mods mods/accounts/hooks/keychain.ts at 722763c
2// (MIT, Copyright (c) 2026 SimpleCORE Inc., see LICENSES/simplecore-claude-mods.MIT.txt).
3// Modified: macOS keychain only; Orca and file-vault helpers removed; read and write
4// helpers over Io added; `security` called by absolute path.
5import type { Io } from './io'
6
7/** The keychain item Claude Code reads its login from, with its default config directory. */
8export const LIVE_SERVICE = 'Claude Code-credentials'
9/** `security`'s exit code for an item that is not there. */
10export const ITEM_NOT_FOUND = 44
11const SECURITY = '/usr/bin/security'
12
13/** The environment variables that decide where Claude Code keeps its login. */
14export type StorageVariables = { configDir?: string; secureStorageDir?: string }
15
16/**
17 * The keychain service Claude Code keeps a login under: `Claude Code-credentials`,
18 * then `-` and the first eight hex digits of the SHA-256 of a folder when
19 * `CLAUDE_SECURESTORAGE_CONFIG_DIR` names one, or else when `CLAUDE_CONFIG_DIR`
20 * is set (that folder, as given, NFC). An empty `CLAUDE_SECURESTORAGE_CONFIG_DIR`
21 * keeps the plain name.
22 */
23export async function liveServiceName(variables: StorageVariables, defaultConfigDir: string): Promise<string> {
24  const { secureStorageDir, configDir } = variables
25  const isPlain = secureStorageDir !== undefined ? secureStorageDir === '' : !configDir
26  if (isPlain) return LIVE_SERVICE
27  const folder = (secureStorageDir ?? configDir ?? defaultConfigDir).normalize('NFC')
28  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(folder))
29  const hex = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')
30
31  return `${LIVE_SERVICE}-${hex.slice(0, 8)}`
32}
33
34/** The keychain account Claude Code reads its login with: the user's name, or `claude-code-user` when that holds other characters. */
35export function keychainAccountName(user: string | undefined): string {
36  return user && /^[a-zA-Z0-9._-]+$/.test(user) ? user : 'claude-code-user'
37}
38
39/** The OAuth credential document Claude Code stores, kept whole. */
40export type Credential = {
41  claudeAiOauth: {
42    accessToken: string
43    refreshToken: string
44    expiresAt: number
45    scopes?: string[]
46    subscriptionType?: string
47    rateLimitTier?: string
48    [field: string]: unknown
49  }
50  [field: string]: unknown
51}
52
53export class KeychainError extends Error {}
54
55function toHex(text: string): string {
56  return Array.from(new TextEncoder().encode(text), byte => byte.toString(16).padStart(2, '0')).join('')
57}
58
59/** An item's text as `security -w` prints it: plain when printable, else hex of the bytes. */
60export function itemText(printed: string): string {
61  const text = printed.replace(/\n$/, '')
62  if (!/^[0-9a-f]+$/.test(text) || text.length % 2 !== 0) return text
63  const bytes = new Uint8Array(text.length / 2)
64  for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(text.slice(index * 2, index * 2 + 2), 16)
65  const decoded = new TextDecoder().decode(bytes)
66
67  return decoded.trimStart().startsWith('{') ? decoded : text
68}
69
70export function parseCredential(text: string): Credential {
71  const parsed: unknown = JSON.parse(text.trim())
72  const oauth = (parsed as Credential | null)?.claudeAiOauth
73  if (typeof oauth?.accessToken !== 'string' || typeof oauth.refreshToken !== 'string') {
74    throw new KeychainError('the keychain item holds no Claude OAuth login')
75  }
76
77  return parsed as Credential
78}
79
80/**
81 * The `security -i` line that creates or replaces an item. The secret travels
82 * on stdin as hex, never in argv, the way Claude Code writes its own item.
83 */
84export function addLine(service: string, account: string, text: string): string {
85  // `security -i` reads the line as a command line of its own: a quote, a backslash or a line break would end it.
86  for (const value of [service, account]) {
87    if (/["\\\n\r]/.test(value)) throw new KeychainError(`refused a keychain name with a quote, backslash or line break: ${JSON.stringify(value)}`)
88  }
89
90  return `add-generic-password -U -a "${account}" -s "${service}" -X "${toHex(text)}"\n`
91}
92
93/** An item's secret, or null when there is no such item. */
94export async function readItem(io: Io, service: string, account: string): Promise<string | null> {
95  const ran = await io.run([SECURITY, 'find-generic-password', '-s', service, '-a', account, '-w'], { timeoutMs: 15_000 })
96  if (ran.exitCode === ITEM_NOT_FOUND) return null
97  if (ran.exitCode !== 0) throw new KeychainError(`cannot read the keychain item "${service}" (security exit ${ran.exitCode})`)
98
99  return itemText(ran.stdout)
100}
101
102/** Creates or replaces an item; the secret goes over stdin. */
103export async function writeItem(io: Io, service: string, account: string, text: string): Promise<void> {
104  const ran = await io.run([SECURITY, '-i'], { stdin: addLine(service, account, text), timeoutMs: 15_000 })
105  if (ran.exitCode !== 0) throw new KeychainError(`cannot write the keychain item "${service}" (security exit ${ran.exitCode})`)
106}
107
108/** Whether two logins are one grant: the same refresh token, whatever access token each holds now. */
109export function isSameGrant(a: Credential | null, b: Credential | null): boolean {
110  return a !== null && b !== null && a.claudeAiOauth.refreshToken === b.claudeAiOauth.refreshToken
111}
112