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

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.
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.
/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.
/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.
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.
| Kind | Example in Claude Code | Terminal setup |
|---|---|---|
login | /rice-account login | Built in. Restore the credentials this session started with. |
claudeai | /rice-account add personal-live | A separate claude.ai browser sign-in. The mod renews its expiring login. |
claudeai-token | /rice-account add personal-token claudeai-token | Generate 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.
| Command | Result |
|---|---|
/rice-account | Open 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 list | Show the text list of saved accounts and their cached identity metadata. |
/rice-account <name> | Switch the current session to a saved account. |
/rice-account login | Restore 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 usage | Show 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.
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.secret-tool. The plugin does not create a separate plaintext token store.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.
| Symptom | What to do |
|---|---|
/rice-account is unknown | Confirm 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 first | The 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 Linux | The 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 in | Run /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 renew | Run /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 update | Run /rice-account setup <name> again to get the current installed path. |
node is missing or too old | Install 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 picker | This 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. |
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.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.
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.
MIT, see LICENSE. The adapted simplecore-inc/claude-mods code keeps its own MIT notice in LICENSES/.
hooks/register.ts 500 lines1import { 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}
500hooks/accounts.ts 88 lines1import 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}
88hooks/config.ts 230 lines1/**
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}
230hooks/env.ts 96 lines1import 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}
96hooks/io.ts 27 lines1// 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}
27hooks/credentials.ts 164 lines1import 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}
164hooks/setup.ts 13 lines1/** 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}
13hooks/identity.ts 93 lines1import 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}
93hooks/picker.tsx 83 lines1import 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}
83hooks/descendants.ts 44 lines1import 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}
44hooks/oauth.ts 120 lines1// 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}
120hooks/keychain.ts 112 lines1// 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