Switch Between Multiple Claude Code Accounts (Without Losing Your History)
The Problem
If you use more than one Claude Code account, the usual advice is to keep a separate config directory per account. That works, but it splits your chat history — each account only sees its own sessions. What most people actually want is the opposite: one shared history, and a quick way to flip which account is logged in.
On macOS this is easy, because the pieces live in three different places:
- Your login token is stored in the macOS Keychain (service
Claude Code-credentials), not in the config folder. - Your chat history and settings live in
~/.claude. - Your account identity (email, org, user id) sits in
~/.claude.json.
So if we keep ~/.claude shared and swap only the token and identity, history stays intact for every account.
Two Gotchas That Break Naive Scripts
A first attempt usually works most of the time, then mysteriously "switches but doesn't switch." Two real reasons:
- The ~30s Keychain cache. A running Claude Code session caches the token for about 30 seconds. Right after a swap it still serves the old account — so it looks broken. You just need to wait ~30s or restart the session.
- The token-refresh race. Claude Code refreshes its own OAuth token under a lock at
~/.claude.lock. If your swap lands mid-refresh, the client overwrites your new token with the old account's refreshed one — the switch is silently lost. The fix: hold that same lock while you write.
The Script
Save this as ~/bin/claude-account and make it executable:
#!/usr/bin/env bash
# claude-account — manage multiple Claude Code accounts that SHARE one config
# dir (~/.claude), so chat history is identical across accounts. Only the login
# (OAuth token in the macOS Keychain) and the account identity in ~/.claude.json
# are swapped.
#
# Commands:
# claude-account add Log in a NEW account and save it as
# claude-account switch Make a saved account active (alias: use)
# claude-account relogin Re-login an existing saved account (token expired)
# claude-account save Snapshot the CURRENTLY logged-in account as
# claude-account logout Log out the active account (clears live token)
# claude-account remove Delete a saved account (alias: rm)
# claude-account rename
# claude-account list List saved accounts (alias: ls)
# claude-account current Show the active account
# claude-account help
#
# Typical first-time setup:
# claude-account save claude3 # you're already logged in as this one
# claude-account add claude2 # guided login for the second account
# claude-account switch claude3 # flip anytime
set -euo pipefail
# Claude Code stores its OAuth token in the macOS Keychain under the generic-
# password service "Claude Code-credentials", account = $USER (mirroring the
# client's own getUsername(): $USER, else the OS user). This is the single,
# stable service name the client reads — confirmed against the client source.
LIVE_SVC="Claude Code-credentials"
ACCT="${USER:-$(id -un)}"
STORE="${HOME}/.claude-accounts"
CLAUDE_JSON="${HOME}/.claude.json"
mkdir -p "$STORE"; chmod 700 "$STORE"
# --- Cooperate with Claude Code's own credential lock -------------------------
# A running Claude Code guards its OAuth-token refresh with a proper-lockfile
# DIRECTORY lock at ~/.claude.lock: it reads the token, refreshes over the
# network, and writes the result — all under that lock. If our swap lands inside
# that window, the client's refresh overwrites our just-written token with the
# OLD account's refreshed one, and the swap is silently lost. Holding the same
# lock while we write makes the client's double-checked re-read see our new
# (non-expired) token and abort its refresh. mkdir is the atomic mutex; a lock
# whose mtime is older than 10s is stale and may be taken over.
CLAUDE_LOCK="${HOME}/.claude.lock"
_lock_held=0
acquire_claude_lock() {
local start; start="$(date +%s)"
while :; do
if mkdir "$CLAUDE_LOCK" 2>/dev/null; then _lock_held=1; return 0; fi
# stale takeover: mtime older than 10s means the holder died
local mt now; mt="$(stat -f %m "$CLAUDE_LOCK" 2>/dev/null || echo 0)"; now="$(date +%s)"
if [ "$((now - mt))" -gt 10 ]; then rmdir "$CLAUDE_LOCK" 2>/dev/null || true; continue; fi
if [ "$((now - start))" -ge 9 ]; then
info "note: Claude Code appears to be refreshing credentials; proceeding without its lock."
return 1
fi
sleep 1
done
}
release_claude_lock() { [ "$_lock_held" = 1 ] && { rmdir "$CLAUDE_LOCK" 2>/dev/null || true; _lock_held=0; }; }
trap release_claude_lock EXIT INT TERM
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
info() { printf '%s\n' "$*"; }
need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; }
need security; need python3
bkp_svc() { printf 'claude-account:%s' "$1"; }
read_live_token() { security find-generic-password -s "$LIVE_SVC" -a "$ACCT" -w 2>/dev/null; }
write_live_token() {
acquire_claude_lock
security delete-generic-password -s "$LIVE_SVC" -a "$ACCT" >/dev/null 2>&1 || true
security add-generic-password -U -s "$LIVE_SVC" -a "$ACCT" -w "$1" >/dev/null
release_claude_lock
}
clear_live_token() {
acquire_claude_lock
security delete-generic-password -s "$LIVE_SVC" -a "$ACCT" >/dev/null 2>&1 || true
release_claude_lock
}
save_bkp_token() {
security delete-generic-password -s "$(bkp_svc "$1")" -a "$ACCT" >/dev/null 2>&1 || true
security add-generic-password -U -s "$(bkp_svc "$1")" -a "$ACCT" -w "$2" >/dev/null
}
read_bkp_token() { security find-generic-password -s "$(bkp_svc "$1")" -a "$ACCT" -w 2>/dev/null; }
delete_bkp_token() { security delete-generic-password -s "$(bkp_svc "$1")" -a "$ACCT" >/dev/null 2>&1 || true; }
dump_identity() {
python3 - "$CLAUDE_JSON" <<'PY'
import json,sys
try: d=json.load(open(sys.argv[1]))
except Exception: d={}
json.dump({k:d[k] for k in ("oauthAccount","userID") if k in d}, sys.stdout)
PY
}
load_identity() {
python3 - "$CLAUDE_JSON" "$1" <<'PY'
import json,sys
p=sys.argv[1]
try: d=json.load(open(p))
except Exception: d={}
d.update(json.load(open(sys.argv[2])))
json.dump(d, open(p,"w"), indent=2)
PY
}
clear_identity() {
python3 - "$CLAUDE_JSON" <<'PY'
import json,sys
p=sys.argv[1]
try: d=json.load(open(p))
except Exception: sys.exit(0)
for k in ("oauthAccount","userID"): d.pop(k,None)
json.dump(d, open(p,"w"), indent=2)
PY
}
email_of() { python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get('oauthAccount',{}).get('emailAddress','?'))" "$1" 2>/dev/null || echo '?'; }
live_email() { python3 -c "import json;print(json.load(open('$CLAUDE_JSON')).get('oauthAccount',{}).get('emailAddress','(none)'))" 2>/dev/null || echo '(none)'; }
# snapshot whatever is currently live into a saved account
do_save() {
local name="$1"
local blob; blob="$(read_live_token)" || true
[ -n "${blob:-}" ] || die "no live token in Keychain — log in first (claude, then /login)."
save_bkp_token "$name" "$blob"
dump_identity > "$STORE/$name.json"; chmod 600 "$STORE/$name.json"
info "saved '$name' ($(email_of "$STORE/$name.json"))"
}
# guided login: clear live creds, wait for the user to /login, then snapshot
guided_login() {
local name="$1" verb="$2"
info ""
info " Logging in account '$name' ($verb)."
info " 1) In a Claude Code session, run: /login"
info " (start one with: claude )"
info " 2) Complete the browser sign-in for the account you want as '$name'."
info ""
clear_identity
clear_live_token
printf ' Press ENTER here once /login has finished... '
read -r _
local blob; blob="$(read_live_token)" || true
[ -n "${blob:-}" ] || die "still no token in Keychain — did the login complete? Nothing saved."
do_save "$name"
info "'$name' is now the active account."
}
cmd="${1:-help}"; a="${2:-}"; b="${3:-}"
case "$cmd" in
save)
[ -n "$a" ] || die "usage: claude-account save "
do_save "$a" ;;
add)
[ -n "$a" ] || die "usage: claude-account add "
[ -f "$STORE/$a.json" ] && die "account '$a' already exists (use: relogin $a, or remove $a first)."
# protect the currently-active account from being lost
cur="$(live_email)"
if [ "$cur" != "(none)" ]; then
info "note: active account is $cur — make sure it's already saved (claude-account list)."
fi
guided_login "$a" "new" ;;
relogin)
[ -n "$a" ] || die "usage: claude-account relogin "
[ -f "$STORE/$a.json" ] || die "no saved account '$a' — use 'add $a' for a new one."
guided_login "$a" "re-login" ;;
switch|use)
[ -n "$a" ] || die "usage: claude-account switch "
[ -f "$STORE/$a.json" ] || die "no saved account '$a' (see: claude-account list)."
blob="$(read_bkp_token "$a")" || true
[ -n "${blob:-}" ] || die "no saved token for '$a' — run: claude-account relogin $a"
write_live_token "$blob"
load_identity "$STORE/$a.json"
info "switched to '$a' ($(email_of "$STORE/$a.json"))"
info "note: a running 'claude' session caches the Keychain token for ~30s —"
info " wait ~30 seconds for it to pick up '$a', or restart the session to switch instantly." ;;
logout)
clear_live_token
clear_identity
info "logged out active account (saved accounts kept). Next 'claude' run will ask to /login."
info "tip: switch back with claude-account switch " ;;
remove|rm)
[ -n "$a" ] || die "usage: claude-account remove "
[ -f "$STORE/$a.json" ] || die "no saved account '$a'."
delete_bkp_token "$a"; rm -f "$STORE/$a.json"
info "removed '$a'." ;;
rename)
[ -n "$a" ] && [ -n "$b" ] || die "usage: claude-account rename "
[ -f "$STORE/$a.json" ] || die "no saved account '$a'."
[ -f "$STORE/$b.json" ] && die "target '$b' already exists."
blob="$(read_bkp_token "$a")" || true
[ -n "${blob:-}" ] && { save_bkp_token "$b" "$blob"; delete_bkp_token "$a"; }
mv "$STORE/$a.json" "$STORE/$b.json"
info "renamed '$a' -> '$b'." ;;
list|ls)
shopt -s nullglob; found=0
active="$(live_email)"
for f in "$STORE"/*.json; do
found=1; n="$(basename "$f" .json)"; e="$(email_of "$f")"
read_bkp_token "$n" >/dev/null 2>&1 && tok="token✓" || tok="token✗ (relogin)"
[ "$e" = "$active" ] && mark="*" || mark=" "
printf ' %s %-14s %-32s [%s]\n' "$mark" "$n" "$e" "$tok"
done
[ "$found" = 1 ] || info " (no saved accounts — run: claude-account save or add )"
[ "$found" = 1 ] && info " (* = currently active)" ;;
current)
info "active account: $(live_email)" ;;
help|--help|-h|*)
# print only the leading comment header (stop at first blank/non-comment line)
awk 'NR==1{next} /^#/{sub(/^# ?/,"");print;next} {exit}' "$0" ;;
esac
Setup
chmod +x ~/bin/claude-account
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
# you're currently logged in — snapshot this account
claude-account save work
# now log in as the second account inside Claude Code
claude # then run: /login
claude-account save personal
Daily Use
claude-account switch work # flip to the work account
claude-account switch personal # flip to the personal account
claude-account list # see saved accounts
After a switch, wait about 30 seconds (the client caches the Keychain token) or restart the claude session to switch instantly. Because ~/.claude never changes, both accounts always see the exact same chat history.
Tip: keep a rarely-used account alive
Each account's refresh token lasts ~30 days. Every time you actually use an account, the client refreshes it — but only in the live Keychain entry. Re-snapshot after using it so your saved copy stays fresh and you never have to log in again:
claude-account switch personal # use it for a while...
claude-account save personal # re-snapshot the freshly-refreshed token
Why This Beats Directory-Based Profiles
- Shared history — one
~/.claude, so no split sessions. - Secure — tokens stay in the encrypted Keychain, never in a plaintext file.
- Tiny — no gigabytes of duplicated plugins/history per profile.
- Reliable — cooperates with Claude Code's own lock, so a swap is never silently overwritten by a token refresh.
That's the whole thing: a compact script that gives you clean multi-account switching on Claude Code, with your history following you everywhere.

0 Comments:
Leave a Reply