@carjms/codexswitch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JI MyungSik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,274 @@
1
+ # codexswitch
2
+
3
+ **OpenAI Codex CLI 계정을 여러 개 등록해두고, 클릭 한 번(명령 한 줄)으로 전환하거나, 사용량 한도에 걸리면 자동으로 다음 계정으로 넘어가게 해주는 도구입니다.**
4
+
5
+ [KarpelesLab/teamclaude](https://github.com/KarpelesLab/teamclaude)(Claude용 멀티 계정 도구)에서 영감을 받아 Codex CLI에 맞게 만들었습니다. macOS / Windows / Linux를 지원하며, Node.js만 있으면 되고 외부 의존성은 없습니다.
6
+
7
+ ## 이런 분께 필요합니다
8
+
9
+ - ChatGPT 계정이 2개 이상 있고(개인용/회사용, Plus/Pro 등) Codex를 계정 바꿔가며 쓰고 싶은 분
10
+ - 한 계정의 사용량 한도(usage limit)가 차면 **자동으로 다른 계정으로 이어서** 작업하고 싶은 분
11
+ - 매번 `codex logout` → `codex login`을 반복하는 게 지겨운 분
12
+
13
+ Codex CLI는 로그인 정보를 한 파일(`auth.json`)에만 저장해서 원래 계정 1개만 쓸 수 있습니다. codexswitch이 이 한계를 풀어줍니다.
14
+
15
+ ---
16
+
17
+ ## 1. 준비물
18
+
19
+ 두 가지가 미리 설치되어 있어야 합니다.
20
+
21
+ ### ① Node.js (v18 이상)
22
+
23
+ 터미널에서 `node --version`을 입력했을 때 `v18` 이상이 나오면 이 단계는 건너뛰세요.
24
+
25
+ - **macOS**: [nodejs.org](https://nodejs.org/)에서 LTS 버전 설치 파일(.pkg)을 받아 실행하거나, Homebrew가 있다면 터미널에서:
26
+ ```bash
27
+ brew install node
28
+ ```
29
+ - **Windows**: [nodejs.org](https://nodejs.org/)에서 LTS 버전 설치 파일(.msi)을 받아 실행하세요. 설치 중 옵션은 전부 기본값 그대로 "다음"만 눌러도 됩니다.
30
+
31
+ ### ② Codex CLI
32
+
33
+ - **macOS** — 터미널(응용 프로그램 → 유틸리티 → 터미널)에서:
34
+ ```bash
35
+ npm install -g @openai/codex
36
+ ```
37
+ - **Windows** — PowerShell(시작 메뉴에서 "PowerShell" 검색 → 실행)에서:
38
+ ```powershell
39
+ npm install -g @openai/codex
40
+ ```
41
+ > 참고: Codex CLI는 Windows 네이티브 지원이 실험적(experimental) 단계라, OpenAI는 WSL(Windows Subsystem for Linux) 사용을 권장합니다. WSL을 쓰신다면 WSL 터미널 안에서 macOS/Linux 방법을 그대로 따라 하시면 됩니다.
42
+
43
+ 설치 확인:
44
+
45
+ ```bash
46
+ codex --version
47
+ ```
48
+
49
+ ---
50
+
51
+ ## 2. codexswitch 설치
52
+
53
+ macOS와 Windows 모두 같은 명령입니다 (macOS는 터미널, Windows는 PowerShell):
54
+
55
+ ```bash
56
+ npm install -g @carjms/codexswitch
57
+ ```
58
+
59
+ 설치 확인:
60
+
61
+ ```bash
62
+ codexswitch help
63
+ ```
64
+
65
+ 도움말이 나오면 성공입니다. `codexswitch`과 짧은 별칭 `cxs` 두 명령 모두 똑같이 동작합니다 (아래 예시는 짧은 `cxs`를 사용합니다).
66
+
67
+ ---
68
+
69
+ ## 3. 처음 시작하기 (5분 가이드)
70
+
71
+ 계정 2개를 등록하고 전환해보는 전체 과정입니다.
72
+
73
+ ### 3-1. 이미 로그인된 계정 등록하기
74
+
75
+ 전에 `codex login`을 해본 적이 있다면, 그 계정을 그대로 가져옵니다:
76
+
77
+ ```bash
78
+ cxs import
79
+ ```
80
+
81
+ ```
82
+ added account "me@gmail.com" (me@gmail.com, plus)
83
+ set "me@gmail.com" as the active account
84
+ ```
85
+
86
+ 이름을 직접 붙이고 싶다면 `cxs import 개인용` 처럼 뒤에 이름을 쓰면 됩니다.
87
+
88
+ > 아직 한 번도 로그인한 적이 없다면 이 단계는 건너뛰고 3-2로 가세요.
89
+
90
+ ### 3-2. 두 번째 계정 로그인하기
91
+
92
+ ```bash
93
+ cxs login 회사용
94
+ ```
95
+
96
+ 브라우저가 열리면 **추가할 ChatGPT 계정으로** 로그인하세요. (이미 브라우저에 다른 계정이 로그인돼 있다면 로그인 화면에서 계정을 전환하세요.)
97
+
98
+ > 이 과정은 격리된 임시 공간에서 진행되므로 **기존 계정 로그인은 전혀 건드리지 않습니다.**
99
+
100
+ ### 3-3. 등록된 계정 확인
101
+
102
+ ```bash
103
+ cxs list
104
+ ```
105
+
106
+ ```
107
+ name email plan prio status token refreshed
108
+ - ------------ ---------------- ---- ---- ------ ----------------
109
+ * me@gmail.com me@gmail.com plus 0 ok 2026-07-08 09:12
110
+ 회사용 work@company.com pro 0 ok 2026-07-08 09:15
111
+ ```
112
+
113
+ `*` 표시가 현재 활성 계정입니다.
114
+
115
+ ### 3-4. 계정 전환
116
+
117
+ ```bash
118
+ cxs use 회사용
119
+ ```
120
+
121
+ 이제부터 평소처럼 `codex`를 실행하면 회사용 계정으로 동작합니다. 다시 바꾸려면 `cxs use me@gmail.com`, 또는 `cxs next`(다음 계정으로 순환).
122
+
123
+ ### 3-5. 한도 자동 전환으로 실행하기 (핵심 기능)
124
+
125
+ ```bash
126
+ cxs exec "이 프로젝트의 테스트 코드를 작성해줘"
127
+ ```
128
+
129
+ `codex exec`를 실행하다가 **사용량 한도에 걸리면**:
130
+
131
+ 1. 그 계정을 일정 시간 "한도 걸림"으로 표시하고 (에러 메시지의 "try again in 2 hours" 같은 시간을 자동 인식)
132
+ 2. 다음 사용 가능한 계정으로 **같은 명령을 자동 재시도**합니다
133
+ 3. 모든 계정이 소진되면 그때 멈춥니다
134
+
135
+ ```
136
+ [codexswitch] exec as "me@gmail.com"
137
+ ... (작업 중 한도 도달) ...
138
+ [codexswitch] "me@gmail.com" hit a usage/rate limit (paused until 2026-07-08 14:30) — rotating
139
+ [codexswitch] exec as "회사용" (attempt 2)
140
+ ... (이어서 작업) ...
141
+ ```
142
+
143
+ ### 3-6. 계정 전환 없이 특정 계정으로 실행
144
+
145
+ 전역 활성 계정은 그대로 두고, 이번 한 번만 다른 계정으로 실행할 수도 있습니다:
146
+
147
+ ```bash
148
+ cxs run 회사용 # 회사용 계정으로 codex 대화형 실행
149
+ cxs run 회사용 exec "..." # 회사용 계정으로 codex exec 실행
150
+ ```
151
+
152
+ 각 계정은 격리된 자기만의 환경에서 실행되므로, **터미널 2개를 열어 서로 다른 계정으로 동시에 codex를 돌릴 수도 있습니다.** (설정과 세션 기록은 공유됩니다.)
153
+
154
+ ---
155
+
156
+ ## 4. 명령어 전체 목록
157
+
158
+ | 명령 | 설명 |
159
+ |---|---|
160
+ | `cxs login [이름]` | 새 계정 로그인 후 저장 (기존 로그인 유지, 이름 생략 시 이메일 사용) |
161
+ | `cxs import [이름]` | 현재 `~/.codex`에 로그인된 계정을 저장소로 가져오기 |
162
+ | `cxs list` | 계정 목록: 활성 표시(`*`), 이메일, 플랜, 우선순위, 한도 상태 |
163
+ | `cxs use <이름>` | 활성 계정 전환 |
164
+ | `cxs current` | 현재 활성 계정 확인 |
165
+ | `cxs next` | 다음 사용 가능한 계정으로 순환 전환 |
166
+ | `cxs run [이름] [인자...]` | 전환 없이 특정 계정으로 codex 실행 (격리 환경) |
167
+ | `cxs exec [인자...]` | `codex exec` + 한도 도달 시 자동 계정 로테이션 |
168
+ | `cxs exec -a <이름> ...` | 특정 계정부터 exec 시작 |
169
+ | `cxs remove <이름>` | 계정 삭제 |
170
+ | `cxs rename <옛이름> <새이름>` | 계정 이름 변경 |
171
+ | `cxs disable / enable <이름>` | 로테이션에서 임시 제외 / 복귀 |
172
+ | `cxs priority <이름> <숫자>` | 로테이션 우선순위 (낮을수록 먼저, 기본 0) |
173
+ | `cxs clear-limit <이름>` | 기록된 한도 상태 수동 해제 |
174
+ | `cxs cooldown [분]` | 한도 감지 시 기본 대기 시간 조회/설정 (기본 60분) |
175
+ | `cxs sync` | codex가 갱신한 토큰을 저장소에 반영 |
176
+
177
+ ---
178
+
179
+ ## 5. 자주 묻는 질문 / 문제 해결
180
+
181
+ **Q. `codexswitch: command not found` (또는 "인식할 수 없는 명령") 이 떠요.**
182
+ npm 전역 설치 경로가 PATH에 없는 경우입니다. 터미널/PowerShell을 **완전히 닫았다 다시 열어보세요.** 그래도 안 되면 `npm config get prefix`로 나온 경로(Windows는 그 경로 자체, macOS는 그 아래 `bin` 폴더)를 PATH에 추가하세요.
183
+
184
+ **Q. macOS에서 `npm install -g` 하다가 `EACCES` 권한 오류가 나요.**
185
+ `sudo npm install -g @carjms/codexswitch`로 설치하거나, 더 좋은 방법으로는 [nvm](https://github.com/nvm-sh/nvm)으로 Node.js를 설치하면 권한 문제가 사라집니다.
186
+
187
+ **Q. `codex CLI not found` 오류가 나요.**
188
+ Codex CLI가 설치되지 않았거나 PATH에 없는 경우입니다. `npm install -g @openai/codex` 후 다시 시도하세요. 특별한 위치에 설치했다면 환경변수 `CODEX_SWITCH_CODEX_BIN`에 전체 경로를 지정하면 됩니다.
189
+
190
+ **Q. 계정을 전환하면 기존 대화 세션이나 설정이 날아가나요?**
191
+ 아니요. 전환되는 것은 **인증 정보(auth.json)뿐**입니다. `config.toml` 설정, 세션 기록, 스킬 등은 모든 계정이 공유합니다.
192
+
193
+ **Q. Windows에서 `run`/`exec`가 잘 동작하나요?**
194
+ 동작합니다. 내부적으로 macOS/Linux는 심볼릭 링크, Windows는 디렉토리 정션(관리자 권한 불필요)을 사용합니다. 파일 공유까지 완전하게 하려면 Windows **설정 → 개발자 모드**를 켜는 것을 추천하지만, 꺼져 있어도 복사 방식으로 자동 대체되어 문제없이 동작합니다.
195
+
196
+ **Q. 한도에 걸렸다고 표시된 계정을 바로 다시 쓰고 싶어요.**
197
+ `cxs clear-limit <이름>` 으로 해제하면 됩니다.
198
+
199
+ **Q. 계정 이름에 한글을 써도 되나요?**
200
+ 네. `회사용`, `개인용` 같은 한글 이름 모두 가능합니다. (허용 문자: 한글 등 모든 언어의 글자, 숫자, 공백, `@ . _ + -`)
201
+
202
+ **Q. 여러 계정을 쓰는 게 약관에 문제되지 않나요?**
203
+ 본인 소유의 정당한 계정들(예: 개인 계정과 회사 계정)을 전환하는 용도로 사용하세요. 한도 우회를 목적으로 한 계정 남용은 OpenAI 이용약관에 어긋날 수 있으며, 사용에 대한 책임은 사용자에게 있습니다.
204
+
205
+ ---
206
+
207
+ ## 6. 데이터가 저장되는 위치
208
+
209
+ | 항목 | macOS/Linux | Windows |
210
+ |---|---|---|
211
+ | 계정 저장소 | `~/.codex-switch/` | `C:\Users\<사용자>\.codex-switch\` |
212
+ | Codex 설정 | `~/.codex/` | `C:\Users\<사용자>\.codex\` |
213
+
214
+ ```
215
+ .codex-switch/
216
+ ├── meta.json # 활성 계정, 우선순위, 한도 상태, 쿨다운 설정
217
+ ├── accounts/<이름>.json # 계정별 인증 정보 사본 (권한 600)
218
+ └── profiles/<이름>/ # run/exec용 계정별 격리 실행 환경
219
+ ```
220
+
221
+ ### 환경변수로 위치 바꾸기
222
+
223
+ | 변수 | 기본값 | 설명 |
224
+ |---|---|---|
225
+ | `CODEX_SWITCH_HOME` | `~/.codex-switch` | 계정 저장소 위치 |
226
+ | `CODEX_HOME` | `~/.codex` | 관리 대상 codex 설정 디렉토리 |
227
+ | `CODEX_SWITCH_CODEX_BIN` | `codex` | codex 바이너리 경로 |
228
+
229
+ - macOS/Linux: `export CODEX_SWITCH_HOME=/원하는/경로`
230
+ - Windows PowerShell: `$env:CODEX_SWITCH_HOME = "D:\원하는\경로"` (영구 설정은 `setx CODEX_SWITCH_HOME "D:\원하는\경로"`)
231
+
232
+ ---
233
+
234
+ ## 7. 설계 (어떻게 동작하나)
235
+
236
+ ### 핵심 아이디어 3가지
237
+
238
+ **1. 계정 전환 = auth.json 교체 (`use`)**
239
+ Codex CLI는 인증을 `auth.json` 파일 하나로 관리합니다. 저장해둔 계정별 사본을 원자적(atomic rename)으로 써넣는 것만으로 계정이 바뀌고, 설정·세션·히스토리는 그대로 유지됩니다.
240
+
241
+ **2. 오버레이 프로필 = 전환 없는 격리 실행 (`run` / `exec`)**
242
+ Codex CLI는 `CODEX_HOME` 환경변수로 설정 디렉토리를 바꿀 수 있습니다. 계정마다 프로필 디렉토리를 만들되 `auth.json`만 실제 파일로 두고, 나머지(`config.toml`, `sessions/`, `skills/` 등)는 실제 `~/.codex`로 연결(macOS/Linux: 심볼릭 링크, Windows: 정션/복사)합니다. 설정과 세션은 공유하면서 인증만 격리되므로 서로 다른 계정으로 동시 실행이 가능합니다. (sqlite 파일은 동시 접근 시 손상 위험이 있어 공유하지 않습니다.)
243
+
244
+ **3. 한도 기반 자동 로테이션 (`exec`)** — teamclaude의 핵심 개념
245
+ `codex exec`의 출력을 실시간으로 흘려보내면서 동시에 수집해 `usage limit / rate limit / 429` 패턴을 감지합니다. 감지되면 해당 계정을 쿨다운 처리(에러 메시지의 "try again in N hours" 파싱, 실패 시 기본 60분)하고, 우선순위 순으로 다음 계정을 골라 같은 명령을 재시도합니다. 한도가 아닌 일반 오류는 로테이션하지 않고 그대로 종료 코드를 전달합니다.
246
+
247
+ ### 토큰 수명 관리
248
+
249
+ codex는 실행 중 스스로 토큰을 갱신해 `auth.json`을 다시 씁니다. 갱신본이 유실되지 않도록 계정 전환 직전과 `run`/`exec` 종료 시점에 현재 auth.json을 저장소로 **sync-back**합니다 (토큰의 `account_id`로 매칭, `last_refresh`가 최신일 때만 덮어씀).
250
+
251
+ ### 보안
252
+
253
+ - 인증 파일은 권한 `600`, 디렉토리는 `700`으로 생성 (macOS/Linux)
254
+ - 토큰을 화면에 출력하지 않음 — JWT는 이메일/플랜 표시용으로만 로컬 디코딩
255
+ - 네트워크 요청 없음 — 모든 인증/갱신은 codex CLI 자신이 수행
256
+
257
+ ### teamclaude와의 차이
258
+
259
+ teamclaude는 로컬 MITM 프록시로 API 트래픽을 가로채 할당량을 실시간 추적하며 요청 단위로 계정을 바꿉니다. Codex CLI는 할당량 조회를 외부에 노출하지 않으므로, codexswitch는 프록시 대신 **프로필 전환 + 출력 감지 기반의 명령 단위 로테이션**이라는 더 단순하고 안전한 방식을 택했습니다.
260
+
261
+ ---
262
+
263
+ ## 개발
264
+
265
+ ```bash
266
+ git clone https://github.com/JIMyungSik/codexswitch.git
267
+ cd codexswitch
268
+ npm link # 로컬 개발 버전을 전역 명령으로 연결
269
+ npm test # 가짜 codex 바이너리로 전체 흐름 검증 (실제 ~/.codex는 건드리지 않음)
270
+ ```
271
+
272
+ ## 라이선스
273
+
274
+ MIT
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('../src/cli.js').main(process.argv.slice(2)).then(
5
+ (code) => process.exit(code || 0),
6
+ (err) => {
7
+ console.error(`error: ${err && err.message ? err.message : err}`);
8
+ process.exit(1);
9
+ }
10
+ );
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@carjms/codexswitch",
3
+ "version": "0.1.0",
4
+ "description": "Multi-account manager for OpenAI Codex CLI with quota-based rotation (inspired by KarpelesLab/teamclaude)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/JIMyungSik/codexswitch.git"
9
+ },
10
+ "homepage": "https://github.com/JIMyungSik/codexswitch#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/JIMyungSik/codexswitch/issues"
13
+ },
14
+ "keywords": ["codex", "openai", "cli", "multi-account", "account-switcher", "rotation"],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "commonjs",
19
+ "bin": {
20
+ "codexswitch": "bin/codex-switch.js",
21
+ "cxs": "bin/codex-switch.js"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "scripts": {
27
+ "test": "node test/smoke.test.js"
28
+ },
29
+ "files": [
30
+ "bin",
31
+ "src",
32
+ "README.md"
33
+ ]
34
+ }
package/src/cli.js ADDED
@@ -0,0 +1,355 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { readJSONSafe, writeJSONAtomic, authInfo, ensureDir, fmtDate, fmtRemaining, table } = require('./util.js');
7
+ const store = require('./store.js');
8
+ const runner = require('./runner.js');
9
+
10
+ const HELP = `codexswitch — multi-account manager for the OpenAI Codex CLI
11
+
12
+ Accounts
13
+ login [name] log in to a new account (isolated "codex login") and store it
14
+ import [name] import the account currently in ~/.codex/auth.json
15
+ list list stored accounts (alias: accounts, status)
16
+ use <name> make <name> the active account in ~/.codex
17
+ current show the active account
18
+ next rotate to the next usable account
19
+ remove <name> delete a stored account
20
+ rename <old> <new> rename a stored account
21
+ enable <name> re-enable a disabled account
22
+ disable <name> temporarily exclude an account from rotation
23
+ priority <name> <n> set rotation priority (lower = preferred, default 0)
24
+ clear-limit <name> forget a recorded rate-limit for an account
25
+
26
+ Running codex
27
+ run [name] [args...] run codex as <name> (or active account) in an isolated
28
+ per-account CODEX_HOME; config/sessions are shared
29
+ exec [args...] run "codex exec ..." and auto-rotate to the next
30
+ account when a usage/rate limit is hit
31
+ -a, --account <n> start exec with a specific account
32
+
33
+ Maintenance
34
+ sync save tokens refreshed by codex back into the store
35
+ cooldown [minutes] show/set rate-limit cooldown (default 60)
36
+ help show this help
37
+
38
+ Environment
39
+ CODEX_SWITCH_HOME data dir (default ~/.codex-switch)
40
+ CODEX_HOME codex config dir codexswitch manages (default ~/.codex)
41
+ CODEX_SWITCH_CODEX_BIN path to the codex binary (default "codex" on PATH)`;
42
+
43
+ function out(msg) {
44
+ console.log(msg);
45
+ }
46
+
47
+ function requireArg(args, i, what) {
48
+ if (args[i] == null) throw new Error(`missing ${what} (see "codexswitch help")`);
49
+ return args[i];
50
+ }
51
+
52
+ function defaultName(auth) {
53
+ const info = authInfo(auth);
54
+ if (!info.email || info.email === '(api key)') {
55
+ throw new Error('could not derive an account name from the token; pass a name explicitly');
56
+ }
57
+ return info.email;
58
+ }
59
+
60
+ function storeAccount(name, auth) {
61
+ store.ensureDirs();
62
+ const info = authInfo(auth);
63
+ if (!info.accountId && !auth.OPENAI_API_KEY) {
64
+ throw new Error('auth.json has neither OAuth tokens nor an API key — refusing to import');
65
+ }
66
+ const existed = store.accountExists(name);
67
+ store.writeAccountAuth(name, auth);
68
+ const meta = store.loadMeta();
69
+ if (!meta.accounts[name]) meta.accounts[name] = { priority: 0, addedAt: Date.now() };
70
+ store.saveMeta(meta);
71
+ out(`${existed ? 'updated' : 'added'} account "${name}"${info.email ? ` (${info.email}, ${info.plan || 'unknown plan'})` : ''}`);
72
+ return name;
73
+ }
74
+
75
+ function cmdImport(args) {
76
+ const p = store.paths();
77
+ const auth = readJSONSafe(p.authPath);
78
+ if (!auth) throw new Error(`no auth.json found at ${p.authPath} — run "codex login" first`);
79
+ const name = args[0] || defaultName(auth);
80
+ storeAccount(name, auth);
81
+ const meta = store.loadMeta();
82
+ if (!meta.active) {
83
+ meta.active = name;
84
+ store.saveMeta(meta);
85
+ out(`set "${name}" as the active account`);
86
+ }
87
+ }
88
+
89
+ function cmdLogin(args) {
90
+ runner.assertCodexAvailable();
91
+ store.ensureDirs();
92
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codexswitch-login-'));
93
+ try {
94
+ out('opening codex login in an isolated profile (your current account is untouched)...');
95
+ const r = runner.spawnCodexSync(['login'], {
96
+ env: { ...process.env, CODEX_HOME: tmp },
97
+ stdio: 'inherit',
98
+ });
99
+ if (r.status !== 0) throw new Error(`codex login exited with status ${r.status}`);
100
+ const auth = readJSONSafe(path.join(tmp, 'auth.json'));
101
+ if (!auth) throw new Error('login finished but no auth.json was produced');
102
+ const name = args[0] || defaultName(auth);
103
+ storeAccount(name, auth);
104
+ const meta = store.loadMeta();
105
+ if (!meta.active) {
106
+ cmdUse([name]);
107
+ } else {
108
+ out(`stored. activate it with: codexswitch use ${name}`);
109
+ }
110
+ } finally {
111
+ fs.rmSync(tmp, { recursive: true, force: true });
112
+ }
113
+ }
114
+
115
+ function cmdList() {
116
+ const accounts = store.listAccounts();
117
+ if (accounts.length === 0) {
118
+ out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
119
+ return;
120
+ }
121
+ const now = Date.now();
122
+ const rows = accounts.map((a) => [
123
+ a.active ? '*' : '',
124
+ a.name,
125
+ a.email,
126
+ a.plan,
127
+ a.priority,
128
+ a.disabled ? 'disabled' : a.limitedUntil && a.limitedUntil > now ? `limited ${fmtRemaining(a.limitedUntil)}` : 'ok',
129
+ fmtDate(a.lastRefresh),
130
+ ]);
131
+ out(table(rows, ['', 'name', 'email', 'plan', 'prio', 'status', 'token refreshed']));
132
+ }
133
+
134
+ function cmdUse(args) {
135
+ const name = requireArg(args, 0, 'account name');
136
+ const auth = store.readAccountAuth(name);
137
+ store.syncBack(); // keep refreshed tokens of the outgoing account
138
+ const p = store.paths();
139
+ ensureDir(p.codexHome);
140
+ writeJSONAtomic(p.authPath, auth, 0o600);
141
+ const meta = store.loadMeta();
142
+ meta.active = name;
143
+ if (!meta.accounts[name]) meta.accounts[name] = { priority: 0 };
144
+ meta.accounts[name].lastUsed = Date.now();
145
+ store.saveMeta(meta);
146
+ const info = authInfo(auth);
147
+ out(`now using "${name}"${info.email ? ` (${info.email}, ${info.plan || 'unknown plan'})` : ''}`);
148
+ }
149
+
150
+ function cmdCurrent() {
151
+ const meta = store.loadMeta();
152
+ const p = store.paths();
153
+ const live = readJSONSafe(p.authPath);
154
+ if (!live) {
155
+ out(`no auth.json at ${p.authPath}`);
156
+ return;
157
+ }
158
+ const info = authInfo(live);
159
+ const match = store.listAccounts().find((a) => a.accountId === info.accountId);
160
+ const name = match ? match.name : '(not stored — run "codexswitch import")';
161
+ const note = meta.active && match && meta.active !== match.name ? ` (meta says "${meta.active}" — out of sync)` : '';
162
+ out(`active: ${name}${note}`);
163
+ out(` email: ${info.email || '-'}\n plan: ${info.plan || '-'}\n token refreshed: ${fmtDate(info.lastRefresh)}`);
164
+ }
165
+
166
+ function cmdNext() {
167
+ const meta = store.loadMeta();
168
+ const next = store.pickAccount(meta.active ? [meta.active] : []);
169
+ if (!next) throw new Error('no other usable account available');
170
+ cmdUse([next.name]);
171
+ }
172
+
173
+ function cmdRemove(args) {
174
+ const name = requireArg(args, 0, 'account name');
175
+ if (!store.accountExists(name)) throw new Error(`no such account: ${name}`);
176
+ fs.rmSync(store.accountPath(name), { force: true });
177
+ fs.rmSync(path.join(store.paths().profilesDir, name), { recursive: true, force: true });
178
+ const meta = store.loadMeta();
179
+ delete meta.accounts[name];
180
+ if (meta.active === name) meta.active = null;
181
+ store.saveMeta(meta);
182
+ out(`removed "${name}"`);
183
+ }
184
+
185
+ function cmdRename(args) {
186
+ const from = requireArg(args, 0, 'current name');
187
+ const to = requireArg(args, 1, 'new name');
188
+ const auth = store.readAccountAuth(from);
189
+ if (store.accountExists(to)) throw new Error(`account "${to}" already exists`);
190
+ store.writeAccountAuth(to, auth);
191
+ fs.rmSync(store.accountPath(from), { force: true });
192
+ const meta = store.loadMeta();
193
+ meta.accounts[to] = meta.accounts[from] || { priority: 0 };
194
+ delete meta.accounts[from];
195
+ if (meta.active === from) meta.active = to;
196
+ store.saveMeta(meta);
197
+ out(`renamed "${from}" -> "${to}"`);
198
+ }
199
+
200
+ function setFlag(name, patch) {
201
+ if (!store.accountExists(name)) throw new Error(`no such account: ${name}`);
202
+ const meta = store.loadMeta();
203
+ meta.accounts[name] = { ...(meta.accounts[name] || {}), ...patch };
204
+ store.saveMeta(meta);
205
+ }
206
+
207
+ function cmdPriority(args) {
208
+ const name = requireArg(args, 0, 'account name');
209
+ const n = parseInt(requireArg(args, 1, 'priority number'), 10);
210
+ if (Number.isNaN(n)) throw new Error('priority must be a number');
211
+ setFlag(name, { priority: n });
212
+ out(`priority of "${name}" set to ${n}`);
213
+ }
214
+
215
+ function cmdCooldown(args) {
216
+ const meta = store.loadMeta();
217
+ if (args[0] == null) {
218
+ out(`cooldown: ${meta.cooldownMinutes} minutes`);
219
+ return;
220
+ }
221
+ const n = parseInt(args[0], 10);
222
+ if (Number.isNaN(n) || n <= 0) throw new Error('cooldown must be a positive number of minutes');
223
+ meta.cooldownMinutes = n;
224
+ store.saveMeta(meta);
225
+ out(`cooldown set to ${n} minutes`);
226
+ }
227
+
228
+ function cmdSync() {
229
+ const updated = store.syncBack();
230
+ out(updated ? `synced refreshed tokens into "${updated}"` : 'nothing to sync');
231
+ }
232
+
233
+ async function cmdRun(args) {
234
+ let name = null;
235
+ let rest = args;
236
+ if (args[0] && store.accountExists(args[0])) {
237
+ name = args[0];
238
+ rest = args.slice(1);
239
+ }
240
+ if (!name) {
241
+ const meta = store.loadMeta();
242
+ const picked = store.pickAccount();
243
+ if (!picked) throw new Error('no usable account — add one with "codexswitch login"');
244
+ name = meta.active && store.accountExists(meta.active) ? meta.active : picked.name;
245
+ }
246
+ if (rest[0] === '--') rest = rest.slice(1);
247
+ out(`[codexswitch] running codex as "${name}"`);
248
+ const res = await runner.runCodex(name, rest);
249
+ return res.code;
250
+ }
251
+
252
+ async function cmdExec(args) {
253
+ let explicit = null;
254
+ const rest = [];
255
+ for (let i = 0; i < args.length; i++) {
256
+ if (args[i] === '-a' || args[i] === '--account') {
257
+ explicit = requireArg(args, ++i, 'account name after --account');
258
+ } else {
259
+ rest.push(args[i]);
260
+ }
261
+ }
262
+ if (explicit && !store.accountExists(explicit)) throw new Error(`no such account: ${explicit}`);
263
+
264
+ const meta = store.loadMeta();
265
+ const total = store.listAccounts().length;
266
+ if (total === 0) throw new Error('no accounts — add one with "codexswitch login"');
267
+
268
+ const tried = [];
269
+ for (let attempt = 0; attempt < total; attempt++) {
270
+ let name;
271
+ if (explicit && attempt === 0) {
272
+ name = explicit;
273
+ } else {
274
+ const picked = store.pickAccount(tried);
275
+ if (!picked) break;
276
+ name = picked.name;
277
+ }
278
+ tried.push(name);
279
+ console.error(`[codexswitch] exec as "${name}"${attempt > 0 ? ` (attempt ${attempt + 1})` : ''}`);
280
+ const res = await runner.runCodex(name, ['exec', ...rest], { capture: true });
281
+ if (res.code === 0) {
282
+ store.clearLimited(name);
283
+ return 0;
284
+ }
285
+ if (runner.looksRateLimited(res.output)) {
286
+ const until = Date.now() + runner.limitCooldownMs(res.output, meta.cooldownMinutes);
287
+ store.markLimited(name, until);
288
+ console.error(`[codexswitch] "${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating`);
289
+ continue;
290
+ }
291
+ return res.code; // real failure, don't burn other accounts on it
292
+ }
293
+ console.error('[codexswitch] all accounts are rate-limited or disabled');
294
+ return 2;
295
+ }
296
+
297
+ async function main(argv) {
298
+ const [cmd, ...args] = argv;
299
+ switch (cmd) {
300
+ case undefined:
301
+ case 'help':
302
+ case '--help':
303
+ case '-h':
304
+ out(HELP);
305
+ return 0;
306
+ case 'login':
307
+ return cmdLogin(args), 0;
308
+ case 'import':
309
+ return cmdImport(args), 0;
310
+ case 'list':
311
+ case 'accounts':
312
+ case 'status':
313
+ return cmdList(), 0;
314
+ case 'use':
315
+ return cmdUse(args), 0;
316
+ case 'current':
317
+ case 'whoami':
318
+ return cmdCurrent(), 0;
319
+ case 'next':
320
+ return cmdNext(), 0;
321
+ case 'remove':
322
+ case 'rm':
323
+ return cmdRemove(args), 0;
324
+ case 'rename':
325
+ return cmdRename(args), 0;
326
+ case 'enable':
327
+ setFlag(requireArg(args, 0, 'account name'), { disabled: false });
328
+ out(`enabled "${args[0]}"`);
329
+ return 0;
330
+ case 'disable':
331
+ setFlag(requireArg(args, 0, 'account name'), { disabled: true });
332
+ out(`disabled "${args[0]}"`);
333
+ return 0;
334
+ case 'priority':
335
+ return cmdPriority(args), 0;
336
+ case 'clear-limit': {
337
+ const name = requireArg(args, 0, 'account name');
338
+ store.clearLimited(name);
339
+ out(`cleared rate-limit record for "${name}"`);
340
+ return 0;
341
+ }
342
+ case 'cooldown':
343
+ return cmdCooldown(args), 0;
344
+ case 'sync':
345
+ return cmdSync(), 0;
346
+ case 'run':
347
+ return cmdRun(args);
348
+ case 'exec':
349
+ return cmdExec(args);
350
+ default:
351
+ throw new Error(`unknown command "${cmd}" (see "codexswitch help")`);
352
+ }
353
+ }
354
+
355
+ module.exports = { main };
package/src/runner.js ADDED
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawn, spawnSync } = require('child_process');
6
+ const { ensureDir, parseDurationMs } = require('./util.js');
7
+ const store = require('./store.js');
8
+
9
+ let cachedBin = null;
10
+
11
+ // Resolve the codex binary. On Windows an npm-installed codex is usually a
12
+ // "codex.cmd" shim which Node refuses to spawn directly (EINVAL), so resolve
13
+ // the real path via `where` and prefer a native .exe when one exists.
14
+ function codexBin() {
15
+ if (process.env.CODEX_SWITCH_CODEX_BIN) return process.env.CODEX_SWITCH_CODEX_BIN;
16
+ if (cachedBin) return cachedBin;
17
+ cachedBin = 'codex';
18
+ if (process.platform === 'win32') {
19
+ const r = spawnSync('where', ['codex'], { encoding: 'utf8' });
20
+ if (r.status === 0 && r.stdout) {
21
+ const lines = r.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
22
+ cachedBin = lines.find((l) => /\.exe$/i.test(l)) || lines[0] || 'codex';
23
+ }
24
+ }
25
+ return cachedBin;
26
+ }
27
+
28
+ // cmd.exe argument quoting for the .cmd/.bat shim case (shell: true).
29
+ function winQuote(arg) {
30
+ if (arg === '') return '""';
31
+ if (!/[\s"^&|<>()%!;,=]/.test(arg)) return arg;
32
+ return '"' + arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1') + '"';
33
+ }
34
+
35
+ // Node cannot spawn .cmd/.bat files without a shell; wrap args accordingly.
36
+ function spawnPlan(args) {
37
+ const bin = codexBin();
38
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(bin)) {
39
+ return { bin: winQuote(bin), args: args.map(winQuote), shell: true };
40
+ }
41
+ return { bin, args, shell: false };
42
+ }
43
+
44
+ function spawnCodexSync(args, options = {}) {
45
+ const plan = spawnPlan(args);
46
+ return spawnSync(plan.bin, plan.args, { ...options, shell: plan.shell });
47
+ }
48
+
49
+ function assertCodexAvailable() {
50
+ const r = spawnCodexSync(['--version'], { stdio: 'ignore' });
51
+ if ((r.error && r.error.code === 'ENOENT') || r.status === 127) {
52
+ throw new Error(
53
+ `codex CLI not found ("${codexBin()}"). Install it with "npm install -g @openai/codex" or set CODEX_SWITCH_CODEX_BIN to its path.`
54
+ );
55
+ }
56
+ }
57
+
58
+ // Build a per-account CODEX_HOME overlay: every top-level entry of the real
59
+ // CODEX_HOME is symlinked into the profile so config, sessions, skills and
60
+ // history are shared — except auth.json (per-account, copied from the store)
61
+ // and sqlite databases (excluded: two codex processes writing the same
62
+ // sqlite file through symlinks risks corruption; each profile keeps its own).
63
+ function buildProfile(name) {
64
+ const p = store.paths();
65
+ const profile = path.join(p.profilesDir, name);
66
+ ensureDir(profile);
67
+
68
+ let entries = [];
69
+ try {
70
+ entries = fs.readdirSync(p.codexHome);
71
+ } catch {
72
+ /* no real codex home yet — profile starts empty */
73
+ }
74
+
75
+ // Drop stale symlinks (target removed, or entry no longer shareable).
76
+ for (const entry of fs.readdirSync(profile)) {
77
+ const dest = path.join(profile, entry);
78
+ let st;
79
+ try {
80
+ st = fs.lstatSync(dest);
81
+ } catch {
82
+ continue;
83
+ }
84
+ if (!st.isSymbolicLink()) continue;
85
+ const shouldExist = entries.includes(entry) && shareable(entry);
86
+ if (!shouldExist || !fs.existsSync(dest)) fs.rmSync(dest, { force: true });
87
+ }
88
+
89
+ for (const entry of entries) {
90
+ if (!shareable(entry)) continue;
91
+ const dest = path.join(profile, entry);
92
+ const target = path.join(p.codexHome, entry);
93
+ if (isLink(dest)) continue;
94
+ if (fs.existsSync(dest)) {
95
+ // Windows copy fallback: refresh the copy when the original changed.
96
+ if (process.platform === 'win32') refreshCopy(target, dest);
97
+ continue;
98
+ }
99
+ linkEntry(target, dest);
100
+ }
101
+
102
+ const auth = store.readAccountAuth(name);
103
+ const authFile = path.join(profile, 'auth.json');
104
+ fs.writeFileSync(authFile, JSON.stringify(auth, null, 2) + '\n', { mode: 0o600 });
105
+ return profile;
106
+ }
107
+
108
+ function shareable(entry) {
109
+ if (entry === 'auth.json') return false;
110
+ if (/\.sqlite(-wal|-shm|-journal)?$/.test(entry)) return false;
111
+ if (entry === 'tmp') return false;
112
+ return true;
113
+ }
114
+
115
+ function isLink(file) {
116
+ try {
117
+ return fs.lstatSync(file).isSymbolicLink();
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ // Share one entry into the profile. POSIX: plain symlink. Windows: junction
124
+ // for directories (no admin required); for files try a symlink (works with
125
+ // Developer Mode) and fall back to a plain copy.
126
+ function linkEntry(target, dest) {
127
+ let isDir = false;
128
+ try {
129
+ isDir = fs.statSync(target).isDirectory();
130
+ } catch {
131
+ return;
132
+ }
133
+ if (process.platform !== 'win32') {
134
+ fs.symlinkSync(target, dest);
135
+ return;
136
+ }
137
+ try {
138
+ fs.symlinkSync(target, dest, isDir ? 'junction' : 'file');
139
+ } catch {
140
+ if (!isDir) {
141
+ try {
142
+ fs.copyFileSync(target, dest);
143
+ } catch {
144
+ /* unshareable entry — codex will recreate what it needs */
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ function refreshCopy(target, dest) {
151
+ try {
152
+ const s = fs.statSync(target);
153
+ const d = fs.statSync(dest);
154
+ if (s.isFile() && d.isFile() && s.mtimeMs > d.mtimeMs) fs.copyFileSync(target, dest);
155
+ } catch {
156
+ /* best effort */
157
+ }
158
+ }
159
+
160
+ // Run codex for one account inside its overlay profile.
161
+ // capture=false: interactive, stdio inherited. capture=true: stream output
162
+ // through while also collecting it so the caller can detect limit errors.
163
+ function runCodex(name, args, { capture = false } = {}) {
164
+ assertCodexAvailable();
165
+ const profile = buildProfile(name);
166
+ const env = { ...process.env, CODEX_HOME: profile };
167
+
168
+ return new Promise((resolve) => {
169
+ const plan = spawnPlan(args);
170
+ const child = spawn(plan.bin, plan.args, {
171
+ env,
172
+ shell: plan.shell,
173
+ stdio: capture ? ['inherit', 'pipe', 'pipe'] : 'inherit',
174
+ });
175
+ let out = '';
176
+ if (capture) {
177
+ const collect = (stream, dest) => {
178
+ stream.on('data', (chunk) => {
179
+ out += chunk.toString('utf8');
180
+ if (out.length > 262144) out = out.slice(-131072);
181
+ dest.write(chunk);
182
+ });
183
+ };
184
+ collect(child.stdout, process.stdout);
185
+ collect(child.stderr, process.stderr);
186
+ }
187
+ child.on('error', (err) => resolve({ code: 1, output: out, error: err }));
188
+ child.on('close', (code) => {
189
+ // codex may have refreshed the token while running — persist it.
190
+ try {
191
+ store.syncBackFrom(path.join(profile, 'auth.json'));
192
+ } catch {
193
+ /* best effort */
194
+ }
195
+ resolve({ code: code == null ? 1 : code, output: out });
196
+ });
197
+ });
198
+ }
199
+
200
+ const LIMIT_RE =
201
+ /usage limit|rate limit|too many requests|quota (?:exceeded|reached)|\b429\b|hit your (?:usage|weekly|5h) limit/i;
202
+
203
+ function looksRateLimited(output) {
204
+ return LIMIT_RE.test(output || '');
205
+ }
206
+
207
+ // Try to extract "try again in 2 hours 30 minutes" style hints; fall back
208
+ // to the configured cooldown.
209
+ function limitCooldownMs(output, cooldownMinutes) {
210
+ const m = /try again (?:in|after)\s+([^.\n]+)/i.exec(output || '');
211
+ const parsed = m ? parseDurationMs(m[1]) : null;
212
+ return parsed || cooldownMinutes * 60000;
213
+ }
214
+
215
+ module.exports = {
216
+ codexBin,
217
+ spawnCodexSync,
218
+ assertCodexAvailable,
219
+ buildProfile,
220
+ runCodex,
221
+ looksRateLimited,
222
+ limitCooldownMs,
223
+ };
package/src/store.js ADDED
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { ensureDir, readJSON, readJSONSafe, writeJSONAtomic, authInfo } = require('./util.js');
7
+
8
+ // Directory layout:
9
+ // ~/.codex-switch/
10
+ // meta.json account registry (priority/disabled/limits) + active name
11
+ // accounts/<name>.json stored copy of that account's codex auth.json
12
+ // profiles/<name>/ per-account CODEX_HOME overlay used by `run`/`exec`
13
+ // Overridable for tests / custom setups via CODEX_SWITCH_HOME.
14
+ function paths() {
15
+ const home = process.env.CODEX_SWITCH_HOME || path.join(os.homedir(), '.codex-switch');
16
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
17
+ return {
18
+ home,
19
+ codexHome,
20
+ accountsDir: path.join(home, 'accounts'),
21
+ profilesDir: path.join(home, 'profiles'),
22
+ metaPath: path.join(home, 'meta.json'),
23
+ authPath: path.join(codexHome, 'auth.json'),
24
+ };
25
+ }
26
+
27
+ function loadMeta() {
28
+ const p = paths();
29
+ const meta = readJSONSafe(p.metaPath, {}) || {};
30
+ if (!meta.accounts) meta.accounts = {};
31
+ if (!('active' in meta)) meta.active = null;
32
+ if (!meta.cooldownMinutes) meta.cooldownMinutes = 60;
33
+ return meta;
34
+ }
35
+
36
+ function saveMeta(meta) {
37
+ writeJSONAtomic(paths().metaPath, meta);
38
+ }
39
+
40
+ function accountPath(name) {
41
+ // Unicode letters/digits are fine (names become filenames); block path
42
+ // separators, traversal, and characters invalid on Windows filesystems.
43
+ if (!/^[\p{L}\p{N}@._+ -]+$/u.test(name) || name.includes('..') || /^[. ]|[. ]$/.test(name)) {
44
+ throw new Error(`invalid account name "${name}" (letters, digits, spaces, @ . _ + - only)`);
45
+ }
46
+ return path.join(paths().accountsDir, `${name}.json`);
47
+ }
48
+
49
+ function accountExists(name) {
50
+ try {
51
+ return fs.existsSync(accountPath(name));
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function readAccountAuth(name) {
58
+ if (!accountExists(name)) throw new Error(`no such account: ${name} (see "codexswitch list")`);
59
+ return readJSON(accountPath(name));
60
+ }
61
+
62
+ function writeAccountAuth(name, auth) {
63
+ writeJSONAtomic(accountPath(name), auth, 0o600);
64
+ }
65
+
66
+ function listAccounts() {
67
+ const p = paths();
68
+ const meta = loadMeta();
69
+ let files = [];
70
+ try {
71
+ files = fs.readdirSync(p.accountsDir).filter((f) => f.endsWith('.json'));
72
+ } catch {
73
+ /* no accounts yet */
74
+ }
75
+ return files
76
+ .map((f) => {
77
+ const name = f.slice(0, -5);
78
+ const auth = readJSONSafe(path.join(p.accountsDir, f));
79
+ const m = meta.accounts[name] || {};
80
+ return {
81
+ name,
82
+ ...authInfo(auth),
83
+ priority: m.priority ?? 0,
84
+ disabled: !!m.disabled,
85
+ limitedUntil: m.limitedUntil || null,
86
+ active: meta.active === name,
87
+ };
88
+ })
89
+ .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
90
+ }
91
+
92
+ // Copy refreshed tokens from a live auth.json back into the store, so a
93
+ // token refresh done by codex itself is never lost when we switch accounts.
94
+ // Matches by account_id; prefers the account we believe deployed the file.
95
+ function syncBackFrom(authFile) {
96
+ const cur = readJSONSafe(authFile);
97
+ if (!cur) return null;
98
+ const curInfo = authInfo(cur);
99
+ if (!curInfo.accountId) return null;
100
+ const meta = loadMeta();
101
+ const candidates = listAccounts().filter((a) => a.accountId === curInfo.accountId);
102
+ if (candidates.length === 0) return null;
103
+ const target = candidates.find((a) => a.name === meta.active) || candidates[0];
104
+ const stored = readAccountAuth(target.name);
105
+ const newer =
106
+ !stored.last_refresh || (cur.last_refresh && cur.last_refresh > stored.last_refresh);
107
+ if (newer && JSON.stringify(stored) !== JSON.stringify(cur)) {
108
+ writeAccountAuth(target.name, cur);
109
+ return target.name;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function syncBack() {
115
+ return syncBackFrom(paths().authPath);
116
+ }
117
+
118
+ function markLimited(name, untilTs) {
119
+ const meta = loadMeta();
120
+ if (!meta.accounts[name]) meta.accounts[name] = {};
121
+ meta.accounts[name].limitedUntil = untilTs;
122
+ saveMeta(meta);
123
+ }
124
+
125
+ function clearLimited(name) {
126
+ const meta = loadMeta();
127
+ if (meta.accounts[name] && meta.accounts[name].limitedUntil) {
128
+ delete meta.accounts[name].limitedUntil;
129
+ saveMeta(meta);
130
+ }
131
+ }
132
+
133
+ // Pick the best usable account: enabled, not currently rate-limited,
134
+ // lowest priority number first. `exclude` skips accounts already tried.
135
+ function pickAccount(exclude = []) {
136
+ const now = Date.now();
137
+ const usable = listAccounts().filter(
138
+ (a) => !a.disabled && !exclude.includes(a.name) && (!a.limitedUntil || a.limitedUntil <= now)
139
+ );
140
+ if (usable.length === 0) return null;
141
+ const active = usable.find((a) => a.active);
142
+ return active || usable[0];
143
+ }
144
+
145
+ module.exports = {
146
+ paths,
147
+ loadMeta,
148
+ saveMeta,
149
+ accountPath,
150
+ accountExists,
151
+ readAccountAuth,
152
+ writeAccountAuth,
153
+ listAccounts,
154
+ syncBack,
155
+ syncBackFrom,
156
+ markLimited,
157
+ clearLimited,
158
+ pickAccount,
159
+ ensureDirs() {
160
+ const p = paths();
161
+ ensureDir(p.home);
162
+ ensureDir(p.accountsDir);
163
+ ensureDir(p.profilesDir);
164
+ },
165
+ };
package/src/util.js ADDED
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function ensureDir(dir, mode = 0o700) {
7
+ fs.mkdirSync(dir, { recursive: true, mode });
8
+ }
9
+
10
+ function readJSON(file) {
11
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
12
+ }
13
+
14
+ function readJSONSafe(file, fallback = null) {
15
+ try {
16
+ return readJSON(file);
17
+ } catch {
18
+ return fallback;
19
+ }
20
+ }
21
+
22
+ // Write JSON atomically (tmp file + rename) so a crash never leaves a
23
+ // half-written auth/meta file behind.
24
+ function writeJSONAtomic(file, data, mode = 0o600) {
25
+ ensureDir(path.dirname(file));
26
+ const tmp = `${file}.tmp-${process.pid}`;
27
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode });
28
+ fs.renameSync(tmp, file);
29
+ }
30
+
31
+ // Decode a JWT payload without verifying the signature. We only use this to
32
+ // display identity info (email, plan) that codex already trusts locally.
33
+ function decodeJwtPayload(token) {
34
+ try {
35
+ const part = token.split('.')[1];
36
+ const pad = part + '='.repeat((4 - (part.length % 4)) % 4);
37
+ return JSON.parse(Buffer.from(pad, 'base64url').toString('utf8'));
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ // Extract display info from a codex auth.json object.
44
+ function authInfo(auth) {
45
+ const info = { email: null, plan: null, accountId: null, lastRefresh: null };
46
+ if (!auth || typeof auth !== 'object') return info;
47
+ info.lastRefresh = auth.last_refresh || null;
48
+ const tokens = auth.tokens || {};
49
+ info.accountId = tokens.account_id || null;
50
+ const payload = tokens.id_token ? decodeJwtPayload(tokens.id_token) : null;
51
+ if (payload) {
52
+ info.email = payload.email || null;
53
+ const oa = payload['https://api.openai.com/auth'] || {};
54
+ info.plan = oa.chatgpt_plan_type || null;
55
+ if (!info.accountId) info.accountId = oa.chatgpt_account_id || null;
56
+ }
57
+ if (!info.email && auth.OPENAI_API_KEY) info.email = '(api key)';
58
+ return info;
59
+ }
60
+
61
+ // Parse durations like "2 hours 30 minutes", "5h", "3 days" into ms.
62
+ function parseDurationMs(text) {
63
+ if (!text) return null;
64
+ let ms = 0;
65
+ const re = /(\d+(?:\.\d+)?)\s*(d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:utes?)?)?|s(?:ec(?:onds?)?)?)\b/gi;
66
+ let m;
67
+ while ((m = re.exec(text)) !== null) {
68
+ const n = parseFloat(m[1]);
69
+ const unit = m[2][0].toLowerCase();
70
+ ms += n * { d: 86400000, h: 3600000, m: 60000, s: 1000 }[unit];
71
+ }
72
+ return ms > 0 ? ms : null;
73
+ }
74
+
75
+ function fmtDate(ts) {
76
+ if (!ts) return '-';
77
+ const d = new Date(ts);
78
+ if (isNaN(d.getTime())) return String(ts);
79
+ const p = (n) => String(n).padStart(2, '0');
80
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
81
+ }
82
+
83
+ function fmtRemaining(untilTs) {
84
+ const ms = untilTs - Date.now();
85
+ if (ms <= 0) return 'now';
86
+ const h = Math.floor(ms / 3600000);
87
+ const m = Math.round((ms % 3600000) / 60000);
88
+ return h > 0 ? `${h}h${m}m` : `${m}m`;
89
+ }
90
+
91
+ function table(rows, headers) {
92
+ const all = [headers, ...rows].map((r) => r.map((c) => String(c == null ? '-' : c)));
93
+ const widths = headers.map((_, i) => Math.max(...all.map((r) => r[i].length)));
94
+ const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
95
+ return [line(all[0]), line(widths.map((w) => '-'.repeat(w))), ...all.slice(1).map(line)].join('\n');
96
+ }
97
+
98
+ module.exports = {
99
+ ensureDir,
100
+ readJSON,
101
+ readJSONSafe,
102
+ writeJSONAtomic,
103
+ decodeJwtPayload,
104
+ authInfo,
105
+ parseDurationMs,
106
+ fmtDate,
107
+ fmtRemaining,
108
+ table,
109
+ };