From 315d8cdc5e7fd283af586e01a225c47330820504 Mon Sep 17 00:00:00 2001 From: dr-diffie Date: Sat, 5 Sep 2026 00:01:33 +0200 Subject: [PATCH 1/2] feat(cli): expose machine-readable quota limits --- README.md | 1 + docs/features.md | 1 + docs/reference/commands.md | 33 ++- lib/codex-manager.ts | 13 + lib/codex-manager/account-manager-commands.ts | 1 + lib/codex-manager/commands/limits.ts | 148 ++++++++++ lib/codex-manager/help.ts | 1 + scripts/codex-routing.js | 1 + test/codex-manager-cli.test.ts | 51 ++++ test/codex-routing.test.ts | 4 +- test/documentation.test.ts | 2 +- test/limits-command.test.ts | 274 ++++++++++++++++++ 12 files changed, 526 insertions(+), 4 deletions(-) create mode 100644 lib/codex-manager/commands/limits.ts create mode 100644 test/limits-command.test.ts diff --git a/README.md b/README.md index 0c7307532..91d6fd190 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ which would rebind the row to a different workspace. | Command | What it answers | | --- | --- | | `codex-multi-auth report --live --json` | How do I get the full machine-readable health report? | +| `codex-multi-auth limits --json [--refresh]` | How do I read account quota windows without parsing internal cache files or terminal text? | | `codex-multi-auth fix --live --model gpt-5.5` | How do I run live repair probes with a chosen model? | | `codex-multi-auth why-selected --json` | Which account does the selector pick now, and why? | | `codex-multi-auth usage --since 24h --by project` | What local usage has been recorded recently? | diff --git a/docs/features.md b/docs/features.md index fcd3e5ae5..dd5243b01 100644 --- a/docs/features.md +++ b/docs/features.md @@ -25,6 +25,7 @@ User-facing capability map for Codex CLI multi-account OAuth, account switching, | --- | --- | --- | | Readiness and risk forecast | Suggests the best next account | `codex-multi-auth forecast` | | Live quota probe mode | Uses live headers for stronger decisions (probe leads with `gpt-5.6-sol`) | `codex-multi-auth forecast --live` | +| Machine-readable quota snapshot | Joins configured accounts to cached quota windows without exposing credentials; optional refresh retains the dashboard's five-minute freshness floor | `codex-multi-auth limits --json [--refresh]` | | Best-account helper | Shortcut for selection-oriented workflows | `codex-multi-auth best` | | JSON report output | Inspect account state in automation or support workflows | `codex-multi-auth report --live --json` | | Why-selected explanation | Explains current routing/selection context | `codex-multi-auth why-selected` | diff --git a/docs/reference/commands.md b/docs/reference/commands.md index cd1e0583e..239bc1955 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -41,6 +41,7 @@ Compatibility forms are supported for migrations and wrapper-routed environments | `codex-multi-auth login` | Open interactive auth dashboard. Flags: `--device-auth`, `--manual`/`--no-browser`, `--org `, `--preserve-selection`, `--account ` | | `codex-multi-auth status` | Print account pool, pin, runtime metrics, and storage summary (`list` is the same command) | | `codex-multi-auth check` | Live-probe account health against the Codex backend | +| `codex-multi-auth limits --json` | Print configured accounts joined to their cached quota windows; add `--refresh` for an age-gated refresh | --- @@ -81,6 +82,36 @@ Turning `showQuotaDetails` off reduces the line to a bare `live session OK`. --- +## `codex-multi-auth limits` + +Prints a stable, machine-readable quota snapshot for local integrations: + +```console +codex-multi-auth limits --json +codex-multi-auth limits --json --refresh +``` + +The default command reads the local quota cache and performs no network +requests. `--refresh` reuses the dashboard's sequential quota refresh and its +five-minute freshness floor: only enabled accounts with usable credentials and +missing or stale cache entries are probed. Countdown text should be calculated +by the consumer from `resetAtMs`; the command emits numeric values rather than +locale-formatted dates. + +The top-level object has `schemaVersion: 1`, a millisecond `generatedAt`, a +`mode` of `cached` or `refresh` describing the requested command mode, and +`accounts`. Each configured account includes +`index`, `label`, `enabled`, `current`, and either a `quota` object or `null`. +Quota objects contain `updatedAt`, HTTP `status`, `planType`, and `primary` / +`secondary` windows with `usedPercent`, `windowMinutes`, and `resetAtMs`. +Unavailable provider values are explicit JSON `null`; internal probe-model names, +credentials, and orphan cache entries are not emitted. + +`--json` is required. `--help` / `-h` prints focused usage. Unknown flags fail +with exit code 1 without reading account storage or quota cache. + +--- + ## Daily Use | Command | Description | @@ -149,7 +180,7 @@ Turning `showQuotaDetails` off reduces the line to a bare `live session OK`. | `--org ` | login | Bind this login to a specific ChatGPT workspace/org id (same seat can be registered as personal vs team/business) | | `--preserve-selection` | login | Add or refresh credentials without changing the active global/model-family selections or manual pin; performs one sign-in and exits | | `--account ` | login | Re-authenticate exactly one saved account. Implies `--preserve-selection`, refuses a different OAuth identity before writing, keeps a disabled account disabled, and cannot be combined with `--org` | -| `--json` | verify-flagged, verify, why-selected, best, forecast, report, usage, budget, models, monitor, integrations, fix, doctor, config explain, debug bundle, history | Print machine-readable output | +| `--json` | limits, verify-flagged, verify, why-selected, best, forecast, report, usage, budget, models, monitor, integrations, fix, doctor, config explain, debug bundle, history | Print machine-readable output | | `--csv` | usage | Print or write CSV bucket output | | `--explain` | forecast, report | Include reasoning details (forecast text/JSON, report text) | | `--live` | best, forecast, report, fix | Use live probe before decisions/output | diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 4d1daf0fe..3a4d07317 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -35,6 +35,7 @@ import { runBudgetCommand } from "./codex-manager/commands/budget.js"; import { runBridgeCommand } from "./codex-manager/commands/bridge.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; import { runIntegrationsCommand } from "./codex-manager/commands/integrations.js"; +import { runLimitsCommand } from "./codex-manager/commands/limits.js"; import { runModelsCommand } from "./codex-manager/commands/models.js"; import { runMonitorCommand } from "./codex-manager/commands/monitor.js"; import { runConfigExplainCommand } from "./codex-manager/commands/config-explain.js"; @@ -89,6 +90,7 @@ import { runHistoryCommand } from "./codex-manager/commands/history.js"; import { runUnpinCommand } from "./codex-manager/commands/unpin.js"; import { runWorkspaceCommand } from "./codex-manager/commands/workspace.js"; import { runUsageCommand } from "./codex-manager/commands/usage.js"; +import { refreshQuotaCacheForMenu } from "./codex-manager/login-menu-data.js"; import { printUsage } from "./codex-manager/help.js"; import { availabilityTone, @@ -518,6 +520,17 @@ const CLI_COMMAND_HANDLERS: ReadonlyMap = new Map< ["login", (rest) => runAuthLogin(rest, { runForecast, createRepairCommandDeps })], ["list", runListOrStatusCommand], ["status", runListOrStatusCommand], + [ + "limits", + (rest) => + runLimitsCommand(rest, { + setStoragePath, + loadAccounts, + loadQuotaCache, + refreshQuotaCache: refreshQuotaCacheForMenu, + resolveActiveIndex, + }), + ], [ "switch", (rest) => diff --git a/lib/codex-manager/account-manager-commands.ts b/lib/codex-manager/account-manager-commands.ts index fc91916cf..8d7ca3ab6 100644 --- a/lib/codex-manager/account-manager-commands.ts +++ b/lib/codex-manager/account-manager-commands.ts @@ -14,6 +14,7 @@ export const ACCOUNT_MANAGER_COMMANDS = new Set([ "login", "list", "status", + "limits", "switch", "unpin", "workspace", diff --git a/lib/codex-manager/commands/limits.ts b/lib/codex-manager/commands/limits.ts new file mode 100644 index 000000000..4f9a99426 --- /dev/null +++ b/lib/codex-manager/commands/limits.ts @@ -0,0 +1,148 @@ +import { formatAccountLabel } from "../../accounts.js"; +import { findQuotaCacheEntryForAccount } from "../../quota-readiness.js"; +import type { QuotaCacheData, QuotaCacheEntry } from "../../quota-cache.js"; +import type { AccountStorageV3 } from "../../storage.js"; + +const LIMITS_SCHEMA_VERSION = 1; +const LIMITS_REFRESH_MAX_AGE_MS = 5 * 60_000; +const LIMITS_USAGE = "Usage: codex-multi-auth limits --json [--refresh]"; + +export interface LimitsCommandDeps { + setStoragePath: (path: string | null) => void; + loadAccounts: () => Promise; + loadQuotaCache: () => Promise; + refreshQuotaCache: ( + storage: AccountStorageV3, + cache: QuotaCacheData, + maxAgeMs: number, + ) => Promise; + resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; + getNow?: () => number; + logInfo?: (message: string) => void; + logError?: (message: string) => void; +} + +interface ParsedLimitsOptions { + json: boolean; + refresh: boolean; + help: boolean; +} + +function parseLimitsOptions(args: string[]): + | { ok: true; options: ParsedLimitsOptions } + | { ok: false; message: string } { + const options: ParsedLimitsOptions = { json: false, refresh: false, help: false }; + for (const arg of args) { + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--refresh") { + options.refresh = true; + continue; + } + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + return { ok: false, message: `Unknown limits option: ${arg}` }; + } + if (!options.json && !options.help) { + return { ok: false, message: LIMITS_USAGE }; + } + return { ok: true, options }; +} + +function publicWindow(window: QuotaCacheEntry["primary"]) { + return { + usedPercent: window.usedPercent ?? null, + windowMinutes: window.windowMinutes ?? null, + resetAtMs: window.resetAtMs ?? null, + }; +} + +function publicQuotaEntry(entry: QuotaCacheEntry) { + return { + updatedAt: entry.updatedAt, + status: entry.status, + planType: entry.planType ?? null, + primary: publicWindow(entry.primary), + secondary: publicWindow(entry.secondary), + }; +} + +export async function runLimitsCommand( + args: string[], + deps: LimitsCommandDeps, +): Promise { + const parsed = parseLimitsOptions(args); + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + if (!parsed.ok) { + logError(parsed.message); + return 1; + } + if (parsed.options.help) { + logInfo(LIMITS_USAGE); + return 0; + } + + deps.setStoragePath(null); + const storage = await deps.loadAccounts(); + if (!storage || storage.accounts.length === 0) { + const generatedAt = deps.getNow?.() ?? Date.now(); + logInfo( + JSON.stringify( + { + schemaVersion: LIMITS_SCHEMA_VERSION, + generatedAt, + mode: parsed.options.refresh ? "refresh" : "cached", + accounts: [], + }, + null, + 2, + ), + ); + return 0; + } + + let cache = await deps.loadQuotaCache(); + if (parsed.options.refresh) { + cache = await deps.refreshQuotaCache( + storage, + cache, + LIMITS_REFRESH_MAX_AGE_MS, + ); + } + + const generatedAt = deps.getNow?.() ?? Date.now(); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + const accounts = storage.accounts.map((account, index) => { + const quota = findQuotaCacheEntryForAccount( + cache, + account, + storage.accounts, + ); + return { + index, + label: formatAccountLabel(account, index), + enabled: account.enabled !== false, + current: index === activeIndex, + quota: quota ? publicQuotaEntry(quota) : null, + }; + }); + + logInfo( + JSON.stringify( + { + schemaVersion: LIMITS_SCHEMA_VERSION, + generatedAt, + mode: parsed.options.refresh ? "refresh" : "cached", + accounts, + }, + null, + 2, + ), + ); + return 0; +} diff --git a/lib/codex-manager/help.ts b/lib/codex-manager/help.ts index 3e966bb17..07017ab26 100644 --- a/lib/codex-manager/help.ts +++ b/lib/codex-manager/help.ts @@ -11,6 +11,7 @@ export function printUsage(): void { " codex-multi-auth login [--device-auth|--manual|--no-browser] [--org ] [--preserve-selection] [--account ]", " codex-multi-auth status [--json] (list is the same command)", " codex-multi-auth check (always live-probes)", + " codex-multi-auth limits --json [--refresh] (structured quota windows; refresh is age-gated)", "", "Daily use:", " codex-multi-auth list [--json]", diff --git a/scripts/codex-routing.js b/scripts/codex-routing.js index 3dd65a17a..6f1ba15b3 100644 --- a/scripts/codex-routing.js +++ b/scripts/codex-routing.js @@ -2,6 +2,7 @@ const AUTH_SUBCOMMANDS = new Set([ "login", "list", "status", + "limits", "switch", "unpin", "workspace", diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index e53822792..5c8c2c306 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1486,6 +1486,57 @@ describe("codex manager cli commands", () => { ); }); + it("dispatches limits --json through the public manager CLI", async () => { + const now = Date.now(); + storageMocks.loadAccounts.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "acct-limits", + email: "limits@example.com", + accessToken: "access-secret", + refreshToken: "refresh-secret", + addedAt: now, + lastUsed: now, + }, + ], + }); + quotaCacheMocks.loadQuotaCache.mockResolvedValue({ + byAccountId: { + "acct-limits": { + updatedAt: now, + status: 200, + model: "gpt-5.6-codex", + primary: { usedPercent: 25, windowMinutes: 300 }, + secondary: { usedPercent: 50, windowMinutes: 10_080 }, + }, + }, + byEmail: {}, + }); + const logSpy = silenceConsole("log"); + const errorSpy = silenceConsole("error"); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["limits", "--json"]); + const authExitCode = await runCodexMultiAuthCli(["auth", "limits", "--json"]); + + expect(exitCode).toBe(0); + expect(authExitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledTimes(2); + const serialized = String(logSpy.mock.calls[0]?.[0]); + const payload = JSON.parse(serialized) as { + schemaVersion: number; + accounts: Array<{ quota: { primary: { usedPercent: number } } | null }>; + }; + expect(payload.schemaVersion).toBe(1); + expect(payload.accounts[0]?.quota?.primary.usedPercent).toBe(25); + expect(serialized).not.toContain("access-secret"); + expect(serialized).not.toContain("refresh-secret"); + }); + it("runs forecast in json mode", async () => { const now = Date.now(); storageMocks.loadAccounts.mockResolvedValueOnce({ diff --git a/test/codex-routing.test.ts b/test/codex-routing.test.ts index ddf2e6d50..7cb218511 100644 --- a/test/codex-routing.test.ts +++ b/test/codex-routing.test.ts @@ -21,10 +21,10 @@ describe("codex routing helpers", () => { expect(shouldHandleMultiAuthAuth(["status"])).toBe(false); }); - it("routes the newer auth subcommands (unpin, workspace, uninstall) locally", () => { + it("routes the newer auth subcommands (unpin, workspace, limits, uninstall) locally", () => { // cli-manager-01/02: guard against accidental forwarding regressions for the // subcommands added after the original wrapper list was written. - for (const subcommand of ["unpin", "workspace", "uninstall"]) { + for (const subcommand of ["unpin", "workspace", "limits", "uninstall"]) { expect(AUTH_SUBCOMMANDS.has(subcommand), subcommand).toBe(true); expect(shouldHandleMultiAuthAuth(["auth", subcommand]), subcommand).toBe(true); } diff --git a/test/documentation.test.ts b/test/documentation.test.ts index f06306454..707c4b648 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -533,7 +533,7 @@ describe("Documentation Integrity", () => { `codex-multi-auth fix --live --model ${DEFAULT_MODEL}`, ); expect(commandRef).toContain( - "| `--json` | verify-flagged, verify, why-selected, best, forecast, report, usage, budget, models, monitor, integrations, fix, doctor, config explain, debug bundle, history |", + "| `--json` | limits, verify-flagged, verify, why-selected, best, forecast, report, usage, budget, models, monitor, integrations, fix, doctor, config explain, debug bundle, history |", ); expect(commandRef).toContain( "| `--explain` | forecast, report | Include reasoning details (forecast text/JSON, report text) |", diff --git a/test/limits-command.test.ts b/test/limits-command.test.ts new file mode 100644 index 000000000..1cef3cc72 --- /dev/null +++ b/test/limits-command.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it, vi } from "vitest"; +import { runLimitsCommand } from "../lib/codex-manager/commands/limits.js"; +import type { QuotaCacheData } from "../lib/quota-cache.js"; +import { + accountStorageV3Fixture, + storageAccountFixture, +} from "./helpers/cli-test-fixtures.js"; + +const NOW = 1_790_000_000_000; + +function quotaCache(): QuotaCacheData { + return { + byAccountId: { + "acct-1": { + updatedAt: NOW - 60_000, + status: 200, + model: "gpt-5.6-codex", + planType: "plus", + primary: { + usedPercent: 12.5, + windowMinutes: 300, + resetAtMs: NOW + 3_600_000, + }, + secondary: { + usedPercent: 63, + windowMinutes: 10_080, + resetAtMs: NOW + 86_400_000, + }, + }, + orphan: { + updatedAt: NOW, + status: 200, + model: "gpt-5.6-codex", + primary: {}, + secondary: {}, + }, + }, + byEmail: {}, + }; +} + +function createDeps(cache = quotaCache()) { + const storage = accountStorageV3Fixture([ + storageAccountFixture({ + accountId: "acct-1", + email: "one@example.com", + accessToken: "secret-access-token", + }), + storageAccountFixture({ + accountId: "acct-2", + email: "two@example.com", + accessToken: "second-secret-token", + enabled: false, + }), + ]); + return { + setStoragePath: vi.fn(), + loadAccounts: vi.fn().mockResolvedValue(storage), + loadQuotaCache: vi.fn().mockResolvedValue(cache), + refreshQuotaCache: vi.fn().mockResolvedValue(cache), + resolveActiveIndex: vi.fn(() => 0), + getNow: vi.fn(() => NOW), + logInfo: vi.fn(), + logError: vi.fn(), + }; +} + +function emittedJson(deps: ReturnType): Record { + expect(deps.logInfo).toHaveBeenCalledTimes(1); + return JSON.parse(deps.logInfo.mock.calls[0]?.[0] as string) as Record< + string, + unknown + >; +} + +describe("runLimitsCommand", () => { + it("emits configured accounts joined to cached quota and excludes secrets and orphan entries", async () => { + const deps = createDeps(); + + const exitCode = await runLimitsCommand(["--json"], deps); + + expect(exitCode).toBe(0); + expect(deps.refreshQuotaCache).not.toHaveBeenCalled(); + const payload = emittedJson(deps); + expect(payload).toMatchObject({ + schemaVersion: 1, + generatedAt: NOW, + mode: "cached", + }); + expect(payload).not.toHaveProperty("accountCount"); + expect(payload).not.toHaveProperty("activeIndex"); + expect(payload.accounts).toEqual([ + { + index: 0, + label: expect.any(String), + enabled: true, + current: true, + quota: { + updatedAt: NOW - 60_000, + status: 200, + planType: "plus", + primary: { + usedPercent: 12.5, + windowMinutes: 300, + resetAtMs: NOW + 3_600_000, + }, + secondary: { + usedPercent: 63, + windowMinutes: 10_080, + resetAtMs: NOW + 86_400_000, + }, + }, + }, + { + index: 1, + label: expect.any(String), + enabled: false, + current: false, + quota: null, + }, + ]); + const serialized = JSON.stringify(payload); + expect(serialized).not.toContain("secret-access-token"); + expect(serialized).not.toContain("second-secret-token"); + expect(serialized).not.toContain("orphan"); + }); + + it("emits explicit nulls for missing optional provider fields", async () => { + const cache = quotaCache(); + cache.byAccountId["acct-1"] = { + updatedAt: NOW, + status: 200, + model: "gpt-5.6-codex", + primary: {}, + secondary: {}, + }; + const deps = createDeps(cache); + + expect(await runLimitsCommand(["-j"], deps)).toBe(0); + + const accounts = emittedJson(deps).accounts as Array>; + expect(accounts[0]?.quota).toEqual({ + updatedAt: NOW, + status: 200, + planType: null, + primary: { usedPercent: null, windowMinutes: null, resetAtMs: null }, + secondary: { usedPercent: null, windowMinutes: null, resetAtMs: null }, + }); + }); + + it("uses a safe unique-email fallback without exposing orphan cache entries", async () => { + const cache = quotaCache(); + cache.byAccountId = {}; + cache.byEmail["one@example.com"] = { + updatedAt: NOW, + status: 200, + model: "gpt-5.6-codex", + primary: { usedPercent: 9 }, + secondary: {}, + }; + const deps = createDeps(cache); + + expect(await runLimitsCommand(["--json"], deps)).toBe(0); + + const accounts = emittedJson(deps).accounts as Array>; + expect(accounts[0]?.quota).toMatchObject({ primary: { usedPercent: 9 } }); + }); + + it("refuses an ambiguous same-email cache fallback", async () => { + const cache = quotaCache(); + cache.byAccountId = {}; + cache.byEmail["shared@example.com"] = { + updatedAt: NOW, + status: 200, + model: "gpt-5.6-codex", + primary: { usedPercent: 9 }, + secondary: {}, + }; + const deps = createDeps(cache); + deps.loadAccounts.mockResolvedValueOnce( + accountStorageV3Fixture([ + storageAccountFixture({ accountId: "acct-a", email: "shared@example.com" }), + storageAccountFixture({ accountId: "acct-b", email: "shared@example.com" }), + ]), + ); + + expect(await runLimitsCommand(["--json"], deps)).toBe(0); + + const accounts = emittedJson(deps).accounts as Array>; + expect(accounts.map((account) => account.quota)).toEqual([null, null]); + }); + + it("age-gates an explicit refresh before emitting the refreshed cache", async () => { + const deps = createDeps(); + const refreshed = quotaCache(); + refreshed.byAccountId["acct-1"] = { + ...refreshed.byAccountId["acct-1"], + updatedAt: NOW, + primary: { usedPercent: 20, windowMinutes: 300 }, + }; + deps.refreshQuotaCache.mockResolvedValueOnce(refreshed); + + const exitCode = await runLimitsCommand(["--refresh", "--json"], deps); + + expect(exitCode).toBe(0); + expect(deps.refreshQuotaCache).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + 300_000, + ); + expect(deps.refreshQuotaCache.mock.invocationCallOrder[0]).toBeLessThan( + deps.getNow.mock.invocationCallOrder[0] ?? 0, + ); + const payload = emittedJson(deps); + expect(payload.mode).toBe("refresh"); + const accounts = payload.accounts as Array>; + expect(accounts[0]).toMatchObject({ + quota: { updatedAt: NOW, primary: { usedPercent: 20 } }, + }); + }); + + it("emits a stable empty snapshot when no accounts are configured", async () => { + const deps = createDeps(); + deps.loadAccounts.mockResolvedValueOnce(null); + + const exitCode = await runLimitsCommand(["--json"], deps); + + expect(exitCode).toBe(0); + expect(deps.loadQuotaCache).not.toHaveBeenCalled(); + expect(deps.refreshQuotaCache).not.toHaveBeenCalled(); + expect(emittedJson(deps)).toEqual({ + schemaVersion: 1, + generatedAt: NOW, + mode: "cached", + accounts: [], + }); + }); + + it("prints focused help without reading account storage", async () => { + const deps = createDeps(); + + const exitCode = await runLimitsCommand(["--help"], deps); + + expect(exitCode).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + "Usage: codex-multi-auth limits --json [--refresh]", + ); + expect(deps.loadAccounts).not.toHaveBeenCalled(); + }); + + it("requires the explicit JSON output flag", async () => { + const deps = createDeps(); + + const exitCode = await runLimitsCommand([], deps); + + expect(exitCode).toBe(1); + expect(deps.logError).toHaveBeenCalledWith( + "Usage: codex-multi-auth limits --json [--refresh]", + ); + expect(deps.logInfo).not.toHaveBeenCalled(); + }); + + it("rejects unsupported options without emitting a snapshot", async () => { + const deps = createDeps(); + + const exitCode = await runLimitsCommand(["--json", "--unknown"], deps); + + expect(exitCode).toBe(1); + expect(deps.logError).toHaveBeenCalledWith( + "Unknown limits option: --unknown", + ); + expect(deps.logInfo).not.toHaveBeenCalled(); + }); +}); From 9d8aae05e267fc19262b393113ebef86c1ee1356 Mon Sep 17 00:00:00 2001 From: dr-diffie Date: Sat, 5 Sep 2026 00:10:38 +0200 Subject: [PATCH 2/2] docs(cli): address limits contract review --- docs/reference/commands.md | 7 +++++++ lib/codex-manager/commands/limits.ts | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 239bc1955..5c86820a1 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -89,6 +89,8 @@ Prints a stable, machine-readable quota snapshot for local integrations: ```console codex-multi-auth limits --json codex-multi-auth limits --json --refresh +codex-multi-auth auth limits --json # supported namespaced alias +codex-multi-auth auth limits --json --refresh ``` The default command reads the local quota cache and performs no network @@ -679,6 +681,11 @@ failure. ## Upgrade Notes +- `codex-multi-auth limits` adds a machine-readable quota contract. It requires + `--json`, emits schema version 1, defaults to zero-network cached mode, and + accepts `--refresh` for the existing sequential five-minute age-gated refresh. + The namespaced `codex-multi-auth auth limits ...` form is an alias. No npm + scripts or storage migrations were added. - `codex-multi-auth login` remains browser-first by default. - `codex-multi-auth login --org ` binds the login to one ChatGPT workspace. - `codex-multi-auth login --device-auth` uses OpenAI Codex device-code login. It prints `https://auth.openai.com/codex/device` and a one-time code, then polls for completion without opening a browser or starting the local callback server. diff --git a/lib/codex-manager/commands/limits.ts b/lib/codex-manager/commands/limits.ts index 4f9a99426..1726f005d 100644 --- a/lib/codex-manager/commands/limits.ts +++ b/lib/codex-manager/commands/limits.ts @@ -28,6 +28,7 @@ interface ParsedLimitsOptions { help: boolean; } +/** Parse the intentionally small, JSON-only limits command surface. */ function parseLimitsOptions(args: string[]): | { ok: true; options: ParsedLimitsOptions } | { ok: false; message: string } { @@ -53,6 +54,7 @@ function parseLimitsOptions(args: string[]): return { ok: true, options }; } +/** Convert a cached quota window to the explicit-null public JSON contract. */ function publicWindow(window: QuotaCacheEntry["primary"]) { return { usedPercent: window.usedPercent ?? null, @@ -61,6 +63,7 @@ function publicWindow(window: QuotaCacheEntry["primary"]) { }; } +/** Remove internal probe metadata and stabilize optional quota fields. */ function publicQuotaEntry(entry: QuotaCacheEntry) { return { updatedAt: entry.updatedAt, @@ -71,6 +74,12 @@ function publicQuotaEntry(entry: QuotaCacheEntry) { }; } +/** + * Emit configured accounts joined to safe cached quota records. + * + * Cached mode performs no provider requests. Refresh mode delegates to the + * existing sequential, age-gated refresh path before serializing the snapshot. + */ export async function runLimitsCommand( args: string[], deps: LimitsCommandDeps,