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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,23 @@ Examples:
/codex:cancel task-abc123
```

### `/codex:usage`

Shows your current Codex rate limits and account plan.

Examples:

```bash
/codex:usage
/codex:usage --json
```

Use it to:

- check how much of your 5h or weekly limit remains
- see your current plan type
- check code review limits

### `/codex:setup`

Checks whether Codex is installed and authenticated.
Expand Down
11 changes: 11 additions & 0 deletions plugins/codex/commands/usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
description: Show Codex rate limits and account plan for the current account
argument-hint: '[--json]'
disable-model-invocation: true
allowed-tools: Bash(node:*)
---

!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" usage $ARGUMENTS`

Present the output as a compact status display.
If there is an auth error, tell the user to run `!codex login`.
59 changes: 57 additions & 2 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ import {
renderJobStatusReport,
renderSetupReport,
renderStatusReport,
renderTaskResult
renderTaskResult,
renderUsageReport
} from "./lib/render.mjs";

const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
Expand All @@ -80,7 +81,8 @@ function printUsage() {
" node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs result [job-id] [--json]",
" node scripts/codex-companion.mjs cancel [job-id] [--json]"
" node scripts/codex-companion.mjs cancel [job-id] [--json]",
" node scripts/codex-companion.mjs usage [--json]"
].join("\n")
);
}
Expand Down Expand Up @@ -958,6 +960,56 @@ async function handleCancel(argv) {
outputCommandResult(payload, renderCancelReport(nextJob), options.json);
}

async function handleUsage(argv) {
const { options } = parseCommandInput(argv, {
valueOptions: ["cwd"],
booleanOptions: ["json"]
});

const cwd = resolveCommandCwd(options);
ensureCodexReady(cwd);

const codexHome = process.env.CODEX_HOME || path.join(process.env.HOME || process.env.USERPROFILE || "", ".codex");
const authFile = path.join(codexHome, "auth.json");

if (!fs.existsSync(authFile)) {
throw new Error("Codex auth file not found. Run `!codex login` and retry.");
}

let auth;
try {
auth = JSON.parse(fs.readFileSync(authFile, "utf8"));
} catch {
throw new Error("Codex auth file is corrupted. Delete ~/.codex/auth.json and run `!codex login`.");
}
const tokens = auth.tokens ?? {};
const accessToken = tokens.access_token ?? "";
const accountId = tokens.account_id ?? "";

if (!accessToken) {
throw new Error("No access token found. Run `!codex login` and retry.");
}

const url = "https://chatgpt.com/backend-api/wham/usage";
const res = await fetch(url, {
headers: {
"Authorization": `Bearer ${accessToken}`,
"ChatGPT-Account-Id": accountId,
"User-Agent": "codex-cli"
}
});

if (!res.ok) {
const hint = res.status === 401 || res.status === 403
? " Your token may have expired. Run `!codex login` and retry."
: "";
throw new Error(`Usage API request failed: ${res.status} ${res.statusText}.${hint}`);
}

const payload = await res.json();
outputCommandResult(payload, renderUsageReport(payload), options.json);
}

async function main() {
const [subcommand, ...argv] = process.argv.slice(2);
if (!subcommand || subcommand === "help" || subcommand === "--help") {
Expand Down Expand Up @@ -995,6 +1047,9 @@ async function main() {
case "cancel":
await handleCancel(argv);
break;
case "usage":
await handleUsage(argv);
break;
default:
throw new Error(`Unknown subcommand: ${subcommand}`);
}
Expand Down
85 changes: 85 additions & 0 deletions plugins/codex/scripts/lib/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -463,3 +463,88 @@ export function renderCancelReport(job) {

return `${lines.join("\n").trimEnd()}\n`;
}

function formatUsageWindow(window, label) {
if (!window) {
return null;
}
const used = window.used_percent ?? 0;
const remaining = Math.max(0, Math.round(100 - used));
const secs = window.limit_window_seconds;
const resetAt = window.reset_at;

let windowLabel = "unknown";
if (secs >= 604800) {
windowLabel = "Weekly";
} else if (secs >= 86400) {
windowLabel = `${Math.floor(secs / 86400)}d`;
} else if (secs >= 3600) {
windowLabel = `${Math.floor(secs / 3600)}h`;
} else {
windowLabel = `${Math.floor(secs / 60)}m`;
}

let resetStr = "unknown";
if (resetAt) {
const resetDate = new Date(resetAt * 1000);
resetStr = resetDate.toLocaleString("en-GB", {
hour: "2-digit",
minute: "2-digit",
day: "numeric",
month: "short"
});
}

return `- ${label}${windowLabel} limit: ${remaining}% left (resets ${resetStr})`;
}

export function renderUsageReport(data) {
const lines = [
"# Codex Usage",
""
];

const plan = data.plan_type ?? "unknown";
lines.push(`Plan: ${plan.charAt(0).toUpperCase() + plan.slice(1)}`);

const rl = data.rate_limit ?? {};
if (rl.allowed === false) {
lines.push("");
lines.push("Status: not allowed");
}

lines.push("");
lines.push("Limits:");

const primary = formatUsageWindow(rl.primary_window, "");
const secondary = formatUsageWindow(rl.secondary_window, "");
if (primary) {
lines.push(primary);
}
if (secondary) {
lines.push(secondary);
}
if (rl.limit_reached) {
lines.push("- WARNING: Rate limit reached!");
}

const cr = data.code_review_rate_limit ?? {};
const crPrimary = formatUsageWindow(cr.primary_window, "Code review ");
if (crPrimary) {
lines.push(crPrimary);
}

const credits = data.credits ?? {};
if (credits.unlimited) {
lines.push("- Credits: Unlimited");
} else if (credits.has_credits && credits.balance && credits.balance !== "0") {
const balance = Math.round(Number(credits.balance)) || credits.balance;
lines.push(`- Credits: ${balance}`);
}

if (data.spend_control?.reached) {
lines.push("- WARNING: Spend control limit reached!");
}

return `${lines.join("\n").trimEnd()}\n`;
}
3 changes: 2 additions & 1 deletion tests/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ test("continue is not exposed as a user-facing command", () => {
"result.md",
"review.md",
"setup.md",
"status.md"
"status.md",
"usage.md"
]);
});

Expand Down