Skip to content

Account Management

This page is the sidekick-shared API guide. For the end-user walkthrough (VS Code Accounts view, status bar, sidekick accounts, credential health, parallel sessions, platform notes) see Account Switcher.

Sidekick account management lets Node hosts acquire, list, and switch Claude Max and Codex CLI accounts through sidekick-shared. The API is designed for desktop apps, VS Code extension hosts, and CLIs that need isolated login flows without reimplementing Claude/Codex credential detection.

Requires sidekick-shared@^0.25.0. Every login and switch entry point comes in a sync and an async form (switchAccount/switchAccountAsync, getAccountLoginStatus/getAccountLoginStatusAsync, finalizeAccountLogin/finalizeAccountLoginAsync, prepareCodexAccount/prepareCodexAccountAsync, finalizeCodexAccount/finalizeCodexAccountAsync, switchToCodexAccount/switchToCodexAccountAsync). The sync forms probe the codex CLI with blocking child processes (up to a few seconds) and are meant for one-shot CLI callers; hosts with a UI event loop — extension hosts, desktop apps — should use the async forms, which run the same probes off the loop. On sidekick-shared 0.21.0–0.24.4 only the sync forms exist.

Account State

Use the provider-neutral helpers when building account switchers:

import { getActiveAccountStatus, listAllAccounts, switchAccountAsync } from 'sidekick-shared';

const status = getActiveAccountStatus();
const all = listAllAccounts();

const result = await switchAccountAsync('claude-code', 'account-uuid');
if (!result.success) throw new Error(result.error);
if (result.warning) showWarning(result.warning);

listAllAccounts() returns Claude entries, Codex profiles, and active account ids keyed by provider:

type AccountProviderId = 'claude-code' | 'codex';

interface ListAllAccountsResult {
  claude: AccountEntry[];
  codex: SavedAccountProfile[];
  activeByProvider: Record<AccountProviderId, string | null>;
}

Live vs. saved active account

The activeByProvider ids above come from the saved registry pointer, which only Sidekick's own switch flow updates. For display surfaces that must reflect the account a user is actually logged into — even after a native claude /login or codex login outside Sidekick — use the live-first resolvers instead:

import { resolveActiveClaudeAccount, resolveActiveCodexAccount } from 'sidekick-shared';
import type { ResolvedActiveAccount } from 'sidekick-shared';

const claude: ResolvedActiveAccount = resolveActiveClaudeAccount();
// claude.source === 'live'     → from live provider auth (label set when it matches a saved profile)
// claude.source === 'registry' → no usable live identity; fell back to the saved active pointer
// claude.source === 'none'     → neither a live identity nor a saved active account

const codex = resolveActiveCodexAccount();

Each resolver prefers the live provider auth (~/.claude/.claude.json oauthAccount; the ~/.codex/auth.json id_token JWT) over the saved pointer, falls back to the registry, and — on an unambiguous match to a saved profile — self-heals the activeByProvider pointer so registry-keyed data (quota history, auto-switch) tracks the real account. Self-heal is best-effort and never creates or deletes profiles; an unknown live account is shown as-is with no label.

React to account changes

Instead of polling getActiveAccountStatus(), subscribe to onAccountsChanged() (0.25.0). It combines process-local mutation signals, filesystem watches on the account stores, and a low-frequency catch-up poll, and emits only when the status actually changed:

import { onAccountsChanged } from 'sidekick-shared';

const subscription = onAccountsChanged(
  ({ reason, status }) => {
    // reason: 'local' | 'filesystem' | 'poll'
    refreshAccountSwitcher(status.claude, status.codex);
  },
  { emitCurrent: true },
);
// later: subscription.dispose();

The library's own quota services (QuotaPoller, MultiProviderQuotaService, CodexQuotaWatcher) subscribe to the same signal: they stay dormant while no matching account exists and wake when one appears.

Sync and Health (0.26.6)

The live homes (CLAUDE_CONFIG_DIR or ~/.claude; CODEX_HOME or ~/.codex) are the source of truth for which account is logged in; saved profiles are backups that syncLiveAccountState() keeps fresh. One sync registers logins the registry has never seen (label = email, metadata.origin: 'live-sync'), folds the live credential into a private backup when it is newer (Claude: expiresAt; Codex: last_refresh, then access-token expiry), merges duplicate Codex profiles for one workspace (oldest keeps id and label; archived homes have their account credentials removed after verified backup), and re-points the active pointer silently. ensureDefaultAccounts() runs it at startup after removing abandoned isolated logins; onAccountsChanged() runs it on every filesystem or poll event before reporting.

import { syncLiveAccountState, listAccountsWithHealth, getAccountHealth } from 'sidekick-shared';

const report = await syncLiveAccountState({ reason: 'manual' });
report.claude.registered; // { id, email } when a live login was learned

for (const view of listAccountsWithHealth()) {
  view.health.state; // 'fresh' | 'expiring' | 'expired' | 'unknown' | 'missing'
  view.health.refreshExpiresAt; // ms since epoch; `refreshExpiryEstimated` when derived from the snapshot age
  view.source; // 'learned' (auto-registered) | 'registered'
}

Health comes from a secret-free health.json sidecar next to each profile, written by every credential store. getAccountHealth(provider, id, { probe: 'cache' }) never spawns; probe: 'store' re-reads the stored credential (a bounded Keychain call on macOS) and rewrites the sidecar. listAccountsWithHealth defaults to probe: 'auto', which probes only profiles that have no sidecar yet. Use persist: false for inspection without sidecar writes. Claude refresh tokens expire about three weeks after issue; snapshots that predate the CLI recording that expiry are estimated and marked as such.

One Copy per Claude Login (0.27.6)

Claude Code rotates the refresh token on every refresh, so every refresh-token chain may have exactly one copy a claude process can use. The library enforces that:

Account Profile-home store (suffixed Keychain item / <home>/.credentials.json) Private backup accounts/credentials/<uuid>.credentials.json
live empty (identity and health.json only) mirrors the live credential; Claude Code never reads it
inactive the single usable copy, claudeAiOauth only (MCP tokens follow live) last known credential
  • storeClaudeProfileCredentials() is the chokepoint: for the live account it writes the backup and sidecar only. syncLiveAccountState() removes a copy of the live login left in its profile home (report.claude.demoted), and when that copy is strictly newer than live it installs it first (report.claude.repaired).
  • Every write into the live store passes decideLiveClaudeWrite(): a switch needs the outgoing login captured first; a re-login (applyClaudeProfileToLiveHome) only replaces live with a strictly fresher credential of the same account; a rollback never clobbers a store that changed. writeActiveCredentials() now requires a config directory — the live store is only written through a guarded switch.
  • A switch holds the swap lock and Claude Code's refresh locks (<home>/.oauth_refresh.lock), writes a journal (accounts/claude/switch-journal.json), and the next sync finishes or rolls back an interrupted one from the live refresh-token fingerprint. Sidecars record refreshFp (sha256 prefix, never the token).
  • Keychain writes use -X <hex> through security -i up to 4032 bytes and argv beyond (Claude Code's own behaviour), then read back; ClaudeCredentialWriteError reports a write that did not land intact. readClaudeCredentialStore() tells ok, absent, corrupt, and error apart.

Ledger and doctor

Every directory Sidekick writes a Claude credential into, or points a login at, is recorded in accounts/claude/credential-homes.json (readClaudeCredentialLedger()), and its Keychain item carries a sidekick-agent-hub:<directory> comment (SIDEKICK_KEYCHAIN_COMMENT_PREFIX). inspectClaudeCredentialStores() reports orphan, live-duplicate, corrupt, and stale-entry findings for stores whose ownership is proven that way, never by name pattern; repairClaudeCredentialStores({ dryRun: false }) fixes them (dry run by default). sidekick accounts doctor [--fix] is the CLI front end.

One Copy per Codex Login (0.27.6)

The live account has no account credential in its profile home. Private backups are byte-preserved, verified, mode 0600, and stored under accounts/codex/backups/<id>.auth.json (recognized legacy account credentials use <id>.credentials.json). No CLI runs in this backup directory. readStoredCodexAccount(id) reads the freshest saved credential without materializing it. getAccountLaunchEnv uses the live home for the live account, and can restore an inactive account's backup for an explicit launch.

Every live write uses the shared guard policy and one Codex installer. Switches capture and verify the outgoing login, verify the target backup, journal the transfer, recheck the live snapshot, remove the target's usable copy, install and verify live, and commit the registry. Only then does the outgoing account get its inactive copy. report.codex.demoted, repaired, and journal describe migration and recovery, just as for Claude. Codex freshness uses last_refresh, then access-token expiry; filesystem mtime never authorizes a live write. Unknown or tied evidence cannot authorize replacing the same login. Rollback requires an exact snapshot match.

Codex's refresh semaphore is process-local. Live writes are blocked while Codex CLI/app/IDE consumers are detected or process discovery fails. force bypasses only credential health. No process probe can exclude a new external writer starting after the probe; callers must close Codex consumers before changing live auth.

getCodexCredentialStoreMode() rejects keyring, auto (even with an old auth file), ephemeral, and unreadable configuration for account operations. An absent setting follows Codex's file default. Profile homes force file mode. Modern .credentials.json may hold MCP credentials; Sidekick preserves those and handles only recognized legacy account payloads. A legacy install that would overwrite unrelated data is refused.

Explicit beginAccountLogin('codex', ...) never imports live credentials. Temporary login homes live under accounts/codex/logins/<loginId>/; the returned configDir/codexHome is the path the host should use. Stop the login process before calling finalize or discard. The shared spawn helper and VS Code runner do this themselves. Finalization transfers the login into private/canonical storage and removes its temporary home. If activation is blocked (for example while Codex apps run), the result has success: false, profileId set, and an error starting "The login was saved, but could not be activated"; the login is saved and the live account is unchanged. Hosts that must not fail there can finalize with activate: false and switch later. prepareCodexAccount(label) retains current-login import behavior; pass { importCurrent: false } for a fresh isolated sign-in. Authenticated abandoned homes with unknown identities are reported for manual review.

Doctor

inspectCodexCredentialStores() reports live duplicates, old stash credentials, abandoned homes, unreadable stores, and interrupted switches. repairCodexCredentialStores() is a dry run by default; pass { dryRun: false } to repair proven Sidekick-owned stores after stopping Codex. Unknown or unreadable credentials are preserved. sidekick accounts doctor [--provider codex] is read-only; --fix uses the existing confirmation/--yes flow.

Switch Result and Undo (0.26.6)

switchAccount[Async] returns a SwitchAccountResult (a superset of AccountManagerResult, so existing callers compile):

const result = await switchAccountAsync('claude-code', id, { force: false, verifyWithCli: false });
result.verified; // the live store read back the written credential
result.verification; // 'store' | 'cli' | 'none' | 'failed'; the result explains recovery
result.needsLogin; // the target's stored credential is expired or missing; nothing was written
result.runningConsumers; // [{ kind: 'claude-cli' | 'claude-desktop' | 'codex-cli' | 'codex-app' | 'vscode-extension-host', pids, switched, reachability }]
result.warnings; // per-consumer reachability sentences + health warnings (also joined into `warning`)
result.hints; // "New claude sessions use …"
result.undoToken; // pass to undoLastSwitch(provider, token) to revert; a newer switch invalidates it
result.alreadyActive; // the target was already live; nothing was written

The switch is two-phase: the outgoing live credential is captured first (registering the account if unknown; the switch refuses when it cannot be saved, even with force), then the target is installed, verified by re-reading the store, and only then does the active pointer move. force bypasses Claude health and keep-alive checks. For Codex it bypasses only health; consumer and lease checks remain mandatory. detectRunningAccountConsumers() returns the same consumer list without switching; describeAccountConsumer(kind) builds an entry for kinds the host detects itself (its own extension host).

Isolated Launch (0.26.6)

import { getAccountLaunchEnv } from 'sidekick-shared';

const launch = getAccountLaunchEnv('codex', id); // { env: { CODEX_HOME }, envUnset, command, home, health, warnings, error? }

The env points a child claude/codex at the profile home without touching the live login. envUnset lists variables the host must remove from the child (CLAUDE_SECURESTORAGE_CONFIG_DIR overrides Claude Code's keychain naming). The helper refuses expired/missing profiles. For the live account it returns the live home instead, because a session in its profile home would be a second copy of the live login: Codex gets CODEX_HOME set to the live home, and Claude drops CLAUDE_CONFIG_DIR (listed in envUnset) when the live home is the default one. An inactive Claude profile whose home has no credential is restored from its backup first, with a warning; an inactive Codex profile is restored silently, and the launch is refused when its profile copy is older than its backup or no safe credential exists. Pass restoreBackup: false to skip restoring (keep-alive does). Codex launches require file-mode credential storage.

Keep-Alive (0.26.6)

refreshInactiveAccounts() refreshes eligible inactive accounts through the official CLI. As of 0.27.6 the Codex refresh goes through codex app-server and both providers take a lease, as described below. Claude uses claude auth status. Codex uses a bounded stdio codex app-server session: initialize, initialized, then account/read with refreshToken: true. It starts no inference turn. codex login status only reads ChatGPT login state and cannot serve as keep-alive.

Both providers skip the live account, a matching live refresh fingerprint, live-sync provenance, missing profile credentials, and copies older than their private backup. Eligibility and a lease are acquired under the provider swap lock. Codex switches cannot bypass that lease with force. The refresh process is stopped before the lease is released. Codex success requires a persisted credential advance for the expected account; an RPC response or exit code alone is insufficient. Unsupported CLI versions receive an update hint.

Hosts continue to use the existing runner injection signature. keepAliveCommand('codex') now describes an app-server process; the default runner implements its stdio protocol. Custom runners must implement that protocol or provide an equivalent test double, rather than merely spawn the command and wait.

Windows Notes

Credentials are plain files under %USERPROFILE%\.claude and %USERPROFILE%\.codex; atomic writes retry transient EPERM/EBUSY sharing violations. Running consumers are detected with tasklist. The Claude Desktop and Codex desktop apps are matched by image name (Claude.exe, Codex.exe).

TTY-Less Login

beginAccountLogin creates an isolated profile and returns the command a host should run. It does not spawn a process and does not change the active account.

import {
  beginAccountLogin,
  getAccountLoginStatusAsync,
  finalizeAccountLoginAsync,
} from 'sidekick-shared';

const begin = beginAccountLogin('claude-code', 'Work'); // runs `claude auth login` (or `codex login`)
if (!begin.success) throw new Error(begin.error);
// begin.env: variables to set; begin.envUnset: variables the child must NOT inherit.
// Pass { existingAccountId } to re-authenticate a saved profile in place.

if (begin.alreadyComplete) {
  const res = await finalizeAccountLoginAsync('claude-code', begin.loginId, { activate: true });
  if (!res.success) throw new Error(res.error);
} else {
  // Spawn begin.command with begin.args in your terminal or PTY.
  // Merge begin.env into the child environment.

  while ((await getAccountLoginStatusAsync('claude-code', begin.loginId)).state === 'pending') {
    await sleep(2000);
  }

  const res = await finalizeAccountLoginAsync('claude-code', begin.loginId, { activate: true });
  if (!res.success) throw new Error(res.error);
  if (res.warning) showWarning(res.warning);
}

A host that gives up on a login (cancelled, timed out, closing) calls discardAccountLogin(provider, loginId), which removes the temporary home and its Keychain item and refuses ids of saved accounts. finalizeAccountLogin removes the temporary home on every exit once the sign-in is seen.

For hosts that can let Sidekick spawn the child process, use the convenience wrapper (it discards the pending login on every exit that does not end in a saved account):

import { spawnAccountLogin } from 'sidekick-shared';

const res = await spawnAccountLogin('codex', 'Work', {
  stdio: 'inherit',
  onStatus: (status) => updateLoginUi(status),
  timeoutMs: 180_000,
});

Runtime Schemas

sidekick-shared exports Zod schemas from sidekick-shared/schemas; the package root re-exports only the pre-0.26.6 ones (accountProviderIdSchema through listAllAccountsResultSchema):

import {
  beginAccountLoginResultSchema,
  accountLoginStatusSchema,
  accountManagerResultSchema,
  listAllAccountsResultSchema,
} from 'sidekick-shared/schemas';

Use these at IPC or sidecar boundaries so runtime validation and TypeScript types stay aligned:

const payload = listAllAccountsResultSchema.parse(await sidecar.invoke('listAccounts'));

As of 0.25.0, account and quota entry points guarantee results that validate against the schemas exported in the same release — re-parsing a value returned directly by the library is unnecessary; reserve .parse() for data that crossed a process or IPC boundary.

Available account-management schemas:

Schema Validates
accountProviderIdSchema 'claude-code' or 'codex'
beginAccountLoginResultSchema login begin success/failure payloads
accountLoginStatusSchema pending, authenticated, or failed status
accountManagerResultSchema switch/finalize result payloads
accountEntrySchema Claude account registry entries
savedAccountProfileSchema provider-neutral saved account profiles
listAllAccountsResultSchema provider-neutral account list payloads
accountHealthSchema, accountViewSchema per-account health and list views (0.26.6)
switchAccountResultSchema verified switch results with consumers and undo token
runningAccountConsumerSchema running apps that hold a login
syncReportSchema syncLiveAccountState reports
accountLaunchEnvSchema isolated-launch environments
lastSwitchRecordSchema undo records

Operational Notes

  • Browser OAuth is interactive; the host must present the spawned Claude or Codex login terminal.
  • macOS may show a keychain prompt the first time security reads Claude Code's item.
  • Codex OS-keyring logins are refused at add and switch time (see above); file credentials are required to finalize.
  • Running consumers keep the previous account until they restart; SwitchAccountResult.runningConsumers names them. The Claude Desktop app is never switched.
  • claude auth login is the default login command (0.26.6); override with opts.loginCommand or SIDEKICK_CLAUDE_LOGIN_ARGS for older CLIs. spawnAccountLogin treats timeoutMs as an inactivity budget and maxTimeoutMs (default 900 s) as the hard ceiling.
  • The shell-hook helpers (installShellHook, uninstallShellHook, isShellHookInstalled, setTerminalActiveProfile) were removed in 0.26.6; use getAccountLaunchEnv and writeAccountLauncher.
  • Deprecated in 0.27.6: writeLauncher (pins one profile home; writeAccountLauncher resolves the home at run time), applyActiveClaudeToLiveHome (now a guarded re-login that refuses to follow the pointer to another account), and resolveActiveClaudeHome (returns the live home for the live account).
  • A host that runs Sidekick against a throwaway SIDEKICK_CONFIG_DIR should also give it a throwaway HOME; since 0.27.6 no Keychain item is created for the live login either way.