codex-account-orchestrator 1.1.1 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2026-01-27
9
+
10
+ ### Added
11
+
12
+ - `cao switch` interactive account picker and `cao current` convenience command
13
+ - `cao status --compact` for one-line per-account summaries
14
+ - `cao import codex-auth` to migrate snapshots from codex-auth
15
+ - `cao run --gateway` for running through the local gateway without CLI fallback
16
+
17
+ ### Changed
18
+
19
+ - Gateway status now includes token expiry and last refresh hints
20
+ - README expanded with migration, switching, and gateway run guidance
21
+
8
22
  ## [1.1.1] - 2026-01-27
9
23
 
10
24
  ### Fixed
package/README.md CHANGED
@@ -17,6 +17,7 @@ Codex OAuth account fallback orchestrator. CAO keeps **separate `CODEX_HOME` dir
17
17
  - Automatic fallback on quota exhaustion (keyword-based detector)
18
18
  - Gateway mode for seamless account switching without session drops
19
19
  - Lightweight observability via `cao status` and `cao list --details`
20
+ - Interactive switching and codex-auth snapshot import
20
21
  - Strict TypeScript build with a small, dependency-light CLI
21
22
 
22
23
  ## Requirements
@@ -67,6 +68,18 @@ cao add accountA --device-auth
67
68
  cao use accountA
68
69
  ```
69
70
 
71
+ Or pick interactively:
72
+
73
+ ```bash
74
+ cao switch
75
+ ```
76
+
77
+ Check the current default account:
78
+
79
+ ```bash
80
+ cao current
81
+ ```
82
+
70
83
  ### 3. Run with fallback
71
84
 
72
85
  ```bash
@@ -99,6 +112,12 @@ cao list
99
112
  cao status
100
113
  ```
101
114
 
115
+ ### Compact summary
116
+
117
+ ```bash
118
+ cao status --compact
119
+ ```
120
+
102
121
  You can also use:
103
122
 
104
123
  ```bash
@@ -128,6 +147,12 @@ Gateway mode keeps the Codex session open while switching accounts on quota erro
128
147
  cao gateway start
129
148
  ```
130
149
 
150
+ Run Codex through the gateway (no CLI fallback, gateway handles switching):
151
+
152
+ ```bash
153
+ cao run --gateway
154
+ ```
155
+
131
156
  Tune upstream retry/backoff (for transient 5xx/network errors):
132
157
 
133
158
  ```bash
@@ -192,6 +217,26 @@ Key files:
192
217
  - `<account>/auth.json`: account-scoped tokens managed by Codex
193
218
  - `<account>/config.toml`: account-scoped Codex configuration
194
219
 
220
+ ## Migration from codex-auth
221
+
222
+ Import snapshots created by `codex-auth`:
223
+
224
+ ```bash
225
+ cao import codex-auth
226
+ ```
227
+
228
+ Custom source directory:
229
+
230
+ ```bash
231
+ cao import codex-auth --source ~/.codex/accounts
232
+ ```
233
+
234
+ Overwrite existing auth files if needed:
235
+
236
+ ```bash
237
+ cao import codex-auth --overwrite
238
+ ```
239
+
195
240
  ## Development
196
241
 
197
242
  Build:
package/dist/cli_main.js CHANGED
@@ -6,7 +6,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const commander_1 = require("commander");
8
8
  const fs_1 = __importDefault(require("fs"));
9
+ const os_1 = __importDefault(require("os"));
9
10
  const path_1 = __importDefault(require("path"));
11
+ const promises_1 = require("readline/promises");
10
12
  const account_manager_1 = require("./account_manager");
11
13
  const account_inspector_1 = require("./account_inspector");
12
14
  const account_status_store_1 = require("./account_status_store");
@@ -78,10 +80,44 @@ program
78
80
  }
79
81
  renderAccountDetails(inspections);
80
82
  });
83
+ program
84
+ .command("switch")
85
+ .argument("[name]", "Account name")
86
+ .description("Switch the default account (interactive if omitted)")
87
+ .action(async (name) => {
88
+ const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
89
+ (0, account_manager_1.ensureBaseDir)(baseDir);
90
+ const inspections = (0, account_inspector_1.inspectAccounts)(baseDir);
91
+ if (inspections.length === 0) {
92
+ process.stdout.write("No accounts registered. Use `cao add <name>` first.\n");
93
+ return;
94
+ }
95
+ const resolved = name ?? (await promptForAccountSelection(inspections));
96
+ if (!resolved) {
97
+ process.stdout.write("No account selected.\n");
98
+ return;
99
+ }
100
+ const registry = (0, account_manager_1.setDefaultAccount)(baseDir, resolved);
101
+ process.stdout.write(`Default account set to: ${registry.default_account}\n`);
102
+ });
103
+ program
104
+ .command("current")
105
+ .description("Show the current default account")
106
+ .action(() => {
107
+ const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
108
+ (0, account_manager_1.ensureBaseDir)(baseDir);
109
+ const registry = (0, registry_store_1.loadRegistry)(baseDir);
110
+ if (!registry.default_account) {
111
+ process.stdout.write("No default account set.\n");
112
+ return;
113
+ }
114
+ process.stdout.write(`${registry.default_account}\n`);
115
+ });
81
116
  program
82
117
  .command("status")
83
118
  .description("Show detailed account status and cooldown/usage signals")
84
119
  .option("--json", "Output account status as JSON")
120
+ .option("--compact", "Output a compact one-line summary per account")
85
121
  .action((options) => {
86
122
  const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
87
123
  (0, account_manager_1.ensureBaseDir)(baseDir);
@@ -94,8 +130,97 @@ program
94
130
  renderAccountDetailsJson(inspections);
95
131
  return;
96
132
  }
133
+ if (options.compact) {
134
+ renderAccountCompact(inspections);
135
+ return;
136
+ }
97
137
  renderAccountDetails(inspections);
98
138
  });
139
+ const importCommand = program.command("import").description("Import accounts from other tools");
140
+ importCommand
141
+ .command("codex-auth")
142
+ .description("Import account snapshots from codex-auth")
143
+ .option("--source <path>", "Source directory with codex-auth snapshots", path_1.default.join(os_1.default.homedir(), ".codex", "accounts"))
144
+ .option("--overwrite", "Overwrite existing auth.json files")
145
+ .option("--current <name>", "Treat this account as active during import")
146
+ .option("--default <name>", "Set this account as the default after import")
147
+ .action((options) => {
148
+ const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
149
+ (0, account_manager_1.ensureBaseDir)(baseDir);
150
+ const sourceDir = path_1.default.resolve(options.source);
151
+ if (!fs_1.default.existsSync(sourceDir)) {
152
+ process.stderr.write(`Source directory not found: ${sourceDir}\n`);
153
+ process.exit(1);
154
+ return;
155
+ }
156
+ const entries = fs_1.default.readdirSync(sourceDir);
157
+ const snapshotFiles = entries.filter((entry) => entry.endsWith(".json"));
158
+ if (snapshotFiles.length === 0) {
159
+ process.stdout.write(`No snapshot files found in ${sourceDir}.\n`);
160
+ return;
161
+ }
162
+ const importedNames = [];
163
+ let importedCount = 0;
164
+ let skippedCount = 0;
165
+ let errorCount = 0;
166
+ for (const fileName of snapshotFiles) {
167
+ const rawName = path_1.default.basename(fileName, ".json");
168
+ let normalizedName;
169
+ try {
170
+ normalizedName = (0, account_manager_1.validateAccountName)(rawName);
171
+ }
172
+ catch (error) {
173
+ process.stderr.write(`Skipping snapshot '${fileName}': ${error.message}\n`);
174
+ errorCount += 1;
175
+ continue;
176
+ }
177
+ const sourcePath = path_1.default.join(sourceDir, fileName);
178
+ let parsed;
179
+ try {
180
+ parsed = JSON.parse(fs_1.default.readFileSync(sourcePath, "utf8"));
181
+ }
182
+ catch (error) {
183
+ process.stderr.write(`Skipping snapshot '${fileName}': invalid JSON (${error.message}).\n`);
184
+ errorCount += 1;
185
+ continue;
186
+ }
187
+ (0, account_manager_1.addAccount)(baseDir, normalizedName);
188
+ const accountDir = (0, paths_1.getAccountDir)(baseDir, normalizedName);
189
+ (0, account_manager_1.ensureAccountConfig)(accountDir);
190
+ const authPath = getAuthFilePath(accountDir);
191
+ if (fs_1.default.existsSync(authPath) && !options.overwrite) {
192
+ skippedCount += 1;
193
+ continue;
194
+ }
195
+ fs_1.default.writeFileSync(authPath, JSON.stringify(parsed, null, 2) + "\n", "utf8");
196
+ importedNames.push(normalizedName);
197
+ importedCount += 1;
198
+ }
199
+ const activeName = options.default ??
200
+ options.current ??
201
+ findCodexAuthActiveAccount(sourceDir) ??
202
+ undefined;
203
+ if (activeName) {
204
+ try {
205
+ const normalizedActive = (0, account_manager_1.validateAccountName)(activeName);
206
+ const registry = (0, registry_store_1.loadRegistry)(baseDir);
207
+ if (registry.accounts.includes(normalizedActive)) {
208
+ (0, account_manager_1.setDefaultAccount)(baseDir, normalizedActive);
209
+ process.stdout.write(`Default account set to: ${normalizedActive}\n`);
210
+ }
211
+ else {
212
+ process.stderr.write(`Requested default '${normalizedActive}' not found in imported accounts.\n`);
213
+ }
214
+ }
215
+ catch (error) {
216
+ process.stderr.write(`Unable to set default account '${activeName}': ${error.message}\n`);
217
+ }
218
+ }
219
+ process.stdout.write(`Import complete. Imported: ${importedCount}, skipped: ${skippedCount}, errors: ${errorCount}.\n`);
220
+ if (importedNames.length > 0) {
221
+ process.stdout.write(`Imported accounts: ${importedNames.join(", ")}\n`);
222
+ }
223
+ });
99
224
  program
100
225
  .command("use")
101
226
  .argument("<name>", "Account name")
@@ -221,6 +346,8 @@ program
221
346
  .command("run")
222
347
  .option("--account <name>", "Run with a specific account")
223
348
  .option("--codex <path>", "Path to the codex binary", "codex")
349
+ .option("--gateway", "Route Codex traffic through the local gateway")
350
+ .option("--gateway-url <url>", "Gateway base URL", "http://127.0.0.1:4319")
224
351
  .option("--no-fallback", "Disable automatic fallback")
225
352
  .option("--max-passes <count>", "Retry passes when all accounts hit quota", "2")
226
353
  .option("--retry-delay <seconds>", "Delay between retry passes in seconds", "0")
@@ -276,6 +403,74 @@ function normalizeCodexArgs(args, codexBin) {
276
403
  function getAuthFilePath(accountDir) {
277
404
  return path_1.default.join(accountDir, constants_1.AUTH_FILE_NAME);
278
405
  }
406
+ async function promptForAccountSelection(inspections) {
407
+ if (!process.stdin.isTTY) {
408
+ process.stderr.write("No TTY available. Please provide an account name.\n");
409
+ return undefined;
410
+ }
411
+ process.stdout.write("Select an account:\n");
412
+ for (let index = 0; index < inspections.length; index += 1) {
413
+ const inspection = inspections[index];
414
+ const marker = inspection.isDefault ? "*" : " ";
415
+ process.stdout.write(`${index + 1}. ${marker} ${inspection.name}\n`);
416
+ }
417
+ const rl = (0, promises_1.createInterface)({ input: process.stdin, output: process.stdout });
418
+ const answer = (await rl.question("Enter a number or name (blank to cancel): ")).trim();
419
+ rl.close();
420
+ if (!answer) {
421
+ return undefined;
422
+ }
423
+ const index = Number.parseInt(answer, 10);
424
+ if (!Number.isNaN(index)) {
425
+ const selected = inspections[index - 1];
426
+ return selected?.name;
427
+ }
428
+ try {
429
+ const normalized = (0, account_manager_1.validateAccountName)(answer);
430
+ const exists = inspections.some((inspection) => inspection.name === normalized);
431
+ if (!exists) {
432
+ process.stderr.write(`Account not found: ${normalized}\n`);
433
+ return undefined;
434
+ }
435
+ return normalized;
436
+ }
437
+ catch (error) {
438
+ process.stderr.write(`Invalid account name: ${error.message}\n`);
439
+ return undefined;
440
+ }
441
+ }
442
+ function findCodexAuthActiveAccount(sourceDir) {
443
+ const parentDir = path_1.default.dirname(sourceDir);
444
+ const candidates = [
445
+ path_1.default.join(sourceDir, ".active"),
446
+ path_1.default.join(sourceDir, "active"),
447
+ path_1.default.join(sourceDir, ".current"),
448
+ path_1.default.join(sourceDir, "current"),
449
+ path_1.default.join(sourceDir, ".selected"),
450
+ path_1.default.join(sourceDir, "selected"),
451
+ path_1.default.join(parentDir, ".active"),
452
+ path_1.default.join(parentDir, "active"),
453
+ path_1.default.join(parentDir, ".current"),
454
+ path_1.default.join(parentDir, "current"),
455
+ path_1.default.join(parentDir, ".selected"),
456
+ path_1.default.join(parentDir, "selected")
457
+ ];
458
+ for (const candidate of candidates) {
459
+ if (!fs_1.default.existsSync(candidate)) {
460
+ continue;
461
+ }
462
+ try {
463
+ const value = fs_1.default.readFileSync(candidate, "utf8").trim();
464
+ if (value.length > 0) {
465
+ return value;
466
+ }
467
+ }
468
+ catch {
469
+ continue;
470
+ }
471
+ }
472
+ return undefined;
473
+ }
279
474
  function renderAccountSummary(inspections) {
280
475
  for (const inspection of inspections) {
281
476
  const marker = inspection.isDefault ? "*" : " ";
@@ -351,6 +546,18 @@ function renderAccountDetailsJson(inspections) {
351
546
  const payload = inspections.map((inspection) => toAccountDetailRecord(inspection, referenceMs));
352
547
  process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
353
548
  }
549
+ function renderAccountCompact(inspections) {
550
+ const referenceMs = Date.now();
551
+ for (const inspection of inspections) {
552
+ const marker = inspection.isDefault ? "*" : " ";
553
+ const status = inspection.status ?? {};
554
+ const loginStatus = inspection.loggedIn ? "logged-in" : "not-logged-in";
555
+ const expires = formatExpiryShort(inspection.tokenDetails?.expiresAtMs, referenceMs);
556
+ const lastQuota = formatRelativeShort(status.lastQuotaAtMs, referenceMs);
557
+ const cooldown = formatCooldownShort(status.cooldownUntilMs, referenceMs);
558
+ process.stdout.write(`${marker} ${inspection.name} (${loginStatus}) | expires: ${expires} | cooldown: ${cooldown} | last_quota: ${lastQuota} | failures: ${status.consecutiveFailures ?? 0}\n`);
559
+ }
560
+ }
354
561
  function formatDuration(durationMs) {
355
562
  const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
356
563
  if (totalSeconds < 60) {
@@ -382,6 +589,32 @@ function formatTimestampWithRelative(timestampMs, referenceMs) {
382
589
  const relative = diffMs > 0 ? `in ${formatDuration(diffMs)}` : `${formatDuration(-diffMs)} ago`;
383
590
  return `${iso} (${relative})`;
384
591
  }
592
+ function formatRelativeShort(timestampMs, referenceMs) {
593
+ if (!timestampMs) {
594
+ return "none";
595
+ }
596
+ const diffMs = timestampMs - referenceMs;
597
+ if (diffMs <= 0) {
598
+ return `${formatDuration(-diffMs)} ago`;
599
+ }
600
+ return `in ${formatDuration(diffMs)}`;
601
+ }
602
+ function formatExpiryShort(timestampMs, referenceMs) {
603
+ if (!timestampMs) {
604
+ return "unknown";
605
+ }
606
+ const diffMs = timestampMs - referenceMs;
607
+ if (diffMs <= 0) {
608
+ return "expired";
609
+ }
610
+ return `in ${formatDuration(diffMs)}`;
611
+ }
612
+ function formatCooldownShort(timestampMs, referenceMs) {
613
+ if (!timestampMs || timestampMs <= referenceMs) {
614
+ return "none";
615
+ }
616
+ return `in ${formatDuration(timestampMs - referenceMs)}`;
617
+ }
385
618
  function formatCooldown(cooldownUntilMs, referenceMs) {
386
619
  if (!cooldownUntilMs) {
387
620
  return "none";
@@ -433,8 +666,16 @@ function buildGatewayOverrides(options) {
433
666
  }
434
667
  async function runWithFallback(options, baseDir, accounts, codexArgs) {
435
668
  const codexBin = options.codex;
669
+ const gatewayUrl = options.gateway ? options.gatewayUrl : undefined;
670
+ const fallbackEnabled = options.fallback && !options.gateway;
436
671
  const maxPasses = normalizeMaxPasses(options.maxPasses);
437
672
  const retryDelayMs = normalizeDelay(options.retryDelay);
673
+ if (options.gateway && gatewayUrl) {
674
+ process.stderr.write(`Gateway routing enabled: ${gatewayUrl}\n`);
675
+ if (options.fallback) {
676
+ process.stderr.write("Gateway mode disables CLI fallback (handled by gateway).\n");
677
+ }
678
+ }
438
679
  for (let passIndex = 0; passIndex < maxPasses; passIndex += 1) {
439
680
  let quotaFailures = 0;
440
681
  let lastExitCode = 1;
@@ -448,7 +689,7 @@ async function runWithFallback(options, baseDir, accounts, codexArgs) {
448
689
  lastAttemptAtMs: attemptAtMs
449
690
  }));
450
691
  process.stderr.write(`Using account: ${name}\n`);
451
- const result = await (0, process_runner_1.runCodexOnce)(codexBin, codexArgs, accountDir, options.fallback);
692
+ const result = await (0, process_runner_1.runCodexOnce)(codexBin, codexArgs, accountDir, fallbackEnabled, gatewayUrl ? { OPENAI_BASE_URL: gatewayUrl } : {});
452
693
  lastExitCode = result.exitCode;
453
694
  if (result.exitCode === 0) {
454
695
  (0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
@@ -462,7 +703,7 @@ async function runWithFallback(options, baseDir, accounts, codexArgs) {
462
703
  process.exit(0);
463
704
  return;
464
705
  }
465
- if (!options.fallback) {
706
+ if (!fallbackEnabled) {
466
707
  (0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
467
708
  ...previous,
468
709
  lastAttemptAtMs: attemptAtMs,
@@ -500,7 +741,7 @@ async function runWithFallback(options, baseDir, accounts, codexArgs) {
500
741
  process.stderr.write(`Quota exhausted. Falling back to: ${nextName}\n`);
501
742
  }
502
743
  }
503
- if (!options.fallback) {
744
+ if (!fallbackEnabled) {
504
745
  process.exit(lastExitCode);
505
746
  return;
506
747
  }
@@ -2,4 +2,4 @@ export interface RunResult {
2
2
  exitCode: number;
3
3
  quotaError: boolean;
4
4
  }
5
- export declare function runCodexOnce(codexBin: string, codexArgs: string[], accountDir: string, captureOutput: boolean): Promise<RunResult>;
5
+ export declare function runCodexOnce(codexBin: string, codexArgs: string[], accountDir: string, captureOutput: boolean, envOverrides?: Record<string, string | undefined>): Promise<RunResult>;
@@ -4,9 +4,13 @@ exports.runCodexOnce = runCodexOnce;
4
4
  const child_process_1 = require("child_process");
5
5
  const output_capture_1 = require("./output_capture");
6
6
  const quota_detector_1 = require("./quota_detector");
7
- async function runCodexOnce(codexBin, codexArgs, accountDir, captureOutput) {
7
+ async function runCodexOnce(codexBin, codexArgs, accountDir, captureOutput, envOverrides = {}) {
8
8
  const capture = new output_capture_1.OutputCapture();
9
- const env = { ...process.env, CODEX_HOME: accountDir };
9
+ const env = {
10
+ ...process.env,
11
+ CODEX_HOME: accountDir,
12
+ ...envOverrides
13
+ };
10
14
  const child = (0, child_process_1.spawn)(codexBin, codexArgs, {
11
15
  env,
12
16
  stdio: captureOutput ? ["inherit", "pipe", "pipe"] : ["inherit", "inherit", "inherit"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-account-orchestrator",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Codex OAuth account fallback orchestrator with seamless gateway mode",
5
5
  "main": "dist/cli_main.js",
6
6
  "types": "dist/cli_main.d.ts",