Skip to content

Commit b11e346

Browse files
committed
余量页新增账号登录
- 新增,余量页支持直接登录并添加新账号 - 优化,相同账号重复登录时复用已有存档 - 修复,登录失败后自动恢复原有账号状态 - 更新,发版说明与安装文档到 0.1.4
1 parent 72c20c8 commit b11e346

5 files changed

Lines changed: 230 additions & 18 deletions

File tree

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Fast switching and quota inspection for multiple Codex accounts.
2828
- Active account shown at the top and separated from the rest
2929
- Live refresh of quota windows and reset times
3030
- Automatic snapshot save for the current account when needed
31+
- Add a new account directly from the quota view via the official `codex login` flow
3132
- Agent-friendly CLI: `--list`, `--json`, `--best`, `--switch`, `--save-current`
3233
- Homebrew and `pipx` friendly distribution
3334
- Repository-local skill for guided agent usage
@@ -62,7 +63,7 @@ python3 -m pip install "git+https://github.com/mileson/CJFCodexSwitcher.git"
6263
Requirements:
6364

6465
- Python 3.8+
65-
- An existing local Codex login at `~/.codex/auth.json`
66+
- A working `codex` CLI on your machine
6667

6768
Install from source:
6869

@@ -89,9 +90,15 @@ Key fields:
8990
Bottom actions:
9091

9192
- `Enter`: refresh current page
93+
- `a`: start the official `codex login` flow, then auto-save the authenticated account and keep it active
9294
- `#`: switch to the selected account
9395
- `0`: exit the tool
9496

97+
Notes:
98+
99+
- If the current account is not archived yet, entering the live quota view will save it automatically
100+
- The add-account flow reuses the official browser login from `codex login`; in headless environments you can use `codex login --device-auth`
101+
95102
## Agent / CLI Commands
96103

97104
```bash
@@ -149,7 +156,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md).
149156

150157
Current recommended release notes:
151158

152-
- [v0.1.3 Release Notes](docs/releases/v0.1.3.md)
159+
- [v0.1.4 Release Notes](docs/releases/v0.1.4.md)
153160

154161
## License
155162

README_CN.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- 当前账号用 `[当前]` 标记,并固定显示在最前,同时与其他账号空一行区分
2222
- 切换成功后自动刷新页面
2323
- 多个账号并发实时刷新,减少等待时间
24+
- 支持在余量页直接调用官方 `codex login` 添加新账号,并自动归档到切换器
2425
- 提供 Agent 友好的非交互 CLI:`--list``--json``--best``--switch``--save-current`
2526
- 项目内置专用 Skill,方便 Agent 按统一规则操作
2627

@@ -54,7 +55,7 @@ python3 -m pip install "git+https://github.com/mileson/CJFCodexSwitcher.git"
5455
前置要求:
5556

5657
- Python 3.8+
57-
- 本机已经使用过 Codex,并存在 `~/.codex/auth.json`
58+
- 本机可用 `codex` 命令
5859

5960
从源码安装:
6061

@@ -81,12 +82,14 @@ codex-switcher
8182
底部操作:
8283

8384
- `Enter`:刷新当前页面
85+
- 输入 `a`:调用官方 `codex login` 添加账号,登录成功后自动归档并保持当前账号
8486
- 输入 `#` 序号:切换到对应账号
8587
- 输入 `0`:退出工具
8688

8789
说明:
8890

8991
- 当前账号如果尚未存档,进入查看余量页时会自动创建存档
92+
- 添加账号复用官方浏览器登录流程;如果处于远程 / 无头环境,可手动使用 `codex login --device-auth`
9093

9194
## Agent / CLI 快捷命令
9295

@@ -150,7 +153,7 @@ CJFCodexSwitcher/
150153

151154
当前推荐版本 release notes:
152155

153-
- [v0.1.2 Release Notes](docs/releases/v0.1.2.md)
156+
- [v0.1.4 Release Notes](docs/releases/v0.1.4.md)
154157

155158
## License
156159

codex_switcher.py

Lines changed: 178 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
Codex Switcher - 跨平台 Codex 账号管理工具
55
66
功能:
7-
(1) 查看所有账号余量(包括 5小时/每周限额)
8-
(2) 切换账号
9-
(3) 存档当前登录账号
10-
(4) 刷新使用量(通过浏览器)
7+
(1) 启动后直接进入账号余量列表
8+
(2) 按编号切换已存档账号
9+
(3) 在余量页内直接调用官方 codex login 添加账号
10+
(4) 自动存档当前登录账号并刷新使用量
1111
(0) 退出
1212
1313
支持平台: macOS, Linux, Windows
@@ -37,6 +37,7 @@
3737
USAGE_API_URL = "https://chatgpt.com/backend-api/wham/usage"
3838
RESTART_DRY_RUN_ENV = "CODEX_SWITCHER_DRY_RUN_RESTART"
3939
MAX_REFRESH_WORKERS = 6
40+
LOGIN_FILE_CREDENTIALS_CONFIG = 'cli_auth_credentials_store="file"'
4041
ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;]*m')
4142

4243
# ============== 跨平台路径配置 ==============
@@ -913,28 +914,147 @@ def load_current_auth() -> Optional[dict]:
913914
"""加载当前 auth.json"""
914915
return load_auth_data_from_path(get_auth_file())
915916

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
921926

922927
accounts_dir = get_accounts_dir()
923928
accounts_dir.mkdir(parents=True, exist_ok=True)
924929

925930
safe_name = "".join(c if c.isalnum() or c in '-_' else '_' for c in name)
926931

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():
929934
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
930935
target_file = accounts_dir / f"auth_{safe_name}_{timestamp}.json"
931936

932937
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)
934966
return True
935967
except Exception:
936968
return False
937969

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+
9381058
def list_saved_accounts() -> List[dict]:
9391059
"""列出所有已存档的账号"""
9401060
accounts_dir = get_accounts_dir()
@@ -1537,10 +1657,52 @@ def print_view_all_actions(rows: List[dict]):
15371657
print(f"{Colors.BOLD} 操作面板{Colors.ENDC}")
15381658
print(f"{Colors.DIM} {'─' * 40}{Colors.ENDC}")
15391659
print(f" {Colors.CYAN}[编号]{Colors.ENDC} 切换账号")
1660+
print(f" {Colors.CYAN}[a]{Colors.ENDC} 添加账号(官方登录)")
15401661
print(f" {Colors.CYAN}[0]{Colors.ENDC} 退出工具")
15411662
print(f" {Colors.DIM}[Enter]{Colors.ENDC} 刷新当前页面")
15421663
print()
15431664

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+
15441706
def view_all_accounts():
15451707
"""查看所有账号(自动刷新使用量)"""
15461708
while True:
@@ -1570,11 +1732,14 @@ def view_all_accounts():
15701732
continue
15711733
if choice == '0':
15721734
return
1735+
if choice.lower() == 'a':
1736+
add_account_from_view()
1737+
continue
15731738

15741739
try:
15751740
idx = int(choice) - 1
15761741
except ValueError:
1577-
print(f"\n{Colors.RED} 请输入编号、0 或直接回车{Colors.ENDC}")
1742+
print(f"\n{Colors.RED} 请输入编号、a、0 或直接回车{Colors.ENDC}")
15781743
input(f"{Colors.DIM}按回车键继续...{Colors.ENDC}")
15791744
continue
15801745

docs/releases/v0.1.4.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# CJFCodexSwitcher v0.1.4
2+
3+
This release adds a direct add-account flow to the live quota view so new Codex accounts can be logged in, archived, and activated without leaving the tool.
4+
5+
## Highlights
6+
7+
- A new `a` action is available in the live quota view to start the official `codex login` flow
8+
- Newly authenticated accounts are archived automatically and kept as the active account
9+
- Existing archives for the same account are updated in place instead of creating duplicate snapshots
10+
- Failed or canceled login attempts restore the previous `~/.codex/auth.json` state
11+
- The English and Chinese READMEs now describe the Homebrew-ready add-account workflow
12+
13+
## Installation
14+
15+
### Homebrew
16+
17+
```bash
18+
brew tap mileson/cjfcodexswitcher && brew upgrade cjfcodexswitcher
19+
```
20+
21+
### pipx
22+
23+
```bash
24+
pipx install --force git+https://github.com/mileson/CJFCodexSwitcher.git
25+
```
26+
27+
## Verification
28+
29+
- `python3 -m py_compile codex_switcher.py`
30+
- Simulated `codex login` runs cover new-account archive creation, same-account archive update, and failed-login rollback
31+
- The live quota view now shows `a` as the add-account action
32+
33+
## How To Use
34+
35+
- Run `codex-switcher`
36+
- In the live quota view, press `a` to start the official login flow
37+
- After login completes, return to the quota list and confirm the new account appears as `[当前]`

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "codex-switcher"
7-
version = "0.1.3"
7+
version = "0.1.4"
88
description = "Codex account switcher with live quota inspection and agent-friendly CLI commands"
99
readme = "README.md"
1010
requires-python = ">=3.8"

0 commit comments

Comments
 (0)