1615 lines
49 KiB
Bash
Executable File
1615 lines
49 KiB
Bash
Executable File
#!/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 '<!DOCTYPE html>'; 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 <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
|
|
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 <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 " --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
|