@bjesuiter/codex-switcher 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/cdx.mjs +620 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  CLI tool to switch between multiple OpenAI accounts for [OpenCode](https://opencode.ai).
4
4
 
5
+ ## Why codex-switcher?
6
+
7
+ Anthropic has a $100/month plan, but OpenAI only offers $20 and $200 plans.
8
+ So: switching between two $20 plans is the poor man's $100 plan for OpenAI. ^^
9
+
5
10
  ## Supported Configurations
6
11
 
7
12
  - **OpenAI Plus & Pro subscription accounts**: Log in to multiple OpenAI accounts via OAuth and switch the active auth credentials used by OpenCode.
@@ -86,6 +91,9 @@ Running `cdx` without arguments opens an interactive menu to:
86
91
  | `cdx switch <id>` | Switch to specific account |
87
92
  | `cdx label` | Label an account (interactive) |
88
93
  | `cdx label <account> <label>` | Assign label directly |
94
+ | `cdx status` | Show account status, token expiry, and usage |
95
+ | `cdx usage` | Show usage overview for all accounts |
96
+ | `cdx usage <account>` | Show detailed usage for a specific account |
89
97
  | `cdx --help` | Show help |
90
98
  | `cdx --version` | Show version |
91
99
 
package/cdx.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from "commander";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
5
  import path from "node:path";
5
6
  import os from "node:os";
6
- import { existsSync } from "node:fs";
7
7
  import * as p from "@clack/prompts";
8
8
  import { spawn } from "node:child_process";
9
9
  import { generatePKCE } from "@openauthjs/openauth/pkce";
@@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto";
11
11
  import http from "node:http";
12
12
 
13
13
  //#region package.json
14
- var version = "1.0.4";
14
+ var version = "1.1.0";
15
15
 
16
16
  //#endregion
17
17
  //#region lib/paths.ts
@@ -19,7 +19,8 @@ const defaultConfigDir = path.join(os.homedir(), ".config", "cdx");
19
19
  const defaultPaths = {
20
20
  configDir: defaultConfigDir,
21
21
  configPath: path.join(defaultConfigDir, "accounts.json"),
22
- authPath: path.join(os.homedir(), ".local", "share", "opencode", "auth.json")
22
+ authPath: path.join(os.homedir(), ".local", "share", "opencode", "auth.json"),
23
+ codexAuthPath: path.join(os.homedir(), ".codex", "auth.json")
23
24
  };
24
25
  let currentPaths = { ...defaultPaths };
25
26
  const getPaths = () => currentPaths;
@@ -36,22 +37,72 @@ const resetPaths = () => {
36
37
  const createTestPaths = (testDir) => ({
37
38
  configDir: path.join(testDir, "config"),
38
39
  configPath: path.join(testDir, "config", "accounts.json"),
39
- authPath: path.join(testDir, "auth", "auth.json")
40
+ authPath: path.join(testDir, "auth", "auth.json"),
41
+ codexAuthPath: path.join(testDir, "codex", "auth.json")
40
42
  });
41
43
 
42
44
  //#endregion
43
45
  //#region lib/auth.ts
46
+ const readExistingJson = async (filePath) => {
47
+ if (!existsSync(filePath)) return {};
48
+ try {
49
+ const raw = await readFile(filePath, "utf8");
50
+ const parsed = JSON.parse(raw);
51
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
52
+ } catch {
53
+ return {};
54
+ }
55
+ };
44
56
  const writeAuthFile = async (payload) => {
45
57
  const { authPath } = getPaths();
46
58
  await mkdir(path.dirname(authPath), { recursive: true });
47
- const authJson = { openai: {
59
+ const existing = await readExistingJson(authPath);
60
+ existing.openai = {
48
61
  type: "oauth",
49
62
  refresh: payload.refresh,
50
63
  access: payload.access,
51
64
  expires: payload.expires,
52
65
  accountId: payload.accountId
53
- } };
54
- await writeFile(authPath, JSON.stringify(authJson, null, 2), "utf8");
66
+ };
67
+ await writeFile(authPath, JSON.stringify(existing, null, 2), "utf8");
68
+ };
69
+ const writeCodexAuthFile = async (payload) => {
70
+ const { codexAuthPath } = getPaths();
71
+ await mkdir(path.dirname(codexAuthPath), { recursive: true });
72
+ const existing = await readExistingJson(codexAuthPath);
73
+ existing.tokens = {
74
+ ...typeof existing.tokens === "object" && existing.tokens !== null ? existing.tokens : {},
75
+ id_token: payload.idToken ?? null,
76
+ access_token: payload.access,
77
+ refresh_token: payload.refresh,
78
+ account_id: payload.accountId
79
+ };
80
+ existing.last_refresh = (/* @__PURE__ */ new Date()).toISOString();
81
+ await writeFile(codexAuthPath, JSON.stringify(existing, null, 2), "utf8");
82
+ };
83
+ const writeAllAuthFiles = async (payload) => {
84
+ await writeAuthFile(payload);
85
+ if (payload.idToken) {
86
+ await writeCodexAuthFile(payload);
87
+ return {
88
+ codexWritten: true,
89
+ codexMissingIdToken: false,
90
+ codexCleared: false
91
+ };
92
+ }
93
+ const { codexAuthPath } = getPaths();
94
+ let codexCleared = false;
95
+ if (existsSync(codexAuthPath)) try {
96
+ await rm(codexAuthPath);
97
+ codexCleared = true;
98
+ } catch {
99
+ codexCleared = false;
100
+ }
101
+ return {
102
+ codexWritten: false,
103
+ codexMissingIdToken: true,
104
+ codexCleared
105
+ };
55
106
  };
56
107
 
57
108
  //#endregion
@@ -210,9 +261,35 @@ const exchangeAuthorizationCode = async (code, verifier) => {
210
261
  type: "success",
211
262
  access: json.access_token,
212
263
  refresh: json.refresh_token,
213
- expires: Date.now() + json.expires_in * 1e3
264
+ expires: Date.now() + json.expires_in * 1e3,
265
+ idToken: json.id_token
214
266
  };
215
267
  };
268
+ const refreshAccessToken = async (refreshToken) => {
269
+ try {
270
+ const response = await fetch(TOKEN_URL, {
271
+ method: "POST",
272
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
273
+ body: new URLSearchParams({
274
+ grant_type: "refresh_token",
275
+ refresh_token: refreshToken,
276
+ client_id: CLIENT_ID
277
+ })
278
+ });
279
+ if (!response.ok) return { type: "failed" };
280
+ const json = await response.json();
281
+ if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return { type: "failed" };
282
+ return {
283
+ type: "success",
284
+ access: json.access_token,
285
+ refresh: json.refresh_token,
286
+ expires: Date.now() + json.expires_in * 1e3,
287
+ idToken: json.id_token
288
+ };
289
+ } catch {
290
+ return { type: "failed" };
291
+ }
292
+ };
216
293
  const decodeJWT = (token) => {
217
294
  try {
218
295
  const parts = token.split(".");
@@ -353,6 +430,60 @@ const addAccountToConfig = async (accountId, label) => {
353
430
  };
354
431
  await saveConfig(config);
355
432
  };
433
+ const performRefresh = async (targetAccountId, label) => {
434
+ const displayName = label ?? targetAccountId;
435
+ p.log.step(`Refreshing credentials for "${displayName}"...`);
436
+ let flow;
437
+ try {
438
+ flow = await createAuthorizationFlow();
439
+ } catch (error) {
440
+ const msg = error instanceof Error ? error.message : String(error);
441
+ p.log.error(`Failed to create authorization flow: ${msg}`);
442
+ return null;
443
+ }
444
+ const server = await startOAuthServer(flow.state);
445
+ if (!server.ready) {
446
+ p.log.error("Failed to start local server on port 1455.");
447
+ p.log.info("Please ensure the port is not in use.");
448
+ return null;
449
+ }
450
+ const spinner = p.spinner();
451
+ p.log.info("Opening browser for authentication...");
452
+ openBrowser(flow.url);
453
+ spinner.start("Waiting for authentication...");
454
+ const result = await server.waitForCode();
455
+ server.close();
456
+ if (!result) {
457
+ spinner.stop("Authentication timed out or failed.");
458
+ return null;
459
+ }
460
+ spinner.message("Exchanging authorization code...");
461
+ const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
462
+ if (tokenResult.type === "failed") {
463
+ spinner.stop("Failed to exchange authorization code.");
464
+ return null;
465
+ }
466
+ const newAccountId = extractAccountId(tokenResult.access);
467
+ if (!newAccountId) {
468
+ spinner.stop("Failed to extract account ID from token.");
469
+ return null;
470
+ }
471
+ if (newAccountId !== targetAccountId) {
472
+ spinner.stop(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
473
+ return null;
474
+ }
475
+ spinner.message("Updating credentials...");
476
+ saveKeychainPayload(newAccountId, {
477
+ refresh: tokenResult.refresh,
478
+ access: tokenResult.access,
479
+ expires: tokenResult.expires,
480
+ accountId: newAccountId,
481
+ ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
482
+ });
483
+ spinner.stop("Credentials refreshed!");
484
+ p.log.success(`Account "${displayName}" credentials updated in Keychain.`);
485
+ return { accountId: newAccountId };
486
+ };
356
487
  const performLogin = async () => {
357
488
  p.intro("cdx login - Add OpenAI account");
358
489
  const flow = await createAuthorizationFlow();
@@ -388,7 +519,8 @@ const performLogin = async () => {
388
519
  refresh: tokenResult.refresh,
389
520
  access: tokenResult.access,
390
521
  expires: tokenResult.expires,
391
- accountId
522
+ accountId,
523
+ ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
392
524
  });
393
525
  spinner.stop("Login successful!");
394
526
  const labelInput = await p.text({
@@ -403,6 +535,115 @@ const performLogin = async () => {
403
535
  return { accountId };
404
536
  };
405
537
 
538
+ //#endregion
539
+ //#region lib/refresh.ts
540
+ const writeActiveAuthFilesIfCurrent = async (accountId) => {
541
+ if (!configExists()) return null;
542
+ const config = await loadConfig();
543
+ const current = config.accounts[config.current];
544
+ if (!current || current.accountId !== accountId) return null;
545
+ return writeAllAuthFiles(loadKeychainPayload(accountId));
546
+ };
547
+
548
+ //#endregion
549
+ //#region lib/status.ts
550
+ const formatDuration = (ms) => {
551
+ const absMs = Math.abs(ms);
552
+ const seconds = Math.floor(absMs / 1e3);
553
+ const minutes = Math.floor(seconds / 60);
554
+ const hours = Math.floor(minutes / 60);
555
+ const days = Math.floor(hours / 24);
556
+ if (days > 0) {
557
+ const remainingHours = hours % 24;
558
+ return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
559
+ }
560
+ if (hours > 0) {
561
+ const remainingMinutes = minutes % 60;
562
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
563
+ }
564
+ if (minutes > 0) return `${minutes}m`;
565
+ return `${seconds}s`;
566
+ };
567
+ const formatExpiry = (expiresAt) => {
568
+ if (expiresAt === null) return "unknown";
569
+ const remaining = expiresAt - Date.now();
570
+ if (remaining <= 0) return `EXPIRED ${formatDuration(remaining)} ago`;
571
+ return `expires in ${formatDuration(remaining)}`;
572
+ };
573
+ const readOpenCodeAuthAccount = async () => {
574
+ const { authPath } = getPaths();
575
+ if (!existsSync(authPath)) return {
576
+ exists: false,
577
+ accountId: null
578
+ };
579
+ try {
580
+ const raw = await readFile(authPath, "utf8");
581
+ return {
582
+ exists: true,
583
+ accountId: JSON.parse(raw).openai?.accountId ?? null
584
+ };
585
+ } catch {
586
+ return {
587
+ exists: true,
588
+ accountId: null
589
+ };
590
+ }
591
+ };
592
+ const readCodexAuthAccount = async () => {
593
+ const { codexAuthPath } = getPaths();
594
+ if (!existsSync(codexAuthPath)) return {
595
+ exists: false,
596
+ accountId: null
597
+ };
598
+ try {
599
+ const raw = await readFile(codexAuthPath, "utf8");
600
+ return {
601
+ exists: true,
602
+ accountId: JSON.parse(raw).tokens?.account_id ?? null
603
+ };
604
+ } catch {
605
+ return {
606
+ exists: true,
607
+ accountId: null
608
+ };
609
+ }
610
+ };
611
+ const getAccountStatus = (accountId, isCurrent, label) => {
612
+ const keychainExists = keychainPayloadExists(accountId);
613
+ let expiresAt = null;
614
+ let hasIdToken = false;
615
+ if (keychainExists) try {
616
+ const payload = loadKeychainPayload(accountId);
617
+ expiresAt = payload.expires;
618
+ hasIdToken = !!payload.idToken;
619
+ } catch {}
620
+ return {
621
+ accountId,
622
+ label,
623
+ isCurrent,
624
+ keychainExists,
625
+ hasIdToken,
626
+ expiresAt,
627
+ expiresIn: formatExpiry(expiresAt)
628
+ };
629
+ };
630
+ const getStatus = async () => {
631
+ const accounts = [];
632
+ if (configExists()) {
633
+ const config = await loadConfig();
634
+ for (let i = 0; i < config.accounts.length; i++) {
635
+ const account = config.accounts[i];
636
+ accounts.push(getAccountStatus(account.accountId, i === config.current, account.label));
637
+ }
638
+ }
639
+ const [opencodeAuth, codexAuth] = await Promise.all([readOpenCodeAuthAccount(), readCodexAuthAccount()]);
640
+ return {
641
+ accounts,
642
+ opencodeAuth,
643
+ codexAuth
644
+ };
645
+ };
646
+
406
647
  //#endregion
407
648
  //#region lib/interactive.ts
408
649
  const getAccountDisplay = (accountId, isCurrent, label) => {
@@ -456,15 +697,68 @@ const handleSwitchAccount = async () => {
456
697
  p.log.error("Invalid selection.");
457
698
  return;
458
699
  }
459
- await writeAuthFile(loadKeychainPayload(selectedAccount.accountId));
700
+ let payload;
701
+ try {
702
+ payload = loadKeychainPayload(selectedAccount.accountId);
703
+ } catch {
704
+ p.log.error(`Missing credentials for account ${selectedAccount.label ?? selectedAccount.accountId}. Re-login with 'cdx login'.`);
705
+ return;
706
+ }
707
+ const result = await writeAllAuthFiles(payload);
460
708
  config.current = selected;
461
709
  await saveConfig(config);
462
710
  const displayName = selectedAccount.label ?? selectedAccount.accountId;
711
+ const opencodeMark = "✓";
712
+ const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
463
713
  p.log.success(`Switched to account ${displayName}`);
714
+ p.log.message(` OpenCode: ${opencodeMark}`);
715
+ p.log.message(` Codex CLI: ${codexMark}`);
464
716
  };
465
717
  const handleAddAccount = async () => {
466
718
  await performLogin();
467
719
  };
720
+ const handleRefreshAccount = async () => {
721
+ if (!configExists()) {
722
+ p.log.warning("No accounts configured. Use 'Add account' first.");
723
+ return;
724
+ }
725
+ const config = await loadConfig();
726
+ if (config.accounts.length === 0) {
727
+ p.log.warning("No accounts to refresh.");
728
+ return;
729
+ }
730
+ const currentAccountId = config.accounts[config.current]?.accountId;
731
+ const options = config.accounts.map((account) => ({
732
+ value: account.accountId,
733
+ label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
734
+ }));
735
+ const selected = await p.select({
736
+ message: "Select account to refresh:",
737
+ options
738
+ });
739
+ if (p.isCancel(selected)) {
740
+ p.log.info("Cancelled.");
741
+ return;
742
+ }
743
+ const accountId = selected;
744
+ const account = config.accounts.find((a) => a.accountId === accountId);
745
+ try {
746
+ const result = await performRefresh(accountId, account?.label);
747
+ if (!result) p.log.warning("Refresh was not completed.");
748
+ else {
749
+ const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
750
+ if (authResult) {
751
+ const codexMark = authResult.codexWritten ? "✓" : authResult.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
752
+ p.log.message("Updated active auth files:");
753
+ p.log.message(" OpenCode: ✓");
754
+ p.log.message(` Codex CLI: ${codexMark}`);
755
+ }
756
+ }
757
+ } catch (error) {
758
+ const msg = error instanceof Error ? error.message : String(error);
759
+ p.log.error(`Refresh failed: ${msg}`);
760
+ }
761
+ };
468
762
  const handleRemoveAccount = async () => {
469
763
  if (!configExists()) {
470
764
  p.log.warning("No accounts configured.");
@@ -552,6 +846,26 @@ const handleLabelAccount = async () => {
552
846
  if (newLabel) p.log.success(`Account ${accountId} labeled as "${newLabel}".`);
553
847
  else p.log.success(`Label removed from account ${accountId}.`);
554
848
  };
849
+ const handleStatus = async () => {
850
+ const status = await getStatus();
851
+ if (status.accounts.length === 0) {
852
+ p.log.warning("No accounts configured. Use 'Add account' to get started.");
853
+ return;
854
+ }
855
+ p.log.info("Account status:");
856
+ for (const account of status.accounts) {
857
+ const marker = account.isCurrent ? "→ " : " ";
858
+ const name = account.label ? `${account.label} (${account.accountId})` : account.accountId;
859
+ const keychain = account.keychainExists ? "" : " [no keychain]";
860
+ const idToken = account.hasIdToken ? "" : " [no id_token]";
861
+ p.log.message(`${marker}${name} — ${account.expiresIn}${keychain}${idToken}`);
862
+ }
863
+ const ocStatus = status.opencodeAuth.exists ? `active: ${status.opencodeAuth.accountId ?? "unknown"}` : "not found";
864
+ const cxStatus = status.codexAuth.exists ? `active: ${status.codexAuth.accountId ?? "unknown"}` : "not found";
865
+ p.log.info(`Auth files:`);
866
+ p.log.message(` OpenCode: ${ocStatus}`);
867
+ p.log.message(` Codex CLI: ${cxStatus}`);
868
+ };
555
869
  const runInteractiveMode = async () => {
556
870
  p.intro("cdx - OpenAI Account Switcher");
557
871
  let running = true;
@@ -578,6 +892,10 @@ const runInteractiveMode = async () => {
578
892
  value: "add",
579
893
  label: "Add account (OAuth login)"
580
894
  },
895
+ {
896
+ value: "refresh",
897
+ label: "Refresh account (re-login)"
898
+ },
581
899
  {
582
900
  value: "remove",
583
901
  label: "Remove account"
@@ -586,6 +904,10 @@ const runInteractiveMode = async () => {
586
904
  value: "label",
587
905
  label: "Label account"
588
906
  },
907
+ {
908
+ value: "status",
909
+ label: "Account status & token expiry"
910
+ },
589
911
  {
590
912
  value: "exit",
591
913
  label: "Exit"
@@ -606,12 +928,18 @@ const runInteractiveMode = async () => {
606
928
  case "add":
607
929
  await handleAddAccount();
608
930
  break;
931
+ case "refresh":
932
+ await handleRefreshAccount();
933
+ break;
609
934
  case "remove":
610
935
  await handleRemoveAccount();
611
936
  break;
612
937
  case "label":
613
938
  await handleLabelAccount();
614
939
  break;
940
+ case "status":
941
+ await handleStatus();
942
+ break;
615
943
  case "exit":
616
944
  running = false;
617
945
  break;
@@ -621,6 +949,162 @@ const runInteractiveMode = async () => {
621
949
  p.outro("Goodbye!");
622
950
  };
623
951
 
952
+ //#endregion
953
+ //#region lib/usage.ts
954
+ const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
955
+ const USER_AGENT = "cdx-cli";
956
+ /**
957
+ * Hits the undocumented OpenAI usage endpoint (may change without notice).
958
+ */
959
+ const fetchUsageRaw = async (accessToken, accountId) => {
960
+ const headers = {
961
+ Authorization: `Bearer ${accessToken}`,
962
+ "User-Agent": USER_AGENT,
963
+ Accept: "application/json"
964
+ };
965
+ if (accountId) headers["ChatGPT-Account-Id"] = accountId;
966
+ return fetch(USAGE_ENDPOINT, { headers });
967
+ };
968
+ /**
969
+ * Fetches usage for an account. On 401, refreshes the token and retries once.
970
+ */
971
+ const fetchUsage = async (accountId) => {
972
+ let payload;
973
+ try {
974
+ payload = loadKeychainPayload(accountId);
975
+ } catch (err) {
976
+ return {
977
+ ok: false,
978
+ error: {
979
+ type: "auth_failed",
980
+ message: err instanceof Error ? err.message : "Failed to load credentials"
981
+ }
982
+ };
983
+ }
984
+ try {
985
+ let response = await fetchUsageRaw(payload.access, payload.accountId);
986
+ if (response.status === 401) {
987
+ const refreshResult = await refreshAccessToken(payload.refresh);
988
+ if (refreshResult.type === "failed") return {
989
+ ok: false,
990
+ error: {
991
+ type: "auth_failed",
992
+ message: "Token expired and refresh failed. Try 'cdx login' to re-authenticate."
993
+ }
994
+ };
995
+ const updatedPayload = {
996
+ ...payload,
997
+ access: refreshResult.access,
998
+ refresh: refreshResult.refresh,
999
+ expires: refreshResult.expires,
1000
+ idToken: refreshResult.idToken ?? payload.idToken
1001
+ };
1002
+ saveKeychainPayload(accountId, updatedPayload);
1003
+ response = await fetchUsageRaw(updatedPayload.access, updatedPayload.accountId);
1004
+ if (!response.ok) return {
1005
+ ok: false,
1006
+ error: {
1007
+ type: "auth_failed",
1008
+ message: `Usage API returned ${response.status} after token refresh.`
1009
+ }
1010
+ };
1011
+ } else if (!response.ok) return {
1012
+ ok: false,
1013
+ error: {
1014
+ type: "unexpected",
1015
+ message: `Usage API returned ${response.status}: ${response.statusText}`
1016
+ }
1017
+ };
1018
+ return {
1019
+ ok: true,
1020
+ data: await response.json()
1021
+ };
1022
+ } catch (err) {
1023
+ return {
1024
+ ok: false,
1025
+ error: {
1026
+ type: "network_error",
1027
+ message: err instanceof Error ? err.message : "Network request failed"
1028
+ }
1029
+ };
1030
+ }
1031
+ };
1032
+ const formatWindowLabel = (seconds) => {
1033
+ const hours = seconds / 3600;
1034
+ if (hours >= 24) {
1035
+ const days = Math.round(hours / 24);
1036
+ return days === 7 ? "weekly" : `${days}d`;
1037
+ }
1038
+ return `${Math.round(hours)}h`;
1039
+ };
1040
+ const formatResetCountdown = (resetAtUnix) => {
1041
+ const diff = resetAtUnix * 1e3 - Date.now();
1042
+ if (diff <= 0) return "now";
1043
+ const minutes = Math.floor(diff / 6e4);
1044
+ const hours = Math.floor(minutes / 60);
1045
+ const remainingMinutes = minutes % 60;
1046
+ if (hours > 0) return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
1047
+ return `${minutes}m`;
1048
+ };
1049
+ const formatPercentageBar = (usedPercent) => {
1050
+ const width = 20;
1051
+ const filled = Math.round(usedPercent / 100 * width);
1052
+ const empty = width - filled;
1053
+ return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${usedPercent}% used`;
1054
+ };
1055
+ const formatWindow = (label, w) => {
1056
+ return [
1057
+ `${label} (${formatWindowLabel(w.limit_window_seconds)} window):`,
1058
+ ` ${formatPercentageBar(w.used_percent)}`,
1059
+ ` Resets in: ${formatResetCountdown(w.reset_at)}`
1060
+ ];
1061
+ };
1062
+ const formatUsage = (usage) => {
1063
+ const lines = [];
1064
+ const plan = usage.plan_type ?? "unknown";
1065
+ lines.push(`Plan: ${plan}`);
1066
+ lines.push("");
1067
+ if (usage.rate_limit?.primary_window) lines.push(...formatWindow("Primary", usage.rate_limit.primary_window));
1068
+ if (usage.rate_limit?.secondary_window) lines.push(...formatWindow("Secondary", usage.rate_limit.secondary_window));
1069
+ if (usage.credits) {
1070
+ lines.push("");
1071
+ if (usage.credits.unlimited) lines.push("Credits: unlimited");
1072
+ else if (usage.credits.has_credits && usage.credits.balance !== void 0) lines.push(`Credits: $${Number(usage.credits.balance).toFixed(2)}`);
1073
+ else if (!usage.credits.has_credits) lines.push("Credits: none");
1074
+ }
1075
+ return lines.join("\n");
1076
+ };
1077
+ const formatUsageBars = (usage, indent = " ") => {
1078
+ const windows = [];
1079
+ if (usage.rate_limit?.primary_window) windows.push({
1080
+ label: formatWindowLabel(usage.rate_limit.primary_window.limit_window_seconds),
1081
+ window: usage.rate_limit.primary_window
1082
+ });
1083
+ if (usage.rate_limit?.secondary_window) windows.push({
1084
+ label: formatWindowLabel(usage.rate_limit.secondary_window.limit_window_seconds),
1085
+ window: usage.rate_limit.secondary_window
1086
+ });
1087
+ const maxLabelLen = Math.max(...windows.map((w) => w.label.length), 0);
1088
+ return windows.map(({ label, window: w }) => {
1089
+ return `${indent}${label.padEnd(maxLabelLen)} ${formatPercentageBar(w.used_percent)} resets in ${formatResetCountdown(w.reset_at)}`;
1090
+ });
1091
+ };
1092
+ const formatUsageOverview = (entries) => {
1093
+ const lines = [];
1094
+ for (let i = 0; i < entries.length; i++) {
1095
+ const entry = entries[i];
1096
+ const marker = entry.isCurrent ? "→ " : " ";
1097
+ if (entry.result.ok) {
1098
+ const usage = entry.result.data;
1099
+ const plan = usage.plan_type ?? "unknown";
1100
+ lines.push(`${marker}${entry.displayName} (${plan})`);
1101
+ lines.push(...formatUsageBars(usage));
1102
+ } else lines.push(`${marker}${entry.displayName}: [error] ${entry.result.error.message}`);
1103
+ if (i < entries.length - 1) lines.push("");
1104
+ }
1105
+ return lines.join("\n");
1106
+ };
1107
+
624
1108
  //#endregion
625
1109
  //#region cdx.ts
626
1110
  const switchNext = async () => {
@@ -629,22 +1113,30 @@ const switchNext = async () => {
629
1113
  const nextAccount = config.accounts[nextIndex];
630
1114
  if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
631
1115
  const payload = loadKeychainPayload(nextAccount.accountId);
632
- await writeAuthFile(payload);
1116
+ const result = await writeAllAuthFiles(payload);
633
1117
  config.current = nextIndex;
634
1118
  await saveConfig(config);
635
1119
  const displayName = nextAccount.label ?? payload.accountId;
1120
+ const opencodeMark = "✓";
1121
+ const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
636
1122
  process.stdout.write(`Switched to account ${displayName}\n`);
1123
+ process.stdout.write(` OpenCode: ${opencodeMark}\n`);
1124
+ process.stdout.write(` Codex CLI: ${codexMark}\n`);
637
1125
  };
638
1126
  const switchToAccount = async (identifier) => {
639
1127
  const config = await loadConfig();
640
1128
  const index = config.accounts.findIndex((a) => a.accountId === identifier || a.label === identifier);
641
1129
  if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
642
1130
  const account = config.accounts[index];
643
- await writeAuthFile(loadKeychainPayload(account.accountId));
1131
+ const result = await writeAllAuthFiles(loadKeychainPayload(account.accountId));
644
1132
  config.current = index;
645
1133
  await saveConfig(config);
646
1134
  const displayName = account.label ?? account.accountId;
1135
+ const opencodeMark = "✓";
1136
+ const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
647
1137
  process.stdout.write(`Switched to account ${displayName}\n`);
1138
+ process.stdout.write(` OpenCode: ${opencodeMark}\n`);
1139
+ process.stdout.write(` Codex CLI: ${codexMark}\n`);
648
1140
  };
649
1141
  const interactiveMode = runInteractiveMode;
650
1142
  const createProgram = (deps = {}) => {
@@ -663,6 +1155,30 @@ const createProgram = (deps = {}) => {
663
1155
  process.exit(1);
664
1156
  }
665
1157
  });
1158
+ 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) => {
1159
+ try {
1160
+ if (account) {
1161
+ const target = (await loadConfig()).accounts.find((a) => a.accountId === account || a.label === account);
1162
+ if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1163
+ const result = await performRefresh(target.accountId, target.label);
1164
+ if (!result) {
1165
+ process.stderr.write("Refresh failed.\n");
1166
+ process.exit(1);
1167
+ }
1168
+ const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
1169
+ if (authResult) {
1170
+ const codexMark = authResult.codexWritten ? "✓" : authResult.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
1171
+ process.stdout.write("Updated active auth files:\n");
1172
+ process.stdout.write(" OpenCode: ✓\n");
1173
+ process.stdout.write(` Codex CLI: ${codexMark}\n`);
1174
+ }
1175
+ } else await handleRefreshAccount();
1176
+ } catch (error) {
1177
+ const message = error instanceof Error ? error.message : String(error);
1178
+ process.stderr.write(`${message}\n`);
1179
+ process.exit(1);
1180
+ }
1181
+ });
666
1182
  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) => {
667
1183
  try {
668
1184
  if (options.next) await switchNext();
@@ -690,6 +1206,96 @@ const createProgram = (deps = {}) => {
690
1206
  process.exit(1);
691
1207
  }
692
1208
  });
1209
+ program.command("status").description("Show account status, token expiry, and auth file state").action(async () => {
1210
+ try {
1211
+ const status = await getStatus();
1212
+ if (status.accounts.length === 0) {
1213
+ process.stdout.write("No accounts configured. Use 'cdx login' to add one.\n");
1214
+ return;
1215
+ }
1216
+ process.stdout.write("\n");
1217
+ for (let i = 0; i < status.accounts.length; i++) {
1218
+ const account = status.accounts[i];
1219
+ const marker = account.isCurrent ? "→ " : " ";
1220
+ const warnings = [];
1221
+ if (!account.keychainExists) warnings.push("[no keychain]");
1222
+ if (!account.hasIdToken) warnings.push("[no id_token]");
1223
+ const warnStr = warnings.length > 0 ? ` ${warnings.join(" ")}` : "";
1224
+ const displayName = account.label ?? account.accountId;
1225
+ process.stdout.write(`${marker}${displayName}${warnStr}\n`);
1226
+ if (account.label) process.stdout.write(` ${account.accountId}\n`);
1227
+ process.stdout.write(` ${account.expiresIn}\n`);
1228
+ const usageResult = await fetchUsage(account.accountId);
1229
+ if (usageResult.ok) {
1230
+ const bars = formatUsageBars(usageResult.data);
1231
+ for (const bar of bars) process.stdout.write(`${bar}\n`);
1232
+ }
1233
+ if (i < status.accounts.length - 1) process.stdout.write("\n");
1234
+ }
1235
+ const resolveLabel = (id) => {
1236
+ if (!id) return "unknown";
1237
+ return status.accounts.find((a) => a.accountId === id)?.label ?? id;
1238
+ };
1239
+ process.stdout.write("\nAuth files:\n");
1240
+ const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
1241
+ process.stdout.write(` OpenCode: ${ocStatus}\n`);
1242
+ const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
1243
+ process.stdout.write(` Codex CLI: ${cxStatus}\n`);
1244
+ process.stdout.write("\n");
1245
+ } catch (error) {
1246
+ const message = error instanceof Error ? error.message : String(error);
1247
+ process.stderr.write(`${message}\n`);
1248
+ process.exit(1);
1249
+ }
1250
+ });
1251
+ 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) => {
1252
+ try {
1253
+ const config = await loadConfig();
1254
+ if (account) {
1255
+ const found = config.accounts.find((a) => a.accountId === account || a.label === account);
1256
+ if (!found) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1257
+ const result = await fetchUsage(found.accountId);
1258
+ if (!result.ok) throw new Error(result.error.message);
1259
+ const displayName = found.label ? `${found.label} (${found.accountId})` : found.accountId;
1260
+ process.stdout.write(`\n${displayName}\n${formatUsage(result.data)}\n\n`);
1261
+ } else {
1262
+ if (config.accounts.length === 0) throw new Error("No accounts configured. Use 'cdx login' to add one.");
1263
+ const results = await Promise.allSettled(config.accounts.map((a) => fetchUsage(a.accountId)));
1264
+ const entries = config.accounts.map((a, i) => {
1265
+ const settled = results[i];
1266
+ const displayName = a.label ? `${a.label} (${a.accountId})` : a.accountId;
1267
+ const result = settled.status === "fulfilled" ? settled.value : {
1268
+ ok: false,
1269
+ error: {
1270
+ type: "network_error",
1271
+ message: settled.reason?.message ?? "Fetch failed"
1272
+ }
1273
+ };
1274
+ return {
1275
+ displayName,
1276
+ isCurrent: i === config.current,
1277
+ result
1278
+ };
1279
+ });
1280
+ process.stdout.write(`\n${formatUsageOverview(entries)}\n\n`);
1281
+ }
1282
+ } catch (error) {
1283
+ const message = error instanceof Error ? error.message : String(error);
1284
+ process.stderr.write(`${message}\n`);
1285
+ process.exit(1);
1286
+ }
1287
+ });
1288
+ program.command("help").description("Show available commands and usage information").argument("[command]", "Show help for a specific command").action((commandName) => {
1289
+ if (commandName) {
1290
+ const cmd = program.commands.find((c) => c.name() === commandName);
1291
+ if (cmd) cmd.outputHelp();
1292
+ else {
1293
+ process.stderr.write(`Unknown command: ${commandName}\n`);
1294
+ program.outputHelp();
1295
+ process.exit(1);
1296
+ }
1297
+ } else program.outputHelp();
1298
+ });
693
1299
  program.command("version").description("Show CLI version").action(() => {
694
1300
  process.stdout.write(`${version}\n`);
695
1301
  });
@@ -714,4 +1320,4 @@ if (import.meta.main) main().catch((error) => {
714
1320
  });
715
1321
 
716
1322
  //#endregion
717
- export { createProgram, createTestPaths, getPaths, interactiveMode, loadConfig, resetPaths, runInteractiveMode, saveConfig, setPaths, switchNext, switchToAccount, writeAuthFile };
1323
+ export { createProgram, createTestPaths, getPaths, interactiveMode, loadConfig, resetPaths, runInteractiveMode, saveConfig, setPaths, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjesuiter/codex-switcher",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
6
6
  "bin": {