Skip to content

Instantly share code, notes, and snippets.

@Madd0g
Created July 28, 2025 19:53
Show Gist options
  • Select an option

  • Save Madd0g/dfad71d623784d6c1ec13d061a7b1de8 to your computer and use it in GitHub Desktop.

Select an option

Save Madd0g/dfad71d623784d6c1ec13d061a7b1de8 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# Multi-Account Switcher for Claude Code
# Simple tool to manage and switch between multiple Claude Code accounts
set -euo pipefail
# Configuration
readonly BACKUP_DIR="$HOME/.claude-switch-backup"
readonly SEQUENCE_FILE="$BACKUP_DIR/sequence.json"
# Container detection
is_running_in_container() {
# Check for Docker environment file
if [[ -f /.dockerenv ]]; then
return 0
fi
# Check cgroup for container indicators
if [[ -f /proc/1/cgroup ]] && grep -q 'docker\|lxc\|containerd\|kubepods' /proc/1/cgroup 2>/dev/null; then
return 0
fi
# Check mount info for container filesystems
if [[ -f /proc/self/mountinfo ]] && grep -q 'docker\|overlay' /proc/self/mountinfo 2>/dev/null; then
return 0
fi
# Check for common container environment variables
if [[ -n "${CONTAINER:-}" ]] || [[ -n "${container:-}" ]]; then
return 0
fi
return 1
}
# Platform detection
detect_platform() {
case "$(uname -s)" in
Darwin) echo "macos" ;;
Linux)
if [[ -n "${WSL_DISTRO_NAME:-}" ]]; then
echo "wsl"
else
echo "linux"
fi
;;
*) echo "unknown" ;;
esac
}
# Get Claude configuration file path with fallback
get_claude_config_path() {
local primary_config="$HOME/.claude/.claude.json"
local fallback_config="$HOME/.claude.json"
# Check primary location first
if [[ -f "$primary_config" ]]; then
# Verify it has valid oauthAccount structure
if jq -e '.oauthAccount' "$primary_config" >/dev/null 2>&1; then
echo "$primary_config"
return
fi
fi
# Fallback to standard location
echo "$fallback_config"
}
# Basic validation that JSON is valid
validate_json() {
local file="$1"
if ! jq . "$file" >/dev/null 2>&1; then
echo "Error: Invalid JSON in $file"
return 1
fi
}
# Email validation function
validate_email() {
local email="$1"
# Use robust regex for email validation
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
return 0
else
return 1
fi
}
# Account identifier resolution function
resolve_account_identifier() {
local identifier="$1"
if [[ "$identifier" =~ ^[0-9]+$ ]]; then
echo "$identifier" # It's a number
else
# Look up account number by email
local account_num
account_num=$(jq -r --arg email "$identifier" '.accounts | to_entries[] | select(.value.email == $email) | .key' "$SEQUENCE_FILE" 2>/dev/null)
if [[ -n "$account_num" && "$account_num" != "null" ]]; then
echo "$account_num"
else
echo ""
fi
fi
}
# Safe JSON write with validation
write_json() {
local file="$1"
local content="$2"
local temp_file
temp_file=$(mktemp "${file}.XXXXXX")
echo "$content" > "$temp_file"
if ! jq . "$temp_file" >/dev/null 2>&1; then
rm -f "$temp_file"
echo "Error: Generated invalid JSON"
return 1
fi
mv "$temp_file" "$file"
chmod 600 "$file"
}
# Check Bash version (4.4+ required)
check_bash_version() {
local version
version=$(bash --version | head -n1 | grep -oE '[0-9]+\.[0-9]+' | head -n1)
if ! awk -v ver="$version" 'BEGIN { exit (ver >= 4.4 ? 0 : 1) }'; then
echo "Error: Bash 4.4+ required (found $version)"
exit 1
fi
}
# Check dependencies
check_dependencies() {
for cmd in jq; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Error: Required command '$cmd' not found"
echo "Install with: apt install $cmd (Linux) or brew install $cmd (macOS)"
exit 1
fi
done
}
# Setup backup directories
setup_directories() {
mkdir -p "$BACKUP_DIR"/{configs,credentials}
chmod 700 "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"/{configs,credentials}
}
# Claude Code process detection (Node.js app)
is_claude_running() {
ps -eo pid,comm,args | awk '$2 == "claude" || $3 == "claude" {exit 0} END {exit 1}'
}
# Wait for Claude Code to close (no timeout - user controlled)
wait_for_claude_close() {
if ! is_claude_running; then
return 0
fi
echo "Claude Code is running. Please close it first."
echo "Waiting for Claude Code to close..."
while is_claude_running; do
sleep 1
done
echo "Claude Code closed. Continuing..."
}
# Get current account info from .claude.json
get_current_account() {
if [[ ! -f "$(get_claude_config_path)" ]]; then
echo "none"
return
fi
if ! validate_json "$(get_claude_config_path)"; then
echo "none"
return
fi
local email
email=$(jq -r '.oauthAccount.emailAddress // empty' "$(get_claude_config_path)" 2>/dev/null)
echo "${email:-none}"
}
# Detect which Claude Code service name is used in keychain
get_claude_service_name() {
if security find-generic-password -s "Claude Code-credentials" >/dev/null 2>&1; then
echo "Claude Code-credentials"
elif security find-generic-password -s "Claude Code" >/dev/null 2>&1; then
echo "Claude Code"
else
echo ""
fi
}
# Read credentials based on platform
read_credentials() {
local platform
platform=$(detect_platform)
case "$platform" in
macos)
local service_name
service_name=$(get_claude_service_name)
if [[ -n "$service_name" ]]; then
security find-generic-password -s "$service_name" -w 2>/dev/null || echo ""
else
echo ""
fi
;;
linux|wsl)
if [[ -f "$HOME/.claude/.credentials.json" ]]; then
cat "$HOME/.claude/.credentials.json"
else
echo ""
fi
;;
esac
}
# Write credentials based on platform
write_credentials() {
local credentials="$1"
local platform
platform=$(detect_platform)
case "$platform" in
macos)
local service_name
service_name=$(get_claude_service_name)
if [[ -z "$service_name" ]]; then
# Default to -credentials for new installations
service_name="Claude Code-credentials"
fi
security add-generic-password -U -s "$service_name" -a "$USER" -w "$credentials" 2>/dev/null
;;
linux|wsl)
mkdir -p "$HOME/.claude"
printf '%s' "$credentials" > "$HOME/.claude/.credentials.json"
chmod 600 "$HOME/.claude/.credentials.json"
;;
esac
}
# Read account credentials from backup
read_account_credentials() {
local account_num="$1"
local email="$2"
local platform
platform=$(detect_platform)
case "$platform" in
macos)
security find-generic-password -s "Claude Code-Account-${account_num}-${email}" -w 2>/dev/null || echo ""
;;
linux|wsl)
local cred_file="$BACKUP_DIR/credentials/.claude-credentials-${account_num}-${email}.json"
if [[ -f "$cred_file" ]]; then
cat "$cred_file"
else
echo ""
fi
;;
esac
}
# Write account credentials to backup
write_account_credentials() {
local account_num="$1"
local email="$2"
local credentials="$3"
local platform
platform=$(detect_platform)
case "$platform" in
macos)
security add-generic-password -U -s "Claude Code-Account-${account_num}-${email}" -a "$USER" -w "$credentials" 2>/dev/null
;;
linux|wsl)
local cred_file="$BACKUP_DIR/credentials/.claude-credentials-${account_num}-${email}.json"
printf '%s' "$credentials" > "$cred_file"
chmod 600 "$cred_file"
;;
esac
}
# Read account config from backup
read_account_config() {
local account_num="$1"
local email="$2"
local config_file="$BACKUP_DIR/configs/.claude-config-${account_num}-${email}.json"
if [[ -f "$config_file" ]]; then
cat "$config_file"
else
echo ""
fi
}
# Write account config to backup
write_account_config() {
local account_num="$1"
local email="$2"
local config="$3"
local config_file="$BACKUP_DIR/configs/.claude-config-${account_num}-${email}.json"
echo "$config" > "$config_file"
chmod 600 "$config_file"
}
# Initialize sequence.json if it doesn't exist
init_sequence_file() {
if [[ ! -f "$SEQUENCE_FILE" ]]; then
local init_content='{
"activeAccountNumber": null,
"lastUpdated": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'",
"sequence": [],
"accounts": {}
}'
write_json "$SEQUENCE_FILE" "$init_content"
fi
}
# Get next account number
get_next_account_number() {
if [[ ! -f "$SEQUENCE_FILE" ]]; then
echo "1"
return
fi
local max_num
max_num=$(jq -r '.accounts | keys | map(tonumber) | max // 0' "$SEQUENCE_FILE")
echo $((max_num + 1))
}
# Check if account exists by email
account_exists() {
local email="$1"
if [[ ! -f "$SEQUENCE_FILE" ]]; then
return 1
fi
jq -e --arg email "$email" '.accounts[] | select(.email == $email)' "$SEQUENCE_FILE" >/dev/null 2>&1
}
# Add account
cmd_add_account() {
setup_directories
init_sequence_file
local current_email
current_email=$(get_current_account)
if [[ "$current_email" == "none" ]]; then
echo "Error: No active Claude account found. Please log in first."
exit 1
fi
if account_exists "$current_email"; then
echo "Account $current_email is already managed."
exit 0
fi
local account_num
account_num=$(get_next_account_number)
# Get current service name and credentials
local service_name current_creds current_config
service_name=$(get_claude_service_name)
current_creds=$(read_credentials)
current_config=$(cat "$(get_claude_config_path)")
if [[ -z "$current_creds" ]]; then
echo "Error: No credentials found for current account"
exit 1
fi
if [[ -z "$service_name" ]]; then
echo "Error: Could not determine Claude Code service name"
exit 1
fi
# Get account UUID
local account_uuid
account_uuid=$(jq -r '.oauthAccount.accountUuid' "$(get_claude_config_path)")
# Store backups
write_account_credentials "$account_num" "$current_email" "$current_creds"
write_account_config "$account_num" "$current_email" "$current_config"
# Update sequence.json with service name
local updated_sequence
updated_sequence=$(jq --arg num "$account_num" --arg email "$current_email" --arg uuid "$account_uuid" --arg service "$service_name" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
.accounts[$num] = {
email: $email,
uuid: $uuid,
serviceName: $service,
added: $now
} |
.sequence += [$num | tonumber] |
.activeAccountNumber = ($num | tonumber) |
.lastUpdated = $now
' "$SEQUENCE_FILE")
write_json "$SEQUENCE_FILE" "$updated_sequence"
echo "Added Account $account_num: $current_email (service: $service_name)"
}
# Remove account
cmd_remove_account() {
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --remove-account <account_number|email>"
exit 1
fi
local identifier="$1"
local account_num
if [[ ! -f "$SEQUENCE_FILE" ]]; then
echo "Error: No accounts are managed yet"
exit 1
fi
# Handle email vs numeric identifier
if [[ "$identifier" =~ ^[0-9]+$ ]]; then
account_num="$identifier"
else
# Validate email format
if ! validate_email "$identifier"; then
echo "Error: Invalid email format: $identifier"
exit 1
fi
# Resolve email to account number
account_num=$(resolve_account_identifier "$identifier")
if [[ -z "$account_num" ]]; then
echo "Error: No account found with email: $identifier"
exit 1
fi
fi
local account_info
account_info=$(jq -r --arg num "$account_num" '.accounts[$num] // empty' "$SEQUENCE_FILE")
if [[ -z "$account_info" ]]; then
echo "Error: Account-$account_num does not exist"
exit 1
fi
local email
email=$(echo "$account_info" | jq -r '.email')
local active_account
active_account=$(jq -r '.activeAccountNumber' "$SEQUENCE_FILE")
if [[ "$active_account" == "$account_num" ]]; then
echo "Warning: Account-$account_num ($email) is currently active"
fi
echo -n "Are you sure you want to permanently remove Account-$account_num ($email)? [y/N] "
read -r confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "Cancelled"
exit 0
fi
# Remove backup files
local platform
platform=$(detect_platform)
case "$platform" in
macos)
security delete-generic-password -s "Claude Code-Account-${account_num}-${email}" 2>/dev/null || true
;;
linux|wsl)
rm -f "$BACKUP_DIR/credentials/.claude-credentials-${account_num}-${email}.json"
;;
esac
rm -f "$BACKUP_DIR/configs/.claude-config-${account_num}-${email}.json"
# Update sequence.json
local updated_sequence
updated_sequence=$(jq --arg num "$account_num" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
del(.accounts[$num]) |
.sequence = (.sequence | map(select(. != ($num | tonumber)))) |
.lastUpdated = $now
' "$SEQUENCE_FILE")
write_json "$SEQUENCE_FILE" "$updated_sequence"
echo "Account-$account_num ($email) has been removed"
}
# First-run setup workflow
first_run_setup() {
local current_email
current_email=$(get_current_account)
if [[ "$current_email" == "none" ]]; then
echo "No active Claude account found. Please log in first."
return 1
fi
echo -n "No managed accounts found. Add current account ($current_email) to managed list? [Y/n] "
read -r response
if [[ "$response" == "n" || "$response" == "N" ]]; then
echo "Setup cancelled. You can run '$0 --add-account' later."
return 1
fi
cmd_add_account
return 0
}
# List accounts
cmd_list() {
if [[ ! -f "$SEQUENCE_FILE" ]]; then
echo "No accounts are managed yet."
first_run_setup
exit 0
fi
# Get current active account from .claude.json
local current_email
current_email=$(get_current_account)
# Find which account number corresponds to the current email
local active_account_num=""
if [[ "$current_email" != "none" ]]; then
active_account_num=$(jq -r --arg email "$current_email" '.accounts | to_entries[] | select(.value.email == $email) | .key' "$SEQUENCE_FILE" 2>/dev/null)
fi
echo "Accounts:"
jq -r --arg active "$active_account_num" '
.sequence[] as $num |
.accounts["\($num)"] |
if "\($num)" == $active then
" \($num): \(.email) (active)"
else
" \($num): \(.email)"
end
' "$SEQUENCE_FILE"
}
# Switch to next account
cmd_switch() {
if [[ ! -f "$SEQUENCE_FILE" ]]; then
echo "Error: No accounts are managed yet"
exit 1
fi
local current_email
current_email=$(get_current_account)
if [[ "$current_email" == "none" ]]; then
echo "Error: No active Claude account found"
exit 1
fi
# Check if current account is managed
if ! account_exists "$current_email"; then
echo "Notice: Active account '$current_email' was not managed."
cmd_add_account
local account_num
account_num=$(jq -r '.activeAccountNumber' "$SEQUENCE_FILE")
echo "It has been automatically added as Account-$account_num."
echo "Please run './ccswitch.sh --switch' again to switch to the next account."
exit 0
fi
# wait_for_claude_close
local active_account sequence
active_account=$(jq -r '.activeAccountNumber' "$SEQUENCE_FILE")
sequence=($(jq -r '.sequence[]' "$SEQUENCE_FILE"))
# Find next account in sequence
local next_account current_index=0
for i in "${!sequence[@]}"; do
if [[ "${sequence[i]}" == "$active_account" ]]; then
current_index=$i
break
fi
done
next_account="${sequence[$(((current_index + 1) % ${#sequence[@]}))]}"
perform_switch "$next_account"
}
# Switch to specific account
cmd_switch_to() {
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --switch-to <account_number|email>"
exit 1
fi
local identifier="$1"
local target_account
if [[ ! -f "$SEQUENCE_FILE" ]]; then
echo "Error: No accounts are managed yet"
exit 1
fi
# Handle email vs numeric identifier
if [[ "$identifier" =~ ^[0-9]+$ ]]; then
target_account="$identifier"
else
# Validate email format
if ! validate_email "$identifier"; then
echo "Error: Invalid email format: $identifier"
exit 1
fi
# Resolve email to account number
target_account=$(resolve_account_identifier "$identifier")
if [[ -z "$target_account" ]]; then
echo "Error: No account found with email: $identifier"
exit 1
fi
fi
local account_info
account_info=$(jq -r --arg num "$target_account" '.accounts[$num] // empty' "$SEQUENCE_FILE")
if [[ -z "$account_info" ]]; then
echo "Error: Account-$target_account does not exist"
exit 1
fi
# wait_for_claude_close
perform_switch "$target_account"
}
# Perform the actual account switch
perform_switch() {
local target_account="$1"
# Get current and target account info
local current_account target_email current_email target_service current_service
current_account=$(jq -r '.activeAccountNumber' "$SEQUENCE_FILE")
target_email=$(jq -r --arg num "$target_account" '.accounts[$num].email' "$SEQUENCE_FILE")
target_service=$(jq -r --arg num "$target_account" '.accounts[$num].serviceName // empty' "$SEQUENCE_FILE")
current_email=$(get_current_account)
current_service=$(get_claude_service_name)
if [[ -z "$target_service" ]]; then
echo "Error: No service name stored for Account-$target_account. Re-add this account."
exit 1
fi
# Step 1: Backup current account
local current_creds current_config
current_creds=$(read_credentials)
current_config=$(cat "$(get_claude_config_path)")
write_account_credentials "$current_account" "$current_email" "$current_creds"
write_account_config "$current_account" "$current_email" "$current_config"
# Step 2: Retrieve target account
local target_creds target_config
target_creds=$(read_account_credentials "$target_account" "$target_email")
target_config=$(read_account_config "$target_account" "$target_email")
if [[ -z "$target_creds" || -z "$target_config" ]]; then
echo "Error: Missing backup data for Account-$target_account"
exit 1
fi
# Step 3: Clean old keychain entry and write to new service name
if [[ -n "$current_service" && "$current_service" != "$target_service" ]]; then
# Remove the old service entry
security delete-generic-password -s "$current_service" 2>/dev/null || true
echo "Removed old keychain entry: $current_service"
fi
# Write credentials to the correct service name for target account
security add-generic-password -U -s "$target_service" -a "$USER" -w "$target_creds" 2>/dev/null
echo "Added keychain entry: $target_service"
# Step 4: Update config file
local oauth_section
oauth_section=$(echo "$target_config" | jq '.oauthAccount' 2>/dev/null)
if [[ -z "$oauth_section" || "$oauth_section" == "null" ]]; then
echo "Error: Invalid oauthAccount in backup"
exit 1
fi
local merged_config
merged_config=$(jq --argjson oauth "$oauth_section" '.oauthAccount = $oauth' "$(get_claude_config_path)" 2>/dev/null)
if [[ $? -ne 0 ]]; then
echo "Error: Failed to merge config"
exit 1
fi
write_json "$(get_claude_config_path)" "$merged_config"
# Step 5: Update state
local updated_sequence
updated_sequence=$(jq --arg num "$target_account" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
.activeAccountNumber = ($num | tonumber) |
.lastUpdated = $now
' "$SEQUENCE_FILE")
write_json "$SEQUENCE_FILE" "$updated_sequence"
echo "Switched to Account-$target_account ($target_email) using service: $target_service"
cmd_list
echo ""
echo "Please restart Claude Code to use the new authentication."
echo ""
}
# Show usage
show_usage() {
echo "Multi-Account Switcher for Claude Code"
echo "Usage: $0 [COMMAND]"
echo ""
echo "Commands:"
echo " --add-account Add current account to managed accounts"
echo " --remove-account <num|email> Remove account by number or email"
echo " --list List all managed accounts"
echo " --switch Rotate to next account in sequence"
echo " --switch-to <num|email> Switch to specific account number or email"
echo " --help Show this help message"
echo ""
echo "Examples:"
echo " $0 --add-account"
echo " $0 --list"
echo " $0 --switch"
echo " $0 --switch-to 2"
echo " $0 --switch-to user@example.com"
echo " $0 --remove-account user@example.com"
}
# Main script logic
main() {
# Basic checks - allow root execution in containers
if [[ $EUID -eq 0 ]] && ! is_running_in_container; then
echo "Error: Do not run this script as root (unless running in a container)"
exit 1
fi
check_bash_version
check_dependencies
case "${1:-}" in
--add-account)
cmd_add_account
;;
--remove-account)
shift
cmd_remove_account "$@"
;;
--list)
cmd_list
;;
--switch)
cmd_switch
;;
--switch-to)
shift
cmd_switch_to "$@"
;;
--help)
show_usage
;;
"")
show_usage
;;
*)
echo "Error: Unknown command '$1'"
show_usage
exit 1
;;
esac
}
# Check if script is being sourced or executed
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment