@bjesuiter/codex-switcher 1.0.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 +21 -0
- package/README.md +128 -0
- package/cdx.mjs +714 -0
- package/package.json +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 bjesuiter
|
|
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,128 @@
|
|
|
1
|
+
# cdx
|
|
2
|
+
|
|
3
|
+
CLI tool to switch between multiple OpenAI accounts for [OpenCode](https://opencode.ai).
|
|
4
|
+
|
|
5
|
+
## Supported Configurations
|
|
6
|
+
|
|
7
|
+
- **OpenAI Plus & Pro subscription accounts**: Log in to multiple OpenAI accounts via OAuth and switch the active auth credentials used by OpenCode.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- macOS (uses Keychain via the `security` command)
|
|
12
|
+
- [Bun](https://bun.sh) runtime
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun add -g @bjesuiter/codex-switch
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This exposes the `cdx` binary globally.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
### Add your first account
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cdx login
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Opens your browser to authenticate with OpenAI. After successful login, your credentials are stored securely in macOS Keychain.
|
|
31
|
+
|
|
32
|
+
### Switch between accounts
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cdx switch
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Interactive picker to select an account. Writes credentials to `~/.local/share/opencode/auth.json`.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cdx switch --next
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Cycles to the next configured account without prompting.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cdx switch <account-id-or-label>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Switch directly to a specific account by ID or label.
|
|
51
|
+
|
|
52
|
+
### Label accounts
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
cdx label
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Interactive prompt to assign a friendly name to an account.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cdx label <account> <new-label>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Assign a label directly.
|
|
65
|
+
|
|
66
|
+
### Interactive mode
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cdx
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Running `cdx` without arguments opens an interactive menu to:
|
|
73
|
+
- List all configured accounts
|
|
74
|
+
- Switch to a different account
|
|
75
|
+
- Add a new account (OAuth login)
|
|
76
|
+
- Remove an account
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
| Command | Description |
|
|
81
|
+
|---------|-------------|
|
|
82
|
+
| `cdx` | Interactive mode |
|
|
83
|
+
| `cdx login` | Add a new OpenAI account via OAuth |
|
|
84
|
+
| `cdx switch` | Switch account (interactive picker) |
|
|
85
|
+
| `cdx switch --next` | Cycle to next account |
|
|
86
|
+
| `cdx switch <id>` | Switch to specific account |
|
|
87
|
+
| `cdx label` | Label an account (interactive) |
|
|
88
|
+
| `cdx label <account> <label>` | Assign label directly |
|
|
89
|
+
| `cdx --help` | Show help |
|
|
90
|
+
| `cdx --version` | Show version |
|
|
91
|
+
|
|
92
|
+
## How It Works
|
|
93
|
+
|
|
94
|
+
- OAuth credentials are stored securely in macOS Keychain
|
|
95
|
+
- Account list is stored in `~/.config/cdx/accounts.json`
|
|
96
|
+
- Active account credentials are written to `~/.local/share/opencode/auth.json`
|
|
97
|
+
|
|
98
|
+
## For Developers
|
|
99
|
+
|
|
100
|
+
### Install from source
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
git clone https://github.com/bjesuiter/codex-switcher.git
|
|
104
|
+
cd codex-switcher
|
|
105
|
+
bun install
|
|
106
|
+
bun link
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Manual Configuration (Advanced)
|
|
110
|
+
|
|
111
|
+
You can also manually add accounts to Keychain:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
security add-generic-password -a "ACCOUNT_ID" -s "cdx-openai-ACCOUNT_ID" -w '{"refresh":"REFRESH","access":"ACCESS","expires":1234567890,"accountId":"ACCOUNT_ID"}' -U
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
And create the accounts list manually:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"current": 0,
|
|
122
|
+
"accounts": [
|
|
123
|
+
{ "accountId": "ACCOUNT_ID", "keychainService": "cdx-openai-ACCOUNT_ID" }
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Save to `~/.config/cdx/accounts.json`.
|
package/cdx.mjs
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import projectVersion from "project-version";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import * as p from "@clack/prompts";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
|
|
14
|
+
//#region lib/paths.ts
|
|
15
|
+
const defaultConfigDir = path.join(os.homedir(), ".config", "cdx");
|
|
16
|
+
const defaultPaths = {
|
|
17
|
+
configDir: defaultConfigDir,
|
|
18
|
+
configPath: path.join(defaultConfigDir, "accounts.json"),
|
|
19
|
+
authPath: path.join(os.homedir(), ".local", "share", "opencode", "auth.json")
|
|
20
|
+
};
|
|
21
|
+
let currentPaths = { ...defaultPaths };
|
|
22
|
+
const getPaths = () => currentPaths;
|
|
23
|
+
const setPaths = (paths) => {
|
|
24
|
+
currentPaths = {
|
|
25
|
+
...currentPaths,
|
|
26
|
+
...paths
|
|
27
|
+
};
|
|
28
|
+
if (paths.configDir && !paths.configPath) currentPaths.configPath = path.join(paths.configDir, "accounts.json");
|
|
29
|
+
};
|
|
30
|
+
const resetPaths = () => {
|
|
31
|
+
currentPaths = { ...defaultPaths };
|
|
32
|
+
};
|
|
33
|
+
const createTestPaths = (testDir) => ({
|
|
34
|
+
configDir: path.join(testDir, "config"),
|
|
35
|
+
configPath: path.join(testDir, "config", "accounts.json"),
|
|
36
|
+
authPath: path.join(testDir, "auth", "auth.json")
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region lib/auth.ts
|
|
41
|
+
const writeAuthFile = async (payload) => {
|
|
42
|
+
const { authPath } = getPaths();
|
|
43
|
+
await mkdir(path.dirname(authPath), { recursive: true });
|
|
44
|
+
const authJson = { openai: {
|
|
45
|
+
type: "oauth",
|
|
46
|
+
refresh: payload.refresh,
|
|
47
|
+
access: payload.access,
|
|
48
|
+
expires: payload.expires,
|
|
49
|
+
accountId: payload.accountId
|
|
50
|
+
} };
|
|
51
|
+
await writeFile(authPath, JSON.stringify(authJson, null, 2), "utf8");
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region lib/config.ts
|
|
56
|
+
const loadConfig = async () => {
|
|
57
|
+
const { configPath } = getPaths();
|
|
58
|
+
if (!existsSync(configPath)) throw new Error(`Missing config at ${configPath}. Create accounts.json to list Keychain services.`);
|
|
59
|
+
const raw = await readFile(configPath, "utf8");
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
if (!Array.isArray(parsed.accounts) || parsed.accounts.length === 0) throw new Error("accounts.json must include a non-empty accounts array.");
|
|
62
|
+
if (typeof parsed.current !== "number" || Number.isNaN(parsed.current)) parsed.current = 0;
|
|
63
|
+
return parsed;
|
|
64
|
+
};
|
|
65
|
+
const saveConfig = async (config) => {
|
|
66
|
+
const { configDir, configPath } = getPaths();
|
|
67
|
+
await mkdir(configDir, { recursive: true });
|
|
68
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
69
|
+
};
|
|
70
|
+
const configExists = () => {
|
|
71
|
+
const { configPath } = getPaths();
|
|
72
|
+
return existsSync(configPath);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region lib/keychain.ts
|
|
77
|
+
const SERVICE_PREFIX = "cdx-openai-";
|
|
78
|
+
const getKeychainService = (accountId) => {
|
|
79
|
+
return `${SERVICE_PREFIX}${accountId}`;
|
|
80
|
+
};
|
|
81
|
+
const runSecurity = (args) => {
|
|
82
|
+
const result = Bun.spawnSync({
|
|
83
|
+
cmd: ["security", ...args],
|
|
84
|
+
stderr: "pipe",
|
|
85
|
+
stdout: "pipe"
|
|
86
|
+
});
|
|
87
|
+
if (result.exitCode !== 0) {
|
|
88
|
+
const message = result.stderr.toString().trim();
|
|
89
|
+
throw new Error(message || "Keychain command failed");
|
|
90
|
+
}
|
|
91
|
+
return result.stdout.toString();
|
|
92
|
+
};
|
|
93
|
+
const runSecuritySafe = (args) => {
|
|
94
|
+
const result = Bun.spawnSync({
|
|
95
|
+
cmd: ["security", ...args],
|
|
96
|
+
stderr: "pipe",
|
|
97
|
+
stdout: "pipe"
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
success: result.exitCode === 0,
|
|
101
|
+
output: result.exitCode === 0 ? result.stdout.toString() : result.stderr.toString()
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
const saveKeychainPayload = (accountId, payload) => {
|
|
105
|
+
runSecurity([
|
|
106
|
+
"add-generic-password",
|
|
107
|
+
"-a",
|
|
108
|
+
accountId,
|
|
109
|
+
"-s",
|
|
110
|
+
getKeychainService(accountId),
|
|
111
|
+
"-w",
|
|
112
|
+
JSON.stringify(payload),
|
|
113
|
+
"-U"
|
|
114
|
+
]);
|
|
115
|
+
};
|
|
116
|
+
const loadKeychainPayload = (accountId) => {
|
|
117
|
+
const raw = runSecurity([
|
|
118
|
+
"find-generic-password",
|
|
119
|
+
"-s",
|
|
120
|
+
getKeychainService(accountId),
|
|
121
|
+
"-w"
|
|
122
|
+
]).trim();
|
|
123
|
+
if (!raw) throw new Error(`No Keychain payload found for account ${accountId}.`);
|
|
124
|
+
const parsed = JSON.parse(raw);
|
|
125
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Keychain payload for account ${accountId} is missing required fields.`);
|
|
126
|
+
return parsed;
|
|
127
|
+
};
|
|
128
|
+
const deleteKeychainPayload = (accountId) => {
|
|
129
|
+
runSecurity([
|
|
130
|
+
"delete-generic-password",
|
|
131
|
+
"-s",
|
|
132
|
+
getKeychainService(accountId)
|
|
133
|
+
]);
|
|
134
|
+
};
|
|
135
|
+
const keychainPayloadExists = (accountId) => {
|
|
136
|
+
return runSecuritySafe([
|
|
137
|
+
"find-generic-password",
|
|
138
|
+
"-s",
|
|
139
|
+
getKeychainService(accountId)
|
|
140
|
+
]).success;
|
|
141
|
+
};
|
|
142
|
+
const listKeychainAccounts = () => {
|
|
143
|
+
const result = Bun.spawnSync({
|
|
144
|
+
cmd: ["security", "dump-keychain"],
|
|
145
|
+
stderr: "pipe",
|
|
146
|
+
stdout: "pipe"
|
|
147
|
+
});
|
|
148
|
+
if (result.exitCode !== 0) return [];
|
|
149
|
+
const output = result.stdout.toString();
|
|
150
|
+
const accounts = [];
|
|
151
|
+
const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX}([^"]+)"`, "g");
|
|
152
|
+
let match;
|
|
153
|
+
while ((match = serviceRegex.exec(output)) !== null) if (match[1]) accounts.push(match[1]);
|
|
154
|
+
return [...new Set(accounts)];
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region lib/oauth/constants.ts
|
|
159
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
160
|
+
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
161
|
+
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
162
|
+
const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
163
|
+
const SCOPE = "openid profile email offline_access";
|
|
164
|
+
const CALLBACK_PORT = 1455;
|
|
165
|
+
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region lib/oauth/auth.ts
|
|
168
|
+
const createState = () => {
|
|
169
|
+
return randomBytes(16).toString("hex");
|
|
170
|
+
};
|
|
171
|
+
const createAuthorizationFlow = async () => {
|
|
172
|
+
const pkce = await generatePKCE();
|
|
173
|
+
const state = createState();
|
|
174
|
+
const url = new URL(AUTHORIZE_URL);
|
|
175
|
+
url.searchParams.set("response_type", "code");
|
|
176
|
+
url.searchParams.set("client_id", CLIENT_ID);
|
|
177
|
+
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
178
|
+
url.searchParams.set("scope", SCOPE);
|
|
179
|
+
url.searchParams.set("code_challenge", pkce.challenge);
|
|
180
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
181
|
+
url.searchParams.set("state", state);
|
|
182
|
+
url.searchParams.set("id_token_add_organizations", "true");
|
|
183
|
+
url.searchParams.set("codex_cli_simplified_flow", "true");
|
|
184
|
+
url.searchParams.set("originator", "codex_cli_rs");
|
|
185
|
+
return {
|
|
186
|
+
pkce,
|
|
187
|
+
state,
|
|
188
|
+
url: url.toString()
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
const exchangeAuthorizationCode = async (code, verifier) => {
|
|
192
|
+
const res = await fetch(TOKEN_URL, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
195
|
+
body: new URLSearchParams({
|
|
196
|
+
grant_type: "authorization_code",
|
|
197
|
+
client_id: CLIENT_ID,
|
|
198
|
+
code,
|
|
199
|
+
code_verifier: verifier,
|
|
200
|
+
redirect_uri: REDIRECT_URI
|
|
201
|
+
})
|
|
202
|
+
});
|
|
203
|
+
if (!res.ok) return { type: "failed" };
|
|
204
|
+
const json = await res.json();
|
|
205
|
+
if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return { type: "failed" };
|
|
206
|
+
return {
|
|
207
|
+
type: "success",
|
|
208
|
+
access: json.access_token,
|
|
209
|
+
refresh: json.refresh_token,
|
|
210
|
+
expires: Date.now() + json.expires_in * 1e3
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
const decodeJWT = (token) => {
|
|
214
|
+
try {
|
|
215
|
+
const parts = token.split(".");
|
|
216
|
+
if (parts.length !== 3) return null;
|
|
217
|
+
const payload = parts[1];
|
|
218
|
+
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
|
|
219
|
+
return JSON.parse(decoded);
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const extractAccountId = (accessToken) => {
|
|
225
|
+
const payload = decodeJWT(accessToken);
|
|
226
|
+
if (!payload) return null;
|
|
227
|
+
const authClaim = payload["https://api.openai.com/auth"];
|
|
228
|
+
if (authClaim?.user_id) return authClaim.user_id;
|
|
229
|
+
return payload.sub ?? null;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region lib/oauth/server.ts
|
|
234
|
+
const AUTH_TIMEOUT_MS = 300 * 1e3;
|
|
235
|
+
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
236
|
+
<html>
|
|
237
|
+
<head>
|
|
238
|
+
<title>cdx - Login Successful</title>
|
|
239
|
+
<style>
|
|
240
|
+
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a1a; color: #fff; }
|
|
241
|
+
.container { text-align: center; padding: 2rem; }
|
|
242
|
+
h1 { color: #10b981; margin-bottom: 1rem; }
|
|
243
|
+
p { color: #9ca3af; }
|
|
244
|
+
</style>
|
|
245
|
+
</head>
|
|
246
|
+
<body>
|
|
247
|
+
<div class="container">
|
|
248
|
+
<h1>Login Successful!</h1>
|
|
249
|
+
<p>You can close this window and return to the terminal.</p>
|
|
250
|
+
</div>
|
|
251
|
+
</body>
|
|
252
|
+
</html>`;
|
|
253
|
+
const startOAuthServer = (state) => {
|
|
254
|
+
let resolveCode = null;
|
|
255
|
+
let hasResolved = false;
|
|
256
|
+
const codePromise = new Promise((resolve) => {
|
|
257
|
+
resolveCode = resolve;
|
|
258
|
+
});
|
|
259
|
+
let server;
|
|
260
|
+
const finalize = (result) => {
|
|
261
|
+
if (hasResolved) return;
|
|
262
|
+
hasResolved = true;
|
|
263
|
+
if (resolveCode) resolveCode(result);
|
|
264
|
+
try {
|
|
265
|
+
server.close();
|
|
266
|
+
} catch {}
|
|
267
|
+
};
|
|
268
|
+
server = http.createServer((req, res) => {
|
|
269
|
+
try {
|
|
270
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
271
|
+
if (url.pathname !== "/auth/callback") {
|
|
272
|
+
res.statusCode = 404;
|
|
273
|
+
res.end("Not found");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (url.searchParams.get("state") !== state) {
|
|
277
|
+
res.statusCode = 400;
|
|
278
|
+
res.end("State mismatch");
|
|
279
|
+
finalize(null);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const code = url.searchParams.get("code");
|
|
283
|
+
if (!code) {
|
|
284
|
+
res.statusCode = 400;
|
|
285
|
+
res.end("Missing authorization code");
|
|
286
|
+
finalize(null);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
res.statusCode = 200;
|
|
290
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
291
|
+
res.end(SUCCESS_HTML);
|
|
292
|
+
finalize({ code });
|
|
293
|
+
} catch {
|
|
294
|
+
res.statusCode = 500;
|
|
295
|
+
res.end("Internal error");
|
|
296
|
+
finalize(null);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
server.listen(CALLBACK_PORT, "127.0.0.1", () => {
|
|
301
|
+
const timeout = setTimeout(() => finalize(null), AUTH_TIMEOUT_MS);
|
|
302
|
+
resolve({
|
|
303
|
+
port: CALLBACK_PORT,
|
|
304
|
+
ready: true,
|
|
305
|
+
close: () => {
|
|
306
|
+
clearTimeout(timeout);
|
|
307
|
+
server.close();
|
|
308
|
+
},
|
|
309
|
+
waitForCode: () => codePromise
|
|
310
|
+
});
|
|
311
|
+
}).on("error", () => {
|
|
312
|
+
resolve({
|
|
313
|
+
port: CALLBACK_PORT,
|
|
314
|
+
ready: false,
|
|
315
|
+
close: () => {
|
|
316
|
+
try {
|
|
317
|
+
server.close();
|
|
318
|
+
} catch {}
|
|
319
|
+
},
|
|
320
|
+
waitForCode: async () => null
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region lib/oauth/login.ts
|
|
328
|
+
const openBrowser = (url) => {
|
|
329
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], {
|
|
330
|
+
detached: true,
|
|
331
|
+
stdio: "ignore"
|
|
332
|
+
}).unref();
|
|
333
|
+
};
|
|
334
|
+
const addAccountToConfig = async (accountId, label) => {
|
|
335
|
+
let config;
|
|
336
|
+
if (configExists()) {
|
|
337
|
+
config = await loadConfig();
|
|
338
|
+
if (!config.accounts.some((a) => a.accountId === accountId)) config.accounts.push({
|
|
339
|
+
accountId,
|
|
340
|
+
keychainService: getKeychainService(accountId),
|
|
341
|
+
...label ? { label } : {}
|
|
342
|
+
});
|
|
343
|
+
} else config = {
|
|
344
|
+
current: 0,
|
|
345
|
+
accounts: [{
|
|
346
|
+
accountId,
|
|
347
|
+
keychainService: getKeychainService(accountId),
|
|
348
|
+
...label ? { label } : {}
|
|
349
|
+
}]
|
|
350
|
+
};
|
|
351
|
+
await saveConfig(config);
|
|
352
|
+
};
|
|
353
|
+
const performLogin = async () => {
|
|
354
|
+
p.intro("cdx login - Add OpenAI account");
|
|
355
|
+
const flow = await createAuthorizationFlow();
|
|
356
|
+
const server = await startOAuthServer(flow.state);
|
|
357
|
+
if (!server.ready) {
|
|
358
|
+
p.log.error("Failed to start local server on port 1455.");
|
|
359
|
+
p.log.info("Please ensure the port is not in use.");
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const spinner = p.spinner();
|
|
363
|
+
p.log.info("Opening browser for authentication...");
|
|
364
|
+
openBrowser(flow.url);
|
|
365
|
+
spinner.start("Waiting for authentication...");
|
|
366
|
+
const result = await server.waitForCode();
|
|
367
|
+
server.close();
|
|
368
|
+
if (!result) {
|
|
369
|
+
spinner.stop("Authentication timed out or failed.");
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
spinner.message("Exchanging authorization code...");
|
|
373
|
+
const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
|
|
374
|
+
if (tokenResult.type === "failed") {
|
|
375
|
+
spinner.stop("Failed to exchange authorization code.");
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
const accountId = extractAccountId(tokenResult.access);
|
|
379
|
+
if (!accountId) {
|
|
380
|
+
spinner.stop("Failed to extract account ID from token.");
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
spinner.message("Saving credentials...");
|
|
384
|
+
saveKeychainPayload(accountId, {
|
|
385
|
+
refresh: tokenResult.refresh,
|
|
386
|
+
access: tokenResult.access,
|
|
387
|
+
expires: tokenResult.expires,
|
|
388
|
+
accountId
|
|
389
|
+
});
|
|
390
|
+
spinner.stop("Login successful!");
|
|
391
|
+
const labelInput = await p.text({
|
|
392
|
+
message: "Enter a label for this account (or press Enter to skip):",
|
|
393
|
+
placeholder: "e.g. Work, Personal"
|
|
394
|
+
});
|
|
395
|
+
const label = !p.isCancel(labelInput) && labelInput?.trim() ? labelInput.trim() : void 0;
|
|
396
|
+
await addAccountToConfig(accountId, label);
|
|
397
|
+
const displayName = label ?? accountId;
|
|
398
|
+
p.log.success(`Account "${displayName}" saved to Keychain and config.`);
|
|
399
|
+
p.outro("You can now use 'cdx switch' to activate this account.");
|
|
400
|
+
return { accountId };
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region lib/interactive.ts
|
|
405
|
+
const getAccountDisplay = (accountId, isCurrent, label) => {
|
|
406
|
+
const name = label ? `${label} (${accountId})` : accountId;
|
|
407
|
+
return isCurrent ? `${name} (current)` : name;
|
|
408
|
+
};
|
|
409
|
+
const handleListAccounts = async () => {
|
|
410
|
+
if (!configExists()) {
|
|
411
|
+
p.log.warning("No accounts configured. Use 'Add account' to get started.");
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const config = await loadConfig();
|
|
415
|
+
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
416
|
+
p.log.info("Configured accounts:");
|
|
417
|
+
for (const account of config.accounts) {
|
|
418
|
+
const marker = account.accountId === currentAccountId ? "→ " : " ";
|
|
419
|
+
const displayName = account.label ? `${account.label} (${account.accountId})` : account.accountId;
|
|
420
|
+
const status = keychainPayloadExists(account.accountId) ? "" : " (missing credentials)";
|
|
421
|
+
p.log.message(`${marker}${displayName}${status}`);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const handleSwitchAccount = async () => {
|
|
425
|
+
if (!configExists()) {
|
|
426
|
+
p.log.warning("No accounts configured. Use 'Add account' first.");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const config = await loadConfig();
|
|
430
|
+
if (config.accounts.length === 0) {
|
|
431
|
+
p.log.warning("No accounts found. Use 'Add account' first.");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (config.accounts.length === 1) {
|
|
435
|
+
p.log.info("Only one account configured. Nothing to switch.");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
439
|
+
const options = config.accounts.map((account, index) => ({
|
|
440
|
+
value: index,
|
|
441
|
+
label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
|
|
442
|
+
}));
|
|
443
|
+
const selected = await p.select({
|
|
444
|
+
message: "Select account to activate:",
|
|
445
|
+
options
|
|
446
|
+
});
|
|
447
|
+
if (p.isCancel(selected)) {
|
|
448
|
+
p.log.info("Cancelled.");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const selectedAccount = config.accounts[selected];
|
|
452
|
+
if (!selectedAccount) {
|
|
453
|
+
p.log.error("Invalid selection.");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
await writeAuthFile(loadKeychainPayload(selectedAccount.accountId));
|
|
457
|
+
config.current = selected;
|
|
458
|
+
await saveConfig(config);
|
|
459
|
+
const displayName = selectedAccount.label ?? selectedAccount.accountId;
|
|
460
|
+
p.log.success(`Switched to account ${displayName}`);
|
|
461
|
+
};
|
|
462
|
+
const handleAddAccount = async () => {
|
|
463
|
+
await performLogin();
|
|
464
|
+
};
|
|
465
|
+
const handleRemoveAccount = async () => {
|
|
466
|
+
if (!configExists()) {
|
|
467
|
+
p.log.warning("No accounts configured.");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const config = await loadConfig();
|
|
471
|
+
if (config.accounts.length === 0) {
|
|
472
|
+
p.log.warning("No accounts to remove.");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
476
|
+
const options = config.accounts.map((account) => ({
|
|
477
|
+
value: account.accountId,
|
|
478
|
+
label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
|
|
479
|
+
}));
|
|
480
|
+
const selected = await p.select({
|
|
481
|
+
message: "Select account to remove:",
|
|
482
|
+
options
|
|
483
|
+
});
|
|
484
|
+
if (p.isCancel(selected)) {
|
|
485
|
+
p.log.info("Cancelled.");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const accountId = selected;
|
|
489
|
+
const confirmed = await p.confirm({
|
|
490
|
+
message: `Are you sure you want to remove account ${accountId}?`,
|
|
491
|
+
initialValue: false
|
|
492
|
+
});
|
|
493
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
494
|
+
p.log.info("Cancelled.");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
deleteKeychainPayload(accountId);
|
|
499
|
+
} catch {}
|
|
500
|
+
const previousAccountId = config.accounts[config.current]?.accountId;
|
|
501
|
+
config.accounts = config.accounts.filter((a) => a.accountId !== accountId);
|
|
502
|
+
if (config.accounts.length === 0) config.current = 0;
|
|
503
|
+
else if (accountId === previousAccountId) config.current = 0;
|
|
504
|
+
else {
|
|
505
|
+
const newIndex = config.accounts.findIndex((a) => a.accountId === previousAccountId);
|
|
506
|
+
config.current = newIndex >= 0 ? newIndex : 0;
|
|
507
|
+
}
|
|
508
|
+
await saveConfig(config);
|
|
509
|
+
p.log.success(`Removed account ${accountId}`);
|
|
510
|
+
};
|
|
511
|
+
const handleLabelAccount = async () => {
|
|
512
|
+
if (!configExists()) {
|
|
513
|
+
p.log.warning("No accounts configured.");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const config = await loadConfig();
|
|
517
|
+
if (config.accounts.length === 0) {
|
|
518
|
+
p.log.warning("No accounts to label.");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
522
|
+
const options = config.accounts.map((account) => ({
|
|
523
|
+
value: account.accountId,
|
|
524
|
+
label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
|
|
525
|
+
}));
|
|
526
|
+
const selected = await p.select({
|
|
527
|
+
message: "Select account to label:",
|
|
528
|
+
options
|
|
529
|
+
});
|
|
530
|
+
if (p.isCancel(selected)) {
|
|
531
|
+
p.log.info("Cancelled.");
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const accountId = selected;
|
|
535
|
+
const account = config.accounts.find((a) => a.accountId === accountId);
|
|
536
|
+
const labelInput = await p.text({
|
|
537
|
+
message: "Enter new label (or leave empty to remove label):",
|
|
538
|
+
placeholder: "e.g. Work, Personal",
|
|
539
|
+
initialValue: account?.label ?? ""
|
|
540
|
+
});
|
|
541
|
+
if (p.isCancel(labelInput)) {
|
|
542
|
+
p.log.info("Cancelled.");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const newLabel = labelInput?.trim() || void 0;
|
|
546
|
+
const target = config.accounts.find((a) => a.accountId === accountId);
|
|
547
|
+
if (target) target.label = newLabel;
|
|
548
|
+
await saveConfig(config);
|
|
549
|
+
if (newLabel) p.log.success(`Account ${accountId} labeled as "${newLabel}".`);
|
|
550
|
+
else p.log.success(`Label removed from account ${accountId}.`);
|
|
551
|
+
};
|
|
552
|
+
const runInteractiveMode = async () => {
|
|
553
|
+
p.intro("cdx - OpenAI Account Switcher");
|
|
554
|
+
let running = true;
|
|
555
|
+
while (running) {
|
|
556
|
+
const keychainAccounts = listKeychainAccounts();
|
|
557
|
+
let currentInfo = "";
|
|
558
|
+
if (configExists()) try {
|
|
559
|
+
const config = await loadConfig();
|
|
560
|
+
const current = config.accounts[config.current];
|
|
561
|
+
if (current) currentInfo = ` (current: ${current.label ?? current.accountId})`;
|
|
562
|
+
} catch {}
|
|
563
|
+
const action = await p.select({
|
|
564
|
+
message: `What would you like to do?${currentInfo}`,
|
|
565
|
+
options: [
|
|
566
|
+
{
|
|
567
|
+
value: "list",
|
|
568
|
+
label: `List accounts (${keychainAccounts.length} in Keychain)`
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
value: "switch",
|
|
572
|
+
label: "Switch account"
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
value: "add",
|
|
576
|
+
label: "Add account (OAuth login)"
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
value: "remove",
|
|
580
|
+
label: "Remove account"
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
value: "label",
|
|
584
|
+
label: "Label account"
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
value: "exit",
|
|
588
|
+
label: "Exit"
|
|
589
|
+
}
|
|
590
|
+
]
|
|
591
|
+
});
|
|
592
|
+
if (p.isCancel(action)) {
|
|
593
|
+
running = false;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
switch (action) {
|
|
597
|
+
case "list":
|
|
598
|
+
await handleListAccounts();
|
|
599
|
+
break;
|
|
600
|
+
case "switch":
|
|
601
|
+
await handleSwitchAccount();
|
|
602
|
+
break;
|
|
603
|
+
case "add":
|
|
604
|
+
await handleAddAccount();
|
|
605
|
+
break;
|
|
606
|
+
case "remove":
|
|
607
|
+
await handleRemoveAccount();
|
|
608
|
+
break;
|
|
609
|
+
case "label":
|
|
610
|
+
await handleLabelAccount();
|
|
611
|
+
break;
|
|
612
|
+
case "exit":
|
|
613
|
+
running = false;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
if (running && action !== "exit") p.log.message("");
|
|
617
|
+
}
|
|
618
|
+
p.outro("Goodbye!");
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region cdx.ts
|
|
623
|
+
const switchNext = async () => {
|
|
624
|
+
const config = await loadConfig();
|
|
625
|
+
const nextIndex = (config.current + 1) % config.accounts.length;
|
|
626
|
+
const nextAccount = config.accounts[nextIndex];
|
|
627
|
+
if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
|
|
628
|
+
const payload = loadKeychainPayload(nextAccount.accountId);
|
|
629
|
+
await writeAuthFile(payload);
|
|
630
|
+
config.current = nextIndex;
|
|
631
|
+
await saveConfig(config);
|
|
632
|
+
const displayName = nextAccount.label ?? payload.accountId;
|
|
633
|
+
process.stdout.write(`Switched to account ${displayName}\n`);
|
|
634
|
+
};
|
|
635
|
+
const switchToAccount = async (identifier) => {
|
|
636
|
+
const config = await loadConfig();
|
|
637
|
+
const index = config.accounts.findIndex((a) => a.accountId === identifier || a.label === identifier);
|
|
638
|
+
if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
|
|
639
|
+
const account = config.accounts[index];
|
|
640
|
+
await writeAuthFile(loadKeychainPayload(account.accountId));
|
|
641
|
+
config.current = index;
|
|
642
|
+
await saveConfig(config);
|
|
643
|
+
const displayName = account.label ?? account.accountId;
|
|
644
|
+
process.stdout.write(`Switched to account ${displayName}\n`);
|
|
645
|
+
};
|
|
646
|
+
const interactiveMode = runInteractiveMode;
|
|
647
|
+
const createProgram = (deps = {}) => {
|
|
648
|
+
const program = new Command();
|
|
649
|
+
const runLogin = deps.performLogin ?? performLogin;
|
|
650
|
+
program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(projectVersion, "-v, --version");
|
|
651
|
+
program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
|
|
652
|
+
try {
|
|
653
|
+
if (!await runLogin()) {
|
|
654
|
+
process.stderr.write("Login failed.\n");
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
} catch (error) {
|
|
658
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
659
|
+
process.stderr.write(`${message}\n`);
|
|
660
|
+
process.exit(1);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
program.command("switch").description("Switch OpenAI account (interactive picker, by name, or --next)").argument("[account-id]", "Account ID to switch to directly").option("-n, --next", "Cycle to the next configured account").action(async (accountId, options) => {
|
|
664
|
+
try {
|
|
665
|
+
if (options.next) await switchNext();
|
|
666
|
+
else if (accountId) await switchToAccount(accountId);
|
|
667
|
+
else await handleSwitchAccount();
|
|
668
|
+
} catch (error) {
|
|
669
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
670
|
+
process.stderr.write(`${message}\n`);
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
program.command("label").description("Add or change label for an account").argument("[account]", "Account ID or current label to relabel").argument("[new-label]", "New label to assign").action(async (account, newLabel) => {
|
|
675
|
+
try {
|
|
676
|
+
if (account && newLabel) {
|
|
677
|
+
const config = await loadConfig();
|
|
678
|
+
const target = config.accounts.find((a) => a.accountId === account || a.label === account);
|
|
679
|
+
if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
680
|
+
target.label = newLabel;
|
|
681
|
+
await saveConfig(config);
|
|
682
|
+
process.stdout.write(`Account ${target.accountId} labeled as "${newLabel}".\n`);
|
|
683
|
+
} else await handleLabelAccount();
|
|
684
|
+
} catch (error) {
|
|
685
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
686
|
+
process.stderr.write(`${message}\n`);
|
|
687
|
+
process.exit(1);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
program.command("version").description("Show CLI version").action(() => {
|
|
691
|
+
process.stdout.write(`${projectVersion}\n`);
|
|
692
|
+
});
|
|
693
|
+
program.action(async () => {
|
|
694
|
+
try {
|
|
695
|
+
await interactiveMode();
|
|
696
|
+
} catch (error) {
|
|
697
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
698
|
+
process.stderr.write(`${message}\n`);
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
return program;
|
|
703
|
+
};
|
|
704
|
+
const main = async () => {
|
|
705
|
+
await createProgram().parseAsync(process.argv);
|
|
706
|
+
};
|
|
707
|
+
main().catch((error) => {
|
|
708
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
709
|
+
process.stderr.write(`${message}\n`);
|
|
710
|
+
process.exit(1);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
//#endregion
|
|
714
|
+
export { createProgram, createTestPaths, getPaths, interactiveMode, loadConfig, resetPaths, runInteractiveMode, saveConfig, setPaths, switchNext, switchToAccount, writeAuthFile };
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bjesuiter/codex-switcher",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cdx": "cdx.mjs"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"openai",
|
|
11
|
+
"opencode",
|
|
12
|
+
"codex",
|
|
13
|
+
"account-switcher",
|
|
14
|
+
"cli"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/bjesuiter/codex-switcher.git"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "bjesuiter",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@clack/prompts": "^0.11.0",
|
|
24
|
+
"@openauthjs/openauth": "^0.4.3",
|
|
25
|
+
"commander": "^14.0.2",
|
|
26
|
+
"project-version": "^2.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|