diff --git a/README.md b/README.md index ddeeff4..866a0ba 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/plugins/codex/commands/usage.md b/plugins/codex/commands/usage.md new file mode 100644 index 0000000..d7cea35 --- /dev/null +++ b/plugins/codex/commands/usage.md @@ -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`. diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 201d1c7..7606249 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -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))); @@ -80,7 +81,8 @@ function printUsage() { " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [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") ); } @@ -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") { @@ -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}`); } diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec1852..440f2c6 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -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`; +} diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index ef5adb0..50a3622 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -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" ]); });