楼主有两个5x账号,深感切换不便,便写了个脚本,可能会有bug,请自行用claude/codex修复~。
需要提前运行: pip install rich 进行rich库安装
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import secrets
import shlex
import shutil
import subprocess
import sys
import hashlib
from datetime import datetime
from pathlib import Path
from typing import Any
try:
import pwd # type: ignore
except ImportError: # pragma: no cover - Windows
pwd = None # type: ignore
try:
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Confirm, Prompt
from rich.table import Table
from rich.text import Text
except ImportError:
print("缺少依赖 rich,请先执行: pip install rich", file=sys.stderr)
sys.exit(1)
console = Console()
HOME = Path.home()
ROOT = Path(os.environ.get("CLAUDE_SWITCHER_HOME", HOME / ".claude-switcher-direct"))
SLOTS_HOME = ROOT / "slots"
AUTO_BACKUPS_HOME = ROOT / "auto-backups"
STATE_FILE = ROOT / "state.json"
LIVE_MODERN_CONFIG = HOME / ".claude.json"
LIVE_LEGACY_CONFIG = HOME / ".claude" / ".config.json"
LIVE_CREDENTIALS = HOME / ".claude" / ".credentials.json"
RESERVED_COMMANDS = {
"help",
"--help",
"-h",
"tui",
"add-account",
"add",
"doctor",
"check",
"normalize-live",
"normalize",
"list",
"ls",
"save",
"capture",
"switch",
"use",
"login",
"logout",
"launch",
"run",
"current",
"whoami",
"paths",
"env",
"remove",
"rm",
}
def effective_platform() -> str:
forced = os.environ.get("CLAUDE_SWITCHER_FORCE_PLATFORM")
if forced:
return forced
return sys.platform
def is_macos() -> bool:
return effective_platform() == "darwin"
def env_truthy(name: str) -> bool:
value = os.environ.get(name)
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "on"}
def ensure_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def read_json(path: Path, fallback: Any = None) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return fallback
def write_json(path: Path, data: Any) -> None:
ensure_dir(path.parent)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def write_bytes(path: Path, data: bytes, *, chmod_600: bool = False) -> None:
ensure_dir(path.parent)
path.write_bytes(data)
if chmod_600 and os.name != "nt":
try:
path.chmod(0o600)
except Exception:
pass
def timestamp_slug() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
def sanitize_name(name: str) -> str:
invalid = '<>:"/\\|?*'
out: list[str] = []
for ch in name.strip():
if ord(ch) < 32 or ch in invalid:
out.append("-")
elif ch.isspace():
out.append("-")
else:
out.append(ch)
text = "".join(out)
while "--" in text:
text = text.replace("--", "-")
return text.strip("-")
def require_name(name: str | None, what: str = "名称") -> str:
value = (name or "").strip()
if not value:
fail(f"缺少{what}。")
return value
def load_state() -> dict[str, Any]:
state = read_json(STATE_FILE, None)
if isinstance(state, dict) and isinstance(state.get("slots"), dict):
state.setdefault("version", 1)
state.setdefault("lastApplied", None)
state.setdefault("accountUserIDs", {})
return state
return {"version": 1, "lastApplied": None, "slots": {}, "accountUserIDs": {}}
def save_state(state: dict[str, Any]) -> None:
write_json(STATE_FILE, state)
def oauth_file_suffix() -> str:
if os.environ.get("CLAUDE_CODE_CUSTOM_OAUTH_URL"):
return "-custom-oauth"
if os.environ.get("USER_TYPE") == "ant":
if env_truthy("USE_LOCAL_OAUTH"):
return "-local-oauth"
if env_truthy("USE_STAGING_OAUTH"):
return "-staging-oauth"
return ""
def get_claude_config_home_dir() -> Path:
custom = os.environ.get("CLAUDE_CONFIG_DIR")
if custom:
return Path(custom).expanduser()
return HOME / ".claude"
def get_macos_keychain_service_name() -> str:
config_dir = str(get_claude_config_home_dir())
is_default_dir = "CLAUDE_CONFIG_DIR" not in os.environ
dir_hash = "" if is_default_dir else "-" + hashlib.sha256(config_dir.encode("utf-8")).hexdigest()[:8]
return f"Claude Code{oauth_file_suffix()}-credentials{dir_hash}"
def get_macos_keychain_username() -> str:
if os.environ.get("USER"):
return os.environ["USER"]
if pwd is not None:
try:
return pwd.getpwuid(os.getuid()).pw_name
except Exception:
pass
return "claude-code-user"
def get_security_bin() -> str:
return os.environ.get("CLAUDE_SWITCHER_SECURITY_BIN", "security")
def read_macos_keychain_json() -> dict[str, Any] | None:
if not is_macos():
return None
try:
result = subprocess.run(
[
get_security_bin(),
"find-generic-password",
"-a",
get_macos_keychain_username(),
"-w",
"-s",
get_macos_keychain_service_name(),
],
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError:
return None
except Exception:
return None
if result.returncode != 0 or not result.stdout:
return None
try:
return json.loads(result.stdout.strip())
except Exception:
return None
def write_macos_keychain_json(data: dict[str, Any]) -> bool:
if not is_macos():
return False
try:
payload = json.dumps(data, ensure_ascii=False, indent=2)
hex_value = payload.encode("utf-8").hex()
result = subprocess.run(
[
get_security_bin(),
"add-generic-password",
"-U",
"-a",
get_macos_keychain_username(),
"-s",
get_macos_keychain_service_name(),
"-X",
hex_value,
],
capture_output=True,
text=True,
check=False,
)
return result.returncode == 0
except Exception:
return False
def read_live_credentials_json() -> tuple[dict[str, Any] | None, str]:
if is_macos():
keychain_data = read_macos_keychain_json()
if isinstance(keychain_data, dict):
return keychain_data, "keychain"
file_data = read_json(LIVE_CREDENTIALS, None)
if isinstance(file_data, dict):
return file_data, "file"
return None, "missing"
def generate_user_id() -> str:
return secrets.token_hex(32)
def short_id(value: str | None, length: int = 12) -> str:
if not value:
return "-"
if len(value) <= length:
return value
return f"{value[:length]}..."
def account_key(email: str | None, account_uuid: str | None) -> str | None:
if account_uuid:
return f"account_uuid:{account_uuid}"
if email:
return f"email:{email.strip().lower()}"
return None
def remember_account_user_id(
state: dict[str, Any],
*,
user_id: str | None,
email: str | None,
account_uuid: str | None,
) -> None:
key = account_key(email, account_uuid)
if not key or not user_id:
return
state.setdefault("accountUserIDs", {})
state["accountUserIDs"][key] = user_id
def get_account_bound_user_id(
state: dict[str, Any],
*,
email: str | None,
account_uuid: str | None,
) -> str | None:
key = account_key(email, account_uuid)
if not key:
return None
value = (state.get("accountUserIDs") or {}).get(key)
return value if isinstance(value, str) and value else None
def get_saved_user_ids(state: dict[str, Any], *, exclude_name: str | None = None) -> set[str]:
found: set[str] = set()
for slot_name, slot in state.get("slots", {}).items():
if exclude_name and slot_name == exclude_name:
continue
user_id = slot.get("userID")
if isinstance(user_id, str) and user_id:
found.add(user_id)
continue
meta = read_json(slot_files(Path(slot["dir"]))["meta"], {}) or {}
meta_user_id = meta.get("userID")
if isinstance(meta_user_id, str) and meta_user_id:
found.add(meta_user_id)
return found
def choose_slot_user_id(
state: dict[str, Any],
slot_name: str,
preferred: str | None = None,
*,
email: str | None = None,
account_uuid: str | None = None,
) -> str:
slot = state.get("slots", {}).get(slot_name) or {}
email = email or slot.get("email")
account_uuid = account_uuid or slot.get("accountUuid")
requested_key = account_key(email, account_uuid)
slot_key = account_key(slot.get("email"), slot.get("accountUuid"))
bound = get_account_bound_user_id(state, email=email, account_uuid=account_uuid)
if bound:
return bound
reuse_slot_specific_id = not requested_key or not slot_key or requested_key == slot_key
existing = slot.get("userID")
if reuse_slot_specific_id and isinstance(existing, str) and existing:
return existing
meta = read_json(slot_files(slot_dir(slot_name))["meta"], {}) or {}
bound = get_account_bound_user_id(
state,
email=meta.get("email"),
account_uuid=meta.get("accountUuid"),
)
if bound:
return bound
meta_user_id = meta.get("userID")
meta_key = account_key(meta.get("email"), meta.get("accountUuid"))
if (reuse_slot_specific_id or not meta_key or meta_key == requested_key) and isinstance(meta_user_id, str) and meta_user_id:
return meta_user_id
used = get_saved_user_ids(state, exclude_name=slot_name)
if isinstance(preferred, str) and preferred and preferred not in used:
return preferred
while True:
candidate = generate_user_id()
if candidate not in used:
return candidate
def apply_user_id_to_snapshot(directory: Path, user_id: str) -> None:
files = slot_files(directory)
config = read_json(files["config"], None)
if isinstance(config, dict):
config["userID"] = user_id
write_json(files["config"], config)
meta = read_json(files["meta"], {}) or {}
meta["userID"] = user_id
write_json(files["meta"], meta)
def apply_user_id_to_live(user_id: str) -> None:
paths = live_paths()
config = read_json(paths["active_config"], None)
if not isinstance(config, dict):
return
config["userID"] = user_id
payload = json.dumps(config, ensure_ascii=False, indent=2).encode("utf-8")
write_bytes(LIVE_MODERN_CONFIG, payload)
write_bytes(LIVE_LEGACY_CONFIG, payload)
def slot_dir(name: str) -> Path:
safe = sanitize_name(name)
if not safe:
fail("slot 名称非法。")
return (SLOTS_HOME / safe).resolve()
def slot_files(directory: Path) -> dict[str, Path]:
return {
"config": directory / "global_config.json",
"credentials": directory / "credentials.json",
"macos_keychain": directory / "macos_keychain_credentials.json",
"meta": directory / "meta.json",
}
def live_paths() -> dict[str, Path]:
active_config = LIVE_LEGACY_CONFIG if LIVE_LEGACY_CONFIG.exists() else LIVE_MODERN_CONFIG
return {
"modern_config": LIVE_MODERN_CONFIG,
"legacy_config": LIVE_LEGACY_CONFIG,
"active_config": active_config,
"credentials": LIVE_CREDENTIALS,
}
def detect_claude_command() -> str:
return os.environ.get("CLAUDE_BIN") or ("claude.cmd" if os.name == "nt" else "claude")
def get_installed_claude_info() -> dict[str, Any]:
command = detect_claude_command()
resolved = shutil.which(command)
info: dict[str, Any] = {
"command": command,
"resolved": resolved,
"package_json": None,
"version": None,
}
if not resolved:
return info
resolved_path = Path(resolved)
candidates = []
if resolved_path.name.lower().endswith(".cmd") or resolved_path.name.lower().endswith(".ps1"):
candidates.append(resolved_path.parent / "node_modules" / "@anthropic-ai" / "claude-code" / "package.json")
candidates.append(resolved_path.parent / "node_modules" / "@anthropic-ai" / "claude-code" / "package.json")
for candidate in candidates:
if candidate.exists():
info["package_json"] = str(candidate)
pkg = read_json(candidate, {}) or {}
if isinstance(pkg, dict):
info["version"] = pkg.get("version")
break
return info
def run_claude(args: list[str]) -> int:
claude_bin = detect_claude_command()
command_preview = f"{claude_bin} {' '.join(shlex.quote(a) for a in args)}".strip()
console.print(
Panel(
Text.from_markup(
f"[bold cyan]启动 Claude[/]\n"
f"命令: [magenta]{command_preview}[/]\n"
f"当前 live 文件: [yellow]{live_paths()['active_config']}[/]"
),
title="Launch",
border_style="cyan",
)
)
try:
if os.name == "nt":
cmdline = subprocess.list2cmdline([claude_bin, *args])
result = subprocess.run(cmdline, shell=True)
else:
result = subprocess.run([claude_bin, *args])
return int(result.returncode)
except FileNotFoundError:
fail("启动 Claude 失败:未找到 claude 命令。可检查 PATH,或设置 CLAUDE_BIN。")
except Exception as exc:
fail(f"启动 Claude 失败:{exc}")
return 1
def read_live_status() -> dict[str, Any]:
paths = live_paths()
config = read_json(paths["active_config"], {}) or {}
credentials, credentials_source = read_live_credentials_json()
credentials = credentials or {}
oauth = credentials.get("claudeAiOauth") or {}
return {
"active_config_path": str(paths["active_config"]),
"modern_config_exists": paths["modern_config"].exists(),
"legacy_config_exists": paths["legacy_config"].exists(),
"credentials_exists": paths["credentials"].exists(),
"credentials_source": credentials_source,
"macos_keychain_service": get_macos_keychain_service_name() if is_macos() else None,
"macos_keychain_present": credentials_source == "keychain",
"user_id": config.get("userID") or None,
"email": (((config.get("oauthAccount") or {}).get("emailAddress")) or None),
"account_uuid": (((config.get("oauthAccount") or {}).get("accountUuid")) or None),
"organization_uuid": (((config.get("oauthAccount") or {}).get("organizationUuid")) or None),
"has_access_token": bool(oauth.get("accessToken")),
"has_refresh_token": bool(oauth.get("refreshToken")),
"expires_at": oauth.get("expiresAt"),
"subscription_type": oauth.get("subscriptionType"),
"rate_limit_tier": oauth.get("rateLimitTier"),
}
def read_slot_status(name: str) -> dict[str, Any]:
files = slot_files(slot_dir(name))
meta = read_json(files["meta"], {}) or {}
config = read_json(files["config"], {}) or {}
credentials = read_json(files["credentials"], {}) or {}
oauth = credentials.get("claudeAiOauth") or {}
return {
"name": name,
"dir": str(files["meta"].parent),
"saved_at": meta.get("savedAt"),
"kind": meta.get("kind", "manual"),
"user_id": meta.get("userID") or config.get("userID") or None,
"email": meta.get("email") or (((config.get("oauthAccount") or {}).get("emailAddress")) or None),
"account_uuid": meta.get("accountUuid") or (((config.get("oauthAccount") or {}).get("accountUuid")) or None),
"organization_uuid": (((config.get("oauthAccount") or {}).get("organizationUuid")) or None),
"has_config": files["config"].exists(),
"has_credentials": files["credentials"].exists(),
"has_macos_keychain_snapshot": files["macos_keychain"].exists(),
"has_access_token": bool(oauth.get("accessToken")),
"has_refresh_token": bool(oauth.get("refreshToken")),
"expires_at": oauth.get("expiresAt"),
"subscription_type": oauth.get("subscriptionType"),
"rate_limit_tier": oauth.get("rateLimitTier"),
"meta": meta,
}
def format_time(value: Any) -> str:
if not value:
return "-"
try:
return datetime.fromtimestamp(float(value) / 1000.0).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return str(value)
def fail(message: str) -> None:
console.print(f"[bold red][claude-switcher][/bold red] {message}")
raise SystemExit(1)
def ok(message: str) -> None:
console.print(f"[bold green][OK][/bold green] {message}")
def note(message: str) -> None:
console.print(f"[bold yellow][INFO][/bold yellow] {message}")
def save_snapshot_from_live(target_dir: Path, name: str, kind: str) -> dict[str, Any]:
ensure_dir(target_dir)
live = live_paths()
copied_any = False
if live["active_config"].exists():
write_bytes(slot_files(target_dir)["config"], live["active_config"].read_bytes())
copied_any = True
credentials_json, credentials_source = read_live_credentials_json()
if isinstance(credentials_json, dict):
payload = json.dumps(credentials_json, ensure_ascii=False, indent=2).encode("utf-8")
write_bytes(slot_files(target_dir)["credentials"], payload, chmod_600=True)
if is_macos():
write_bytes(slot_files(target_dir)["macos_keychain"], payload, chmod_600=True)
copied_any = True
elif live["credentials"].exists():
write_bytes(slot_files(target_dir)["credentials"], live["credentials"].read_bytes(), chmod_600=True)
copied_any = True
if not copied_any:
fail("当前 live 文件里没有可备份内容(未找到配置或凭证文件)。")
status = read_live_status()
meta = {
"name": name,
"kind": kind,
"savedAt": datetime.now().isoformat(timespec="seconds"),
"userID": status["user_id"],
"email": status["email"],
"accountUuid": status["account_uuid"],
"activeConfigPath": status["active_config_path"],
"credentialsSource": credentials_source,
"modernConfigExists": status["modern_config_exists"],
"legacyConfigExists": status["legacy_config_exists"],
"credentialsExists": status["credentials_exists"],
}
write_json(slot_files(target_dir)["meta"], meta)
return meta
def create_auto_backup() -> dict[str, Any] | None:
live = live_paths()
if not live["active_config"].exists() and not live["credentials"].exists():
return None
name = f"auto_{timestamp_slug()}"
directory = AUTO_BACKUPS_HOME / name
meta = save_snapshot_from_live(directory, name, "auto")
meta["dir"] = str(directory)
return meta
def save_live_to_slot(name: str | None) -> dict[str, Any]:
slot_name = require_name(name, "slot 名称")
state = load_state()
directory = slot_dir(slot_name)
meta = save_snapshot_from_live(directory, slot_name, "manual")
slot_user_id = choose_slot_user_id(
state,
slot_name,
preferred=meta.get("userID"),
email=meta.get("email"),
account_uuid=meta.get("accountUuid"),
)
apply_user_id_to_snapshot(directory, slot_user_id)
apply_user_id_to_live(slot_user_id)
meta = read_json(slot_files(directory)["meta"], {}) or meta
state["slots"][slot_name] = {
"name": slot_name,
"dir": str(directory),
"savedAt": meta["savedAt"],
"userID": slot_user_id,
"email": meta.get("email"),
"accountUuid": meta.get("accountUuid"),
}
remember_account_user_id(
state,
user_id=slot_user_id,
email=meta.get("email"),
account_uuid=meta.get("accountUuid"),
)
state["lastApplied"] = slot_name
save_state(state)
ok(f"已把当前 live 文件保存到 slot: {slot_name}")
note(f"目录: {directory}")
note(f"userID: {slot_user_id}")
note("当前 live .claude.json 也已同步为这个 slot 的 userID")
return meta
def ensure_slot_exists(state: dict[str, Any], name: str) -> dict[str, Any]:
slot = state["slots"].get(name)
if not slot:
fail(f"找不到 slot: {name}")
return slot
def restore_slot_to_live(name: str | None, *, backup_current: bool = True) -> dict[str, Any] | None:
slot_name = require_name(name, "slot 名称")
state = load_state()
slot = ensure_slot_exists(state, slot_name)
directory = Path(slot["dir"])
files = slot_files(directory)
slot_status = read_slot_status(slot_name)
if not files["config"].exists() and not files["credentials"].exists():
fail(f"slot {slot_name} 没有可恢复的文件。")
auto_meta = create_auto_backup() if backup_current else None
if files["config"].exists():
slot_user_id = choose_slot_user_id(
state,
slot_name,
preferred=slot_status["user_id"],
email=slot_status["email"],
account_uuid=slot_status["account_uuid"],
)
config_data = read_json(files["config"], None)
if isinstance(config_data, dict):
config_data["userID"] = slot_user_id
write_json(files["config"], config_data)
apply_user_id_to_snapshot(directory, slot_user_id)
config_bytes = json.dumps(config_data, ensure_ascii=False, indent=2).encode("utf-8")
else:
config_bytes = files["config"].read_bytes()
slot_user_id = slot.get("userID") or slot_status["user_id"]
# 为了兼容 Claude 源码里 legacy 优先逻辑,恢复时同步写到两个路径
write_bytes(LIVE_MODERN_CONFIG, config_bytes)
write_bytes(LIVE_LEGACY_CONFIG, config_bytes)
if slot_user_id:
slot["userID"] = slot_user_id
if files["credentials"].exists():
write_bytes(LIVE_CREDENTIALS, files["credentials"].read_bytes(), chmod_600=True)
if is_macos():
macos_source_file = files["macos_keychain"] if files["macos_keychain"].exists() else files["credentials"]
macos_payload = read_json(macos_source_file, None)
if isinstance(macos_payload, dict):
if write_macos_keychain_json(macos_payload):
note(f"已恢复 macOS Keychain: {get_macos_keychain_service_name()}")
else:
note("警告:macOS Keychain 恢复失败,当前将依赖 .credentials.json fallback")
state["lastApplied"] = slot_name
slot["email"] = slot_status["email"]
slot["accountUuid"] = slot_status["account_uuid"]
remember_account_user_id(
state,
user_id=slot.get("userID"),
email=slot_status["email"],
account_uuid=slot_status["account_uuid"],
)
save_state(state)
ok(f"已恢复 slot 到 live 文件: {slot_name}")
if auto_meta:
note(f"切换前自动备份: {auto_meta['dir']}")
note(f"live config: {LIVE_MODERN_CONFIG} + {LIVE_LEGACY_CONFIG}")
note(f"live credentials: {LIVE_CREDENTIALS}")
if slot.get("userID"):
note(f"已写入 slot 专属 userID: {slot['userID']}")
return auto_meta
def remove_slot(name: str | None) -> None:
slot_name = require_name(name, "slot 名称")
state = load_state()
slot = ensure_slot_exists(state, slot_name)
directory = Path(slot["dir"])
if directory.exists():
shutil.rmtree(directory)
del state["slots"][slot_name]
if state.get("lastApplied") == slot_name:
state["lastApplied"] = None
save_state(state)
ok(f"已删除 slot: {slot_name}")
def show_current() -> None:
state = load_state()
live = read_live_status()
console.print(f"[bold cyan]lastApplied:[/] {state.get('lastApplied') or '-'}")
console.print(f"[bold cyan]live email:[/] {live['email'] or '-'}")
console.print(f"[bold cyan]live userID:[/] {live['user_id'] or '-'}")
console.print(f"[bold cyan]active config:[/] {live['active_config_path']}")
def show_whoami(name: str | None = None) -> None:
if name:
status = read_slot_status(name)
title = f"Slot: {name}"
else:
status = read_live_status()
title = "Live Files"
table = Table(title=title, show_header=False, box=None)
table.add_column("k", style="cyan", no_wrap=True)
table.add_column("v")
if name:
table.add_row("dir", status["dir"])
table.add_row("savedAt", status["saved_at"] or "-")
else:
table.add_row("activeConfig", status["active_config_path"])
table.add_row("modernConfigExists", "yes" if status["modern_config_exists"] else "no")
table.add_row("legacyConfigExists", "yes" if status["legacy_config_exists"] else "no")
table.add_row("credentialsExists", "yes" if status["credentials_exists"] else "no")
table.add_row("userID", status["user_id"] or "-")
table.add_row("email", status["email"] or "-")
table.add_row("accountUuid", status["account_uuid"] or "-")
table.add_row("organizationUuid", status["organization_uuid"] or "-")
table.add_row("subscriptionType", status["subscription_type"] or "-")
table.add_row("rateLimitTier", status["rate_limit_tier"] or "-")
table.add_row("hasAccessToken", "yes" if status["has_access_token"] else "no")
table.add_row("hasRefreshToken", "yes" if status["has_refresh_token"] else "no")
table.add_row("expiresAt", format_time(status["expires_at"]))
console.print(table)
def show_paths(name: str | None = None) -> None:
table = Table(show_header=False, box=None, title="Paths")
table.add_column("k", style="cyan", no_wrap=True)
table.add_column("v")
table.add_row("storageRoot", str(ROOT))
table.add_row("slotsRoot", str(SLOTS_HOME))
table.add_row("autoBackupsRoot", str(AUTO_BACKUPS_HOME))
table.add_row("liveModernConfig", str(LIVE_MODERN_CONFIG))
table.add_row("liveLegacyConfig", str(LIVE_LEGACY_CONFIG))
table.add_row("liveCredentials", str(LIVE_CREDENTIALS))
if is_macos():
table.add_row("macOSKeychainService", get_macos_keychain_service_name())
if name:
table.add_row("slotDir", str(slot_dir(name)))
files = slot_files(slot_dir(name))
table.add_row("slotConfig", str(files["config"]))
table.add_row("slotCredentials", str(files["credentials"]))
table.add_row("slotMacKeychain", str(files["macos_keychain"]))
table.add_row("slotMeta", str(files["meta"]))
console.print(table)
def print_env() -> None:
console.print("[yellow]这个版本不依赖 CLAUDE_CONFIG_DIR。[/]")
console.print("[yellow]它会直接修改官方 live 文件:[/]")
console.print(str(LIVE_MODERN_CONFIG))
console.print(str(LIVE_LEGACY_CONFIG))
console.print(str(LIVE_CREDENTIALS))
def normalize_live_config(*, backup_current: bool = True) -> dict[str, Any] | None:
live = live_paths()
config = read_json(live["active_config"], None)
if not isinstance(config, dict):
fail("当前 live config 不存在或无法解析,无法 normalize。")
auto_meta = create_auto_backup() if backup_current else None
payload = json.dumps(config, ensure_ascii=False, indent=2).encode("utf-8")
write_bytes(LIVE_MODERN_CONFIG, payload)
write_bytes(LIVE_LEGACY_CONFIG, payload)
ok("已完成 live config normalize")
if auto_meta:
note(f"normalize 前自动备份: {auto_meta['dir']}")
note(f"已同步: {LIVE_MODERN_CONFIG}")
note(f"已同步: {LIVE_LEGACY_CONFIG}")
return auto_meta
def collect_doctor_data() -> dict[str, Any]:
state = load_state()
live = read_live_status()
install = get_installed_claude_info()
findings: list[tuple[str, str]] = []
warnings: list[str] = []
ok_items: list[str] = []
if install["resolved"]:
ok_items.append(f"已检测到 Claude 命令: {install['resolved']}")
else:
findings.append(("error", "未在 PATH 中找到 claude 命令"))
if install["version"]:
ok_items.append(f"本机 Claude Code 版本: {install['version']}")
else:
warnings.append("未能解析已安装 Claude Code 版本")
if live["modern_config_exists"] or live["legacy_config_exists"]:
ok_items.append(f"检测到 live config: {live['active_config_path']}")
else:
findings.append(("error", "未找到 live config(.claude.json / .claude/.config.json)"))
if live["credentials_exists"]:
ok_items.append("检测到 live credentials")
else:
warnings.append("未找到 live .credentials.json")
if is_macos():
if live["macos_keychain_present"]:
ok_items.append(f"检测到 macOS Keychain 凭证: {live['macos_keychain_service']}")
else:
warnings.append("macOS 未检测到 Keychain OAuth 凭证,将依赖 .credentials.json fallback")
if live["user_id"]:
ok_items.append("live userID 存在")
else:
findings.append(("error", "live config 缺少 userID"))
slots = state.get("slots", {})
account_to_user_ids: dict[str, set[str]] = {}
user_id_to_accounts: dict[str, set[str]] = {}
for slot_name, slot in sorted(slots.items()):
status = read_slot_status(slot_name)
files = slot_files(Path(slot["dir"]))
meta = read_json(files["meta"], {}) or {}
config = read_json(files["config"], {}) or {}
if not files["config"].exists():
findings.append(("error", f"slot {slot_name} 缺少 global_config.json"))
if not files["credentials"].exists():
warnings.append(f"slot {slot_name} 缺少 credentials.json")
if is_macos() and not files["macos_keychain"].exists():
warnings.append(f"slot {slot_name} 缺少 macos_keychain_credentials.json")
if not files["meta"].exists():
warnings.append(f"slot {slot_name} 缺少 meta.json")
state_user_id = slot.get("userID")
meta_user_id = meta.get("userID")
config_user_id = config.get("userID") if isinstance(config, dict) else None
if state_user_id and meta_user_id and state_user_id != meta_user_id:
findings.append(("error", f"slot {slot_name} 的 state.userID 与 meta.userID 不一致"))
if state_user_id and config_user_id and state_user_id != config_user_id:
findings.append(("error", f"slot {slot_name} 的 state.userID 与 config.userID 不一致"))
if not status["user_id"]:
findings.append(("error", f"slot {slot_name} 缺少 userID"))
acc_key = account_key(status["email"], status["account_uuid"])
if acc_key and status["user_id"]:
account_to_user_ids.setdefault(acc_key, set()).add(status["user_id"])
user_id_to_accounts.setdefault(status["user_id"], set()).add(acc_key)
for acc_key, user_ids in sorted(account_to_user_ids.items()):
if len(user_ids) > 1:
findings.append(("error", f"同一账号 {acc_key} 绑定了多个 userID: {', '.join(sorted(user_ids))}"))
for user_id, account_keys in sorted(user_id_to_accounts.items()):
if len(account_keys) > 1:
findings.append(
(
"error",
f"userID {user_id} 被多个账号共用: {', '.join(sorted(account_keys))}",
)
)
last_applied = state.get("lastApplied")
if last_applied:
if last_applied not in slots:
findings.append(("error", f"lastApplied 指向不存在的 slot: {last_applied}"))
else:
last_status = read_slot_status(last_applied)
if live["user_id"] and last_status["user_id"] and live["user_id"] != last_status["user_id"]:
warnings.append(
f"当前 live userID 与 lastApplied({last_applied}) 不一致,说明 live 状态可能被外部 login/logout 改过"
)
return {
"state": state,
"live": live,
"install": install,
"findings": findings,
"warnings": warnings,
"ok_items": ok_items,
}
def run_doctor() -> bool:
data = collect_doctor_data()
install = data["install"]
live = data["live"]
findings = data["findings"]
warnings = data["warnings"]
ok_items = data["ok_items"]
state = data["state"]
summary = Table(show_header=False, box=None, title="Doctor Summary")
summary.add_column("k", style="cyan", no_wrap=True)
summary.add_column("v")
summary.add_row("Claude command", install["resolved"] or "-")
summary.add_row("Claude version", install["version"] or "-")
summary.add_row("lastApplied", state.get("lastApplied") or "-")
summary.add_row("live email", live["email"] or "-")
summary.add_row("live accountUuid", live["account_uuid"] or "-")
summary.add_row("live userID", live["user_id"] or "-")
summary.add_row("credentials source", live["credentials_source"] or "-")
if is_macos():
summary.add_row("macOS keychain service", live["macos_keychain_service"] or "-")
summary.add_row("slot count", str(len(state.get("slots", {}))))
console.print(summary)
if ok_items:
ok_table = Table(title="Checks OK", show_header=False, box=None)
ok_table.add_column("v", style="green")
for item in ok_items:
ok_table.add_row(f"[OK] {item}")
console.print(ok_table)
if warnings:
warn_table = Table(title="Warnings", show_header=False, box=None)
warn_table.add_column("v", style="yellow")
for item in warnings:
warn_table.add_row(f"[WARN] {item}")
console.print(warn_table)
if findings:
err_table = Table(title="Problems", show_header=False, box=None)
err_table.add_column("severity", style="red", no_wrap=True)
err_table.add_column("message")
for severity, message in findings:
err_table.add_row(severity.upper(), message)
console.print(err_table)
console.print("[bold red]Doctor 发现问题,请先修复再大规模使用。[/]")
return False
console.print("[bold green]Doctor 检查通过:当前配置和已保存 slot 没发现硬冲突。[/]")
return True
def list_slots() -> None:
state = load_state()
console.print(render_header(state))
console.print(render_live_panel(state))
console.print(render_slots_table(state))
console.print(render_auto_backups_table())
def render_header(state: dict[str, Any]) -> Panel:
body = Text()
body.append("模式: ", style="bold cyan")
body.append("复制文件备份 + 直接修改 live 文件\n", style="bold green")
body.append("lastApplied: ", style="bold cyan")
body.append(f"{state.get('lastApplied') or '-'}\n", style="white")
body.append("Claude 命令: ", style="bold cyan")
body.append(detect_claude_command(), style="magenta")
return Panel(body, title="Claude Switcher Direct", border_style="cyan")
def render_live_panel(state: dict[str, Any]) -> Panel:
live = read_live_status()
body = Text()
body.append("当前 live 邮箱: ", style="bold cyan")
body.append(f"{live['email'] or '-'}\n", style="white")
body.append("当前 live userID: ", style="bold cyan")
body.append(f"{short_id(live['user_id'], 20)}\n", style="white")
body.append("Active config: ", style="bold cyan")
body.append(f"{live['active_config_path']}\n", style="white")
body.append("Access/Refresh: ", style="bold cyan")
body.append(
f"{'yes' if live['has_access_token'] else 'no'} / {'yes' if live['has_refresh_token'] else 'no'}\n",
style="white",
)
body.append("过期时间: ", style="bold cyan")
body.append(format_time(live["expires_at"]), style="white")
return Panel(body, title="Live Files", border_style="green")
def render_slots_table(state: dict[str, Any]) -> Table:
table = Table(title="Saved Slots", expand=True)
table.add_column("#", style="dim", width=4, justify="right")
table.add_column("名称", style="bold")
table.add_column("userID", width=16)
table.add_column("邮箱")
table.add_column("Access", width=8, justify="center")
table.add_column("Refresh", width=8, justify="center")
table.add_column("保存时间", width=20)
table.add_column("目录", overflow="fold")
names = sorted(state["slots"])
if not names:
table.add_row("-", "还没有 slot", "-", "-", "-", "-", "-", "-")
return table
for idx, name in enumerate(names, 1):
status = read_slot_status(name)
table.add_row(
str(idx),
name,
short_id(status["user_id"], 14),
status["email"] or "-",
"[green]yes[/]" if status["has_access_token"] else "[red]no[/]",
"[green]yes[/]" if status["has_refresh_token"] else "[red]no[/]",
str(status["saved_at"] or "-"),
status["dir"],
)
return table
def render_slot_picker_table(state: dict[str, Any], title: str = "请选择账号") -> Table:
table = Table(title=title, expand=True)
table.add_column("序号", style="dim", width=6, justify="right")
table.add_column("名称", style="bold")
table.add_column("邮箱")
table.add_column("userID", width=16)
table.add_column("保存时间", width=20)
names = sorted(state["slots"])
if not names:
table.add_row("-", "还没有 slot", "-", "-", "-")
return table
for idx, name in enumerate(names, 1):
status = read_slot_status(name)
table.add_row(
str(idx),
name,
status["email"] or "-",
short_id(status["user_id"], 14),
str(status["saved_at"] or "-"),
)
return table
def render_auto_backups_table(limit: int = 5) -> Table:
table = Table(title=f"Recent Auto Backups (latest {limit})", expand=True)
table.add_column("名称", style="bold")
table.add_column("邮箱")
table.add_column("保存时间")
table.add_column("目录", overflow="fold")
if not AUTO_BACKUPS_HOME.exists():
table.add_row("-", "-", "-", "-")
return table
backup_dirs = sorted(
[p for p in AUTO_BACKUPS_HOME.iterdir() if p.is_dir()],
key=lambda p: p.name,
reverse=True,
)[:limit]
if not backup_dirs:
table.add_row("-", "-", "-", "-")
return table
for directory in backup_dirs:
meta = read_json(directory / "meta.json", {}) or {}
table.add_row(
directory.name,
meta.get("email") or "-",
meta.get("savedAt") or "-",
str(directory),
)
return table
def pause() -> None:
console.input("\n[dim]按 Enter 继续...[/]")
def pick_slot_name_interactive(prompt_text: str) -> str:
state = load_state()
names = sorted(state["slots"])
if not names:
fail("还没有 slot。")
console.print(render_slot_picker_table(state))
raw = Prompt.ask(prompt_text).strip()
if raw.isdigit():
idx = int(raw)
if 1 <= idx <= len(names):
return names[idx - 1]
fail(f"序号超出范围: {raw}")
return require_name(raw, "slot 名称或序号")
def select_slot(state: dict[str, Any], prompt_text: str) -> str:
names = sorted(state["slots"])
if not names:
fail("还没有 slot。")
console.print(render_slot_picker_table(state))
raw = Prompt.ask(prompt_text).strip()
if raw.isdigit():
idx = int(raw)
if 1 <= idx <= len(names):
return names[idx - 1]
fail(f"序号超出范围: {raw}")
return require_name(raw, "slot 名称")
def resolve_slot_input(name_or_index: str | None, *, prompt_text: str) -> str:
state = load_state()
names = sorted(state["slots"])
if not names:
fail("还没有 slot。")
raw = (name_or_index or "").strip()
if not raw:
return pick_slot_name_interactive(prompt_text)
if raw.isdigit():
idx = int(raw)
if 1 <= idx <= len(names):
return names[idx - 1]
fail(f"序号超出范围: {raw}")
if raw not in state["slots"]:
fail(f"找不到 slot: {raw}")
return raw
def tui_save_slot() -> None:
name = Prompt.ask("把当前 live 保存为什么 slot 名称").strip()
if not name:
note("已取消。")
return
save_live_to_slot(name)
def tui_switch_slot() -> None:
state = load_state()
name = select_slot(state, "输入 slot 名称或序号")
restore_slot_to_live(name, backup_current=True)
def tui_login_and_save() -> None:
name = Prompt.ask("登录后保存成哪个 slot").strip()
if not name:
note("已取消。")
return
extra = Prompt.ask("额外 login 参数(可留空)", default="").strip()
auto = create_auto_backup()
if auto:
note(f"登录前自动备份: {auto['dir']}")
code = run_claude(["login", *shlex.split(extra)])
note(f"claude login 退出码: {code}")
if code == 0:
save_live_to_slot(name)
def add_account_flow(current_slot: str | None, new_slot: str | None, extra_args: list[str]) -> int:
current_slot_name = require_name(current_slot, "当前账号 slot 名称")
new_slot_name = require_name(new_slot, "新账号 slot 名称")
ok("步骤 1/2:先保存当前 live 账号")
save_live_to_slot(current_slot_name)
ok("步骤 2/2:开始登录新账号,登录成功后自动保存")
return login_and_save(new_slot_name, extra_args)
def tui_add_account_flow() -> None:
current_slot = Prompt.ask("当前 live 账号保存成哪个 slot").strip()
if not current_slot:
note("已取消。")
return
new_slot = Prompt.ask("新登录账号保存成哪个 slot").strip()
if not new_slot:
note("已取消。")
return
extra = Prompt.ask("额外 login 参数(可留空)", default="").strip()
code = add_account_flow(current_slot, new_slot, shlex.split(extra))
note(f"新增账号流程退出码: {code}")
def tui_logout_live() -> None:
if not Confirm.ask("确认对当前 live 文件执行 claude logout ?", default=False):
note("已取消。")
return
auto = create_auto_backup()
if auto:
note(f"logout 前自动备份: {auto['dir']}")
code = run_claude(["logout"])
note(f"claude logout 退出码: {code}")
def tui_launch_current() -> None:
extra = Prompt.ask("额外 Claude 参数(可留空)", default="").strip()
code = run_claude(shlex.split(extra))
note(f"Claude 退出码: {code}")
def tui_switch_and_launch() -> None:
state = load_state()
name = select_slot(state, "输入要切换并启动的 slot")
restore_slot_to_live(name, backup_current=True)
extra = Prompt.ask("额外 Claude 参数(可留空)", default="").strip()
code = run_claude(shlex.split(extra))
note(f"Claude 退出码: {code}")
def show_tui() -> int:
while True:
state = load_state()
console.clear()
console.print(render_header(state))
console.print(render_live_panel(state))
console.print(render_slots_table(state))
console.print(render_auto_backups_table())
menu = Text.from_markup(
"\n[bold]操作[/]\n"
"[cyan]s[/] 保存当前 live 为 slot "
"[cyan]x[/] 切换 slot 到 live\n"
"[cyan]a[/] 一键新增账号(先保存当前,再登录新号)\n"
"[cyan]l[/] 运行 claude login 并保存 "
"[cyan]n[/] normalize live config "
"[cyan]o[/] 备份后执行 claude logout\n"
"[cyan]r[/] 直接启动当前 live Claude "
"[cyan]y[/] 切换 slot 后启动 Claude\n"
"[cyan]w[/] 查看当前 live 详情 "
"[cyan]i[/] 查看某个 slot 详情\n"
"[cyan]g[/] 运行 doctor 检查 "
"[cyan]p[/] 查看路径 "
"[cyan]d[/] 删除 slot\n"
"[cyan]f[/] 刷新 "
"[cyan]q[/] 退出"
)
console.print(Panel(menu, title="Rich TUI", border_style="green"))
action = Prompt.ask("选择操作", default="s").strip().lower()
try:
if action == "q":
return 0
if action == "f":
continue
if action == "s":
tui_save_slot()
pause()
continue
if action == "x":
tui_switch_slot()
pause()
continue
if action == "a":
tui_add_account_flow()
pause()
continue
if action == "l":
tui_login_and_save()
pause()
continue
if action == "n":
normalize_live_config(backup_current=True)
pause()
continue
if action == "o":
tui_logout_live()
pause()
continue
if action == "r":
tui_launch_current()
pause()
continue
if action == "y":
tui_switch_and_launch()
pause()
continue
if action == "w":
show_whoami(None)
pause()
continue
if action == "i":
state = load_state()
name = select_slot(state, "输入要查看的 slot")
show_whoami(name)
pause()
continue
if action == "g":
run_doctor()
pause()
continue
if action == "p":
target = Prompt.ask("输入 slot 名称(留空只看 live 路径)", default="").strip()
show_paths(target or None)
pause()
continue
if action == "d":
state = load_state()
name = select_slot(state, "输入要删除的 slot")
if Confirm.ask(f"确认删除 slot {name} ?", default=False):
remove_slot(name)
pause()
continue
note(f"未知操作: {action}")
pause()
except SystemExit:
raise
except Exception as exc:
console.print(f"[bold red]发生错误:[/] {exc}")
pause()
def print_help() -> None:
help_text = """
[bold cyan]Claude 账号切换器(复制文件备份 + 直接修改 live 文件)[/]
[bold]核心思路[/]
- 直接操作官方 live 文件
- 切换前自动复制 live 文件做备份
- 再把已保存 slot 的文件覆盖回 live 路径
[bold]live 路径[/]
- ~/.claude.json
- ~/.claude/.config.json
- ~/.claude/.credentials.json
[bold]注意[/]
- 为兼容 Claude 源码里 legacy 优先逻辑,恢复时会同步写入:
~/.claude.json 和 ~/.claude/.config.json
- 这个版本 [yellow]不依赖[/] CLAUDE_CONFIG_DIR
- 每个 slot 会保存并恢复自己的 [bold]userID[/],
也就是 .claude.json 里的 "userID"
- [bold]推荐启用 normalize-live[/]:统一 .claude.json 和 .claude/.config.json,
避免你手工改其中一个后状态漂移
- macOS 上会额外备份 / 恢复 Keychain 里的 Claude OAuth 凭证
[bold]用法[/]
python claude_switcher.py # Rich TUI
python claude_switcher.py tui
python claude_switcher.py add-account <当前slot> <新slot> [claude login 参数...]
python claude_switcher.py doctor
python claude_switcher.py normalize-live
python claude_switcher.py list
python claude_switcher.py save <slot>
python claude_switcher.py switch [slot或序号]
python claude_switcher.py use [slot或序号]
python claude_switcher.py login <slot> [claude login 参数...]
python claude_switcher.py logout
python claude_switcher.py launch [slot或序号] [claude 参数...]
python claude_switcher.py current
python claude_switcher.py whoami [slot或序号|live]
python claude_switcher.py paths [slot或序号|live]
python claude_switcher.py remove [slot或序号]
[bold]推荐流程[/]
1. 先用官方 claude 登录一个账号
2. 保存:
python claude_switcher.py save work
3. 再登录另一个账号
4. 保存:
python claude_switcher.py save personal
5. 之后切换:
python claude_switcher.py switch work
python claude_switcher.py switch personal
[bold]一键新增账号[/]
python claude_switcher.py add-account work personal
含义:
- 先把当前 live 账号保存到 work
- 再执行 claude login
- 登录成功后自动把新账号保存到 personal
[bold]Doctor 检查[/]
python claude_switcher.py doctor
会检查:
- 当前 live 文件是否存在
- 本机 Claude Code 版本是否能识别
- 每个 slot 的 userID / email / accountUuid 是否一致
- 是否存在多个账号共用同一个 userID 的冲突
[bold]Normalize Live[/]
python claude_switcher.py normalize-live
含义:
- 先自动备份当前 live
- 再把当前 active config 同步写入:
~/.claude.json
~/.claude/.config.json
[bold]序号选择[/]
执行 switch / whoami / paths / remove / launch 时,
不传 slot 名也可以,脚本会先把账号列表列出来,
然后让你输入序号选择。
"""
console.print(Panel(Text.from_markup(help_text.strip()), border_style="cyan"))
def launch_command(args: list[str]) -> int:
if args:
state = load_state()
first = args[0]
names = sorted(state["slots"])
if first in state["slots"] or first.isdigit():
selected = first
if first.isdigit():
idx = int(first)
if not (1 <= idx <= len(names)):
fail(f"序号超出范围: {first}")
selected = names[idx - 1]
restore_slot_to_live(selected, backup_current=True)
return run_claude(args[1:])
return run_claude(args)
def login_and_save(name: str | None, extra_args: list[str]) -> int:
slot_name = require_name(name, "slot 名称")
auto = create_auto_backup()
if auto:
note(f"登录前自动备份: {auto['dir']}")
code = run_claude(["login", *extra_args])
note(f"claude login 退出码: {code}")
if code == 0:
save_live_to_slot(slot_name)
return code
def logout_live() -> int:
auto = create_auto_backup()
if auto:
note(f"logout 前自动备份: {auto['dir']}")
return run_claude(["logout"])
def main(argv: list[str]) -> int:
if not argv:
return show_tui()
command, *rest = argv
if command not in RESERVED_COMMANDS:
return launch_command([command, *rest])
if command in {"help", "--help", "-h"}:
print_help()
return 0
if command == "tui":
return show_tui()
if command in {"add-account", "add"}:
return add_account_flow(
rest[0] if len(rest) > 0 else None,
rest[1] if len(rest) > 1 else None,
rest[2:] if len(rest) > 2 else [],
)
if command in {"doctor", "check"}:
return 0 if run_doctor() else 1
if command in {"normalize-live", "normalize"}:
normalize_live_config(backup_current=True)
return 0
if command in {"list", "ls"}:
list_slots()
return 0
if command in {"save", "capture"}:
save_live_to_slot(rest[0] if rest else None)
return 0
if command in {"switch", "use"}:
chosen = resolve_slot_input(rest[0] if rest else None, prompt_text="输入要切换的账号序号或名称")
restore_slot_to_live(chosen, backup_current=True)
return 0
if command == "login":
return login_and_save(rest[0] if rest else None, rest[1:])
if command == "logout":
return logout_live()
if command in {"launch", "run"}:
if not rest:
chosen = resolve_slot_input(None, prompt_text="输入要启动的账号序号或名称")
return launch_command([chosen])
return launch_command(rest)
if command == "current":
show_current()
return 0
if command == "whoami":
if rest and rest[0].strip().lower() == "live":
show_whoami(None)
else:
chosen = resolve_slot_input(rest[0] if rest else None, prompt_text="输入要查看的账号序号或名称")
show_whoami(chosen)
return 0
if command == "paths":
if rest and rest[0].strip().lower() == "live":
show_paths(None)
else:
chosen = resolve_slot_input(rest[0] if rest else None, prompt_text="输入要查看路径的账号序号或名称")
show_paths(chosen)
return 0
if command == "env":
print_env()
return 0
if command in {"remove", "rm"}:
chosen = resolve_slot_input(rest[0] if rest else None, prompt_text="输入要删除的账号序号或名称")
remove_slot(chosen)
return 0
print_help()
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
4 个帖子 - 3 位参与者