@bjesuiter/codex-switcher 1.0.4 → 1.2.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 +16 -2
- package/cdx.mjs +685 -18
- 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.
|
|
@@ -35,7 +40,10 @@ Opens your browser to authenticate with OpenAI. After successful login, your cre
|
|
|
35
40
|
cdx switch
|
|
36
41
|
```
|
|
37
42
|
|
|
38
|
-
Interactive picker to select an account. Writes credentials to
|
|
43
|
+
Interactive picker to select an account. Writes credentials to:
|
|
44
|
+
- `~/.local/share/opencode/auth.json` (OpenCode)
|
|
45
|
+
- `~/.pi/agent/auth.json` (Pi agent, or `$PI_CODING_AGENT_DIR/auth.json` when `PI_CODING_AGENT_DIR` is set)
|
|
46
|
+
- `~/.codex/auth.json` (Codex CLI; requires `id_token`)
|
|
39
47
|
|
|
40
48
|
```bash
|
|
41
49
|
cdx switch --next
|
|
@@ -86,6 +94,9 @@ Running `cdx` without arguments opens an interactive menu to:
|
|
|
86
94
|
| `cdx switch <id>` | Switch to specific account |
|
|
87
95
|
| `cdx label` | Label an account (interactive) |
|
|
88
96
|
| `cdx label <account> <label>` | Assign label directly |
|
|
97
|
+
| `cdx status` | Show account status, token expiry, and usage |
|
|
98
|
+
| `cdx usage` | Show usage overview for all accounts |
|
|
99
|
+
| `cdx usage <account>` | Show detailed usage for a specific account |
|
|
89
100
|
| `cdx --help` | Show help |
|
|
90
101
|
| `cdx --version` | Show version |
|
|
91
102
|
|
|
@@ -93,7 +104,10 @@ Running `cdx` without arguments opens an interactive menu to:
|
|
|
93
104
|
|
|
94
105
|
- OAuth credentials are stored securely in macOS Keychain
|
|
95
106
|
- Account list is stored in `~/.config/cdx/accounts.json`
|
|
96
|
-
- Active account credentials are written to
|
|
107
|
+
- Active account credentials are written to:
|
|
108
|
+
- `~/.local/share/opencode/auth.json`
|
|
109
|
+
- `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json`)
|
|
110
|
+
- `~/.codex/auth.json` (when `id_token` exists)
|
|
97
111
|
|
|
98
112
|
## For Developers
|
|
99
113
|
|
package/cdx.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import {
|
|
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,17 +11,24 @@ import { randomBytes } from "node:crypto";
|
|
|
11
11
|
import http from "node:http";
|
|
12
12
|
|
|
13
13
|
//#region package.json
|
|
14
|
-
var version = "1.0
|
|
14
|
+
var version = "1.2.0";
|
|
15
15
|
|
|
16
16
|
//#endregion
|
|
17
17
|
//#region lib/paths.ts
|
|
18
18
|
const defaultConfigDir = path.join(os.homedir(), ".config", "cdx");
|
|
19
|
-
const
|
|
19
|
+
const resolvePiAuthPath = () => {
|
|
20
|
+
const piAgentDir = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
21
|
+
if (piAgentDir) return path.join(piAgentDir, "auth.json");
|
|
22
|
+
return path.join(os.homedir(), ".pi", "agent", "auth.json");
|
|
23
|
+
};
|
|
24
|
+
const createDefaultPaths = () => ({
|
|
20
25
|
configDir: defaultConfigDir,
|
|
21
26
|
configPath: path.join(defaultConfigDir, "accounts.json"),
|
|
22
|
-
authPath: path.join(os.homedir(), ".local", "share", "opencode", "auth.json")
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
authPath: path.join(os.homedir(), ".local", "share", "opencode", "auth.json"),
|
|
28
|
+
codexAuthPath: path.join(os.homedir(), ".codex", "auth.json"),
|
|
29
|
+
piAuthPath: resolvePiAuthPath()
|
|
30
|
+
});
|
|
31
|
+
let currentPaths = createDefaultPaths();
|
|
25
32
|
const getPaths = () => currentPaths;
|
|
26
33
|
const setPaths = (paths) => {
|
|
27
34
|
currentPaths = {
|
|
@@ -31,27 +38,94 @@ const setPaths = (paths) => {
|
|
|
31
38
|
if (paths.configDir && !paths.configPath) currentPaths.configPath = path.join(paths.configDir, "accounts.json");
|
|
32
39
|
};
|
|
33
40
|
const resetPaths = () => {
|
|
34
|
-
currentPaths =
|
|
41
|
+
currentPaths = createDefaultPaths();
|
|
35
42
|
};
|
|
36
43
|
const createTestPaths = (testDir) => ({
|
|
37
44
|
configDir: path.join(testDir, "config"),
|
|
38
45
|
configPath: path.join(testDir, "config", "accounts.json"),
|
|
39
|
-
authPath: path.join(testDir, "auth", "auth.json")
|
|
46
|
+
authPath: path.join(testDir, "auth", "auth.json"),
|
|
47
|
+
codexAuthPath: path.join(testDir, "codex", "auth.json"),
|
|
48
|
+
piAuthPath: path.join(testDir, "pi", "auth.json")
|
|
40
49
|
});
|
|
41
50
|
|
|
42
51
|
//#endregion
|
|
43
52
|
//#region lib/auth.ts
|
|
53
|
+
const readExistingJson = async (filePath) => {
|
|
54
|
+
if (!existsSync(filePath)) return {};
|
|
55
|
+
try {
|
|
56
|
+
const raw = await readFile(filePath, "utf8");
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
59
|
+
} catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
};
|
|
44
63
|
const writeAuthFile = async (payload) => {
|
|
45
64
|
const { authPath } = getPaths();
|
|
46
65
|
await mkdir(path.dirname(authPath), { recursive: true });
|
|
47
|
-
const
|
|
66
|
+
const existing = await readExistingJson(authPath);
|
|
67
|
+
existing.openai = {
|
|
48
68
|
type: "oauth",
|
|
49
69
|
refresh: payload.refresh,
|
|
50
70
|
access: payload.access,
|
|
51
71
|
expires: payload.expires,
|
|
52
72
|
accountId: payload.accountId
|
|
53
|
-
}
|
|
54
|
-
await writeFile(authPath, JSON.stringify(
|
|
73
|
+
};
|
|
74
|
+
await writeFile(authPath, JSON.stringify(existing, null, 2), "utf8");
|
|
75
|
+
};
|
|
76
|
+
const writeCodexAuthFile = async (payload) => {
|
|
77
|
+
const { codexAuthPath } = getPaths();
|
|
78
|
+
await mkdir(path.dirname(codexAuthPath), { recursive: true });
|
|
79
|
+
const existing = await readExistingJson(codexAuthPath);
|
|
80
|
+
existing.tokens = {
|
|
81
|
+
...typeof existing.tokens === "object" && existing.tokens !== null ? existing.tokens : {},
|
|
82
|
+
id_token: payload.idToken ?? null,
|
|
83
|
+
access_token: payload.access,
|
|
84
|
+
refresh_token: payload.refresh,
|
|
85
|
+
account_id: payload.accountId
|
|
86
|
+
};
|
|
87
|
+
existing.last_refresh = (/* @__PURE__ */ new Date()).toISOString();
|
|
88
|
+
await writeFile(codexAuthPath, JSON.stringify(existing, null, 2), "utf8");
|
|
89
|
+
};
|
|
90
|
+
const writePiAuthFile = async (payload) => {
|
|
91
|
+
const { piAuthPath } = getPaths();
|
|
92
|
+
await mkdir(path.dirname(piAuthPath), { recursive: true });
|
|
93
|
+
const existing = await readExistingJson(piAuthPath);
|
|
94
|
+
existing["openai-codex"] = {
|
|
95
|
+
type: "oauth",
|
|
96
|
+
access: payload.access,
|
|
97
|
+
refresh: payload.refresh,
|
|
98
|
+
expires: payload.expires,
|
|
99
|
+
accountId: payload.accountId
|
|
100
|
+
};
|
|
101
|
+
await writeFile(piAuthPath, JSON.stringify(existing, null, 2), "utf8");
|
|
102
|
+
};
|
|
103
|
+
const writeAllAuthFiles = async (payload) => {
|
|
104
|
+
await writeAuthFile(payload);
|
|
105
|
+
await writePiAuthFile(payload);
|
|
106
|
+
if (payload.idToken) {
|
|
107
|
+
await writeCodexAuthFile(payload);
|
|
108
|
+
return {
|
|
109
|
+
piWritten: true,
|
|
110
|
+
codexWritten: true,
|
|
111
|
+
codexMissingIdToken: false,
|
|
112
|
+
codexCleared: false
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const { codexAuthPath } = getPaths();
|
|
116
|
+
let codexCleared = false;
|
|
117
|
+
if (existsSync(codexAuthPath)) try {
|
|
118
|
+
await rm(codexAuthPath);
|
|
119
|
+
codexCleared = true;
|
|
120
|
+
} catch {
|
|
121
|
+
codexCleared = false;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
piWritten: true,
|
|
125
|
+
codexWritten: false,
|
|
126
|
+
codexMissingIdToken: true,
|
|
127
|
+
codexCleared
|
|
128
|
+
};
|
|
55
129
|
};
|
|
56
130
|
|
|
57
131
|
//#endregion
|
|
@@ -210,9 +284,35 @@ const exchangeAuthorizationCode = async (code, verifier) => {
|
|
|
210
284
|
type: "success",
|
|
211
285
|
access: json.access_token,
|
|
212
286
|
refresh: json.refresh_token,
|
|
213
|
-
expires: Date.now() + json.expires_in * 1e3
|
|
287
|
+
expires: Date.now() + json.expires_in * 1e3,
|
|
288
|
+
idToken: json.id_token
|
|
214
289
|
};
|
|
215
290
|
};
|
|
291
|
+
const refreshAccessToken = async (refreshToken) => {
|
|
292
|
+
try {
|
|
293
|
+
const response = await fetch(TOKEN_URL, {
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
296
|
+
body: new URLSearchParams({
|
|
297
|
+
grant_type: "refresh_token",
|
|
298
|
+
refresh_token: refreshToken,
|
|
299
|
+
client_id: CLIENT_ID
|
|
300
|
+
})
|
|
301
|
+
});
|
|
302
|
+
if (!response.ok) return { type: "failed" };
|
|
303
|
+
const json = await response.json();
|
|
304
|
+
if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return { type: "failed" };
|
|
305
|
+
return {
|
|
306
|
+
type: "success",
|
|
307
|
+
access: json.access_token,
|
|
308
|
+
refresh: json.refresh_token,
|
|
309
|
+
expires: Date.now() + json.expires_in * 1e3,
|
|
310
|
+
idToken: json.id_token
|
|
311
|
+
};
|
|
312
|
+
} catch {
|
|
313
|
+
return { type: "failed" };
|
|
314
|
+
}
|
|
315
|
+
};
|
|
216
316
|
const decodeJWT = (token) => {
|
|
217
317
|
try {
|
|
218
318
|
const parts = token.split(".");
|
|
@@ -353,6 +453,60 @@ const addAccountToConfig = async (accountId, label) => {
|
|
|
353
453
|
};
|
|
354
454
|
await saveConfig(config);
|
|
355
455
|
};
|
|
456
|
+
const performRefresh = async (targetAccountId, label) => {
|
|
457
|
+
const displayName = label ?? targetAccountId;
|
|
458
|
+
p.log.step(`Refreshing credentials for "${displayName}"...`);
|
|
459
|
+
let flow;
|
|
460
|
+
try {
|
|
461
|
+
flow = await createAuthorizationFlow();
|
|
462
|
+
} catch (error) {
|
|
463
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
464
|
+
p.log.error(`Failed to create authorization flow: ${msg}`);
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
const server = await startOAuthServer(flow.state);
|
|
468
|
+
if (!server.ready) {
|
|
469
|
+
p.log.error("Failed to start local server on port 1455.");
|
|
470
|
+
p.log.info("Please ensure the port is not in use.");
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
const spinner = p.spinner();
|
|
474
|
+
p.log.info("Opening browser for authentication...");
|
|
475
|
+
openBrowser(flow.url);
|
|
476
|
+
spinner.start("Waiting for authentication...");
|
|
477
|
+
const result = await server.waitForCode();
|
|
478
|
+
server.close();
|
|
479
|
+
if (!result) {
|
|
480
|
+
spinner.stop("Authentication timed out or failed.");
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
spinner.message("Exchanging authorization code...");
|
|
484
|
+
const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
|
|
485
|
+
if (tokenResult.type === "failed") {
|
|
486
|
+
spinner.stop("Failed to exchange authorization code.");
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
const newAccountId = extractAccountId(tokenResult.access);
|
|
490
|
+
if (!newAccountId) {
|
|
491
|
+
spinner.stop("Failed to extract account ID from token.");
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
if (newAccountId !== targetAccountId) {
|
|
495
|
+
spinner.stop(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
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
|
+
};
|
|
356
510
|
const performLogin = async () => {
|
|
357
511
|
p.intro("cdx login - Add OpenAI account");
|
|
358
512
|
const flow = await createAuthorizationFlow();
|
|
@@ -388,7 +542,8 @@ const performLogin = async () => {
|
|
|
388
542
|
refresh: tokenResult.refresh,
|
|
389
543
|
access: tokenResult.access,
|
|
390
544
|
expires: tokenResult.expires,
|
|
391
|
-
accountId
|
|
545
|
+
accountId,
|
|
546
|
+
...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
|
|
392
547
|
});
|
|
393
548
|
spinner.stop("Login successful!");
|
|
394
549
|
const labelInput = await p.text({
|
|
@@ -403,6 +558,139 @@ const performLogin = async () => {
|
|
|
403
558
|
return { accountId };
|
|
404
559
|
};
|
|
405
560
|
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region lib/refresh.ts
|
|
563
|
+
const writeActiveAuthFilesIfCurrent = async (accountId) => {
|
|
564
|
+
if (!configExists()) return null;
|
|
565
|
+
const config = await loadConfig();
|
|
566
|
+
const current = config.accounts[config.current];
|
|
567
|
+
if (!current || current.accountId !== accountId) return null;
|
|
568
|
+
return writeAllAuthFiles(loadKeychainPayload(accountId));
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region lib/status.ts
|
|
573
|
+
const formatDuration = (ms) => {
|
|
574
|
+
const absMs = Math.abs(ms);
|
|
575
|
+
const seconds = Math.floor(absMs / 1e3);
|
|
576
|
+
const minutes = Math.floor(seconds / 60);
|
|
577
|
+
const hours = Math.floor(minutes / 60);
|
|
578
|
+
const days = Math.floor(hours / 24);
|
|
579
|
+
if (days > 0) {
|
|
580
|
+
const remainingHours = hours % 24;
|
|
581
|
+
return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
|
|
582
|
+
}
|
|
583
|
+
if (hours > 0) {
|
|
584
|
+
const remainingMinutes = minutes % 60;
|
|
585
|
+
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
586
|
+
}
|
|
587
|
+
if (minutes > 0) return `${minutes}m`;
|
|
588
|
+
return `${seconds}s`;
|
|
589
|
+
};
|
|
590
|
+
const formatExpiry = (expiresAt) => {
|
|
591
|
+
if (expiresAt === null) return "unknown";
|
|
592
|
+
const remaining = expiresAt - Date.now();
|
|
593
|
+
if (remaining <= 0) return `EXPIRED ${formatDuration(remaining)} ago`;
|
|
594
|
+
return `expires in ${formatDuration(remaining)}`;
|
|
595
|
+
};
|
|
596
|
+
const readOpenCodeAuthAccount = async () => {
|
|
597
|
+
const { authPath } = getPaths();
|
|
598
|
+
if (!existsSync(authPath)) return {
|
|
599
|
+
exists: false,
|
|
600
|
+
accountId: null
|
|
601
|
+
};
|
|
602
|
+
try {
|
|
603
|
+
const raw = await readFile(authPath, "utf8");
|
|
604
|
+
return {
|
|
605
|
+
exists: true,
|
|
606
|
+
accountId: JSON.parse(raw).openai?.accountId ?? null
|
|
607
|
+
};
|
|
608
|
+
} catch {
|
|
609
|
+
return {
|
|
610
|
+
exists: true,
|
|
611
|
+
accountId: null
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
const readCodexAuthAccount = async () => {
|
|
616
|
+
const { codexAuthPath } = getPaths();
|
|
617
|
+
if (!existsSync(codexAuthPath)) return {
|
|
618
|
+
exists: false,
|
|
619
|
+
accountId: null
|
|
620
|
+
};
|
|
621
|
+
try {
|
|
622
|
+
const raw = await readFile(codexAuthPath, "utf8");
|
|
623
|
+
return {
|
|
624
|
+
exists: true,
|
|
625
|
+
accountId: JSON.parse(raw).tokens?.account_id ?? null
|
|
626
|
+
};
|
|
627
|
+
} catch {
|
|
628
|
+
return {
|
|
629
|
+
exists: true,
|
|
630
|
+
accountId: null
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
const readPiAuthAccount = async () => {
|
|
635
|
+
const { piAuthPath } = getPaths();
|
|
636
|
+
if (!existsSync(piAuthPath)) return {
|
|
637
|
+
exists: false,
|
|
638
|
+
accountId: null
|
|
639
|
+
};
|
|
640
|
+
try {
|
|
641
|
+
const raw = await readFile(piAuthPath, "utf8");
|
|
642
|
+
return {
|
|
643
|
+
exists: true,
|
|
644
|
+
accountId: JSON.parse(raw)["openai-codex"]?.accountId ?? null
|
|
645
|
+
};
|
|
646
|
+
} catch {
|
|
647
|
+
return {
|
|
648
|
+
exists: true,
|
|
649
|
+
accountId: null
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
const getAccountStatus = (accountId, isCurrent, label) => {
|
|
654
|
+
const keychainExists = keychainPayloadExists(accountId);
|
|
655
|
+
let expiresAt = null;
|
|
656
|
+
let hasIdToken = false;
|
|
657
|
+
if (keychainExists) try {
|
|
658
|
+
const payload = loadKeychainPayload(accountId);
|
|
659
|
+
expiresAt = payload.expires;
|
|
660
|
+
hasIdToken = !!payload.idToken;
|
|
661
|
+
} catch {}
|
|
662
|
+
return {
|
|
663
|
+
accountId,
|
|
664
|
+
label,
|
|
665
|
+
isCurrent,
|
|
666
|
+
keychainExists,
|
|
667
|
+
hasIdToken,
|
|
668
|
+
expiresAt,
|
|
669
|
+
expiresIn: formatExpiry(expiresAt)
|
|
670
|
+
};
|
|
671
|
+
};
|
|
672
|
+
const getStatus = async () => {
|
|
673
|
+
const accounts = [];
|
|
674
|
+
if (configExists()) {
|
|
675
|
+
const config = await loadConfig();
|
|
676
|
+
for (let i = 0; i < config.accounts.length; i++) {
|
|
677
|
+
const account = config.accounts[i];
|
|
678
|
+
accounts.push(getAccountStatus(account.accountId, i === config.current, account.label));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const [opencodeAuth, codexAuth, piAuth] = await Promise.all([
|
|
682
|
+
readOpenCodeAuthAccount(),
|
|
683
|
+
readCodexAuthAccount(),
|
|
684
|
+
readPiAuthAccount()
|
|
685
|
+
]);
|
|
686
|
+
return {
|
|
687
|
+
accounts,
|
|
688
|
+
opencodeAuth,
|
|
689
|
+
codexAuth,
|
|
690
|
+
piAuth
|
|
691
|
+
};
|
|
692
|
+
};
|
|
693
|
+
|
|
406
694
|
//#endregion
|
|
407
695
|
//#region lib/interactive.ts
|
|
408
696
|
const getAccountDisplay = (accountId, isCurrent, label) => {
|
|
@@ -456,15 +744,72 @@ const handleSwitchAccount = async () => {
|
|
|
456
744
|
p.log.error("Invalid selection.");
|
|
457
745
|
return;
|
|
458
746
|
}
|
|
459
|
-
|
|
747
|
+
let payload;
|
|
748
|
+
try {
|
|
749
|
+
payload = loadKeychainPayload(selectedAccount.accountId);
|
|
750
|
+
} catch {
|
|
751
|
+
p.log.error(`Missing credentials for account ${selectedAccount.label ?? selectedAccount.accountId}. Re-login with 'cdx login'.`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const result = await writeAllAuthFiles(payload);
|
|
460
755
|
config.current = selected;
|
|
461
756
|
await saveConfig(config);
|
|
462
757
|
const displayName = selectedAccount.label ?? selectedAccount.accountId;
|
|
758
|
+
const opencodeMark = "✓";
|
|
759
|
+
const piMark = result.piWritten ? "✓" : "✗";
|
|
760
|
+
const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
|
|
463
761
|
p.log.success(`Switched to account ${displayName}`);
|
|
762
|
+
p.log.message(` OpenCode: ${opencodeMark}`);
|
|
763
|
+
p.log.message(` Pi Agent: ${piMark}`);
|
|
764
|
+
p.log.message(` Codex CLI: ${codexMark}`);
|
|
464
765
|
};
|
|
465
766
|
const handleAddAccount = async () => {
|
|
466
767
|
await performLogin();
|
|
467
768
|
};
|
|
769
|
+
const handleRefreshAccount = async () => {
|
|
770
|
+
if (!configExists()) {
|
|
771
|
+
p.log.warning("No accounts configured. Use 'Add account' first.");
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const config = await loadConfig();
|
|
775
|
+
if (config.accounts.length === 0) {
|
|
776
|
+
p.log.warning("No accounts to refresh.");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
780
|
+
const options = config.accounts.map((account) => ({
|
|
781
|
+
value: account.accountId,
|
|
782
|
+
label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
|
|
783
|
+
}));
|
|
784
|
+
const selected = await p.select({
|
|
785
|
+
message: "Select account to refresh:",
|
|
786
|
+
options
|
|
787
|
+
});
|
|
788
|
+
if (p.isCancel(selected)) {
|
|
789
|
+
p.log.info("Cancelled.");
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const accountId = selected;
|
|
793
|
+
const account = config.accounts.find((a) => a.accountId === accountId);
|
|
794
|
+
try {
|
|
795
|
+
const result = await performRefresh(accountId, account?.label);
|
|
796
|
+
if (!result) p.log.warning("Refresh was not completed.");
|
|
797
|
+
else {
|
|
798
|
+
const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
|
|
799
|
+
if (authResult) {
|
|
800
|
+
const piMark = authResult.piWritten ? "✓" : "✗";
|
|
801
|
+
const codexMark = authResult.codexWritten ? "✓" : authResult.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
|
|
802
|
+
p.log.message("Updated active auth files:");
|
|
803
|
+
p.log.message(" OpenCode: ✓");
|
|
804
|
+
p.log.message(` Pi Agent: ${piMark}`);
|
|
805
|
+
p.log.message(` Codex CLI: ${codexMark}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
} catch (error) {
|
|
809
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
810
|
+
p.log.error(`Refresh failed: ${msg}`);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
468
813
|
const handleRemoveAccount = async () => {
|
|
469
814
|
if (!configExists()) {
|
|
470
815
|
p.log.warning("No accounts configured.");
|
|
@@ -552,6 +897,28 @@ const handleLabelAccount = async () => {
|
|
|
552
897
|
if (newLabel) p.log.success(`Account ${accountId} labeled as "${newLabel}".`);
|
|
553
898
|
else p.log.success(`Label removed from account ${accountId}.`);
|
|
554
899
|
};
|
|
900
|
+
const handleStatus = async () => {
|
|
901
|
+
const status = await getStatus();
|
|
902
|
+
if (status.accounts.length === 0) {
|
|
903
|
+
p.log.warning("No accounts configured. Use 'Add account' to get started.");
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
p.log.info("Account status:");
|
|
907
|
+
for (const account of status.accounts) {
|
|
908
|
+
const marker = account.isCurrent ? "→ " : " ";
|
|
909
|
+
const name = account.label ? `${account.label} (${account.accountId})` : account.accountId;
|
|
910
|
+
const keychain = account.keychainExists ? "" : " [no keychain]";
|
|
911
|
+
const idToken = account.hasIdToken ? "" : " [no id_token]";
|
|
912
|
+
p.log.message(`${marker}${name} — ${account.expiresIn}${keychain}${idToken}`);
|
|
913
|
+
}
|
|
914
|
+
const ocStatus = status.opencodeAuth.exists ? `active: ${status.opencodeAuth.accountId ?? "unknown"}` : "not found";
|
|
915
|
+
const cxStatus = status.codexAuth.exists ? `active: ${status.codexAuth.accountId ?? "unknown"}` : "not found";
|
|
916
|
+
const piStatus = status.piAuth.exists ? `active: ${status.piAuth.accountId ?? "unknown"}` : "not found";
|
|
917
|
+
p.log.info(`Auth files:`);
|
|
918
|
+
p.log.message(` OpenCode: ${ocStatus}`);
|
|
919
|
+
p.log.message(` Codex CLI: ${cxStatus}`);
|
|
920
|
+
p.log.message(` Pi Agent: ${piStatus}`);
|
|
921
|
+
};
|
|
555
922
|
const runInteractiveMode = async () => {
|
|
556
923
|
p.intro("cdx - OpenAI Account Switcher");
|
|
557
924
|
let running = true;
|
|
@@ -578,6 +945,10 @@ const runInteractiveMode = async () => {
|
|
|
578
945
|
value: "add",
|
|
579
946
|
label: "Add account (OAuth login)"
|
|
580
947
|
},
|
|
948
|
+
{
|
|
949
|
+
value: "refresh",
|
|
950
|
+
label: "Refresh account (re-login)"
|
|
951
|
+
},
|
|
581
952
|
{
|
|
582
953
|
value: "remove",
|
|
583
954
|
label: "Remove account"
|
|
@@ -586,6 +957,10 @@ const runInteractiveMode = async () => {
|
|
|
586
957
|
value: "label",
|
|
587
958
|
label: "Label account"
|
|
588
959
|
},
|
|
960
|
+
{
|
|
961
|
+
value: "status",
|
|
962
|
+
label: "Account status & token expiry"
|
|
963
|
+
},
|
|
589
964
|
{
|
|
590
965
|
value: "exit",
|
|
591
966
|
label: "Exit"
|
|
@@ -606,12 +981,18 @@ const runInteractiveMode = async () => {
|
|
|
606
981
|
case "add":
|
|
607
982
|
await handleAddAccount();
|
|
608
983
|
break;
|
|
984
|
+
case "refresh":
|
|
985
|
+
await handleRefreshAccount();
|
|
986
|
+
break;
|
|
609
987
|
case "remove":
|
|
610
988
|
await handleRemoveAccount();
|
|
611
989
|
break;
|
|
612
990
|
case "label":
|
|
613
991
|
await handleLabelAccount();
|
|
614
992
|
break;
|
|
993
|
+
case "status":
|
|
994
|
+
await handleStatus();
|
|
995
|
+
break;
|
|
615
996
|
case "exit":
|
|
616
997
|
running = false;
|
|
617
998
|
break;
|
|
@@ -621,6 +1002,162 @@ const runInteractiveMode = async () => {
|
|
|
621
1002
|
p.outro("Goodbye!");
|
|
622
1003
|
};
|
|
623
1004
|
|
|
1005
|
+
//#endregion
|
|
1006
|
+
//#region lib/usage.ts
|
|
1007
|
+
const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
1008
|
+
const USER_AGENT = "cdx-cli";
|
|
1009
|
+
/**
|
|
1010
|
+
* Hits the undocumented OpenAI usage endpoint (may change without notice).
|
|
1011
|
+
*/
|
|
1012
|
+
const fetchUsageRaw = async (accessToken, accountId) => {
|
|
1013
|
+
const headers = {
|
|
1014
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1015
|
+
"User-Agent": USER_AGENT,
|
|
1016
|
+
Accept: "application/json"
|
|
1017
|
+
};
|
|
1018
|
+
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
|
1019
|
+
return fetch(USAGE_ENDPOINT, { headers });
|
|
1020
|
+
};
|
|
1021
|
+
/**
|
|
1022
|
+
* Fetches usage for an account. On 401, refreshes the token and retries once.
|
|
1023
|
+
*/
|
|
1024
|
+
const fetchUsage = async (accountId) => {
|
|
1025
|
+
let payload;
|
|
1026
|
+
try {
|
|
1027
|
+
payload = loadKeychainPayload(accountId);
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
return {
|
|
1030
|
+
ok: false,
|
|
1031
|
+
error: {
|
|
1032
|
+
type: "auth_failed",
|
|
1033
|
+
message: err instanceof Error ? err.message : "Failed to load credentials"
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
let response = await fetchUsageRaw(payload.access, payload.accountId);
|
|
1039
|
+
if (response.status === 401) {
|
|
1040
|
+
const refreshResult = await refreshAccessToken(payload.refresh);
|
|
1041
|
+
if (refreshResult.type === "failed") return {
|
|
1042
|
+
ok: false,
|
|
1043
|
+
error: {
|
|
1044
|
+
type: "auth_failed",
|
|
1045
|
+
message: "Token expired and refresh failed. Try 'cdx login' to re-authenticate."
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
const updatedPayload = {
|
|
1049
|
+
...payload,
|
|
1050
|
+
access: refreshResult.access,
|
|
1051
|
+
refresh: refreshResult.refresh,
|
|
1052
|
+
expires: refreshResult.expires,
|
|
1053
|
+
idToken: refreshResult.idToken ?? payload.idToken
|
|
1054
|
+
};
|
|
1055
|
+
saveKeychainPayload(accountId, updatedPayload);
|
|
1056
|
+
response = await fetchUsageRaw(updatedPayload.access, updatedPayload.accountId);
|
|
1057
|
+
if (!response.ok) return {
|
|
1058
|
+
ok: false,
|
|
1059
|
+
error: {
|
|
1060
|
+
type: "auth_failed",
|
|
1061
|
+
message: `Usage API returned ${response.status} after token refresh.`
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
} else if (!response.ok) return {
|
|
1065
|
+
ok: false,
|
|
1066
|
+
error: {
|
|
1067
|
+
type: "unexpected",
|
|
1068
|
+
message: `Usage API returned ${response.status}: ${response.statusText}`
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
return {
|
|
1072
|
+
ok: true,
|
|
1073
|
+
data: await response.json()
|
|
1074
|
+
};
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
return {
|
|
1077
|
+
ok: false,
|
|
1078
|
+
error: {
|
|
1079
|
+
type: "network_error",
|
|
1080
|
+
message: err instanceof Error ? err.message : "Network request failed"
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
const formatWindowLabel = (seconds) => {
|
|
1086
|
+
const hours = seconds / 3600;
|
|
1087
|
+
if (hours >= 24) {
|
|
1088
|
+
const days = Math.round(hours / 24);
|
|
1089
|
+
return days === 7 ? "weekly" : `${days}d`;
|
|
1090
|
+
}
|
|
1091
|
+
return `${Math.round(hours)}h`;
|
|
1092
|
+
};
|
|
1093
|
+
const formatResetCountdown = (resetAtUnix) => {
|
|
1094
|
+
const diff = resetAtUnix * 1e3 - Date.now();
|
|
1095
|
+
if (diff <= 0) return "now";
|
|
1096
|
+
const minutes = Math.floor(diff / 6e4);
|
|
1097
|
+
const hours = Math.floor(minutes / 60);
|
|
1098
|
+
const remainingMinutes = minutes % 60;
|
|
1099
|
+
if (hours > 0) return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
1100
|
+
return `${minutes}m`;
|
|
1101
|
+
};
|
|
1102
|
+
const formatPercentageBar = (usedPercent) => {
|
|
1103
|
+
const width = 20;
|
|
1104
|
+
const filled = Math.round(usedPercent / 100 * width);
|
|
1105
|
+
const empty = width - filled;
|
|
1106
|
+
return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${usedPercent}% used`;
|
|
1107
|
+
};
|
|
1108
|
+
const formatWindow = (label, w) => {
|
|
1109
|
+
return [
|
|
1110
|
+
`${label} (${formatWindowLabel(w.limit_window_seconds)} window):`,
|
|
1111
|
+
` ${formatPercentageBar(w.used_percent)}`,
|
|
1112
|
+
` Resets in: ${formatResetCountdown(w.reset_at)}`
|
|
1113
|
+
];
|
|
1114
|
+
};
|
|
1115
|
+
const formatUsage = (usage) => {
|
|
1116
|
+
const lines = [];
|
|
1117
|
+
const plan = usage.plan_type ?? "unknown";
|
|
1118
|
+
lines.push(`Plan: ${plan}`);
|
|
1119
|
+
lines.push("");
|
|
1120
|
+
if (usage.rate_limit?.primary_window) lines.push(...formatWindow("Primary", usage.rate_limit.primary_window));
|
|
1121
|
+
if (usage.rate_limit?.secondary_window) lines.push(...formatWindow("Secondary", usage.rate_limit.secondary_window));
|
|
1122
|
+
if (usage.credits) {
|
|
1123
|
+
lines.push("");
|
|
1124
|
+
if (usage.credits.unlimited) lines.push("Credits: unlimited");
|
|
1125
|
+
else if (usage.credits.has_credits && usage.credits.balance !== void 0) lines.push(`Credits: $${Number(usage.credits.balance).toFixed(2)}`);
|
|
1126
|
+
else if (!usage.credits.has_credits) lines.push("Credits: none");
|
|
1127
|
+
}
|
|
1128
|
+
return lines.join("\n");
|
|
1129
|
+
};
|
|
1130
|
+
const formatUsageBars = (usage, indent = " ") => {
|
|
1131
|
+
const windows = [];
|
|
1132
|
+
if (usage.rate_limit?.primary_window) windows.push({
|
|
1133
|
+
label: formatWindowLabel(usage.rate_limit.primary_window.limit_window_seconds),
|
|
1134
|
+
window: usage.rate_limit.primary_window
|
|
1135
|
+
});
|
|
1136
|
+
if (usage.rate_limit?.secondary_window) windows.push({
|
|
1137
|
+
label: formatWindowLabel(usage.rate_limit.secondary_window.limit_window_seconds),
|
|
1138
|
+
window: usage.rate_limit.secondary_window
|
|
1139
|
+
});
|
|
1140
|
+
const maxLabelLen = Math.max(...windows.map((w) => w.label.length), 0);
|
|
1141
|
+
return windows.map(({ label, window: w }) => {
|
|
1142
|
+
return `${indent}${label.padEnd(maxLabelLen)} ${formatPercentageBar(w.used_percent)} resets in ${formatResetCountdown(w.reset_at)}`;
|
|
1143
|
+
});
|
|
1144
|
+
};
|
|
1145
|
+
const formatUsageOverview = (entries) => {
|
|
1146
|
+
const lines = [];
|
|
1147
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1148
|
+
const entry = entries[i];
|
|
1149
|
+
const marker = entry.isCurrent ? "→ " : " ";
|
|
1150
|
+
if (entry.result.ok) {
|
|
1151
|
+
const usage = entry.result.data;
|
|
1152
|
+
const plan = usage.plan_type ?? "unknown";
|
|
1153
|
+
lines.push(`${marker}${entry.displayName} (${plan})`);
|
|
1154
|
+
lines.push(...formatUsageBars(usage));
|
|
1155
|
+
} else lines.push(`${marker}${entry.displayName}: [error] ${entry.result.error.message}`);
|
|
1156
|
+
if (i < entries.length - 1) lines.push("");
|
|
1157
|
+
}
|
|
1158
|
+
return lines.join("\n");
|
|
1159
|
+
};
|
|
1160
|
+
|
|
624
1161
|
//#endregion
|
|
625
1162
|
//#region cdx.ts
|
|
626
1163
|
const switchNext = async () => {
|
|
@@ -629,22 +1166,34 @@ const switchNext = async () => {
|
|
|
629
1166
|
const nextAccount = config.accounts[nextIndex];
|
|
630
1167
|
if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
|
|
631
1168
|
const payload = loadKeychainPayload(nextAccount.accountId);
|
|
632
|
-
await
|
|
1169
|
+
const result = await writeAllAuthFiles(payload);
|
|
633
1170
|
config.current = nextIndex;
|
|
634
1171
|
await saveConfig(config);
|
|
635
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";
|
|
636
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`);
|
|
637
1180
|
};
|
|
638
1181
|
const switchToAccount = async (identifier) => {
|
|
639
1182
|
const config = await loadConfig();
|
|
640
1183
|
const index = config.accounts.findIndex((a) => a.accountId === identifier || a.label === identifier);
|
|
641
1184
|
if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
|
|
642
1185
|
const account = config.accounts[index];
|
|
643
|
-
await
|
|
1186
|
+
const result = await writeAllAuthFiles(loadKeychainPayload(account.accountId));
|
|
644
1187
|
config.current = index;
|
|
645
1188
|
await saveConfig(config);
|
|
646
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";
|
|
647
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`);
|
|
648
1197
|
};
|
|
649
1198
|
const interactiveMode = runInteractiveMode;
|
|
650
1199
|
const createProgram = (deps = {}) => {
|
|
@@ -663,6 +1212,32 @@ const createProgram = (deps = {}) => {
|
|
|
663
1212
|
process.exit(1);
|
|
664
1213
|
}
|
|
665
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
|
+
});
|
|
666
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) => {
|
|
667
1242
|
try {
|
|
668
1243
|
if (options.next) await switchNext();
|
|
@@ -690,6 +1265,98 @@ const createProgram = (deps = {}) => {
|
|
|
690
1265
|
process.exit(1);
|
|
691
1266
|
}
|
|
692
1267
|
});
|
|
1268
|
+
program.command("status").description("Show account status, token expiry, and auth file state").action(async () => {
|
|
1269
|
+
try {
|
|
1270
|
+
const status = await getStatus();
|
|
1271
|
+
if (status.accounts.length === 0) {
|
|
1272
|
+
process.stdout.write("No accounts configured. Use 'cdx login' to add one.\n");
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
process.stdout.write("\n");
|
|
1276
|
+
for (let i = 0; i < status.accounts.length; i++) {
|
|
1277
|
+
const account = status.accounts[i];
|
|
1278
|
+
const marker = account.isCurrent ? "→ " : " ";
|
|
1279
|
+
const warnings = [];
|
|
1280
|
+
if (!account.keychainExists) warnings.push("[no keychain]");
|
|
1281
|
+
if (!account.hasIdToken) warnings.push("[no id_token]");
|
|
1282
|
+
const warnStr = warnings.length > 0 ? ` ${warnings.join(" ")}` : "";
|
|
1283
|
+
const displayName = account.label ?? account.accountId;
|
|
1284
|
+
process.stdout.write(`${marker}${displayName}${warnStr}\n`);
|
|
1285
|
+
if (account.label) process.stdout.write(` ${account.accountId}\n`);
|
|
1286
|
+
process.stdout.write(` ${account.expiresIn}\n`);
|
|
1287
|
+
const usageResult = await fetchUsage(account.accountId);
|
|
1288
|
+
if (usageResult.ok) {
|
|
1289
|
+
const bars = formatUsageBars(usageResult.data);
|
|
1290
|
+
for (const bar of bars) process.stdout.write(`${bar}\n`);
|
|
1291
|
+
}
|
|
1292
|
+
if (i < status.accounts.length - 1) process.stdout.write("\n");
|
|
1293
|
+
}
|
|
1294
|
+
const resolveLabel = (id) => {
|
|
1295
|
+
if (!id) return "unknown";
|
|
1296
|
+
return status.accounts.find((a) => a.accountId === id)?.label ?? id;
|
|
1297
|
+
};
|
|
1298
|
+
process.stdout.write("\nAuth files:\n");
|
|
1299
|
+
const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
|
|
1300
|
+
process.stdout.write(` OpenCode: ${ocStatus}\n`);
|
|
1301
|
+
const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
|
|
1302
|
+
process.stdout.write(` Codex CLI: ${cxStatus}\n`);
|
|
1303
|
+
const piStatus = status.piAuth.exists ? `active: ${resolveLabel(status.piAuth.accountId)}` : "not found";
|
|
1304
|
+
process.stdout.write(` Pi Agent: ${piStatus}\n`);
|
|
1305
|
+
process.stdout.write("\n");
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1308
|
+
process.stderr.write(`${message}\n`);
|
|
1309
|
+
process.exit(1);
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
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
|
+
try {
|
|
1314
|
+
const config = await loadConfig();
|
|
1315
|
+
if (account) {
|
|
1316
|
+
const found = config.accounts.find((a) => a.accountId === account || a.label === account);
|
|
1317
|
+
if (!found) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
1318
|
+
const result = await fetchUsage(found.accountId);
|
|
1319
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
1320
|
+
const displayName = found.label ? `${found.label} (${found.accountId})` : found.accountId;
|
|
1321
|
+
process.stdout.write(`\n${displayName}\n${formatUsage(result.data)}\n\n`);
|
|
1322
|
+
} else {
|
|
1323
|
+
if (config.accounts.length === 0) throw new Error("No accounts configured. Use 'cdx login' to add one.");
|
|
1324
|
+
const results = await Promise.allSettled(config.accounts.map((a) => fetchUsage(a.accountId)));
|
|
1325
|
+
const entries = config.accounts.map((a, i) => {
|
|
1326
|
+
const settled = results[i];
|
|
1327
|
+
const displayName = a.label ? `${a.label} (${a.accountId})` : a.accountId;
|
|
1328
|
+
const result = settled.status === "fulfilled" ? settled.value : {
|
|
1329
|
+
ok: false,
|
|
1330
|
+
error: {
|
|
1331
|
+
type: "network_error",
|
|
1332
|
+
message: settled.reason?.message ?? "Fetch failed"
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
return {
|
|
1336
|
+
displayName,
|
|
1337
|
+
isCurrent: i === config.current,
|
|
1338
|
+
result
|
|
1339
|
+
};
|
|
1340
|
+
});
|
|
1341
|
+
process.stdout.write(`\n${formatUsageOverview(entries)}\n\n`);
|
|
1342
|
+
}
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1345
|
+
process.stderr.write(`${message}\n`);
|
|
1346
|
+
process.exit(1);
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
program.command("help").description("Show available commands and usage information").argument("[command]", "Show help for a specific command").action((commandName) => {
|
|
1350
|
+
if (commandName) {
|
|
1351
|
+
const cmd = program.commands.find((c) => c.name() === commandName);
|
|
1352
|
+
if (cmd) cmd.outputHelp();
|
|
1353
|
+
else {
|
|
1354
|
+
process.stderr.write(`Unknown command: ${commandName}\n`);
|
|
1355
|
+
program.outputHelp();
|
|
1356
|
+
process.exit(1);
|
|
1357
|
+
}
|
|
1358
|
+
} else program.outputHelp();
|
|
1359
|
+
});
|
|
693
1360
|
program.command("version").description("Show CLI version").action(() => {
|
|
694
1361
|
process.stdout.write(`${version}\n`);
|
|
695
1362
|
});
|
|
@@ -714,4 +1381,4 @@ if (import.meta.main) main().catch((error) => {
|
|
|
714
1381
|
});
|
|
715
1382
|
|
|
716
1383
|
//#endregion
|
|
717
|
-
export { createProgram, createTestPaths, getPaths, interactiveMode, loadConfig, resetPaths, runInteractiveMode, saveConfig, setPaths, switchNext, switchToAccount, writeAuthFile };
|
|
1384
|
+
export { createProgram, createTestPaths, getPaths, interactiveMode, loadConfig, resetPaths, runInteractiveMode, saveConfig, setPaths, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };
|