codex-reset-credits 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/bin/codex-reset-credits.js +294 -0
- package/package.json +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 codex-reset-credits contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# codex-reset-credits
|
|
2
|
+
|
|
3
|
+
Small command-line utility that shows Codex banked reset credits, their expiry times, and current Codex usage windows.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
- Reads your local Codex auth file at `~/.codex/auth.json`.
|
|
8
|
+
- Uses the access token from that file to call `https://chatgpt.com/backend-api`.
|
|
9
|
+
- Calls these endpoints:
|
|
10
|
+
- `/wham/rate-limit-reset-credits`
|
|
11
|
+
- `/wham/usage`
|
|
12
|
+
- Prints available banked reset credits, the next expiry, per-credit expiry details, and usage reset windows.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Codex CLI/Desktop already signed in on the machine.
|
|
17
|
+
- A readable auth file at `~/.codex/auth.json`.
|
|
18
|
+
- Node.js 18+.
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
const BASE_URL = "https://chatgpt.com/backend-api";
|
|
8
|
+
const CREDITS_URL = `${BASE_URL}/wham/rate-limit-reset-credits`;
|
|
9
|
+
const USAGE_URL = `${BASE_URL}/wham/usage`;
|
|
10
|
+
|
|
11
|
+
const HELP = `Usage: codex-reset-credits [options]
|
|
12
|
+
|
|
13
|
+
Show Codex banked reset credits and current usage.
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--auth <path> Path to Codex auth JSON (default: ~/.codex/auth.json)
|
|
17
|
+
--no-usage Only show banked reset credits
|
|
18
|
+
--raw Print raw JSON responses
|
|
19
|
+
-h, --help Show this help message
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const args = {
|
|
24
|
+
authPath: "~/.codex/auth.json",
|
|
25
|
+
noUsage: false,
|
|
26
|
+
raw: false,
|
|
27
|
+
help: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
31
|
+
const arg = argv[i];
|
|
32
|
+
if (arg === "-h" || arg === "--help") {
|
|
33
|
+
args.help = true;
|
|
34
|
+
} else if (arg === "--no-usage") {
|
|
35
|
+
args.noUsage = true;
|
|
36
|
+
} else if (arg === "--raw") {
|
|
37
|
+
args.raw = true;
|
|
38
|
+
} else if (arg === "--auth") {
|
|
39
|
+
const next = argv[i + 1];
|
|
40
|
+
if (!next) {
|
|
41
|
+
throw new Error("--auth requires a path");
|
|
42
|
+
}
|
|
43
|
+
args.authPath = next;
|
|
44
|
+
i += 1;
|
|
45
|
+
} else {
|
|
46
|
+
throw new Error(`unknown option: ${arg}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return args;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function expandPath(path) {
|
|
54
|
+
if (path === "~") {
|
|
55
|
+
return homedir();
|
|
56
|
+
}
|
|
57
|
+
if (path.startsWith("~/")) {
|
|
58
|
+
return resolve(homedir(), path.slice(2));
|
|
59
|
+
}
|
|
60
|
+
return resolve(path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function assertAsciiUrls() {
|
|
64
|
+
for (const [name, url] of Object.entries({
|
|
65
|
+
BASE_URL,
|
|
66
|
+
CREDITS_URL,
|
|
67
|
+
USAGE_URL,
|
|
68
|
+
})) {
|
|
69
|
+
for (const char of url) {
|
|
70
|
+
if (char.charCodeAt(0) > 0x7f) {
|
|
71
|
+
throw new Error(`${name} contains non-ASCII characters`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function loadAuth(authPath) {
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = await readFile(authPath, "utf8");
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error && error.code === "ENOENT") {
|
|
83
|
+
throw new Error(`auth file not found: ${authPath}`);
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const auth = JSON.parse(raw);
|
|
89
|
+
const token = auth?.tokens?.access_token;
|
|
90
|
+
if (!token) {
|
|
91
|
+
throw new Error(`no access token found in ${authPath}`);
|
|
92
|
+
}
|
|
93
|
+
return auth;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function headers(auth) {
|
|
97
|
+
const result = {
|
|
98
|
+
Authorization: `Bearer ${auth.tokens.access_token}`,
|
|
99
|
+
"OpenAI-Beta": "codex-1",
|
|
100
|
+
originator: "Codex Desktop",
|
|
101
|
+
Accept: "application/json",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const accountId = auth?.tokens?.account_id;
|
|
105
|
+
if (accountId) {
|
|
106
|
+
result["ChatGPT-Account-ID"] = String(accountId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function getJson(url, auth) {
|
|
113
|
+
const response = await fetch(url, {
|
|
114
|
+
method: "GET",
|
|
115
|
+
headers: headers(auth),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const body = await response.text().catch(() => "");
|
|
120
|
+
const detail = body ? `: ${body.slice(0, 300)}` : "";
|
|
121
|
+
throw new Error(`${url} returned HTTP ${response.status}${detail}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return response.json();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseIso(value) {
|
|
128
|
+
if (!value) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const date = new Date(value);
|
|
132
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatDateTime(date) {
|
|
136
|
+
if (!date) {
|
|
137
|
+
return "n/a";
|
|
138
|
+
}
|
|
139
|
+
return date.toLocaleString(undefined, {
|
|
140
|
+
year: "numeric",
|
|
141
|
+
month: "2-digit",
|
|
142
|
+
day: "2-digit",
|
|
143
|
+
hour: "2-digit",
|
|
144
|
+
minute: "2-digit",
|
|
145
|
+
second: "2-digit",
|
|
146
|
+
timeZoneName: "short",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function formatLeft(date) {
|
|
151
|
+
if (!date) {
|
|
152
|
+
return "n/a";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ms = date.getTime() - Date.now();
|
|
156
|
+
if (ms <= 0) {
|
|
157
|
+
return "expired";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const totalMinutes = Math.floor(ms / 60000);
|
|
161
|
+
const days = Math.floor(totalMinutes / 1440);
|
|
162
|
+
const hours = Math.floor((totalMinutes % 1440) / 60);
|
|
163
|
+
const minutes = totalMinutes % 60;
|
|
164
|
+
|
|
165
|
+
if (days > 0) {
|
|
166
|
+
return `${days}d ${hours}h left`;
|
|
167
|
+
}
|
|
168
|
+
if (hours > 0) {
|
|
169
|
+
return `${hours}h ${minutes}m left`;
|
|
170
|
+
}
|
|
171
|
+
return `${minutes}m left`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function bar(percent, width = 24) {
|
|
175
|
+
if (percent === null || percent === undefined || Number.isNaN(percent)) {
|
|
176
|
+
return `[${"-".repeat(width)}]`;
|
|
177
|
+
}
|
|
178
|
+
const clamped = Math.max(0, Math.min(100, Number(percent)));
|
|
179
|
+
const filled = Math.round((width * clamped) / 100);
|
|
180
|
+
return `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeWindow(value) {
|
|
184
|
+
if (!value || typeof value !== "object") {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const usedPercent = value.used_percent ?? value.usedPercent;
|
|
189
|
+
const windowSeconds = value.limit_window_seconds ?? value.window_seconds;
|
|
190
|
+
let resetsAt = value.reset_at ?? value.resetsAt ?? value.resets_at;
|
|
191
|
+
const resetAfterSeconds = value.reset_after_seconds;
|
|
192
|
+
|
|
193
|
+
if (resetsAt === undefined && resetAfterSeconds !== undefined) {
|
|
194
|
+
resetsAt = Math.floor(Date.now() / 1000) + Number(resetAfterSeconds);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (usedPercent === undefined && windowSeconds === undefined && resetsAt === undefined) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
usedPercent: usedPercent === undefined ? null : Number(usedPercent),
|
|
203
|
+
windowSeconds: windowSeconds === undefined ? null : Number(windowSeconds),
|
|
204
|
+
resetsAt: resetsAt === undefined ? null : Number(resetsAt),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parseUsage(data) {
|
|
209
|
+
const rateLimit = data.rate_limit ?? data.rateLimits ?? {};
|
|
210
|
+
return {
|
|
211
|
+
primary: normalizeWindow(rateLimit.primary_window ?? rateLimit.primary),
|
|
212
|
+
secondary: normalizeWindow(rateLimit.secondary_window ?? rateLimit.secondary),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function renderCredits(data) {
|
|
217
|
+
const credits = Array.isArray(data.credits) ? data.credits : [];
|
|
218
|
+
const available = Number.parseInt(data.available_count ?? 0, 10);
|
|
219
|
+
|
|
220
|
+
console.log("Codex banked reset credits");
|
|
221
|
+
console.log(`Available: ${Number.isNaN(available) ? 0 : available}`);
|
|
222
|
+
|
|
223
|
+
if (credits.length === 0) {
|
|
224
|
+
console.log("No credit details returned.");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const sortedCredits = [...credits].sort((a, b) => {
|
|
229
|
+
const aExpiry = parseIso(a.expires_at)?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
|
230
|
+
const bExpiry = parseIso(b.expires_at)?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
|
231
|
+
return aExpiry - bExpiry;
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const nextExpiry = parseIso(sortedCredits[0].expires_at);
|
|
235
|
+
console.log(`Next expiry: ${formatDateTime(nextExpiry)} (${formatLeft(nextExpiry)})`);
|
|
236
|
+
console.log("");
|
|
237
|
+
|
|
238
|
+
sortedCredits.forEach((credit, index) => {
|
|
239
|
+
const granted = parseIso(credit.granted_at);
|
|
240
|
+
const expires = parseIso(credit.expires_at);
|
|
241
|
+
console.log(`${index + 1}. ${credit.status ?? "unknown"}`);
|
|
242
|
+
console.log(` granted: ${formatDateTime(granted)}`);
|
|
243
|
+
console.log(` expires: ${formatDateTime(expires)} (${formatLeft(expires)})`);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderUsage(data) {
|
|
248
|
+
const { primary, secondary } = parseUsage(data);
|
|
249
|
+
console.log("");
|
|
250
|
+
console.log("Codex usage");
|
|
251
|
+
|
|
252
|
+
for (const [label, window] of [
|
|
253
|
+
["5-hour", primary],
|
|
254
|
+
["7-day", secondary],
|
|
255
|
+
]) {
|
|
256
|
+
if (!window) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const used = window.usedPercent;
|
|
261
|
+
const remaining = used === null ? "n/a" : `${Math.max(0, Math.round(100 - used))}% left`;
|
|
262
|
+
const resetsDate = window.resetsAt === null ? null : new Date(window.resetsAt * 1000);
|
|
263
|
+
console.log(`${label.padEnd(7)} ${bar(used)} ${remaining}; resets ${formatLeft(resetsDate)}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function main() {
|
|
268
|
+
const args = parseArgs(process.argv.slice(2));
|
|
269
|
+
if (args.help) {
|
|
270
|
+
process.stdout.write(HELP);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
assertAsciiUrls();
|
|
275
|
+
const authPath = expandPath(args.authPath);
|
|
276
|
+
const auth = await loadAuth(authPath);
|
|
277
|
+
const credits = await getJson(CREDITS_URL, auth);
|
|
278
|
+
const usage = args.noUsage ? null : await getJson(USAGE_URL, auth);
|
|
279
|
+
|
|
280
|
+
if (args.raw) {
|
|
281
|
+
console.log(JSON.stringify({ credits, usage }, null, 2));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
renderCredits(credits);
|
|
286
|
+
if (usage) {
|
|
287
|
+
renderUsage(usage);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
main().catch((error) => {
|
|
292
|
+
console.error(`error: ${error.message}`);
|
|
293
|
+
process.exitCode = 1;
|
|
294
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codex-reset-credits",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Show Codex banked reset credits, expiry times, and usage windows.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codex-reset-credits": "bin/codex-reset-credits.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT"
|
|
18
|
+
}
|