|
#!/usr/bin/env bash |
|
set -euo pipefail |
|
|
|
node --input-type=module <<'JS' |
|
import { homedir } from "node:os"; |
|
import { join } from "node:path"; |
|
import { pathToFileURL } from "node:url"; |
|
|
|
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; |
|
const RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; |
|
|
|
// Read the same local auth file used by Codex Desktop. |
|
const authPath = join(homedir(), ".codex", "auth.json"); |
|
const { default: auth } = await import(pathToFileURL(authPath).href, { |
|
with: { type: "json" }, |
|
}); |
|
|
|
const token = auth.tokens?.access_token; |
|
const account = auth.tokens?.account_id; |
|
|
|
if (!token || !account) { |
|
throw new Error("Missing tokens.access_token or tokens.account_id in ~/.codex/auth.json"); |
|
} |
|
|
|
const headers = { |
|
Authorization: `Bearer ${token}`, |
|
"ChatGPT-Account-ID": account, |
|
"OpenAI-Beta": "codex-1", |
|
originator: "Codex Desktop", |
|
}; |
|
|
|
const fetchJson = async (label, url) => { |
|
const response = await fetch(url, { headers }); |
|
const fallback = response.clone(); |
|
|
|
try { |
|
return { |
|
data: await response.json(), |
|
response, |
|
}; |
|
} catch { |
|
console.log(`${label}: HTTP ${response.status} ${response.statusText}`); |
|
console.log(await fallback.text()); |
|
process.exit(response.ok ? 0 : 1); |
|
} |
|
}; |
|
|
|
const formatDate = (value) => { |
|
if (!value) return "None"; |
|
|
|
return new Intl.DateTimeFormat(undefined, { |
|
dateStyle: "medium", |
|
timeStyle: "short", |
|
}).format(new Date(value)); |
|
}; |
|
|
|
const parseDate = (value) => { |
|
if (!value) return null; |
|
const date = new Date(value); |
|
return Number.isNaN(date.getTime()) ? null : date; |
|
}; |
|
|
|
const clampPercent = (value) => Math.max(0, Math.min(100, Math.round(Number(value) || 0))); |
|
|
|
const formatDuration = (seconds) => { |
|
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return "unknown"; |
|
|
|
const rounded = Math.max(0, Math.round(seconds)); |
|
const days = Math.floor(rounded / 86400); |
|
const hours = Math.floor((rounded % 86400) / 3600); |
|
const minutes = Math.floor((rounded % 3600) / 60); |
|
|
|
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`; |
|
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; |
|
if (minutes > 0) return `${minutes}m`; |
|
return `${rounded}s`; |
|
}; |
|
|
|
const formatUnixSeconds = (value) => { |
|
if (typeof value !== "number" || !Number.isFinite(value)) return "unknown"; |
|
return formatDate(new Date(value * 1000)); |
|
}; |
|
|
|
const limitName = (window, fallback) => { |
|
const seconds = window?.limit_window_seconds; |
|
|
|
if (seconds === 5 * 60 * 60) return "5-hour"; |
|
if (seconds === 7 * 24 * 60 * 60) return "Weekly"; |
|
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return fallback; |
|
|
|
if (seconds < 3600) return `${Math.round(seconds / 60)}m`; |
|
if (seconds < 86400) return `${Math.round(seconds / 3600)}-hour`; |
|
return `${Math.round(seconds / 86400)}-day`; |
|
}; |
|
|
|
const usageBar = (leftPercent) => { |
|
const width = 20; |
|
const filled = Math.round((leftPercent / 100) * width); |
|
return `[${"#".repeat(filled)}${".".repeat(width - filled)}]`; |
|
}; |
|
|
|
const daysUntil = (value) => { |
|
const date = parseDate(value); |
|
if (!date) return ""; |
|
|
|
const ms = date.getTime() - Date.now(); |
|
if (ms < 0) return "expired"; |
|
|
|
const days = Math.ceil(ms / (1000 * 60 * 60 * 24)); |
|
|
|
if (days === 0) return "expires today"; |
|
if (days === 1) return "expires tomorrow"; |
|
return `${days} days left`; |
|
}; |
|
|
|
const daysUntilShort = (value) => { |
|
const label = daysUntil(value); |
|
|
|
if (label === "expires today") return "today"; |
|
if (label === "expires tomorrow") return "tomorrow"; |
|
if (label === "expired") return label; |
|
return label.replace(" days left", "d"); |
|
}; |
|
|
|
const renderLimit = (label, window) => { |
|
if (!window) { |
|
console.log(`${label.padEnd(8)} unavailable`); |
|
return; |
|
} |
|
|
|
const usedPercent = clampPercent(window.used_percent); |
|
const leftPercent = 100 - usedPercent; |
|
const resetIn = formatDuration(window.reset_after_seconds); |
|
const resetAt = formatUnixSeconds(window.reset_at); |
|
|
|
console.log( |
|
`${label.padEnd(8)} ${String(leftPercent).padStart(3)}% left ${usageBar(leftPercent)} ${String(usedPercent).padStart(3)}% used resets in ${resetIn} (${resetAt})` |
|
); |
|
}; |
|
|
|
const renderResetCreditSummary = (resetCredits) => { |
|
if (!Array.isArray(resetCredits.credits)) { |
|
console.log(JSON.stringify(resetCredits, null, 2)); |
|
return []; |
|
} |
|
|
|
const credits = resetCredits.credits; |
|
const availableCredits = credits |
|
.filter((credit) => credit.status === "available") |
|
.sort((a, b) => { |
|
const aExpires = parseDate(a.expires_at)?.getTime() ?? Number.POSITIVE_INFINITY; |
|
const bExpires = parseDate(b.expires_at)?.getTime() ?? Number.POSITIVE_INFINITY; |
|
return aExpires - bExpires; |
|
}); |
|
const redeemedCredits = credits.filter((credit) => credit.redeemed_at || credit.status === "redeemed"); |
|
const availableCount = resetCredits.available_count ?? availableCredits.length; |
|
const soonestAvailable = availableCredits[0]; |
|
const expirySummary = availableCredits.map((credit) => daysUntilShort(credit.expires_at)).filter(Boolean); |
|
|
|
console.log(`Available now: ${availableCount}`); |
|
|
|
if (soonestAvailable) { |
|
console.log(`Use soonest: ${daysUntil(soonestAvailable.expires_at)} - ${formatDate(soonestAvailable.expires_at)}`); |
|
console.log(`Available expiries: ${expirySummary.join(", ")}`); |
|
} else { |
|
console.log("Use soonest: none available"); |
|
} |
|
|
|
console.log(`Other: ${redeemedCredits.length} redeemed, ${credits.length} total listed`); |
|
|
|
return [...availableCredits, ...credits.filter((credit) => credit.status !== "available")]; |
|
}; |
|
|
|
const shortCreditId = (credit) => |
|
credit.id?.replace(/^RateLimitResetCredit_/, "").slice(-8) ?? "unknown"; |
|
|
|
const renderCreditDetails = (credit, index) => { |
|
console.log(`${index + 1}. ${credit.title ?? "Rate limit reset"}`); |
|
console.log(` Status: ${credit.status ?? "unknown"}`); |
|
console.log(` Source: ${credit.profile_user_id ?? "unknown"}`); |
|
console.log(` Granted: ${formatDate(credit.granted_at)}`); |
|
console.log(` Expires: ${formatDate(credit.expires_at)} (${daysUntil(credit.expires_at)})`); |
|
console.log(` Redeemed: ${formatDate(credit.redeemed_at)}`); |
|
console.log(` Credit ID: ...${shortCreditId(credit)}`); |
|
|
|
if (credit.description) { |
|
console.log(` Note: ${credit.description}`); |
|
} |
|
|
|
console.log(""); |
|
}; |
|
|
|
const [usageResult, resetCreditsResult] = await Promise.all([ |
|
fetchJson("Usage", USAGE_URL), |
|
fetchJson("Reset credits", RESET_CREDITS_URL), |
|
]); |
|
|
|
const usage = usageResult.data; |
|
const rateLimit = usage.rate_limit; |
|
|
|
console.log("Codex status"); |
|
|
|
if (usage.plan_type) { |
|
console.log(`Plan: ${usage.plan_type}`); |
|
} |
|
|
|
console.log(""); |
|
console.log("Usage limits"); |
|
renderLimit(limitName(rateLimit?.primary_window, "Primary"), rateLimit?.primary_window); |
|
renderLimit(limitName(rateLimit?.secondary_window, "Secondary"), rateLimit?.secondary_window); |
|
|
|
console.log(""); |
|
console.log("Reset credits"); |
|
const orderedCredits = renderResetCreditSummary(resetCreditsResult.data); |
|
|
|
console.log(""); |
|
console.log( |
|
`Fetch: usage HTTP ${usageResult.response.status} ${usageResult.response.statusText}; reset credits HTTP ${resetCreditsResult.response.status} ${resetCreditsResult.response.statusText}` |
|
); |
|
|
|
if (orderedCredits.length > 0) { |
|
console.log(""); |
|
console.log("Reset credit details"); |
|
console.log(""); |
|
orderedCredits.forEach(renderCreditDetails); |
|
} |
|
|
|
if (!usageResult.response.ok || !resetCreditsResult.response.ok) { |
|
process.exit(1); |
|
} |
|
JS |