#!/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" readonly USAGE_API_URL="https://api.anthropic.com/api/oauth/usage" readonly TOKEN_REFRESH_URL="https://console.anthropic.com/v1/oauth/token" readonly OAUTH_CLIENT_ID="9d1c250a-e61b-44d9-88ed-5944d1962f5e" readonly TOKEN_REFRESH_BUFFER=300 # Refresh if expiring within 5 minutes # 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 curl; 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 is_claude_running() { # Use pgrep for reliable process detection pgrep -x "claude" >/dev/null 2>&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}" } # Read credentials based on platform read_credentials() { local platform platform=$(detect_platform) case "$platform" in macos) security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null || echo "" ;; linux|wsl) if [[ -f "$HOME/.claude/.credentials.json" ]]; then cat "$HOME/.claude/.credentials.json" else echo "" fi ;; esac } # Get OAuth access token from credentials get_access_token() { local creds creds=$(read_credentials) if [[ -z "$creds" ]]; then echo "" return fi echo "$creds" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null } # Get OAuth refresh token from credentials get_refresh_token() { local creds creds=$(read_credentials) if [[ -z "$creds" ]]; then echo "" return fi echo "$creds" | jq -r '.claudeAiOauth.refreshToken // empty' 2>/dev/null } # Get token expiry timestamp from credentials get_token_expiry() { local creds creds=$(read_credentials) if [[ -z "$creds" ]]; then echo "" return fi echo "$creds" | jq -r '.claudeAiOauth.expiresAt // empty' 2>/dev/null } # Check if token is expired or expiring soon is_token_expired() { local expiry_timestamp expiry_timestamp=$(get_token_expiry) if [[ -z "$expiry_timestamp" || "$expiry_timestamp" == "null" ]]; then # No expiry info stored, assume valid (will fail on API call if not) return 1 fi # expiresAt is stored as milliseconds since epoch local now_ms expiry_ms now_ms=$(($(date +%s) * 1000)) expiry_ms="$expiry_timestamp" # Token is expired if current time + buffer >= expiry time # Convert buffer from seconds to milliseconds local buffer_ms=$((TOKEN_REFRESH_BUFFER * 1000)) if [[ $((now_ms + buffer_ms)) -ge $expiry_ms ]]; then return 0 # Token is expired or expiring soon fi return 1 # Token is still valid } # Refresh the OAuth token via API # Returns: JSON response on success, error message prefixed with "ERROR:" on failure, empty on network error refresh_oauth_token_api() { local refresh_token="$1" if [[ -z "$refresh_token" ]]; then echo "ERROR:No refresh token provided" return 1 fi # Use the same format as Claude Code CLI local request_body request_body=$(jq -n \ --arg grant_type "refresh_token" \ --arg refresh_token "$refresh_token" \ --arg client_id "$OAUTH_CLIENT_ID" \ --arg scope "user:inference user:profile" \ '{grant_type: $grant_type, refresh_token: $refresh_token, client_id: $client_id, scope: $scope}') local response response=$(curl -s -X POST "$TOKEN_REFRESH_URL" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d "$request_body" 2>/dev/null) if [[ -z "$response" ]]; then echo "ERROR:No response from server" return 1 fi # Check for error in response or HTML response (Cloudflare block) if echo "$response" | grep -q ''; then echo "ERROR:Cloudflare blocked the request" return 1 fi # Check for OAuth error response local error_type error_desc error_type=$(echo "$response" | jq -r '.error // empty' 2>/dev/null) if [[ -n "$error_type" ]]; then error_desc=$(echo "$response" | jq -r '.error_description // "Unknown error"' 2>/dev/null) echo "ERROR:$error_type - $error_desc" return 1 fi echo "$response" return 0 } # Refresh the OAuth token # Returns: JSON response on success, "ERROR:..." message on failure refresh_oauth_token() { local refresh_token="$1" local response response=$(refresh_oauth_token_api "$refresh_token") # Pass through the response (either JSON or ERROR:message) echo "$response" # Check if it was an error if [[ "$response" == ERROR:* ]]; then return 1 fi return 0 } # Update credentials with new token data update_credentials_with_token() { local new_access_token="$1" local new_refresh_token="$2" local expires_in="$3" local creds creds=$(read_credentials) if [[ -z "$creds" ]]; then return 1 fi # Calculate expiry timestamp in MILLISECONDS (not seconds!) # Claude Code stores expiresAt as a number in milliseconds local now_ms expiry_ms now_ms=$(($(date +%s) * 1000)) expiry_ms=$((now_ms + (expires_in * 1000))) # Update credentials JSON with numeric expiresAt # IMPORTANT: Use -c for compact output (no newlines) for keychain compatibility local updated_creds updated_creds=$(echo "$creds" | jq -c \ --arg access "$new_access_token" \ --arg refresh "$new_refresh_token" \ --argjson expiry "$expiry_ms" \ '.claudeAiOauth.accessToken = $access | .claudeAiOauth.refreshToken = $refresh | .claudeAiOauth.expiresAt = $expiry') write_credentials "$updated_creds" return 0 } # Get a valid access token, refreshing if necessary get_valid_access_token() { local access_token refresh_token access_token=$(get_access_token) if [[ -z "$access_token" ]]; then echo "" return 1 fi # Check if token needs refresh if is_token_expired; then refresh_token=$(get_refresh_token) if [[ -z "$refresh_token" ]]; then # No refresh token available, return current token and hope for the best echo "$access_token" return 0 fi echo "Token expired, refreshing..." >&2 local refresh_response refresh_response=$(refresh_oauth_token "$refresh_token") if [[ -n "$refresh_response" ]]; then local new_access new_refresh expires_in new_access=$(echo "$refresh_response" | jq -r '.access_token // empty') new_refresh=$(echo "$refresh_response" | jq -r '.refresh_token // empty') expires_in=$(echo "$refresh_response" | jq -r '.expires_in // 28800') if [[ -n "$new_access" ]]; then # Use existing refresh token if new one not provided if [[ -z "$new_refresh" ]]; then new_refresh="$refresh_token" fi update_credentials_with_token "$new_access" "$new_refresh" "$expires_in" echo "Token refreshed successfully" >&2 echo "$new_access" return 0 fi fi echo "Token refresh failed, using existing token" >&2 fi echo "$access_token" return 0 } # Get account credentials (internal helper) get_account_credentials() { local account_num="$1" local email="$2" local platform creds platform=$(detect_platform) case "$platform" in macos) creds=$(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 creds=$(cat "$cred_file") else creds="" fi ;; esac # Check if creds is hex-encoded (broken format from old bug) # and try to decode it if [[ -n "$creds" ]] && [[ "$creds" =~ ^[0-9a-fA-F]+$ ]] && [[ ${#creds} -gt 100 ]]; then # Try to decode hex local decoded decoded=$(echo "$creds" | xxd -r -p 2>/dev/null || echo "") if [[ -n "$decoded" ]] && echo "$decoded" | jq '.' >/dev/null 2>&1; then creds="$decoded" fi fi # Validate JSON - return empty if invalid if [[ -n "$creds" ]] && ! echo "$creds" | jq '.' >/dev/null 2>&1; then echo "" return fi echo "$creds" } # Get OAuth access token for a specific account get_account_access_token() { local account_num="$1" local email="$2" local creds creds=$(get_account_credentials "$account_num" "$email") if [[ -z "$creds" ]]; then echo "" return fi echo "$creds" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null } # Get refresh token for a specific account get_account_refresh_token() { local account_num="$1" local email="$2" local creds creds=$(get_account_credentials "$account_num" "$email") if [[ -z "$creds" ]]; then echo "" return fi echo "$creds" | jq -r '.claudeAiOauth.refreshToken // empty' 2>/dev/null } # Get token expiry for a specific account get_account_token_expiry() { local account_num="$1" local email="$2" local creds creds=$(get_account_credentials "$account_num" "$email") if [[ -z "$creds" ]]; then echo "" return fi echo "$creds" | jq -r '.claudeAiOauth.expiresAt // empty' 2>/dev/null } # Check if account token is expired is_account_token_expired() { local account_num="$1" local email="$2" local expiry_timestamp expiry_timestamp=$(get_account_token_expiry "$account_num" "$email") if [[ -z "$expiry_timestamp" || "$expiry_timestamp" == "null" ]]; then return 1 fi local now_ms expiry_ms # Handle both numeric (milliseconds) and string (ISO 8601) formats if [[ "$expiry_timestamp" =~ ^[0-9]+$ ]]; then # Numeric format (milliseconds since epoch) now_ms=$(($(date +%s) * 1000)) expiry_ms="$expiry_timestamp" else # String format (ISO 8601) - convert to milliseconds # This handles legacy credentials with string expiresAt now_ms=$(($(date +%s) * 1000)) local expiry_epoch if date --version >/dev/null 2>&1; then # GNU date expiry_epoch=$(date -d "$expiry_timestamp" +%s 2>/dev/null || echo "0") else # BSD date (macOS) local formatted_date formatted_date=$(echo "$expiry_timestamp" | sed 's/T/ /; s/[Zz]$//; s/[+-][0-9][0-9]:[0-9][0-9]$//') expiry_epoch=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$formatted_date" +%s 2>/dev/null || echo "0") fi expiry_ms=$((expiry_epoch * 1000)) fi # Token is expired if current time + buffer >= expiry time local buffer_ms=$((TOKEN_REFRESH_BUFFER * 1000)) if [[ $((now_ms + buffer_ms)) -ge $expiry_ms ]]; then return 0 fi return 1 } # Update account credentials with new token data update_account_credentials_with_token() { local account_num="$1" local email="$2" local new_access_token="$3" local new_refresh_token="$4" local expires_in="$5" local creds creds=$(get_account_credentials "$account_num" "$email") if [[ -z "$creds" ]]; then return 1 fi # Calculate expiry timestamp in MILLISECONDS (not seconds!) # Claude Code stores expiresAt as a number in milliseconds local now_ms expiry_ms now_ms=$(($(date +%s) * 1000)) expiry_ms=$((now_ms + (expires_in * 1000))) # Update credentials JSON with numeric expiresAt # IMPORTANT: Use -c for compact output (no newlines) for keychain compatibility local updated_creds updated_creds=$(echo "$creds" | jq -c \ --arg access "$new_access_token" \ --arg refresh "$new_refresh_token" \ --argjson expiry "$expiry_ms" \ '.claudeAiOauth.accessToken = $access | .claudeAiOauth.refreshToken = $refresh | .claudeAiOauth.expiresAt = $expiry') write_account_credentials "$account_num" "$email" "$updated_creds" return 0 } # Get valid access token for a specific account, refreshing if necessary get_valid_account_access_token() { local account_num="$1" local email="$2" local access_token refresh_token access_token=$(get_account_access_token "$account_num" "$email") if [[ -z "$access_token" ]]; then echo "" return 1 fi if is_account_token_expired "$account_num" "$email"; then refresh_token=$(get_account_refresh_token "$account_num" "$email") if [[ -z "$refresh_token" ]]; then echo "$access_token" return 0 fi echo "Token expired for account $account_num, refreshing..." >&2 local refresh_response refresh_response=$(refresh_oauth_token "$refresh_token") if [[ -n "$refresh_response" ]]; then local new_access new_refresh expires_in new_access=$(echo "$refresh_response" | jq -r '.access_token // empty') new_refresh=$(echo "$refresh_response" | jq -r '.refresh_token // empty') expires_in=$(echo "$refresh_response" | jq -r '.expires_in // 28800') if [[ -n "$new_access" ]]; then if [[ -z "$new_refresh" ]]; then new_refresh="$refresh_token" fi update_account_credentials_with_token "$account_num" "$email" "$new_access" "$new_refresh" "$expires_in" echo "Token refreshed successfully for account $account_num" >&2 echo "$new_access" return 0 fi fi echo "Token refresh failed for account $account_num, using existing token" >&2 fi echo "$access_token" return 0 } # Fetch usage data from Anthropic API fetch_usage() { local token="$1" if [[ -z "$token" ]]; then echo "" return fi curl -s -X GET "$USAGE_API_URL" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $token" \ -H "anthropic-beta: oauth-2025-04-20" 2>/dev/null } # Format time remaining until reset format_time_remaining() { local reset_at="$1" if [[ -z "$reset_at" || "$reset_at" == "null" ]]; then echo "N/A" return fi local reset_epoch now_epoch diff_seconds # Parse ISO 8601 date - handle both GNU and BSD date if date --version >/dev/null 2>&1; then # GNU date reset_epoch=$(date -d "$reset_at" +%s 2>/dev/null || echo "0") else # BSD date (macOS) # Convert ISO 8601 to format BSD date understands # The API returns UTC times, so we need to parse as UTC local formatted_date formatted_date=$(echo "$reset_at" | sed 's/T/ /; s/[Zz]$//; s/[+-][0-9][0-9]:[0-9][0-9]$//') # Parse the datetime as UTC by temporarily setting TZ reset_epoch=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$formatted_date" +%s 2>/dev/null || echo "0") fi now_epoch=$(date +%s) diff_seconds=$((reset_epoch - now_epoch)) if [[ $diff_seconds -le 0 ]]; then echo "now" return fi local days hours minutes days=$((diff_seconds / 86400)) hours=$(((diff_seconds % 86400) / 3600)) minutes=$(((diff_seconds % 3600) / 60)) if [[ $days -gt 0 ]]; then echo "${days}d ${hours}h ${minutes}m" elif [[ $hours -gt 0 ]]; then echo "${hours}h ${minutes}m" else echo "${minutes}m" fi } # Format reset time for display format_reset_time() { local reset_at="$1" if [[ -z "$reset_at" || "$reset_at" == "null" ]]; then echo "N/A" return fi # Parse and format the date in local timezone if date --version >/dev/null 2>&1; then # GNU date date -d "$reset_at" "+%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$reset_at" else # BSD date (macOS) # The API returns UTC times, so we need to parse as UTC then display in local time local formatted_date formatted_date=$(echo "$reset_at" | sed 's/T/ /; s/[Zz]$//; s/[+-][0-9][0-9]:[0-9][0-9]$//') local epoch # Parse the datetime as UTC by temporarily setting TZ epoch=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$formatted_date" +%s 2>/dev/null || echo "0") if [[ "$epoch" != "0" ]]; then # Display in local timezone date -j -f "%s" "$epoch" "+%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$reset_at" else echo "$reset_at" fi fi } # Create a progress bar progress_bar() { local utilization="$1" local width=20 local filled empty # Handle null or empty if [[ -z "$utilization" || "$utilization" == "null" ]]; then printf '[%s]' "$(printf '%*s' $width '' | tr ' ' '-')" return fi # Round to integer local pct pct=$(printf "%.0f" "$utilization") filled=$((pct * width / 100)) empty=$((width - filled)) # Use different colors based on utilization local bar_char="█" local empty_char="░" printf '[' if [[ $filled -gt 0 ]]; then printf '%*s' "$filled" '' | tr ' ' "$bar_char" fi if [[ $empty -gt 0 ]]; then printf '%*s' "$empty" '' | tr ' ' "$empty_char" fi printf ']' } # Display usage for a single account display_account_usage() { local account_num="$1" local email="$2" local token="$3" local is_active="$4" local active_marker="" if [[ "$is_active" == "true" ]]; then active_marker=" (active)" fi echo "" echo "Account $account_num: $email$active_marker" echo "$(printf '%*s' 60 '' | tr ' ' '-')" if [[ -z "$token" ]]; then echo " Error: No access token available" return fi local usage_data usage_data=$(fetch_usage "$token") if [[ -z "$usage_data" ]]; then echo " Error: Failed to fetch usage data" return fi # Check for API error if echo "$usage_data" | jq -e '.error' >/dev/null 2>&1; then local error_msg error_msg=$(echo "$usage_data" | jq -r '.error.message // .error // "Unknown error"') echo " Error: $error_msg" return fi # Parse usage data local five_hour_util five_hour_reset local seven_day_util seven_day_reset local seven_day_sonnet_util seven_day_sonnet_reset local seven_day_opus_util seven_day_opus_reset local extra_usage_enabled extra_usage_limit extra_usage_used extra_usage_util five_hour_util=$(echo "$usage_data" | jq -r '.five_hour.utilization // "null"') five_hour_reset=$(echo "$usage_data" | jq -r '.five_hour.resets_at // "null"') seven_day_util=$(echo "$usage_data" | jq -r '.seven_day.utilization // "null"') seven_day_reset=$(echo "$usage_data" | jq -r '.seven_day.resets_at // "null"') seven_day_sonnet_util=$(echo "$usage_data" | jq -r '.seven_day_sonnet.utilization // "null"') seven_day_sonnet_reset=$(echo "$usage_data" | jq -r '.seven_day_sonnet.resets_at // "null"') seven_day_opus_util=$(echo "$usage_data" | jq -r '.seven_day_opus.utilization // "null"') seven_day_opus_reset=$(echo "$usage_data" | jq -r '.seven_day_opus.resets_at // "null"') extra_usage_enabled=$(echo "$usage_data" | jq -r '.extra_usage.is_enabled // "null"') extra_usage_limit=$(echo "$usage_data" | jq -r '.extra_usage.monthly_limit // "null"') extra_usage_used=$(echo "$usage_data" | jq -r '.extra_usage.used_credits // "null"') extra_usage_util=$(echo "$usage_data" | jq -r '.extra_usage.utilization // "null"') # Display 5-hour session usage if [[ "$five_hour_util" != "null" ]]; then printf " Session (5h): %s %5.1f%% resets in %s\n" \ "$(progress_bar "$five_hour_util")" \ "$five_hour_util" \ "$(format_time_remaining "$five_hour_reset")" printf " Reset: %s\n" "$(format_reset_time "$five_hour_reset")" fi # Display 7-day weekly usage if [[ "$seven_day_util" != "null" ]]; then printf " Weekly (7d): %s %5.1f%% resets in %s\n" \ "$(progress_bar "$seven_day_util")" \ "$seven_day_util" \ "$(format_time_remaining "$seven_day_reset")" printf " Reset: %s\n" "$(format_reset_time "$seven_day_reset")" fi # Display 7-day Sonnet usage if available if [[ "$seven_day_sonnet_util" != "null" && "$seven_day_sonnet_util" != "0" ]]; then printf " Sonnet (7d): %s %5.1f%% resets in %s\n" \ "$(progress_bar "$seven_day_sonnet_util")" \ "$seven_day_sonnet_util" \ "$(format_time_remaining "$seven_day_sonnet_reset")" fi # Display 7-day Opus usage if available if [[ "$seven_day_opus_util" != "null" && "$seven_day_opus_util" != "0" ]]; then printf " Opus (7d): %s %5.1f%% resets in %s\n" \ "$(progress_bar "$seven_day_opus_util")" \ "$seven_day_opus_util" \ "$(format_time_remaining "$seven_day_opus_reset")" fi # Display extra usage if enabled if [[ "$extra_usage_enabled" == "true" ]]; then echo "" printf " Extra Usage: %s %5.1f%% (\$%.2f / \$%.2f)\n" \ "$(progress_bar "$extra_usage_util")" \ "$extra_usage_util" \ "$(echo "$extra_usage_used / 100" | bc -l)" \ "$(echo "$extra_usage_limit / 100" | bc -l)" fi } # Show usage for all accounts cmd_usage() { if [[ ! -f "$SEQUENCE_FILE" ]]; then # No managed accounts, show usage for current account only local current_email current_email=$(get_current_account) if [[ "$current_email" == "none" ]]; then echo "Error: No active Claude account found" exit 1 fi local token token=$(get_valid_access_token) echo "Claude Code Usage" echo "=================" display_account_usage "0" "$current_email" "$token" "true" exit 0 fi # Get current active account local current_email current_email=$(get_current_account) echo "Claude Code Usage" echo "=================" # Iterate through all managed accounts local account_nums account_nums=$(jq -r '.sequence[]' "$SEQUENCE_FILE") for account_num in $account_nums; do local email is_active token email=$(jq -r --arg num "$account_num" '.accounts[$num].email' "$SEQUENCE_FILE") if [[ "$email" == "$current_email" ]]; then is_active="true" token=$(get_valid_access_token) else is_active="false" token=$(get_valid_account_access_token "$account_num" "$email") fi display_account_usage "$account_num" "$email" "$token" "$is_active" done echo "" } # Write credentials based on platform write_credentials() { local credentials="$1" local platform platform=$(detect_platform) case "$platform" in macos) security add-generic-password -U -s "Claude Code-credentials" -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 # Get current credentials and config local current_creds current_config account_uuid 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 account_uuid=$(jq -r '.oauthAccount.accountUuid' "$(get_claude_config_path)") # Check if account already exists if account_exists "$current_email"; then # Update existing account credentials local account_num account_num=$(resolve_account_identifier "$current_email") if [[ -z "$account_num" ]]; then echo "Error: Failed to resolve account number for $current_email" exit 1 fi # Update stored credentials and config write_account_credentials "$account_num" "$current_email" "$current_creds" write_account_config "$account_num" "$current_email" "$current_config" # Update sequence.json with new timestamp local updated_sequence updated_sequence=$(jq --arg num "$account_num" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" ' .accounts[$num].updated = $now | .activeAccountNumber = ($num | tonumber) | .lastUpdated = $now ' "$SEQUENCE_FILE") write_json "$SEQUENCE_FILE" "$updated_sequence" echo "Updated Account $account_num: $current_email (credentials refreshed)" exit 0 fi # Add new account local account_num account_num=$(get_next_account_number) # Store backups write_account_credentials "$account_num" "$current_email" "$current_creds" write_account_config "$account_num" "$current_email" "$current_config" # Update sequence.json local updated_sequence updated_sequence=$(jq --arg num "$account_num" --arg email "$current_email" --arg uuid "$account_uuid" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" ' .accounts[$num] = { email: $email, uuid: $uuid, 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" } # Remove account cmd_remove_account() { if [[ $# -eq 0 ]]; then echo "Usage: $0 --remove-account " 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 " 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 current_account=$(jq -r '.activeAccountNumber' "$SEQUENCE_FILE") target_email=$(jq -r --arg num "$target_account" '.accounts[$num].email' "$SEQUENCE_FILE") current_email=$(get_current_account) # 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: Activate target account write_credentials "$target_creds" # Extract oauthAccount from backup and validate 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 # Merge with current config and validate 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 # Use existing safe write_json function write_json "$(get_claude_config_path)" "$merged_config" # Step 4: 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)" # Display updated account list cmd_list echo "" echo "Please restart Claude Code to use the new authentication." echo "" } # Refresh tokens for all accounts cmd_refresh() { echo "Refreshing OAuth tokens..." echo "" local refreshed=0 local failed=0 local skipped=0 # Check if Claude Code is running local claude_is_running=false if is_claude_running; then claude_is_running=true echo "Warning: Claude Code is running. Will skip refreshing the active account." echo " (Refreshing would invalidate tokens that Claude Code is using)" echo "" fi # Refresh current account local current_email current_email=$(get_current_account) if [[ "$current_email" != "none" ]]; then local refresh_token refresh_token=$(get_refresh_token) if [[ -n "$refresh_token" ]]; then # Skip refreshing current account if Claude is running if [[ "$claude_is_running" == "true" ]]; then echo "Skipping current account ($current_email): Claude Code is running" echo " To refresh this account, close Claude Code first and run --refresh again" ((skipped++)) else echo -n "Refreshing current account ($current_email)... " local refresh_response refresh_response=$(refresh_oauth_token "$refresh_token") if [[ "$refresh_response" == ERROR:* ]]; then # Extract error message after "ERROR:" local error_msg="${refresh_response#ERROR:}" echo "failed ($error_msg)" ((failed++)) elif [[ -n "$refresh_response" ]]; then local new_access new_refresh expires_in new_access=$(echo "$refresh_response" | jq -r '.access_token // empty') new_refresh=$(echo "$refresh_response" | jq -r '.refresh_token // empty') expires_in=$(echo "$refresh_response" | jq -r '.expires_in // 28800') if [[ -n "$new_access" ]]; then if [[ -z "$new_refresh" ]]; then new_refresh="$refresh_token" fi update_credentials_with_token "$new_access" "$new_refresh" "$expires_in" echo "done (valid for $((expires_in / 3600))h)" ((refreshed++)) else echo "failed (invalid response)" ((failed++)) fi else echo "failed (no response)" ((failed++)) fi fi else echo "Skipping current account ($current_email): no refresh token stored" ((skipped++)) fi fi # Refresh managed accounts (if any) if [[ -f "$SEQUENCE_FILE" ]]; then local account_nums account_nums=$(jq -r '.sequence[]' "$SEQUENCE_FILE") for account_num in $account_nums; do local email email=$(jq -r --arg num "$account_num" '.accounts[$num].email' "$SEQUENCE_FILE") # Skip if this is the current account (already refreshed) if [[ "$email" == "$current_email" ]]; then continue fi local refresh_token refresh_token=$(get_account_refresh_token "$account_num" "$email") if [[ -n "$refresh_token" ]]; then echo -n "Refreshing account $account_num ($email)... " local refresh_response refresh_response=$(refresh_oauth_token "$refresh_token") if [[ "$refresh_response" == ERROR:* ]]; then local error_msg="${refresh_response#ERROR:}" echo "failed ($error_msg)" ((failed++)) elif [[ -n "$refresh_response" ]]; then local new_access new_refresh expires_in new_access=$(echo "$refresh_response" | jq -r '.access_token // empty') new_refresh=$(echo "$refresh_response" | jq -r '.refresh_token // empty') expires_in=$(echo "$refresh_response" | jq -r '.expires_in // 28800') if [[ -n "$new_access" ]]; then if [[ -z "$new_refresh" ]]; then new_refresh="$refresh_token" fi update_account_credentials_with_token "$account_num" "$email" "$new_access" "$new_refresh" "$expires_in" echo "done (valid for $((expires_in / 3600))h)" ((refreshed++)) else echo "failed (invalid response)" ((failed++)) fi else echo "failed (no response)" ((failed++)) fi else echo "Skipping account $account_num ($email): no refresh token stored" ((skipped++)) fi done fi echo "" echo "Refresh complete: $refreshed succeeded, $failed failed, $skipped skipped" if [[ $failed -gt 0 ]]; then echo "" echo "Note: If refresh failed with 'invalid_grant', run 'claude /login' to re-authenticate." fi } # 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 Remove account by number or email" echo " --list List all managed accounts" echo " --switch Rotate to next account in sequence" echo " --switch-to Switch to specific account number or email" echo " --usage Show usage limits for all accounts" echo " --refresh Refresh OAuth tokens for all accounts" 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" echo " $0 --usage" echo " $0 --refresh" } # 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 "$@" ;; --usage) cmd_usage ;; --refresh) cmd_refresh ;; --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