Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
10 changes: 10 additions & 0 deletions plugins/codex/commands/usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
description: Show Codex rate limits and usage for your current plan
argument-hint: '[--json]'
disable-model-invocation: true
allowed-tools: Bash(node:*)
---

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

Present the command output to the user as-is. Do not summarize or condense it.
21 changes: 19 additions & 2 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { parseArgs, splitRawArgumentString } from "./lib/args.mjs";
import {
buildPersistentTaskThreadName,
DEFAULT_CONTINUE_PROMPT,
fetchCodexUsage,
findLatestTaskThread,
getCodexAvailability,
getCodexLoginStatus,
Expand Down Expand Up @@ -59,7 +60,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 +82,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 +961,17 @@ 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);
const report = await fetchCodexUsage(cwd);
outputResult(options.json ? report : renderUsageReport(report), options.json);
}

async function main() {
const [subcommand, ...argv] = process.argv.slice(2);
if (!subcommand || subcommand === "help" || subcommand === "--help") {
Expand Down Expand Up @@ -995,6 +1009,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
92 changes: 92 additions & 0 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
* onProgress: ProgressReporter | null
* }} TurnCaptureState
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readJsonFile } from "./fs.mjs";
import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
import { loadBrokerSession } from "./broker-lifecycle.mjs";
Expand Down Expand Up @@ -950,4 +953,93 @@ export function readOutputSchema(schemaPath) {
return readJsonFile(schemaPath);
}

const CODEX_USAGE_API_URL = "https://api.openai.com/v1/codex/usage";

function resolveCodexHome() {
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
}

function resolveCodexAuthPath() {
return path.join(resolveCodexHome(), "auth.json");
}

function readCodexAuth() {
const authPath = resolveCodexAuthPath();
if (!fs.existsSync(authPath)) {
return null;
}
Comment on lines +967 to +970

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve Codex auth from configured credential storage

fetchCodexUsage treats a missing auth.json file as unauthenticated, but Codex logins are not guaranteed to live at that path (e.g., keychain-backed auth in default auto mode, or relocated state via CODEX_HOME). In those setups users are genuinely logged in, yet /codex:usage will always return the “auth.json not found” error and never reach the API call. Please load auth from Codex’s configured credential store (or obtain a token via Codex itself) instead of hard-failing on file absence.

Useful? React with 👍 / 👎.

try {
return readJsonFile(authPath);
} catch {
return null;
}
}

function extractAccessToken(auth) {
if (!auth) {
return null;
}
const token = auth.tokens?.access_token ?? auth.OPENAI_API_KEY ?? null;
return typeof token === "string" && token.trim() ? token.trim() : null;
}

export async function fetchCodexUsage(cwd) {
const auth = readCodexAuth();
const token = extractAccessToken(auth);

if (!token) {
const loginStatus = getCodexLoginStatus(cwd ?? process.cwd());
if (loginStatus.loggedIn) {
return {
ok: false,
error: "Codex is authenticated via keychain or an external credential store, which `/codex:usage` cannot read directly yet. Check your usage at https://platform.openai.com/usage instead."
};
}
return {
ok: false,
error: "Codex is not authenticated. Run `!codex login` first."
};
}

try {
const response = await fetch(CODEX_USAGE_API_URL, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
}
});

if (!response.ok) {
const body = await response.text().catch(() => "");
return {
ok: false,
error: `Usage API returned ${response.status}${body ? `: ${body}` : ""}`
};
}

const data = await response.json();
return {
ok: true,
data,
planType: auth.tokens?.id_token ? decodePlanType(auth.tokens.id_token) : null
};
} catch (error) {
return {
ok: false,
error: `Failed to fetch usage: ${error.message}`
};
}
}

function decodePlanType(idToken) {
try {
const payload = JSON.parse(
Buffer.from(idToken.split(".")[1], "base64url").toString("utf8")
);
return payload["https://api.openai.com/auth"]?.chatgpt_plan_type ?? null;
} catch {
return null;
}
}

export { DEFAULT_CONTINUE_PROMPT, TASK_THREAD_PREFIX };
91 changes: 91 additions & 0 deletions plugins/codex/scripts/lib/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,97 @@ export function renderStoredJobResult(job, storedJob) {
return `${lines.join("\n").trimEnd()}\n`;
}

function formatPercent(value) {
if (typeof value !== "number" || Number.isNaN(value)) {
return "unknown";
}
return `${Math.round(value)}%`;
}

function formatResetTime(resetAt) {
if (!resetAt) {
return "";
}
try {
const date = new Date(resetAt);
return ` (resets ${date.toLocaleDateString("en-US", { day: "numeric", month: "short" })}, ${date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false })})`;
} catch {
return "";
}
}

function renderRateLimitWindow(label, window) {
if (!window) {
return null;
}
const usedPercent = typeof window.used_percent === "number" ? window.used_percent : null;
const remaining = usedPercent != null ? formatPercent(100 - usedPercent) : "unknown";
const reset = formatResetTime(window.reset_at);
return `- ${label}: ${remaining} left${reset}`;
}

export function renderUsageReport(report) {
if (!report.ok) {
return `# Codex Usage\n\nError: ${report.error}\n`;
}

const data = report.data ?? {};
const planType = report.planType ?? data.plan_type ?? "unknown";

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 Badge Prefer usage API plan over decoded ID token

renderUsageReport currently prioritizes report.planType over data.plan_type, but report.planType is populated from the local auth.json JWT claim in fetchCodexUsage. If local auth state is stale (for example after a plan change), /codex:usage will print an outdated plan even when the API response already includes the current plan. Use data.plan_type first when it exists, and fall back to the decoded token only when the API omits plan info.

Useful? React with 👍 / 👎.

const lines = [
"# Codex Usage",
"",
`Plan: ${planType}`
];

const rateLimit = data.rate_limit;
const codeReviewLimit = data.code_review_rate_limit;
const credits = data.credits;

const limitLines = [];

if (rateLimit) {
const primary = renderRateLimitWindow("Primary limit", rateLimit.primary_window);
if (primary) {
limitLines.push(primary);
}
const secondary = renderRateLimitWindow("Weekly limit", rateLimit.secondary_window);
if (secondary) {
limitLines.push(secondary);
}
}

if (codeReviewLimit) {
const reviewPrimary = renderRateLimitWindow("Code review limit", codeReviewLimit.primary_window);
if (reviewPrimary) {
limitLines.push(reviewPrimary);
}
const reviewSecondary = renderRateLimitWindow("Code review weekly limit", codeReviewLimit.secondary_window);
if (reviewSecondary) {
limitLines.push(reviewSecondary);
}
}

if (limitLines.length > 0) {
lines.push("", "Limits:");
lines.push(...limitLines);
}

if (credits) {
lines.push("");
if (credits.unlimited) {
lines.push("Credits: unlimited");
} else if (credits.has_credits && credits.balance != null) {
lines.push(`Credits: $${credits.balance.toFixed(2)} remaining`);

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 Badge Coerce credit balance before calling toFixed

renderUsageReport assumes credits.balance is a number, but the value comes from JSON and may be serialized as a string; in that case credits.balance.toFixed(2) throws and /codex:usage fails instead of rendering usage data. Please normalize with Number(...) (and validate it is finite) before formatting so the command remains resilient to API serialization differences.

Useful? React with 👍 / 👎.

} else if (credits.has_credits) {
lines.push("Credits: available");
} else {
lines.push("Credits: none");
}
}

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

export function renderCancelReport(job) {
const lines = [
"# Codex Cancel",
Expand Down
10 changes: 9 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 Expand Up @@ -195,6 +196,13 @@ test("hooks keep session-end cleanup and stop gating enabled", () => {
assert.match(source, /session-lifecycle-hook\.mjs/);
});

test("usage command is a deterministic entrypoint for rate limit display", () => {
const usage = read("commands/usage.md");
assert.match(usage, /disable-model-invocation:\s*true/);
assert.match(usage, /codex-companion\.mjs" usage \$ARGUMENTS/);
assert.match(usage, /rate limits/i);
});

test("setup command can offer Codex install and still points users to codex login", () => {
const setup = read("commands/setup.md");
const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8");
Expand Down
Loading