@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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/cdx.mjs +714 -0
  4. 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
+ }