Multi-Account CLI Profiles
Pattern: shell functions (work / personal) activate all tools at once. Store projects per-repo, not in profiles.
Two tool classes, two mechanisms:
- Cloud CLIs (gcloud, gws, Firebase, Netlify) — one
work/personalswitcher flips them all. Steps 1–6 below. - AI coding CLIs (Claude Code, Codex, Gemini) — two mechanisms: isolate per account with a config-dir env var (section right below), or route per request through a local proxy (section after it).
AI Coding CLIs (Claude Code, Codex, Gemini)
Each CLI stores login + config + MCP servers in a home dir and exposes an env var to relocate it. Give each account its own dir → separate login, keys, and MCP; symlink the parts you want shared (skills, instructions). Bare command stays = primary (work); add explicit -work / -personal.
| CLI | Config-dir env var | Default dir | Notes |
|---|---|---|---|
| Claude Code | CLAUDE_CONFIG_DIR |
~/.claude |
Relocates .claude.json (MCP) too. Credentials are OS-keychain entries namespaced per config dir, so two logins coexist — no eviction. |
| Codex | CODEX_HOME |
~/.codex |
Relocates auth.json + config.toml + MCP. Dir must exist first (mkdir -p). |
| Gemini | GEMINI_CLI_HOME |
~/.gemini (config lands in $DIR/.gemini) |
Auth is driven by settings.json (security.auth.selectedType), not by env — setting an API-key env alone does NOT override a cached login. Isolate the whole dir. |
# Claude Code
claude-work() { CLAUDE_CONFIG_DIR="$HOME/.claude" claude "$@"; }
claude-personal() { CLAUDE_CONFIG_DIR="$HOME/.claude-personal" claude "$@"; }
# Codex (run once: mkdir -p ~/.codex-personal)
codex-work() { CODEX_HOME="$HOME/.codex" command codex "$@"; }
codex-personal() { CODEX_HOME="$HOME/.codex-personal" command codex "$@"; }
# Gemini
gemini-personal() { GEMINI_CLI_HOME="$HOME/.gemini-personal" command gemini "$@"; }
First run of any -personal command triggers that CLI's own login flow — sign in with the personal account (subscription login, or API key). The work dir is never touched.
Share skills + instructions, keep accounts/MCP separate. Symlink from personal → primary so learnings and skills live in one place:
ln -s ~/.claude/skills ~/.claude-personal/skills
ln -s ~/.codex/AGENTS.md ~/.codex-personal/AGENTS.md
mkdir -p ~/.gemini-personal/.gemini
ln -s ~/.gemini/GEMINI.md ~/.gemini-personal/.gemini/GEMINI.md
Stay separate: credentials, MCP servers, session history. Some CLIs read agent skills from a shared global dir (e.g. ~/.agents/skills) — check before symlinking; those are already shared.
Billing-only variant (keep ONE config dir, switch only billing subscription→API): don't relocate the dir — inject an API key per command, from the OS keychain, never plaintext:
work-ai() { ANTHROPIC_API_KEY="$(security find-generic-password -s my-work-key -w)" claude "$@"; }
Never export the key globally, or every session bills to the API.
Gotchas (AI CLIs):
| Problem | Fix |
|---|---|
| Personal login evicts work login | One OAuth slot per config dir — give each account its own dir |
CODEX_HOME points to ... does not exist |
mkdir -p the dir before first use |
| Gemini ignores API-key env, reuses old login | Auth is settings-driven; isolate with GEMINI_CLI_HOME and log in fresh there |
| API key leaks into personal/other sessions | Don't export it; inject per command only |
| A launcher/wrapper shadows the real CLI on PATH | Env vars pass through exec — set them before the command; the shim inherits them |
AI Coding CLIs via Local Proxy (CLIProxyAPI)
Use a local proxy when one endpoint must serve every request. Install CLIProxyAPI (github.com/router-for-me/CLIProxyAPI, MIT) from homebrew-core as cliproxyapi. Run it as a brew LaunchAgent on 127.0.0.1:8317.
The proxy holds every account credential at once. It picks one credential per request. Point each client at one endpoint. Stop tracking which subscription has headroom.
It serves Anthropic-compatible (/v1/messages), OpenAI-compatible (/v1/chat/completions, /v1/responses), and Gemini-compatible surfaces on the same port.
Point a client at it with ANTHROPIC_BASE_URL=http://127.0.0.1:8317 and ANTHROPIC_AUTH_TOKEN=<a key you configure>:
ANTHROPIC_BASE_URL=http://127.0.0.1:8317 ANTHROPIC_AUTH_TOKEN=<key-you-configure> claude
The proxy reads its key from its own config file, not the keychain. Keep that file at mode 600.
Routing offers exactly three strategies: round-robin, weighted-round-robin, fill-first. Failover is reactive. The proxy moves to another credential after an error such as 429. It does not read remaining quota. It does not switch before a limit.
| It holds | It cannot hold |
|---|---|
| Claude Code, Codex, Gemini/Antigravity, Grok/xAI, Kimi, Qwen | Cursor, Devin, Meta Muse |
| Any OpenAI-compatible API by key (e.g. OpenRouter) | — |
Cursor, Devin, and Meta Muse give no OAuth surface it can drive.
Trade-off: config-dir switching vs proxy.
| Config-dir switching | Local proxy | |
|---|---|---|
| Reach | Works anywhere, remote machines and cloud runners included; it sets only an env var | Helps only code on that machine; it listens on 127.0.0.1 |
| Switch point | Binds one account at process start; it never switches mid-session | Switches per request, after an error |
Do not expose the proxy on a network. Remote and cloud work still needs the per-account token mechanism.
Credential trap (claude setup-token). claude setup-token mints a long-lived token (about one year). The token runs inference. It cannot read account usage. Anthropic rejects it on /api/oauth/profile with oauth_scope_insufficient and requires any_of(user:profile, user:office). A normal OAuth login carries user:profile. A token that runs inference correctly can still fail anything that shows quota. Two token types, two jobs.
Step 1 — Ask the User
1. Which tools do you use? (gcloud/gws, Firebase, Netlify, others?)
2. What are your account emails? (e.g. name@company.com, name@gmail.com)
3. Do you want project defaults per profile, or set project per-repo?
→ Recommend: per-repo. Projects change too often to bake into a profile.
4. Which account is already authenticated?
Check current state before doing anything:
gcloud config configurations list
firebase login:list
netlify status
Critical: gws Needs Isolated Config Directories
gws stores OAuth credentials in a single encrypted file (~/.config/gws/credentials.enc) with a single encryption key. Switching accounts by copying files causes key/credential mismatches. The only reliable approach is isolated config directories + symlink switching:
mkdir -p ~/.config/gws-work ~/.config/gws-personal
# Each dir gets its own: client_secret.json, credentials.enc, .encryption_key
# In work()/personal() functions — swap the symlink, not individual files:
rm -f ~/.config/gws && ln -s ~/.config/gws-work ~/.config/gws # work
rm -f ~/.config/gws && ln -s ~/.config/gws-personal ~/.config/gws # personal
Always use file-based keyring (not OS keyring) so each dir's encryption key is portable:
export GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file
gws auth login # creates .encryption_key inside the active config dir
Personal GCP project requirements for gws personal config:
- Create GCP project on personal account:
gcloud projects create my-personal - Enable APIs:
gcloud services enable drive.googleapis.com sheets.googleapis.com gmail.googleapis.com docs.googleapis.com - Enable billing (required for quota project):
gcloud billing projects link my-personal --billing-account=<id> - Create OAuth Desktop client via console (no CLI API for this): console.cloud.google.com/apis/credentials
- Add your email as OAuth test user: console.cloud.google.com/apis/credentials/consent
- Download
client_secret.json→~/.config/gws-personal/client_secret.json
Step 2 — gcloud Configurations
# Create named configs (one per account)
gcloud config configurations create work
gcloud config configurations activate work
gcloud config set account you@company.com
gcloud config configurations create personal
gcloud config configurations activate personal
# (leave account empty — set after login)
Authenticate each (requires browser — tell user to run with ! <cmd>):
# Work account
gcloud config configurations activate work
gcloud auth login --account=you@company.com
gcloud auth application-default login # needed for SDK/API calls
# Personal account
gcloud config configurations activate personal
gcloud auth login # sign in as personal email in browser
gcloud auth application-default login
gws inherits whichever gcloud config is active — no extra setup.
Step 3 — Firebase Multi-Account
Firebase CLI supports multiple accounts natively:
firebase login:add # add second account (browser opens)
firebase login:list # verify both accounts present
firebase login:use you@company.com # switch active account
Step 4 — Netlify (env var approach)
Netlify CLI has no native profile support. Use personal access tokens + env var:
- Go to app.netlify.com → User Settings → Personal access tokens
- Create a token for each account
- Store in
~/.zshrc:
NETLIFY_TOKEN_WORK="nfp_xxxx"
NETLIFY_TOKEN_PERSONAL="nfp_yyyy"
Switch by setting NETLIFY_AUTH_TOKEN:
export NETLIFY_AUTH_TOKEN="$NETLIFY_TOKEN_WORK"
netlify status # confirms which account is active
Step 5 — Shell Switcher Functions
Add to ~/.zshrc:
NETLIFY_TOKEN_WORK="" # fill in after getting tokens
NETLIFY_TOKEN_PERSONAL=""
function work() {
gcloud config configurations activate work --quiet
cp ~/.config/gcloud/adc_work.json ~/.config/gcloud/application_default_credentials.json
export GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/adc_work.json
rm -f ~/.config/gws && ln -s ~/.config/gws-work ~/.config/gws
export GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file
export NETLIFY_AUTH_TOKEN="$NETLIFY_TOKEN_WORK"
firebase login:use you@company.com 2>/dev/null
echo "✓ Switched to work (you@company.com)"
echo " gcloud: $(gcloud config get account 2>/dev/null)"
}
function personal() {
gcloud config configurations activate personal --quiet
cp ~/.config/gcloud/adc_personal.json ~/.config/gcloud/application_default_credentials.json
export GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/adc_personal.json
rm -f ~/.config/gws && ln -s ~/.config/gws-personal ~/.config/gws
export GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file
export NETLIFY_AUTH_TOKEN="$NETLIFY_TOKEN_PERSONAL"
firebase login:use you@gmail.com 2>/dev/null
echo "✓ Switched to personal (you@gmail.com)"
echo " gcloud: $(gcloud config get account 2>/dev/null)"
}
function whoami-dev() {
echo "gcloud: $(gcloud config get account 2>/dev/null) [$(gcloud config configurations list --filter=IS_ACTIVE=true --format='value(name)' 2>/dev/null)]"
echo "firebase: $(firebase login:list 2>/dev/null | head -2)"
echo "netlify: $(netlify api getCurrentUser 2>/dev/null | python3 -c 'import sys,json; u=json.load(sys.stdin); print(u.get("email","?"))' 2>/dev/null)"
}
Then reload: source ~/.zshrc
Step 6 — Test Each Profile
# Test work
work
whoami-dev # should show work email everywhere
gcloud projects list # lists work projects
firebase projects:list # lists work Firebase projects
# Test personal
personal
whoami-dev # should show personal email everywhere
gcloud projects list
firebase projects:list
Setting Project Per-Repo
Don't hardcode projects in profiles — they change. Set per-repo instead:
# In any repo directory:
gcloud config set project my-project-id
# Or use direnv for automatic switching:
echo 'export GCLOUD_PROJECT=my-project-id' > .envrc
direnv allow
Gotchas
| Problem | Fix |
|---|---|
gcloud auth fails non-interactively |
Run ! gcloud auth login (needs browser) |
firebase login:use fails |
Run firebase login:add first, then login:use |
NETLIFY_AUTH_TOKEN not persisting |
Add export to the var, or re-run work/personal after new shell |
| gws still uses wrong account | gws does NOT inherit gcloud — it needs isolated dirs + symlink swap |
| gws "Decryption failed" on switch | Do NOT copy credentials files between accounts — use isolated dirs (~/.config/gws-work/, ~/.config/gws-personal/) and symlink swap |
| gws keyring overwrites on re-login | Use GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file — each dir gets its own .encryption_key |
| gws personal project 403 forbidden | Enable billing: gcloud billing projects link <project> --billing-account=<id> |
| gws OAuth consent blocked (testing mode) | Add your email as test user at console.cloud.google.com/apis/credentials/consent |
| ADC (Application Default Credentials) wrong | Save per-account: cp ~/.config/gcloud/application_default_credentials.json ~/.config/gcloud/adc_work.json; swap in switcher function |
Multiple default configs cluttering list |
Rename: gcloud config configurations rename default work |
Cost and Time
Setup time: 15 min (mostly waiting for browser auth flows)1 year)
Cost: Free — all CLI tools are free for auth/project management
Maintenance: Update NETLIFY_TOKEN_* vars when tokens expire (