@bjesuiter/codex-switcher 1.1.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 +43 -5
  2. package/cdx.mjs +390 -231
  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
 
@@ -40,7 +68,10 @@ Opens your browser to authenticate with OpenAI. After successful login, your cre
40
68
  cdx switch
41
69
  ```
42
70
 
43
- Interactive picker to select an account. Writes credentials to `~/.local/share/opencode/auth.json`.
71
+ Interactive picker to select an account. Writes credentials to:
72
+ - `~/.local/share/opencode/auth.json` (OpenCode)
73
+ - `~/.pi/agent/auth.json` (Pi agent, or `$PI_CODING_AGENT_DIR/auth.json` when `PI_CODING_AGENT_DIR` is set)
74
+ - `~/.codex/auth.json` (Codex CLI; requires `id_token`)
44
75
 
45
76
  ```bash
46
77
  cdx switch --next
@@ -86,14 +117,18 @@ Running `cdx` without arguments opens an interactive menu to:
86
117
  |---------|-------------|
87
118
  | `cdx` | Interactive mode |
88
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 |
89
122
  | `cdx switch` | Switch account (interactive picker) |
90
123
  | `cdx switch --next` | Cycle to next account |
91
124
  | `cdx switch <id>` | Switch to specific account |
92
125
  | `cdx label` | Label an account (interactive) |
93
126
  | `cdx label <account> <label>` | Assign label directly |
94
- | `cdx status` | Show account status, token expiry, and usage |
127
+ | `cdx status` | Show account status, token expiry, usage, and auth file state |
95
128
  | `cdx usage` | Show usage overview for all accounts |
96
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 |
97
132
  | `cdx --help` | Show help |
98
133
  | `cdx --version` | Show version |
99
134
 
@@ -101,7 +136,10 @@ Running `cdx` without arguments opens an interactive menu to:
101
136
 
102
137
  - OAuth credentials are stored securely in macOS Keychain
103
138
  - Account list is stored in `~/.config/cdx/accounts.json`
104
- - Active account credentials are written to `~/.local/share/opencode/auth.json`
139
+ - Active account credentials are written to:
140
+ - `~/.local/share/opencode/auth.json`
141
+ - `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json`)
142
+ - `~/.codex/auth.json` (when `id_token` exists)
105
143
 
106
144
  ## For Developers
107
145
 
package/cdx.mjs CHANGED
@@ -1,28 +1,42 @@
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.1.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
18
26
  const defaultConfigDir = path.join(os.homedir(), ".config", "cdx");
19
- const defaultPaths = {
27
+ const resolvePiAuthPath = () => {
28
+ const piAgentDir = process.env.PI_CODING_AGENT_DIR?.trim();
29
+ if (piAgentDir) return path.join(piAgentDir, "auth.json");
30
+ return path.join(os.homedir(), ".pi", "agent", "auth.json");
31
+ };
32
+ const createDefaultPaths = () => ({
20
33
  configDir: defaultConfigDir,
21
34
  configPath: path.join(defaultConfigDir, "accounts.json"),
22
35
  authPath: path.join(os.homedir(), ".local", "share", "opencode", "auth.json"),
23
- codexAuthPath: path.join(os.homedir(), ".codex", "auth.json")
24
- };
25
- let currentPaths = { ...defaultPaths };
36
+ codexAuthPath: path.join(os.homedir(), ".codex", "auth.json"),
37
+ piAuthPath: resolvePiAuthPath()
38
+ });
39
+ let currentPaths = createDefaultPaths();
26
40
  const getPaths = () => currentPaths;
27
41
  const setPaths = (paths) => {
28
42
  currentPaths = {
@@ -32,13 +46,14 @@ const setPaths = (paths) => {
32
46
  if (paths.configDir && !paths.configPath) currentPaths.configPath = path.join(paths.configDir, "accounts.json");
33
47
  };
34
48
  const resetPaths = () => {
35
- currentPaths = { ...defaultPaths };
49
+ currentPaths = createDefaultPaths();
36
50
  };
37
51
  const createTestPaths = (testDir) => ({
38
52
  configDir: path.join(testDir, "config"),
39
53
  configPath: path.join(testDir, "config", "accounts.json"),
40
54
  authPath: path.join(testDir, "auth", "auth.json"),
41
- codexAuthPath: path.join(testDir, "codex", "auth.json")
55
+ codexAuthPath: path.join(testDir, "codex", "auth.json"),
56
+ piAuthPath: path.join(testDir, "pi", "auth.json")
42
57
  });
43
58
 
44
59
  //#endregion
@@ -80,11 +95,26 @@ const writeCodexAuthFile = async (payload) => {
80
95
  existing.last_refresh = (/* @__PURE__ */ new Date()).toISOString();
81
96
  await writeFile(codexAuthPath, JSON.stringify(existing, null, 2), "utf8");
82
97
  };
98
+ const writePiAuthFile = async (payload) => {
99
+ const { piAuthPath } = getPaths();
100
+ await mkdir(path.dirname(piAuthPath), { recursive: true });
101
+ const existing = await readExistingJson(piAuthPath);
102
+ existing["openai-codex"] = {
103
+ type: "oauth",
104
+ access: payload.access,
105
+ refresh: payload.refresh,
106
+ expires: payload.expires,
107
+ accountId: payload.accountId
108
+ };
109
+ await writeFile(piAuthPath, JSON.stringify(existing, null, 2), "utf8");
110
+ };
83
111
  const writeAllAuthFiles = async (payload) => {
84
112
  await writeAuthFile(payload);
113
+ await writePiAuthFile(payload);
85
114
  if (payload.idToken) {
86
115
  await writeCodexAuthFile(payload);
87
116
  return {
117
+ piWritten: true,
88
118
  codexWritten: true,
89
119
  codexMissingIdToken: false,
90
120
  codexCleared: false
@@ -99,6 +129,7 @@ const writeAllAuthFiles = async (payload) => {
99
129
  codexCleared = false;
100
130
  }
101
131
  return {
132
+ piWritten: true,
102
133
  codexWritten: false,
103
134
  codexMissingIdToken: true,
104
135
  codexCleared
@@ -406,10 +437,16 @@ const startOAuthServer = (state) => {
406
437
  //#endregion
407
438
  //#region lib/oauth/login.ts
408
439
  const openBrowser = (url) => {
409
- spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], {
410
- detached: true,
411
- stdio: "ignore"
412
- }).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
+ }
413
450
  };
414
451
  const addAccountToConfig = async (accountId, label) => {
415
452
  let config;
@@ -430,59 +467,74 @@ const addAccountToConfig = async (accountId, label) => {
430
467
  };
431
468
  await saveConfig(config);
432
469
  };
433
- const performRefresh = async (targetAccountId, label) => {
434
- const displayName = label ?? targetAccountId;
435
- p.log.step(`Refreshing credentials for "${displayName}"...`);
436
- let flow;
470
+ const performRefresh = async (targetAccountId, label, options = {}) => {
471
+ const keepAlive = setInterval(() => {}, 1e3);
437
472
  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;
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);
474
537
  }
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
538
  };
487
539
  const performLogin = async () => {
488
540
  p.intro("cdx login - Add OpenAI account");
@@ -496,6 +548,7 @@ const performLogin = async () => {
496
548
  const spinner = p.spinner();
497
549
  p.log.info("Opening browser for authentication...");
498
550
  openBrowser(flow.url);
551
+ p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
499
552
  spinner.start("Waiting for authentication...");
500
553
  const result = await server.waitForCode();
501
554
  server.close();
@@ -608,6 +661,25 @@ const readCodexAuthAccount = async () => {
608
661
  };
609
662
  }
610
663
  };
664
+ const readPiAuthAccount = async () => {
665
+ const { piAuthPath } = getPaths();
666
+ if (!existsSync(piAuthPath)) return {
667
+ exists: false,
668
+ accountId: null
669
+ };
670
+ try {
671
+ const raw = await readFile(piAuthPath, "utf8");
672
+ return {
673
+ exists: true,
674
+ accountId: JSON.parse(raw)["openai-codex"]?.accountId ?? null
675
+ };
676
+ } catch {
677
+ return {
678
+ exists: true,
679
+ accountId: null
680
+ };
681
+ }
682
+ };
611
683
  const getAccountStatus = (accountId, isCurrent, label) => {
612
684
  const keychainExists = keychainPayloadExists(accountId);
613
685
  let expiresAt = null;
@@ -636,11 +708,16 @@ const getStatus = async () => {
636
708
  accounts.push(getAccountStatus(account.accountId, i === config.current, account.label));
637
709
  }
638
710
  }
639
- const [opencodeAuth, codexAuth] = await Promise.all([readOpenCodeAuthAccount(), readCodexAuthAccount()]);
711
+ const [opencodeAuth, codexAuth, piAuth] = await Promise.all([
712
+ readOpenCodeAuthAccount(),
713
+ readCodexAuthAccount(),
714
+ readPiAuthAccount()
715
+ ]);
640
716
  return {
641
717
  accounts,
642
718
  opencodeAuth,
643
- codexAuth
719
+ codexAuth,
720
+ piAuth
644
721
  };
645
722
  };
646
723
 
@@ -650,6 +727,14 @@ const getAccountDisplay = (accountId, isCurrent, label) => {
650
727
  const name = label ? `${label} (${accountId})` : accountId;
651
728
  return isCurrent ? `${name} (current)` : name;
652
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
+ };
653
738
  const handleListAccounts = async () => {
654
739
  if (!configExists()) {
655
740
  p.log.warning("No accounts configured. Use 'Add account' to get started.");
@@ -709,31 +794,33 @@ const handleSwitchAccount = async () => {
709
794
  await saveConfig(config);
710
795
  const displayName = selectedAccount.label ?? selectedAccount.accountId;
711
796
  const opencodeMark = "✓";
797
+ const piMark = result.piWritten ? "✓" : "✗";
712
798
  const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
713
799
  p.log.success(`Switched to account ${displayName}`);
714
800
  p.log.message(` OpenCode: ${opencodeMark}`);
801
+ p.log.message(` Pi Agent: ${piMark}`);
715
802
  p.log.message(` Codex CLI: ${codexMark}`);
716
803
  };
717
804
  const handleAddAccount = async () => {
718
805
  await performLogin();
719
806
  };
720
- const handleRefreshAccount = async () => {
807
+ const handleReloginAccount = async () => {
721
808
  if (!configExists()) {
722
809
  p.log.warning("No accounts configured. Use 'Add account' first.");
723
810
  return;
724
811
  }
725
812
  const config = await loadConfig();
726
813
  if (config.accounts.length === 0) {
727
- p.log.warning("No accounts to refresh.");
814
+ p.log.warning("No accounts to re-login.");
728
815
  return;
729
816
  }
730
817
  const currentAccountId = config.accounts[config.current]?.accountId;
731
818
  const options = config.accounts.map((account) => ({
732
819
  value: account.accountId,
733
- label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
820
+ label: `${getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)} — ${getRefreshExpiryState(account.accountId)}`
734
821
  }));
735
822
  const selected = await p.select({
736
- message: "Select account to refresh:",
823
+ message: "Select account to re-login:",
737
824
  options
738
825
  });
739
826
  if (p.isCancel(selected)) {
@@ -742,21 +829,26 @@ const handleRefreshAccount = async () => {
742
829
  }
743
830
  const accountId = selected;
744
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}`);
745
835
  try {
746
- const result = await performRefresh(accountId, account?.label);
747
- 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.");
748
838
  else {
749
839
  const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
750
840
  if (authResult) {
841
+ const piMark = authResult.piWritten ? "✓" : "✗";
751
842
  const codexMark = authResult.codexWritten ? "✓" : authResult.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
752
843
  p.log.message("Updated active auth files:");
753
844
  p.log.message(" OpenCode: ✓");
845
+ p.log.message(` Pi Agent: ${piMark}`);
754
846
  p.log.message(` Codex CLI: ${codexMark}`);
755
847
  }
756
848
  }
757
849
  } catch (error) {
758
850
  const msg = error instanceof Error ? error.message : String(error);
759
- p.log.error(`Refresh failed: ${msg}`);
851
+ p.log.error(`Re-login failed: ${msg}`);
760
852
  }
761
853
  };
762
854
  const handleRemoveAccount = async () => {
@@ -862,9 +954,11 @@ const handleStatus = async () => {
862
954
  }
863
955
  const ocStatus = status.opencodeAuth.exists ? `active: ${status.opencodeAuth.accountId ?? "unknown"}` : "not found";
864
956
  const cxStatus = status.codexAuth.exists ? `active: ${status.codexAuth.accountId ?? "unknown"}` : "not found";
957
+ const piStatus = status.piAuth.exists ? `active: ${status.piAuth.accountId ?? "unknown"}` : "not found";
865
958
  p.log.info(`Auth files:`);
866
959
  p.log.message(` OpenCode: ${ocStatus}`);
867
960
  p.log.message(` Codex CLI: ${cxStatus}`);
961
+ p.log.message(` Pi Agent: ${piStatus}`);
868
962
  };
869
963
  const runInteractiveMode = async () => {
870
964
  p.intro("cdx - OpenAI Account Switcher");
@@ -893,8 +987,8 @@ const runInteractiveMode = async () => {
893
987
  label: "Add account (OAuth login)"
894
988
  },
895
989
  {
896
- value: "refresh",
897
- label: "Refresh account (re-login)"
990
+ value: "relogin",
991
+ label: "Re-login account"
898
992
  },
899
993
  {
900
994
  value: "remove",
@@ -928,8 +1022,8 @@ const runInteractiveMode = async () => {
928
1022
  case "add":
929
1023
  await handleAddAccount();
930
1024
  break;
931
- case "refresh":
932
- await handleRefreshAccount();
1025
+ case "relogin":
1026
+ await handleReloginAccount();
933
1027
  break;
934
1028
  case "remove":
935
1029
  await handleRemoveAccount();
@@ -949,6 +1043,131 @@ const runInteractiveMode = async () => {
949
1043
  p.outro("Goodbye!");
950
1044
  };
951
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
+
952
1171
  //#endregion
953
1172
  //#region lib/usage.ts
954
1173
  const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
@@ -1106,107 +1325,9 @@ const formatUsageOverview = (entries) => {
1106
1325
  };
1107
1326
 
1108
1327
  //#endregion
1109
- //#region cdx.ts
1110
- const switchNext = async () => {
1111
- const config = await loadConfig();
1112
- const nextIndex = (config.current + 1) % config.accounts.length;
1113
- const nextAccount = config.accounts[nextIndex];
1114
- if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
1115
- const payload = loadKeychainPayload(nextAccount.accountId);
1116
- const result = await writeAllAuthFiles(payload);
1117
- config.current = nextIndex;
1118
- await saveConfig(config);
1119
- const displayName = nextAccount.label ?? payload.accountId;
1120
- const opencodeMark = "✓";
1121
- const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
1122
- process.stdout.write(`Switched to account ${displayName}\n`);
1123
- process.stdout.write(` OpenCode: ${opencodeMark}\n`);
1124
- process.stdout.write(` Codex CLI: ${codexMark}\n`);
1125
- };
1126
- const switchToAccount = async (identifier) => {
1127
- const config = await loadConfig();
1128
- const index = config.accounts.findIndex((a) => a.accountId === identifier || a.label === identifier);
1129
- if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
1130
- const account = config.accounts[index];
1131
- const result = await writeAllAuthFiles(loadKeychainPayload(account.accountId));
1132
- config.current = index;
1133
- await saveConfig(config);
1134
- const displayName = account.label ?? account.accountId;
1135
- const opencodeMark = "✓";
1136
- const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
1137
- process.stdout.write(`Switched to account ${displayName}\n`);
1138
- process.stdout.write(` OpenCode: ${opencodeMark}\n`);
1139
- process.stdout.write(` Codex CLI: ${codexMark}\n`);
1140
- };
1141
- const interactiveMode = runInteractiveMode;
1142
- const createProgram = (deps = {}) => {
1143
- const program = new Command();
1144
- const runLogin = deps.performLogin ?? performLogin;
1145
- program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version");
1146
- program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
1147
- try {
1148
- if (!await runLogin()) {
1149
- process.stderr.write("Login failed.\n");
1150
- process.exit(1);
1151
- }
1152
- } catch (error) {
1153
- const message = error instanceof Error ? error.message : String(error);
1154
- process.stderr.write(`${message}\n`);
1155
- process.exit(1);
1156
- }
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
- });
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) => {
1183
- try {
1184
- if (options.next) await switchNext();
1185
- else if (accountId) await switchToAccount(accountId);
1186
- else await handleSwitchAccount();
1187
- } catch (error) {
1188
- const message = error instanceof Error ? error.message : String(error);
1189
- process.stderr.write(`${message}\n`);
1190
- process.exit(1);
1191
- }
1192
- });
1193
- 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) => {
1194
- try {
1195
- if (account && newLabel) {
1196
- const config = await loadConfig();
1197
- const target = config.accounts.find((a) => a.accountId === account || a.label === account);
1198
- if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1199
- target.label = newLabel;
1200
- await saveConfig(config);
1201
- process.stdout.write(`Account ${target.accountId} labeled as "${newLabel}".\n`);
1202
- } else await handleLabelAccount();
1203
- } catch (error) {
1204
- const message = error instanceof Error ? error.message : String(error);
1205
- process.stderr.write(`${message}\n`);
1206
- process.exit(1);
1207
- }
1208
- });
1209
- 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 () => {
1210
1331
  try {
1211
1332
  const status = await getStatus();
1212
1333
  if (status.accounts.length === 0) {
@@ -1232,92 +1353,130 @@ const createProgram = (deps = {}) => {
1232
1353
  }
1233
1354
  if (i < status.accounts.length - 1) process.stdout.write("\n");
1234
1355
  }
1235
- const resolveLabel = (id) => {
1236
- if (!id) return "unknown";
1237
- 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;
1238
1359
  };
1239
1360
  process.stdout.write("\nAuth files:\n");
1240
1361
  const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
1241
1362
  process.stdout.write(` OpenCode: ${ocStatus}\n`);
1242
1363
  const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
1243
1364
  process.stdout.write(` Codex CLI: ${cxStatus}\n`);
1365
+ const piStatus = status.piAuth.exists ? `active: ${resolveLabel(status.piAuth.accountId)}` : "not found";
1366
+ process.stdout.write(` Pi Agent: ${piStatus}\n`);
1244
1367
  process.stdout.write("\n");
1245
1368
  } catch (error) {
1246
- const message = error instanceof Error ? error.message : String(error);
1247
- process.stderr.write(`${message}\n`);
1248
- process.exit(1);
1369
+ exitWithCommandError(error);
1249
1370
  }
1250
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) => {
1251
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) => {
1252
1413
  try {
1253
1414
  const config = await loadConfig();
1254
1415
  if (account) {
1255
- 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);
1256
1417
  if (!found) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1257
1418
  const result = await fetchUsage(found.accountId);
1258
1419
  if (!result.ok) throw new Error(result.error.message);
1259
1420
  const displayName = found.label ? `${found.label} (${found.accountId})` : found.accountId;
1260
1421
  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`);
1422
+ return;
1281
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`);
1282
1443
  } catch (error) {
1283
- const message = error instanceof Error ? error.message : String(error);
1284
- process.stderr.write(`${message}\n`);
1285
- process.exit(1);
1444
+ exitWithCommandError(error);
1286
1445
  }
1287
1446
  });
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
- });
1447
+ };
1448
+
1449
+ //#endregion
1450
+ //#region lib/commands/version.ts
1451
+ const registerVersionCommand = (program, version) => {
1299
1452
  program.command("version").description("Show CLI version").action(() => {
1300
1453
  process.stdout.write(`${version}\n`);
1301
1454
  });
1302
- program.action(async () => {
1303
- try {
1304
- await interactiveMode();
1305
- } catch (error) {
1306
- const message = error instanceof Error ? error.message : String(error);
1307
- process.stderr.write(`${message}\n`);
1308
- process.exit(1);
1309
- }
1310
- });
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);
1311
1472
  return program;
1312
1473
  };
1313
1474
  const main = async () => {
1314
1475
  await createProgram().parseAsync(process.argv);
1315
1476
  };
1316
1477
  if (import.meta.main) main().catch((error) => {
1317
- const message = error instanceof Error ? error.message : String(error);
1318
- process.stderr.write(`${message}\n`);
1319
- process.exit(1);
1478
+ exitWithCommandError(error);
1320
1479
  });
1321
1480
 
1322
1481
  //#endregion
1323
- export { createProgram, createTestPaths, getPaths, interactiveMode, loadConfig, resetPaths, runInteractiveMode, saveConfig, setPaths, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile };
1482
+ export { createProgram, createTestPaths, getPaths, interactiveMode, loadConfig, resetPaths, runInteractiveMode, saveConfig, setPaths, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjesuiter/codex-switcher",
3
- "version": "1.1.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
  }