@bjesuiter/codex-switcher 1.2.0 → 1.3.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 (3) hide show
  1. package/README.md +35 -3
  2. package/cdx.mjs +326 -228
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -1,6 +1,31 @@
1
1
  # cdx
2
2
 
3
- CLI tool to switch between multiple OpenAI accounts for [OpenCode](https://opencode.ai).
3
+ Switch the coding-agents [pi](https://pi.dev/), [codex](https://developers.openai.com/codex/cli/) and [opencode](https://opencode.ai/) auth between multiple openAI Plus and Pro accounts.
4
+
5
+ ---
6
+
7
+ ## Latest Changes
8
+
9
+ ### 1.3.0
10
+
11
+ #### Features
12
+
13
+ - Rename `cdx refresh` command to `cdx relogin`
14
+
15
+ #### Fixes
16
+
17
+ - Fix `cdx relogin` selector flow exiting early after account selection (now continues into OAuth browser login)
18
+
19
+ #### Internal
20
+
21
+ - Modularize CLI command wiring by moving command handlers into per-command modules under `lib/commands/`, keeping `cdx.ts` as a thin composition entrypoint
22
+ - Update package dependencies and lockfile (`@clack/prompts`, `commander`, `tsdown`, `@types/bun`, `@types/node`)
23
+
24
+ see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
25
+
26
+
27
+
28
+ ---
4
29
 
5
30
  ## Why codex-switcher?
6
31
 
@@ -9,7 +34,10 @@ So: switching between two $20 plans is the poor man's $100 plan for OpenAI. ^^
9
34
 
10
35
  ## Supported Configurations
11
36
 
12
- - **OpenAI Plus & Pro subscription accounts**: Log in to multiple OpenAI accounts via OAuth and switch the active auth credentials used by OpenCode.
37
+ - **OpenAI Plus & Pro subscription accounts**: Log in to multiple OpenAI OAuth accounts and switch the active credentials.
38
+ - **OpenCode auth target**: Writes active credentials to `~/.local/share/opencode/auth.json`.
39
+ - **Pi Agent auth target**: Writes active credentials to `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json`).
40
+ - **Codex CLI auth target**: Writes active credentials to `~/.codex/auth.json` when `id_token` is available.
13
41
 
14
42
  ## Requirements
15
43
 
@@ -89,14 +117,18 @@ Running `cdx` without arguments opens an interactive menu to:
89
117
  |---------|-------------|
90
118
  | `cdx` | Interactive mode |
91
119
  | `cdx login` | Add a new OpenAI account via OAuth |
120
+ | `cdx relogin` | Re-authenticate an existing account via OAuth |
121
+ | `cdx relogin <account>` | Re-authenticate a specific account by ID or label |
92
122
  | `cdx switch` | Switch account (interactive picker) |
93
123
  | `cdx switch --next` | Cycle to next account |
94
124
  | `cdx switch <id>` | Switch to specific account |
95
125
  | `cdx label` | Label an account (interactive) |
96
126
  | `cdx label <account> <label>` | Assign label directly |
97
- | `cdx status` | Show account status, token expiry, and usage |
127
+ | `cdx status` | Show account status, token expiry, usage, and auth file state |
98
128
  | `cdx usage` | Show usage overview for all accounts |
99
129
  | `cdx usage <account>` | Show detailed usage for a specific account |
130
+ | `cdx help [command]` | Show help for all commands or one command |
131
+ | `cdx version` | Show CLI version |
100
132
  | `cdx --help` | Show help |
101
133
  | `cdx --version` | Show version |
102
134
 
package/cdx.mjs CHANGED
@@ -1,17 +1,25 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from "commander";
3
+ import * as p from "@clack/prompts";
3
4
  import { existsSync } from "node:fs";
4
5
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
6
  import path from "node:path";
6
7
  import os from "node:os";
7
- import * as p from "@clack/prompts";
8
8
  import { spawn } from "node:child_process";
9
9
  import { generatePKCE } from "@openauthjs/openauth/pkce";
10
10
  import { randomBytes } from "node:crypto";
11
11
  import http from "node:http";
12
12
 
13
13
  //#region package.json
14
- var version = "1.2.0";
14
+ var version = "1.3.0";
15
+
16
+ //#endregion
17
+ //#region lib/commands/errors.ts
18
+ const exitWithCommandError = (error) => {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ process.stderr.write(`${message}\n`);
21
+ process.exit(1);
22
+ };
15
23
 
16
24
  //#endregion
17
25
  //#region lib/paths.ts
@@ -429,10 +437,16 @@ const startOAuthServer = (state) => {
429
437
  //#endregion
430
438
  //#region lib/oauth/login.ts
431
439
  const openBrowser = (url) => {
432
- spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], {
433
- detached: true,
434
- stdio: "ignore"
435
- }).unref();
440
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
441
+ try {
442
+ spawn(cmd, [url], {
443
+ detached: true,
444
+ stdio: "ignore"
445
+ }).unref();
446
+ } catch (error) {
447
+ const msg = error instanceof Error ? error.message : String(error);
448
+ p.log.warning(`Could not auto-open browser (${msg}).`);
449
+ }
436
450
  };
437
451
  const addAccountToConfig = async (accountId, label) => {
438
452
  let config;
@@ -453,59 +467,74 @@ const addAccountToConfig = async (accountId, label) => {
453
467
  };
454
468
  await saveConfig(config);
455
469
  };
456
- const performRefresh = async (targetAccountId, label) => {
457
- const displayName = label ?? targetAccountId;
458
- p.log.step(`Refreshing credentials for "${displayName}"...`);
459
- let flow;
470
+ const performRefresh = async (targetAccountId, label, options = {}) => {
471
+ const keepAlive = setInterval(() => {}, 1e3);
460
472
  try {
461
- flow = await createAuthorizationFlow();
462
- } catch (error) {
463
- const msg = error instanceof Error ? error.message : String(error);
464
- p.log.error(`Failed to create authorization flow: ${msg}`);
465
- return null;
466
- }
467
- const server = await startOAuthServer(flow.state);
468
- if (!server.ready) {
469
- p.log.error("Failed to start local server on port 1455.");
470
- p.log.info("Please ensure the port is not in use.");
471
- return null;
472
- }
473
- const spinner = p.spinner();
474
- p.log.info("Opening browser for authentication...");
475
- openBrowser(flow.url);
476
- spinner.start("Waiting for authentication...");
477
- const result = await server.waitForCode();
478
- server.close();
479
- if (!result) {
480
- spinner.stop("Authentication timed out or failed.");
481
- return null;
482
- }
483
- spinner.message("Exchanging authorization code...");
484
- const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
485
- if (tokenResult.type === "failed") {
486
- spinner.stop("Failed to exchange authorization code.");
487
- return null;
488
- }
489
- const newAccountId = extractAccountId(tokenResult.access);
490
- if (!newAccountId) {
491
- spinner.stop("Failed to extract account ID from token.");
492
- return null;
493
- }
494
- if (newAccountId !== targetAccountId) {
495
- spinner.stop(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
496
- return null;
473
+ const displayName = label ?? targetAccountId;
474
+ p.log.step(`Re-authenticating account "${displayName}"...`);
475
+ const useSpinner = options.useSpinner ?? true;
476
+ let flow;
477
+ try {
478
+ flow = await createAuthorizationFlow();
479
+ } catch (error) {
480
+ const msg = error instanceof Error ? error.message : String(error);
481
+ p.log.error(`Failed to create authorization flow: ${msg}`);
482
+ process.stderr.write(`Failed to create authorization flow: ${msg}\n`);
483
+ return null;
484
+ }
485
+ const server = await startOAuthServer(flow.state);
486
+ if (!server.ready) {
487
+ p.log.error("Failed to start local server on port 1455.");
488
+ p.log.info("Please ensure the port is not in use.");
489
+ return null;
490
+ }
491
+ const spinner = useSpinner ? p.spinner() : null;
492
+ p.log.info("Opening browser for authentication...");
493
+ openBrowser(flow.url);
494
+ p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
495
+ if (spinner) spinner.start("Waiting for authentication...");
496
+ const result = await server.waitForCode();
497
+ server.close();
498
+ if (!result) {
499
+ if (spinner) spinner.stop("Authentication timed out or failed.");
500
+ else p.log.warning("Authentication timed out or failed.");
501
+ return null;
502
+ }
503
+ if (spinner) spinner.message("Exchanging authorization code...");
504
+ else p.log.message("Exchanging authorization code...");
505
+ const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
506
+ if (tokenResult.type === "failed") {
507
+ if (spinner) spinner.stop("Failed to exchange authorization code.");
508
+ else p.log.error("Failed to exchange authorization code.");
509
+ return null;
510
+ }
511
+ const newAccountId = extractAccountId(tokenResult.access);
512
+ if (!newAccountId) {
513
+ if (spinner) spinner.stop("Failed to extract account ID from token.");
514
+ else p.log.error("Failed to extract account ID from token.");
515
+ return null;
516
+ }
517
+ if (newAccountId !== targetAccountId) {
518
+ if (spinner) spinner.stop("Authentication completed for a different account.");
519
+ else p.log.error("Authentication completed for a different account.");
520
+ throw new Error(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
521
+ }
522
+ if (spinner) spinner.message("Updating credentials...");
523
+ else p.log.message("Updating credentials...");
524
+ saveKeychainPayload(newAccountId, {
525
+ refresh: tokenResult.refresh,
526
+ access: tokenResult.access,
527
+ expires: tokenResult.expires,
528
+ accountId: newAccountId,
529
+ ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
530
+ });
531
+ if (spinner) spinner.stop("Credentials refreshed!");
532
+ else p.log.success("Credentials refreshed!");
533
+ p.log.success(`Account "${displayName}" credentials updated in Keychain.`);
534
+ return { accountId: newAccountId };
535
+ } finally {
536
+ clearInterval(keepAlive);
497
537
  }
498
- spinner.message("Updating credentials...");
499
- saveKeychainPayload(newAccountId, {
500
- refresh: tokenResult.refresh,
501
- access: tokenResult.access,
502
- expires: tokenResult.expires,
503
- accountId: newAccountId,
504
- ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
505
- });
506
- spinner.stop("Credentials refreshed!");
507
- p.log.success(`Account "${displayName}" credentials updated in Keychain.`);
508
- return { accountId: newAccountId };
509
538
  };
510
539
  const performLogin = async () => {
511
540
  p.intro("cdx login - Add OpenAI account");
@@ -519,6 +548,7 @@ const performLogin = async () => {
519
548
  const spinner = p.spinner();
520
549
  p.log.info("Opening browser for authentication...");
521
550
  openBrowser(flow.url);
551
+ p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
522
552
  spinner.start("Waiting for authentication...");
523
553
  const result = await server.waitForCode();
524
554
  server.close();
@@ -697,6 +727,14 @@ const getAccountDisplay = (accountId, isCurrent, label) => {
697
727
  const name = label ? `${label} (${accountId})` : accountId;
698
728
  return isCurrent ? `${name} (current)` : name;
699
729
  };
730
+ const getRefreshExpiryState = (accountId) => {
731
+ if (!keychainPayloadExists(accountId)) return "unknown [no keychain]";
732
+ try {
733
+ return formatExpiry(loadKeychainPayload(accountId).expires);
734
+ } catch {
735
+ return "unknown";
736
+ }
737
+ };
700
738
  const handleListAccounts = async () => {
701
739
  if (!configExists()) {
702
740
  p.log.warning("No accounts configured. Use 'Add account' to get started.");
@@ -766,23 +804,23 @@ const handleSwitchAccount = async () => {
766
804
  const handleAddAccount = async () => {
767
805
  await performLogin();
768
806
  };
769
- const handleRefreshAccount = async () => {
807
+ const handleReloginAccount = async () => {
770
808
  if (!configExists()) {
771
809
  p.log.warning("No accounts configured. Use 'Add account' first.");
772
810
  return;
773
811
  }
774
812
  const config = await loadConfig();
775
813
  if (config.accounts.length === 0) {
776
- p.log.warning("No accounts to refresh.");
814
+ p.log.warning("No accounts to re-login.");
777
815
  return;
778
816
  }
779
817
  const currentAccountId = config.accounts[config.current]?.accountId;
780
818
  const options = config.accounts.map((account) => ({
781
819
  value: account.accountId,
782
- label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
820
+ label: `${getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)} — ${getRefreshExpiryState(account.accountId)}`
783
821
  }));
784
822
  const selected = await p.select({
785
- message: "Select account to refresh:",
823
+ message: "Select account to re-login:",
786
824
  options
787
825
  });
788
826
  if (p.isCancel(selected)) {
@@ -791,9 +829,12 @@ const handleRefreshAccount = async () => {
791
829
  }
792
830
  const accountId = selected;
793
831
  const account = config.accounts.find((a) => a.accountId === accountId);
832
+ const expiryState = getRefreshExpiryState(accountId);
833
+ const displayName = account?.label ?? accountId;
834
+ p.log.info(`Current token status for ${displayName}: ${expiryState}`);
794
835
  try {
795
- const result = await performRefresh(accountId, account?.label);
796
- if (!result) p.log.warning("Refresh was not completed.");
836
+ const result = await performRefresh(accountId, account?.label, { useSpinner: false });
837
+ if (!result) p.log.warning("Re-login was not completed.");
797
838
  else {
798
839
  const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
799
840
  if (authResult) {
@@ -807,7 +848,7 @@ const handleRefreshAccount = async () => {
807
848
  }
808
849
  } catch (error) {
809
850
  const msg = error instanceof Error ? error.message : String(error);
810
- p.log.error(`Refresh failed: ${msg}`);
851
+ p.log.error(`Re-login failed: ${msg}`);
811
852
  }
812
853
  };
813
854
  const handleRemoveAccount = async () => {
@@ -946,8 +987,8 @@ const runInteractiveMode = async () => {
946
987
  label: "Add account (OAuth login)"
947
988
  },
948
989
  {
949
- value: "refresh",
950
- label: "Refresh account (re-login)"
990
+ value: "relogin",
991
+ label: "Re-login account"
951
992
  },
952
993
  {
953
994
  value: "remove",
@@ -981,8 +1022,8 @@ const runInteractiveMode = async () => {
981
1022
  case "add":
982
1023
  await handleAddAccount();
983
1024
  break;
984
- case "refresh":
985
- await handleRefreshAccount();
1025
+ case "relogin":
1026
+ await handleReloginAccount();
986
1027
  break;
987
1028
  case "remove":
988
1029
  await handleRemoveAccount();
@@ -1002,6 +1043,131 @@ const runInteractiveMode = async () => {
1002
1043
  p.outro("Goodbye!");
1003
1044
  };
1004
1045
 
1046
+ //#endregion
1047
+ //#region lib/commands/interactive.ts
1048
+ const registerDefaultInteractiveAction = (program) => {
1049
+ program.action(async () => {
1050
+ try {
1051
+ await runInteractiveMode();
1052
+ } catch (error) {
1053
+ exitWithCommandError(error);
1054
+ }
1055
+ });
1056
+ };
1057
+
1058
+ //#endregion
1059
+ //#region lib/commands/help.ts
1060
+ const registerHelpCommand = (program) => {
1061
+ program.command("help").description("Show available commands and usage information").argument("[command]", "Show help for a specific command").action((commandName) => {
1062
+ if (commandName) {
1063
+ const command = program.commands.find((entry) => entry.name() === commandName);
1064
+ if (command) {
1065
+ command.outputHelp();
1066
+ return;
1067
+ }
1068
+ process.stderr.write(`Unknown command: ${commandName}\n`);
1069
+ program.outputHelp();
1070
+ process.exit(1);
1071
+ }
1072
+ program.outputHelp();
1073
+ });
1074
+ };
1075
+
1076
+ //#endregion
1077
+ //#region lib/commands/label.ts
1078
+ const registerLabelCommand = (program) => {
1079
+ 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) => {
1080
+ try {
1081
+ if (account && newLabel) {
1082
+ const config = await loadConfig();
1083
+ const target = config.accounts.find((entry) => entry.accountId === account || entry.label === account);
1084
+ if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1085
+ target.label = newLabel;
1086
+ await saveConfig(config);
1087
+ process.stdout.write(`Account ${target.accountId} labeled as "${newLabel}".\n`);
1088
+ return;
1089
+ }
1090
+ await handleLabelAccount();
1091
+ } catch (error) {
1092
+ exitWithCommandError(error);
1093
+ }
1094
+ });
1095
+ };
1096
+
1097
+ //#endregion
1098
+ //#region lib/commands/login.ts
1099
+ const registerLoginCommand = (program, deps = {}) => {
1100
+ const runLogin = deps.performLogin ?? performLogin;
1101
+ program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
1102
+ try {
1103
+ if (!await runLogin()) {
1104
+ process.stderr.write("Login failed.\n");
1105
+ process.exit(1);
1106
+ }
1107
+ } catch (error) {
1108
+ exitWithCommandError(error);
1109
+ }
1110
+ });
1111
+ };
1112
+
1113
+ //#endregion
1114
+ //#region lib/commands/output.ts
1115
+ const formatCodexMark = (result) => {
1116
+ if (result.codexWritten) return "✓";
1117
+ if (result.codexCleared) return "⚠ missing id_token (cleared)";
1118
+ return "⚠ missing id_token";
1119
+ };
1120
+ const writeSwitchSummary = (displayName, result) => {
1121
+ const piMark = result.piWritten ? "✓" : "✗";
1122
+ const codexMark = formatCodexMark(result);
1123
+ process.stdout.write(`Switched to account ${displayName}\n`);
1124
+ process.stdout.write(" OpenCode: ✓\n");
1125
+ process.stdout.write(` Pi Agent: ${piMark}\n`);
1126
+ process.stdout.write(` Codex CLI: ${codexMark}\n`);
1127
+ };
1128
+ const writeUpdatedAuthSummary = (result) => {
1129
+ const piMark = result.piWritten ? "✓" : "✗";
1130
+ const codexMark = formatCodexMark(result);
1131
+ process.stdout.write("Updated active auth files:\n");
1132
+ process.stdout.write(" OpenCode: ✓\n");
1133
+ process.stdout.write(` Pi Agent: ${piMark}\n`);
1134
+ process.stdout.write(` Codex CLI: ${codexMark}\n`);
1135
+ };
1136
+
1137
+ //#endregion
1138
+ //#region lib/commands/refresh.ts
1139
+ const registerReloginCommand = (program) => {
1140
+ program.command("relogin").description("Re-authenticate an existing account with full OAuth login (no duplicate account)").argument("[account]", "Account ID or label to re-login").action(async (account) => {
1141
+ try {
1142
+ if (account) {
1143
+ const target = (await loadConfig()).accounts.find((entry) => entry.accountId === account || entry.label === account);
1144
+ if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1145
+ const displayName = target.label ?? target.accountId;
1146
+ let expiryState = "unknown";
1147
+ let keychainState = "";
1148
+ if (keychainPayloadExists(target.accountId)) try {
1149
+ expiryState = formatExpiry(loadKeychainPayload(target.accountId).expires);
1150
+ } catch {
1151
+ expiryState = "unknown";
1152
+ }
1153
+ else keychainState = " [no keychain]";
1154
+ process.stdout.write(`Current token status for ${displayName}: ${expiryState}${keychainState}\n`);
1155
+ const result = await performRefresh(target.accountId, target.label);
1156
+ if (!result) {
1157
+ process.stderr.write("Re-login failed.\n");
1158
+ process.exit(1);
1159
+ }
1160
+ const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
1161
+ if (authResult) writeUpdatedAuthSummary(authResult);
1162
+ return;
1163
+ }
1164
+ await handleReloginAccount();
1165
+ } catch (error) {
1166
+ exitWithCommandError(error);
1167
+ }
1168
+ });
1169
+ };
1170
+
1005
1171
  //#endregion
1006
1172
  //#region lib/usage.ts
1007
1173
  const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
@@ -1159,113 +1325,9 @@ const formatUsageOverview = (entries) => {
1159
1325
  };
1160
1326
 
1161
1327
  //#endregion
1162
- //#region cdx.ts
1163
- const switchNext = async () => {
1164
- const config = await loadConfig();
1165
- const nextIndex = (config.current + 1) % config.accounts.length;
1166
- const nextAccount = config.accounts[nextIndex];
1167
- if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
1168
- const payload = loadKeychainPayload(nextAccount.accountId);
1169
- const result = await writeAllAuthFiles(payload);
1170
- config.current = nextIndex;
1171
- await saveConfig(config);
1172
- const displayName = nextAccount.label ?? payload.accountId;
1173
- const opencodeMark = "✓";
1174
- const piMark = result.piWritten ? "✓" : "✗";
1175
- const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
1176
- process.stdout.write(`Switched to account ${displayName}\n`);
1177
- process.stdout.write(` OpenCode: ${opencodeMark}\n`);
1178
- process.stdout.write(` Pi Agent: ${piMark}\n`);
1179
- process.stdout.write(` Codex CLI: ${codexMark}\n`);
1180
- };
1181
- const switchToAccount = async (identifier) => {
1182
- const config = await loadConfig();
1183
- const index = config.accounts.findIndex((a) => a.accountId === identifier || a.label === identifier);
1184
- if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
1185
- const account = config.accounts[index];
1186
- const result = await writeAllAuthFiles(loadKeychainPayload(account.accountId));
1187
- config.current = index;
1188
- await saveConfig(config);
1189
- const displayName = account.label ?? account.accountId;
1190
- const opencodeMark = "✓";
1191
- const piMark = result.piWritten ? "✓" : "✗";
1192
- const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
1193
- process.stdout.write(`Switched to account ${displayName}\n`);
1194
- process.stdout.write(` OpenCode: ${opencodeMark}\n`);
1195
- process.stdout.write(` Pi Agent: ${piMark}\n`);
1196
- process.stdout.write(` Codex CLI: ${codexMark}\n`);
1197
- };
1198
- const interactiveMode = runInteractiveMode;
1199
- const createProgram = (deps = {}) => {
1200
- const program = new Command();
1201
- const runLogin = deps.performLogin ?? performLogin;
1202
- program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version");
1203
- program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
1204
- try {
1205
- if (!await runLogin()) {
1206
- process.stderr.write("Login failed.\n");
1207
- process.exit(1);
1208
- }
1209
- } catch (error) {
1210
- const message = error instanceof Error ? error.message : String(error);
1211
- process.stderr.write(`${message}\n`);
1212
- process.exit(1);
1213
- }
1214
- });
1215
- program.command("refresh").description("Re-authenticate an existing account (update tokens without creating a duplicate)").argument("[account]", "Account ID or label to refresh").action(async (account) => {
1216
- try {
1217
- if (account) {
1218
- const target = (await loadConfig()).accounts.find((a) => a.accountId === account || a.label === account);
1219
- if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1220
- const result = await performRefresh(target.accountId, target.label);
1221
- if (!result) {
1222
- process.stderr.write("Refresh failed.\n");
1223
- process.exit(1);
1224
- }
1225
- const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
1226
- if (authResult) {
1227
- const piMark = authResult.piWritten ? "✓" : "✗";
1228
- const codexMark = authResult.codexWritten ? "✓" : authResult.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
1229
- process.stdout.write("Updated active auth files:\n");
1230
- process.stdout.write(" OpenCode: ✓\n");
1231
- process.stdout.write(` Pi Agent: ${piMark}\n`);
1232
- process.stdout.write(` Codex CLI: ${codexMark}\n`);
1233
- }
1234
- } else await handleRefreshAccount();
1235
- } catch (error) {
1236
- const message = error instanceof Error ? error.message : String(error);
1237
- process.stderr.write(`${message}\n`);
1238
- process.exit(1);
1239
- }
1240
- });
1241
- 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) => {
1242
- try {
1243
- if (options.next) await switchNext();
1244
- else if (accountId) await switchToAccount(accountId);
1245
- else await handleSwitchAccount();
1246
- } catch (error) {
1247
- const message = error instanceof Error ? error.message : String(error);
1248
- process.stderr.write(`${message}\n`);
1249
- process.exit(1);
1250
- }
1251
- });
1252
- 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) => {
1253
- try {
1254
- if (account && newLabel) {
1255
- const config = await loadConfig();
1256
- const target = config.accounts.find((a) => a.accountId === account || a.label === account);
1257
- if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1258
- target.label = newLabel;
1259
- await saveConfig(config);
1260
- process.stdout.write(`Account ${target.accountId} labeled as "${newLabel}".\n`);
1261
- } else await handleLabelAccount();
1262
- } catch (error) {
1263
- const message = error instanceof Error ? error.message : String(error);
1264
- process.stderr.write(`${message}\n`);
1265
- process.exit(1);
1266
- }
1267
- });
1268
- program.command("status").description("Show account status, token expiry, and auth file state").action(async () => {
1328
+ //#region lib/commands/status.ts
1329
+ const registerStatusCommand = (program) => {
1330
+ program.command("status").description("Show account status, token expiry, usage, and auth file state").action(async () => {
1269
1331
  try {
1270
1332
  const status = await getStatus();
1271
1333
  if (status.accounts.length === 0) {
@@ -1291,9 +1353,9 @@ const createProgram = (deps = {}) => {
1291
1353
  }
1292
1354
  if (i < status.accounts.length - 1) process.stdout.write("\n");
1293
1355
  }
1294
- const resolveLabel = (id) => {
1295
- if (!id) return "unknown";
1296
- return status.accounts.find((a) => a.accountId === id)?.label ?? id;
1356
+ const resolveLabel = (accountId) => {
1357
+ if (!accountId) return "unknown";
1358
+ return status.accounts.find((account) => account.accountId === accountId)?.label ?? accountId;
1297
1359
  };
1298
1360
  process.stdout.write("\nAuth files:\n");
1299
1361
  const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
@@ -1304,80 +1366,116 @@ const createProgram = (deps = {}) => {
1304
1366
  process.stdout.write(` Pi Agent: ${piStatus}\n`);
1305
1367
  process.stdout.write("\n");
1306
1368
  } catch (error) {
1307
- const message = error instanceof Error ? error.message : String(error);
1308
- process.stderr.write(`${message}\n`);
1309
- process.exit(1);
1369
+ exitWithCommandError(error);
1310
1370
  }
1311
1371
  });
1372
+ };
1373
+
1374
+ //#endregion
1375
+ //#region lib/commands/switch.ts
1376
+ const switchNext = async () => {
1377
+ const config = await loadConfig();
1378
+ const nextIndex = (config.current + 1) % config.accounts.length;
1379
+ const nextAccount = config.accounts[nextIndex];
1380
+ if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
1381
+ const payload = loadKeychainPayload(nextAccount.accountId);
1382
+ const result = await writeAllAuthFiles(payload);
1383
+ config.current = nextIndex;
1384
+ await saveConfig(config);
1385
+ writeSwitchSummary(nextAccount.label ?? payload.accountId, result);
1386
+ };
1387
+ const switchToAccount = async (identifier) => {
1388
+ const config = await loadConfig();
1389
+ const index = config.accounts.findIndex((account) => account.accountId === identifier || account.label === identifier);
1390
+ if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
1391
+ const account = config.accounts[index];
1392
+ const result = await writeAllAuthFiles(loadKeychainPayload(account.accountId));
1393
+ config.current = index;
1394
+ await saveConfig(config);
1395
+ writeSwitchSummary(account.label ?? account.accountId, result);
1396
+ };
1397
+ const registerSwitchCommand = (program) => {
1398
+ 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) => {
1399
+ try {
1400
+ if (options.next) await switchNext();
1401
+ else if (accountId) await switchToAccount(accountId);
1402
+ else await handleSwitchAccount();
1403
+ } catch (error) {
1404
+ exitWithCommandError(error);
1405
+ }
1406
+ });
1407
+ };
1408
+
1409
+ //#endregion
1410
+ //#region lib/commands/usage.ts
1411
+ const registerUsageCommand = (program) => {
1312
1412
  program.command("usage").description("Show OpenAI usage for all accounts (or detailed view for one)").argument("[account]", "Account ID or label (shows detailed single-account view)").action(async (account) => {
1313
1413
  try {
1314
1414
  const config = await loadConfig();
1315
1415
  if (account) {
1316
- const found = config.accounts.find((a) => a.accountId === account || a.label === account);
1416
+ const found = config.accounts.find((entry) => entry.accountId === account || entry.label === account);
1317
1417
  if (!found) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1318
1418
  const result = await fetchUsage(found.accountId);
1319
1419
  if (!result.ok) throw new Error(result.error.message);
1320
1420
  const displayName = found.label ? `${found.label} (${found.accountId})` : found.accountId;
1321
1421
  process.stdout.write(`\n${displayName}\n${formatUsage(result.data)}\n\n`);
1322
- } else {
1323
- if (config.accounts.length === 0) throw new Error("No accounts configured. Use 'cdx login' to add one.");
1324
- const results = await Promise.allSettled(config.accounts.map((a) => fetchUsage(a.accountId)));
1325
- const entries = config.accounts.map((a, i) => {
1326
- const settled = results[i];
1327
- const displayName = a.label ? `${a.label} (${a.accountId})` : a.accountId;
1328
- const result = settled.status === "fulfilled" ? settled.value : {
1329
- ok: false,
1330
- error: {
1331
- type: "network_error",
1332
- message: settled.reason?.message ?? "Fetch failed"
1333
- }
1334
- };
1335
- return {
1336
- displayName,
1337
- isCurrent: i === config.current,
1338
- result
1339
- };
1340
- });
1341
- process.stdout.write(`\n${formatUsageOverview(entries)}\n\n`);
1422
+ return;
1342
1423
  }
1424
+ if (config.accounts.length === 0) throw new Error("No accounts configured. Use 'cdx login' to add one.");
1425
+ const results = await Promise.allSettled(config.accounts.map((entry) => fetchUsage(entry.accountId)));
1426
+ const entries = config.accounts.map((entry, index) => {
1427
+ const settled = results[index];
1428
+ const displayName = entry.label ? `${entry.label} (${entry.accountId})` : entry.accountId;
1429
+ const result = settled.status === "fulfilled" ? settled.value : {
1430
+ ok: false,
1431
+ error: {
1432
+ type: "network_error",
1433
+ message: settled.reason?.message ?? "Fetch failed"
1434
+ }
1435
+ };
1436
+ return {
1437
+ displayName,
1438
+ isCurrent: index === config.current,
1439
+ result
1440
+ };
1441
+ });
1442
+ process.stdout.write(`\n${formatUsageOverview(entries)}\n\n`);
1343
1443
  } catch (error) {
1344
- const message = error instanceof Error ? error.message : String(error);
1345
- process.stderr.write(`${message}\n`);
1346
- process.exit(1);
1444
+ exitWithCommandError(error);
1347
1445
  }
1348
1446
  });
1349
- program.command("help").description("Show available commands and usage information").argument("[command]", "Show help for a specific command").action((commandName) => {
1350
- if (commandName) {
1351
- const cmd = program.commands.find((c) => c.name() === commandName);
1352
- if (cmd) cmd.outputHelp();
1353
- else {
1354
- process.stderr.write(`Unknown command: ${commandName}\n`);
1355
- program.outputHelp();
1356
- process.exit(1);
1357
- }
1358
- } else program.outputHelp();
1359
- });
1447
+ };
1448
+
1449
+ //#endregion
1450
+ //#region lib/commands/version.ts
1451
+ const registerVersionCommand = (program, version) => {
1360
1452
  program.command("version").description("Show CLI version").action(() => {
1361
1453
  process.stdout.write(`${version}\n`);
1362
1454
  });
1363
- program.action(async () => {
1364
- try {
1365
- await interactiveMode();
1366
- } catch (error) {
1367
- const message = error instanceof Error ? error.message : String(error);
1368
- process.stderr.write(`${message}\n`);
1369
- process.exit(1);
1370
- }
1371
- });
1455
+ };
1456
+
1457
+ //#endregion
1458
+ //#region cdx.ts
1459
+ const interactiveMode = runInteractiveMode;
1460
+ const createProgram = (deps = {}) => {
1461
+ const program = new Command();
1462
+ program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version");
1463
+ registerLoginCommand(program, deps);
1464
+ registerReloginCommand(program);
1465
+ registerSwitchCommand(program);
1466
+ registerLabelCommand(program);
1467
+ registerStatusCommand(program);
1468
+ registerUsageCommand(program);
1469
+ registerHelpCommand(program);
1470
+ registerVersionCommand(program, version);
1471
+ registerDefaultInteractiveAction(program);
1372
1472
  return program;
1373
1473
  };
1374
1474
  const main = async () => {
1375
1475
  await createProgram().parseAsync(process.argv);
1376
1476
  };
1377
1477
  if (import.meta.main) main().catch((error) => {
1378
- const message = error instanceof Error ? error.message : String(error);
1379
- process.stderr.write(`${message}\n`);
1380
- process.exit(1);
1478
+ exitWithCommandError(error);
1381
1479
  });
1382
1480
 
1383
1481
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjesuiter/codex-switcher",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
6
6
  "bin": {
@@ -20,8 +20,8 @@
20
20
  "license": "MIT",
21
21
  "author": "bjesuiter",
22
22
  "dependencies": {
23
- "@clack/prompts": "^0.11.0",
23
+ "@clack/prompts": "^1.0.0",
24
24
  "@openauthjs/openauth": "^0.4.3",
25
- "commander": "^14.0.2"
25
+ "commander": "^14.0.3"
26
26
  }
27
27
  }