Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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? |
Expand Down
1 change: 1 addition & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
40 changes: 39 additions & 1 deletion docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <org_id>`, `--preserve-selection`, `--account <index\|email\|account_id>` |
| `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 |

---

Expand Down Expand Up @@ -81,6 +82,38 @@ Turning `showQuotaDetails` off reduces the line to a bare `live session OK`.

---

## `codex-multi-auth limits`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Prints a stable, machine-readable quota snapshot for local integrations:

```console
codex-multi-auth limits --json
codex-multi-auth limits --json --refresh
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
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 |
Expand Down Expand Up @@ -149,7 +182,7 @@ Turning `showQuotaDetails` off reduces the line to a bare `live session OK`.
| `--org <org_id>` | 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 <index\|email\|account_id>` | 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 |
Expand Down Expand Up @@ -648,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 <org_id>` 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.
Expand Down
13 changes: 13 additions & 0 deletions lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -518,6 +520,17 @@ const CLI_COMMAND_HANDLERS: ReadonlyMap<string, CliCommandHandler> = new Map<
["login", (rest) => runAuthLogin(rest, { runForecast, createRepairCommandDeps })],
["list", runListOrStatusCommand],
["status", runListOrStatusCommand],
[
"limits",
(rest) =>
runLimitsCommand(rest, {
setStoragePath,
loadAccounts,
loadQuotaCache,
refreshQuotaCache: refreshQuotaCacheForMenu,
resolveActiveIndex,
}),
],
[
"switch",
(rest) =>
Expand Down
1 change: 1 addition & 0 deletions lib/codex-manager/account-manager-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const ACCOUNT_MANAGER_COMMANDS = new Set([
"login",
"list",
"status",
"limits",
"switch",
"unpin",
"workspace",
Expand Down
157 changes: 157 additions & 0 deletions lib/codex-manager/commands/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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<AccountStorageV3 | null>;
loadQuotaCache: () => Promise<QuotaCacheData>;
refreshQuotaCache: (
storage: AccountStorageV3,
cache: QuotaCacheData,
maxAgeMs: number,
) => Promise<QuotaCacheData>;
resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number;
getNow?: () => number;
logInfo?: (message: string) => void;
logError?: (message: string) => void;
}

interface ParsedLimitsOptions {
json: boolean;
refresh: boolean;
help: boolean;
}

/** Parse the intentionally small, JSON-only limits command surface. */
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 };
}

/** Convert a cached quota window to the explicit-null public JSON contract. */
function publicWindow(window: QuotaCacheEntry["primary"]) {
return {
usedPercent: window.usedPercent ?? null,
windowMinutes: window.windowMinutes ?? null,
resetAtMs: window.resetAtMs ?? null,
};
}

/** Remove internal probe metadata and stabilize optional quota fields. */
function publicQuotaEntry(entry: QuotaCacheEntry) {
return {
updatedAt: entry.updatedAt,
status: entry.status,
planType: entry.planType ?? null,
primary: publicWindow(entry.primary),
secondary: publicWindow(entry.secondary),
};
}

/**
* 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,
): Promise<number> {
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,
);
Comment on lines +120 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 concurrent refreshes can overwrite

--refresh adds another concurrent caller to a cache update that performs an unlocked cross-process read, modify, and replacement. overlapping cli or dashboard refreshes can probe the same accounts twice and allow an older snapshot to overwrite newer cache data. add cross-process coordination or freshness-aware merging, with a vitest that runs two refreshes concurrently.

Knowledge Base Used: Quota and refresh orchestration

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/codex-manager/commands/limits.ts
Line: 111-115

Comment:
**concurrent refreshes can overwrite**

`--refresh` adds another concurrent caller to a cache update that performs an unlocked cross-process read, modify, and replacement. overlapping cli or dashboard refreshes can probe the same accounts twice and allow an older snapshot to overwrite newer cache data. add cross-process coordination or freshness-aware merging, with a vitest that runs two refreshes concurrently.

**Knowledge Base Used:** [Quota and refresh orchestration](https://app.greptile.com/zeian/-/custom-context/knowledge-base/ndycode/codex-multi-auth/-/docs/quota-and-refresh-orchestration.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}

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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 raw emails enter json

formatAccountLabel includes the account's unredacted email, so limits --json can send pii to logs and integrations through the generic label field. use a redacted or explicitly public label instead. the vitest coverage checks that access tokens are excluded, but does not check email redaction.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/codex-manager/commands/limits.ts
Line: 128

Comment:
**raw emails enter json**

`formatAccountLabel` includes the account's unredacted email, so `limits --json` can send pii to logs and integrations through the generic `label` field. use a redacted or explicitly public label instead. the vitest coverage checks that access tokens are excluded, but does not check email redaction.

**Knowledge Base Used:**
- [CLI commands and operations](https://app.greptile.com/zeian/-/custom-context/knowledge-base/ndycode/codex-multi-auth/-/docs/cli-commands-and-operations.md)
- [CLI and interactive experience](https://app.greptile.com/zeian/-/custom-context/knowledge-base/ndycode/codex-multi-auth/-/docs/cli-and-interactive-experience.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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;
}
1 change: 1 addition & 0 deletions lib/codex-manager/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function printUsage(): void {
" codex-multi-auth login [--device-auth|--manual|--no-browser] [--org <org_id>] [--preserve-selection] [--account <index|email|account_id>]",
" 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]",
Expand Down
1 change: 1 addition & 0 deletions scripts/codex-routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const AUTH_SUBCOMMANDS = new Set([
"login",
"list",
"status",
"limits",
"switch",
"unpin",
"workspace",
Expand Down
51 changes: 51 additions & 0 deletions test/codex-manager-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 2 additions & 2 deletions test/codex-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion test/documentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) |",
Expand Down
Loading