@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.
- package/README.md +35 -3
- package/cdx.mjs +326 -228
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
# cdx
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
spinner.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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(`
|
|
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: "
|
|
950
|
-
label: "
|
|
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 "
|
|
985
|
-
await
|
|
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
|
|
1163
|
-
const
|
|
1164
|
-
|
|
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 = (
|
|
1295
|
-
if (!
|
|
1296
|
-
return status.accounts.find((
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
1345
|
-
process.stderr.write(`${message}\n`);
|
|
1346
|
-
process.exit(1);
|
|
1444
|
+
exitWithCommandError(error);
|
|
1347
1445
|
}
|
|
1348
1446
|
});
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
23
|
+
"@clack/prompts": "^1.0.0",
|
|
24
24
|
"@openauthjs/openauth": "^0.4.3",
|
|
25
|
-
"commander": "^14.0.
|
|
25
|
+
"commander": "^14.0.3"
|
|
26
26
|
}
|
|
27
27
|
}
|