|
4 | 4 | Codex Switcher - 跨平台 Codex 账号管理工具 |
5 | 5 |
|
6 | 6 | 功能: |
7 | | - (1) 查看所有账号余量(包括 5小时/每周限额) |
8 | | - (2) 切换账号 |
9 | | - (3) 存档当前登录账号 |
10 | | - (4) 刷新使用量(通过浏览器) |
| 7 | + (1) 启动后直接进入账号余量列表 |
| 8 | + (2) 按编号切换已存档账号 |
| 9 | + (3) 在余量页内直接调用官方 codex login 添加账号 |
| 10 | + (4) 自动存档当前登录账号并刷新使用量 |
11 | 11 | (0) 退出 |
12 | 12 |
|
13 | 13 | 支持平台: macOS, Linux, Windows |
|
37 | 37 | USAGE_API_URL = "https://chatgpt.com/backend-api/wham/usage" |
38 | 38 | RESTART_DRY_RUN_ENV = "CODEX_SWITCHER_DRY_RUN_RESTART" |
39 | 39 | MAX_REFRESH_WORKERS = 6 |
| 40 | +LOGIN_FILE_CREDENTIALS_CONFIG = 'cli_auth_credentials_store="file"' |
40 | 41 | ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;]*m') |
41 | 42 |
|
42 | 43 | # ============== 跨平台路径配置 ============== |
@@ -913,28 +914,147 @@ def load_current_auth() -> Optional[dict]: |
913 | 914 | """加载当前 auth.json""" |
914 | 915 | return load_auth_data_from_path(get_auth_file()) |
915 | 916 |
|
916 | | -def save_current_auth(name: str) -> bool: |
917 | | - """存档当前登录账号""" |
918 | | - auth_file = get_auth_file() |
919 | | - if not auth_file.exists(): |
920 | | - return False |
| 917 | +def save_auth_file_snapshot( |
| 918 | + source_path: Path, |
| 919 | + name: str, |
| 920 | + replace_path: Optional[Path] = None, |
| 921 | + allow_timestamp_suffix: bool = True, |
| 922 | +) -> Optional[Path]: |
| 923 | + """将指定 auth 文件存档到账号目录""" |
| 924 | + if not source_path.exists(): |
| 925 | + return None |
921 | 926 |
|
922 | 927 | accounts_dir = get_accounts_dir() |
923 | 928 | accounts_dir.mkdir(parents=True, exist_ok=True) |
924 | 929 |
|
925 | 930 | safe_name = "".join(c if c.isalnum() or c in '-_' else '_' for c in name) |
926 | 931 |
|
927 | | - target_file = accounts_dir / f"auth_{safe_name}.json" |
928 | | - if target_file.exists(): |
| 932 | + target_file = Path(replace_path) if replace_path else accounts_dir / f"auth_{safe_name}.json" |
| 933 | + if not replace_path and allow_timestamp_suffix and target_file.exists(): |
929 | 934 | timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') |
930 | 935 | target_file = accounts_dir / f"auth_{safe_name}_{timestamp}.json" |
931 | 936 |
|
932 | 937 | try: |
933 | | - shutil.copy2(auth_file, target_file) |
| 938 | + shutil.copy2(source_path, target_file) |
| 939 | + return target_file |
| 940 | + except Exception: |
| 941 | + return None |
| 942 | + |
| 943 | +def save_current_auth(name: str) -> bool: |
| 944 | + """存档当前登录账号""" |
| 945 | + return save_auth_file_snapshot(get_auth_file(), name) is not None |
| 946 | + |
| 947 | +def read_auth_file_bytes(auth_path: Path) -> Optional[bytes]: |
| 948 | + """读取 auth 文件原始字节,用于失败回滚""" |
| 949 | + if not auth_path.exists(): |
| 950 | + return None |
| 951 | + try: |
| 952 | + return auth_path.read_bytes() |
| 953 | + except Exception: |
| 954 | + return None |
| 955 | + |
| 956 | +def restore_auth_file(auth_path: Path, backup_bytes: Optional[bytes]) -> bool: |
| 957 | + """恢复 auth 文件到备份内容""" |
| 958 | + try: |
| 959 | + if backup_bytes is None: |
| 960 | + if auth_path.exists(): |
| 961 | + auth_path.unlink() |
| 962 | + return True |
| 963 | + |
| 964 | + auth_path.parent.mkdir(parents=True, exist_ok=True) |
| 965 | + auth_path.write_bytes(backup_bytes) |
934 | 966 | return True |
935 | 967 | except Exception: |
936 | 968 | return False |
937 | 969 |
|
| 970 | +def find_saved_account_path(record_key: str = '', email: str = '') -> Optional[Path]: |
| 971 | + """按账号身份或邮箱查找已存档文件""" |
| 972 | + accounts_dir = get_accounts_dir() |
| 973 | + if not accounts_dir.exists(): |
| 974 | + return None |
| 975 | + |
| 976 | + email_lower = email.lower() if email else '' |
| 977 | + fallback_path = None |
| 978 | + for auth_file in sorted(accounts_dir.glob('auth_*.json')): |
| 979 | + auth_data = load_auth_data_from_path(auth_file) |
| 980 | + if not auth_data: |
| 981 | + continue |
| 982 | + info = get_account_info(auth_data, str(auth_file)) |
| 983 | + if not info: |
| 984 | + continue |
| 985 | + if record_key and info.get('record_key') == record_key: |
| 986 | + return auth_file |
| 987 | + if email_lower and info.get('email', '').lower() == email_lower and fallback_path is None: |
| 988 | + fallback_path = auth_file |
| 989 | + |
| 990 | + return fallback_path |
| 991 | + |
| 992 | +def upsert_current_auth_archive(account_info: dict) -> Tuple[bool, Optional[Path], str]: |
| 993 | + """按账号身份更新或创建当前 auth 存档""" |
| 994 | + record_key = account_info.get('record_key', '') |
| 995 | + email = account_info.get('email', '') |
| 996 | + existing_path = find_saved_account_path(record_key, email) |
| 997 | + archive_path = save_auth_file_snapshot( |
| 998 | + get_auth_file(), |
| 999 | + email or 'account', |
| 1000 | + replace_path=existing_path, |
| 1001 | + allow_timestamp_suffix=existing_path is None, |
| 1002 | + ) |
| 1003 | + if not archive_path: |
| 1004 | + return False, None, 'failed' |
| 1005 | + return True, archive_path, 'updated' if existing_path else 'created' |
| 1006 | + |
| 1007 | +def run_codex_login(auth_mode_args: Optional[List[str]] = None) -> dict: |
| 1008 | + """委托官方 codex login 完成登录""" |
| 1009 | + codex_binary = shutil.which('codex') |
| 1010 | + if not codex_binary: |
| 1011 | + return { |
| 1012 | + 'ok': False, |
| 1013 | + 'error': '未找到 codex 命令,请先安装或确认 PATH 配置', |
| 1014 | + } |
| 1015 | + |
| 1016 | + auth_path = get_auth_file() |
| 1017 | + backup_bytes = read_auth_file_bytes(auth_path) |
| 1018 | + before_auth = load_auth_data_from_path(auth_path) |
| 1019 | + before_info = get_account_info(before_auth, str(auth_path)) if before_auth else None |
| 1020 | + ensure_current_account_saved() |
| 1021 | + |
| 1022 | + command = [codex_binary, '-c', LOGIN_FILE_CREDENTIALS_CONFIG, 'login'] |
| 1023 | + if auth_mode_args: |
| 1024 | + command.extend(auth_mode_args) |
| 1025 | + result = subprocess.run(command, check=False) |
| 1026 | + |
| 1027 | + after_auth = load_auth_data_from_path(auth_path) |
| 1028 | + after_info = get_account_info(after_auth, str(auth_path)) if after_auth else None |
| 1029 | + if not after_info: |
| 1030 | + restored = restore_auth_file(auth_path, backup_bytes) |
| 1031 | + return { |
| 1032 | + 'ok': False, |
| 1033 | + 'error': '未获取到有效登录信息,已恢复原账号', |
| 1034 | + 'restored': restored, |
| 1035 | + 'returncode': result.returncode, |
| 1036 | + } |
| 1037 | + |
| 1038 | + saved, archive_path, archive_action = upsert_current_auth_archive(after_info) |
| 1039 | + same_account = ( |
| 1040 | + before_info is not None and |
| 1041 | + before_info.get('record_key') and |
| 1042 | + before_info.get('record_key') == after_info.get('record_key') |
| 1043 | + ) |
| 1044 | + payload = { |
| 1045 | + 'ok': True, |
| 1046 | + 'account': after_info, |
| 1047 | + 'archive_path': str(archive_path) if archive_path else '', |
| 1048 | + 'archive_action': archive_action, |
| 1049 | + 'same_account': same_account, |
| 1050 | + 'returncode': result.returncode, |
| 1051 | + } |
| 1052 | + if not saved: |
| 1053 | + payload['archive_action'] = 'failed' |
| 1054 | + payload['warning'] = '登录成功,但账号存档失败' |
| 1055 | + |
| 1056 | + return payload |
| 1057 | + |
938 | 1058 | def list_saved_accounts() -> List[dict]: |
939 | 1059 | """列出所有已存档的账号""" |
940 | 1060 | accounts_dir = get_accounts_dir() |
@@ -1537,10 +1657,52 @@ def print_view_all_actions(rows: List[dict]): |
1537 | 1657 | print(f"{Colors.BOLD} 操作面板{Colors.ENDC}") |
1538 | 1658 | print(f"{Colors.DIM} {'─' * 40}{Colors.ENDC}") |
1539 | 1659 | print(f" {Colors.CYAN}[编号]{Colors.ENDC} 切换账号") |
| 1660 | + print(f" {Colors.CYAN}[a]{Colors.ENDC} 添加账号(官方登录)") |
1540 | 1661 | print(f" {Colors.CYAN}[0]{Colors.ENDC} 退出工具") |
1541 | 1662 | print(f" {Colors.DIM}[Enter]{Colors.ENDC} 刷新当前页面") |
1542 | 1663 | print() |
1543 | 1664 |
|
| 1665 | +def add_account_from_view() -> bool: |
| 1666 | + """在余量页中通过官方 codex login 添加账号""" |
| 1667 | + clear_screen() |
| 1668 | + print_header() |
| 1669 | + print(f"\n{Colors.CYAN}>>> 添加账号{Colors.ENDC}") |
| 1670 | + print() |
| 1671 | + print(f"{Colors.DIM} 将调用官方 codex login 完成登录。{Colors.ENDC}") |
| 1672 | + print(f"{Colors.DIM} 登录成功后会自动读取当前账号、写入存档,并保持该账号为当前账号。{Colors.ENDC}") |
| 1673 | + print(f"{Colors.DIM} 如浏览器不可用,可在登录界面使用 device code,或稍后手动运行 codex login --device-auth。{Colors.ENDC}") |
| 1674 | + print() |
| 1675 | + |
| 1676 | + result = run_codex_login() |
| 1677 | + print() |
| 1678 | + if not result.get('ok'): |
| 1679 | + print(f"{Colors.RED} ✗ 添加账号失败:{result.get('error', '未知错误')}{Colors.ENDC}") |
| 1680 | + input(f"{Colors.DIM}按回车键继续...{Colors.ENDC}") |
| 1681 | + return False |
| 1682 | + |
| 1683 | + account = result.get('account', {}) |
| 1684 | + email = account.get('email', 'Unknown') |
| 1685 | + archive_action = result.get('archive_action', 'failed') |
| 1686 | + if result.get('same_account'): |
| 1687 | + print(f"{Colors.YELLOW} 当前仍为同一账号:{email}{Colors.ENDC}") |
| 1688 | + else: |
| 1689 | + print(f"{Colors.GREEN} ✓ 添加并切换成功:{email}{Colors.ENDC}") |
| 1690 | + |
| 1691 | + if archive_action == 'created': |
| 1692 | + print(f"{Colors.DIM} 已新增账号存档: {result.get('archive_path', '')}{Colors.ENDC}") |
| 1693 | + elif archive_action == 'updated': |
| 1694 | + print(f"{Colors.DIM} 已更新现有账号存档: {result.get('archive_path', '')}{Colors.ENDC}") |
| 1695 | + else: |
| 1696 | + print(f"{Colors.YELLOW} {result.get('warning', '账号存档未更新')}{Colors.ENDC}") |
| 1697 | + |
| 1698 | + if result.get('same_account'): |
| 1699 | + time.sleep(1.0) |
| 1700 | + return True |
| 1701 | + |
| 1702 | + finish_switch_with_restart() |
| 1703 | + time.sleep(1.0) |
| 1704 | + return True |
| 1705 | + |
1544 | 1706 | def view_all_accounts(): |
1545 | 1707 | """查看所有账号(自动刷新使用量)""" |
1546 | 1708 | while True: |
@@ -1570,11 +1732,14 @@ def view_all_accounts(): |
1570 | 1732 | continue |
1571 | 1733 | if choice == '0': |
1572 | 1734 | return |
| 1735 | + if choice.lower() == 'a': |
| 1736 | + add_account_from_view() |
| 1737 | + continue |
1573 | 1738 |
|
1574 | 1739 | try: |
1575 | 1740 | idx = int(choice) - 1 |
1576 | 1741 | except ValueError: |
1577 | | - print(f"\n{Colors.RED} 请输入编号、0 或直接回车{Colors.ENDC}") |
| 1742 | + print(f"\n{Colors.RED} 请输入编号、a、0 或直接回车{Colors.ENDC}") |
1578 | 1743 | input(f"{Colors.DIM}按回车键继续...{Colors.ENDC}") |
1579 | 1744 | continue |
1580 | 1745 |
|
|
0 commit comments