Expert IT Solutions

Discover New Ideas and Solutions with CodeEssence Blogs

Get inspired with our insightful blog posts covering innovative solutions, ideas, and strategies to elevate your business.

shape image
shape image
shape image
shape image
shape image
shape image
shape image
image

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:

  1. 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.
  2. 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

Your email address will not be published. Required fields are marked *

Author *

Comment *