codex-resets 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 ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sourav Bhar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # codex-resets
2
+
3
+ Unofficial CLI for checking Codex Desktop reset-credit expirations from your terminal.
4
+
5
+ It reads the local Codex Desktop auth file at runtime, calls the same ChatGPT backend endpoint that the desktop app uses, and prints the available reset credits for the currently logged-in account.
6
+
7
+ ## Safety Model
8
+
9
+ - No token is stored in this repo or copied into the CLI.
10
+ - The CLI reads `~/.codex/auth.json` only on the machine where you run it.
11
+ - The Codex bearer token is sent only to allowlisted HTTPS OpenAI hosts: `chatgpt.com` or `chat.openai.com`.
12
+ - Account IDs, credit IDs, local auth paths, and source profile IDs are redacted by default. Pass `--show-identifiers` only when you intentionally need them.
13
+ - The raw request command is read-only and accepts only relative API paths.
14
+
15
+ This is an unofficial tool that relies on an internal/undocumented Codex Desktop endpoint. It may break if OpenAI changes the app or backend API.
16
+
17
+ ## Quick Start
18
+
19
+ Run it directly with `npx`:
20
+
21
+ ```bash
22
+ npx --yes codex-resets credits summary
23
+ ```
24
+
25
+ Other common commands:
26
+
27
+ ```bash
28
+ npx --yes codex-resets doctor
29
+ npx --yes codex-resets credits list
30
+ npx --yes codex-resets --json credits summary
31
+ ```
32
+
33
+ If the npm package is still propagating, you can run the GitHub version directly:
34
+
35
+ ```bash
36
+ npx --yes github:sourav-bhar/codex-resets credits summary
37
+ ```
38
+
39
+ ## Install
40
+
41
+ To install it globally:
42
+
43
+ ```bash
44
+ npm install -g codex-resets
45
+ codex-resets credits summary
46
+ ```
47
+
48
+ To install from a local checkout:
49
+
50
+ ```bash
51
+ git clone https://github.com/sourav-bhar/codex-resets.git
52
+ cd codex-resets
53
+ make install-local
54
+ ```
55
+
56
+ The local checkout installer copies `codex-resets` to `~/.local/bin`. Make sure `~/.local/bin` is on your `PATH`.
57
+
58
+ ## Usage
59
+
60
+ ```bash
61
+ codex-resets doctor
62
+ codex-resets credits list
63
+ codex-resets credits summary
64
+ codex-resets --json credits summary
65
+ ```
66
+
67
+ To intentionally include local/account identifiers:
68
+
69
+ ```bash
70
+ codex-resets --show-identifiers --json credits list
71
+ ```
72
+
73
+ To use a non-default Codex auth file for one run:
74
+
75
+ ```bash
76
+ codex-resets --auth-file ~/path/to/auth.json credits list
77
+ ```
78
+
79
+ ## JSON Output
80
+
81
+ With `--json`, successful commands emit stable JSON to stdout. Errors also emit JSON and never include bearer tokens or cookies:
82
+
83
+ ```json
84
+ {
85
+ "ok": false,
86
+ "error": {
87
+ "message": "Codex auth file not found",
88
+ "code": "AUTH_FILE_MISSING"
89
+ }
90
+ }
91
+ ```
92
+
93
+ ## Periodic Checks On macOS
94
+
95
+ Generate a LaunchAgent plist:
96
+
97
+ ```bash
98
+ mkdir -p ~/Library/LaunchAgents ~/.codex/resets
99
+ CODEX_RESETS_EXECUTABLE="$(command -v codex-resets)" codex-resets schedule launchd-plist --interval-minutes 360 > ~/Library/LaunchAgents/com.codex-resets.check.plist
100
+ launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.codex-resets.check.plist
101
+ ```
102
+
103
+ The generated job writes JSON summaries to `~/.codex/resets/codex-resets.log` and errors to `~/.codex/resets/codex-resets.err`.
104
+
105
+ ## Raw Read-Only Escape Hatch
106
+
107
+ ```bash
108
+ codex-resets request get /wham/rate-limit-reset-credits
109
+ ```
110
+
111
+ Only `GET` is supported. The CLI deliberately does not include a command to consume or redeem a reset credit.
112
+
113
+ Do not paste raw request output into public issues or social posts. Raw endpoint responses can contain account-specific fields that are not part of the redacted high-level commands.
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ make test
119
+ npm run check
120
+ ```
121
+
122
+ The project has no runtime npm dependencies.
@@ -0,0 +1,842 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+
9
+ const DEFAULT_BASE_URL = "https://chatgpt.com/backend-api";
10
+ const DEFAULT_AUTH_FILE = path.join(os.homedir(), ".codex", "auth.json");
11
+ const DEFAULT_LANGUAGE = "en";
12
+ const DEFAULT_ORIGINATOR = "Codex Desktop";
13
+ const ALLOWED_BASE_HOSTS = new Set(["chatgpt.com", "chat.openai.com"]);
14
+ const MAX_AUTH_FILE_BYTES = 1024 * 1024;
15
+ const MAX_RESPONSE_BYTES = 1024 * 1024;
16
+ const REQUEST_TIMEOUT_MS = 15_000;
17
+
18
+ const HELP = `Usage: codex-resets [global options] <command>
19
+
20
+ Global options:
21
+ --json Emit machine-readable JSON.
22
+ --auth-file <path> Codex auth file to read. Defaults to ~/.codex/auth.json.
23
+ --base-url <url> OpenAI backend API URL. Host must be chatgpt.com or chat.openai.com.
24
+ --tz <iana-zone> Display timezone. Defaults to the system timezone.
25
+ --show-identifiers Include account ids, credit ids, source profile ids, and local auth paths.
26
+ -h, --help Show this help.
27
+
28
+ Commands:
29
+ doctor Verify auth file, token, and endpoint reachability.
30
+ account current Show the currently configured Codex account id.
31
+ credits list [--all] List reset credits. Defaults to available credits.
32
+ credits summary Summarize reset credit counts and next expiration.
33
+ request get <path> Read-only raw GET request using current Codex auth.
34
+ schedule launchd-plist Print a LaunchAgent plist for periodic checks.
35
+
36
+ Examples:
37
+ codex-resets credits list
38
+ codex-resets --json credits summary
39
+ codex-resets schedule launchd-plist --interval-minutes 360
40
+ `;
41
+
42
+ class CliError extends Error {
43
+ constructor(message, code, details = {}) {
44
+ super(message);
45
+ this.name = "CliError";
46
+ this.code = code;
47
+ this.details = details;
48
+ }
49
+ }
50
+
51
+ class ApiError extends CliError {
52
+ constructor(message, status, details = {}) {
53
+ super(message, "API_ERROR", details);
54
+ this.status = status;
55
+ }
56
+ }
57
+
58
+ function defaultTimeZone() {
59
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
60
+ }
61
+
62
+ function expandHome(inputPath) {
63
+ if (!inputPath) return inputPath;
64
+ if (inputPath === "~") return os.homedir();
65
+ if (inputPath.startsWith("~/"))
66
+ return path.join(os.homedir(), inputPath.slice(2));
67
+ return inputPath;
68
+ }
69
+
70
+ function parseArgs(argv) {
71
+ const options = {
72
+ json: false,
73
+ authFile: process.env.CODEX_AUTH_FILE || DEFAULT_AUTH_FILE,
74
+ baseUrl: process.env.CODEX_RESETS_BASE_URL || DEFAULT_BASE_URL,
75
+ timeZone: process.env.CODEX_RESETS_TZ || defaultTimeZone(),
76
+ showIdentifiers: false,
77
+ };
78
+ const args = [];
79
+
80
+ for (let i = 0; i < argv.length; i += 1) {
81
+ const arg = argv[i];
82
+ if (arg === "--json") {
83
+ options.json = true;
84
+ } else if (arg === "--auth-file") {
85
+ i += 1;
86
+ if (!argv[i])
87
+ throw new CliError("--auth-file requires a path", "BAD_ARGS");
88
+ options.authFile = expandHome(argv[i]);
89
+ } else if (arg === "--base-url") {
90
+ i += 1;
91
+ if (!argv[i]) throw new CliError("--base-url requires a URL", "BAD_ARGS");
92
+ options.baseUrl = argv[i];
93
+ } else if (arg === "--tz") {
94
+ i += 1;
95
+ if (!argv[i])
96
+ throw new CliError("--tz requires an IANA timezone", "BAD_ARGS");
97
+ assertTimeZone(argv[i]);
98
+ options.timeZone = argv[i];
99
+ } else if (arg === "--show-identifiers") {
100
+ options.showIdentifiers = true;
101
+ } else if (arg === "-h" || arg === "--help") {
102
+ options.help = true;
103
+ } else {
104
+ args.push(arg);
105
+ }
106
+ }
107
+
108
+ options.authFile = expandHome(options.authFile);
109
+ options.baseUrl = normalizeBaseUrl(options.baseUrl);
110
+ return { options, args };
111
+ }
112
+
113
+ function normalizeBaseUrl(value) {
114
+ let url;
115
+ try {
116
+ url = new URL(value);
117
+ } catch {
118
+ throw new CliError("Invalid base URL", "BAD_BASE_URL");
119
+ }
120
+
121
+ if (url.protocol !== "https:") {
122
+ throw new CliError("Base URL must use https", "BAD_BASE_URL");
123
+ }
124
+ if (!ALLOWED_BASE_HOSTS.has(url.hostname)) {
125
+ throw new CliError(
126
+ `Base URL host is not allowed: ${url.hostname}`,
127
+ "BAD_BASE_URL",
128
+ );
129
+ }
130
+ if (url.username || url.password) {
131
+ throw new CliError("Base URL must not include credentials", "BAD_BASE_URL");
132
+ }
133
+
134
+ url.hash = "";
135
+ url.search = "";
136
+ url.pathname = url.pathname.replace(/\/+$/, "") || "/backend-api";
137
+ if (!url.pathname.endsWith("/backend-api")) {
138
+ throw new CliError(
139
+ "Base URL path must end with /backend-api",
140
+ "BAD_BASE_URL",
141
+ );
142
+ }
143
+ return url.toString().replace(/\/+$/, "");
144
+ }
145
+
146
+ function assertTimeZone(timeZone) {
147
+ try {
148
+ new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
149
+ } catch {
150
+ throw new CliError(`Invalid timezone: ${timeZone}`, "BAD_TIMEZONE");
151
+ }
152
+ }
153
+
154
+ function decodeJwt(token) {
155
+ if (!token || typeof token !== "string") return null;
156
+ const parts = token.split(".");
157
+ if (parts.length < 2) return null;
158
+ try {
159
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
160
+ const padded = payload.padEnd(
161
+ payload.length + ((4 - (payload.length % 4)) % 4),
162
+ "=",
163
+ );
164
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ function readAuth(authFile) {
171
+ let fd;
172
+ let raw;
173
+ let stats;
174
+ try {
175
+ const openFlags =
176
+ fs.constants.O_RDONLY |
177
+ fs.constants.O_NONBLOCK |
178
+ (fs.constants.O_NOFOLLOW || 0);
179
+ fd = fs.openSync(authFile, openFlags);
180
+ stats = fs.fstatSync(fd);
181
+ } catch (error) {
182
+ if (fd !== undefined) {
183
+ try {
184
+ fs.closeSync(fd);
185
+ } catch {
186
+ // Ignore close failures while reporting the original auth-file error.
187
+ }
188
+ }
189
+ if (error && error.code === "ENOENT") {
190
+ throw new CliError("Codex auth file not found", "AUTH_FILE_MISSING", {
191
+ authFile,
192
+ });
193
+ }
194
+ // Use only the syscall code, never error.message: Node fs errors embed the
195
+ // absolute auth path (revealing the home dir / username) in their message,
196
+ // and that message is surfaced unredacted via errorToJson and human output.
197
+ const accessCode = error && error.code ? error.code : "unknown error";
198
+ throw new CliError(
199
+ `Could not access Codex auth file (${accessCode})`,
200
+ "AUTH_FILE_INVALID",
201
+ { authFile },
202
+ );
203
+ }
204
+
205
+ try {
206
+ if (!stats.isFile()) {
207
+ throw new CliError(
208
+ "Codex auth path is not a regular file",
209
+ "AUTH_FILE_NOT_REGULAR",
210
+ { authFile },
211
+ );
212
+ }
213
+ if (stats.size > MAX_AUTH_FILE_BYTES) {
214
+ throw new CliError(
215
+ `Codex auth file is larger than ${MAX_AUTH_FILE_BYTES} bytes`,
216
+ "AUTH_FILE_TOO_LARGE",
217
+ { authFile },
218
+ );
219
+ }
220
+ raw = fs.readFileSync(fd, "utf8");
221
+ } finally {
222
+ if (fd !== undefined) fs.closeSync(fd);
223
+ }
224
+
225
+ let auth;
226
+ try {
227
+ auth = JSON.parse(raw);
228
+ } catch (error) {
229
+ throw new CliError(
230
+ `Could not parse Codex auth file: ${error.message}`,
231
+ "AUTH_FILE_INVALID",
232
+ { authFile },
233
+ );
234
+ }
235
+
236
+ const accessToken = auth?.tokens?.access_token;
237
+ if (!accessToken || typeof accessToken !== "string") {
238
+ throw new CliError(
239
+ "Codex auth file does not contain tokens.access_token",
240
+ "ACCESS_TOKEN_MISSING",
241
+ { authFile },
242
+ );
243
+ }
244
+
245
+ const tokenClaims = decodeJwt(accessToken);
246
+ const idTokenClaims = decodeJwt(auth?.tokens?.id_token);
247
+ return {
248
+ authFile,
249
+ accessToken,
250
+ accountId:
251
+ auth?.tokens?.account_id ||
252
+ tokenClaims?.https?.account_id ||
253
+ tokenClaims?.account_id ||
254
+ null,
255
+ tokenClaims,
256
+ idTokenClaims,
257
+ lastRefresh: auth?.last_refresh || null,
258
+ };
259
+ }
260
+
261
+ function tokenInfo(auth) {
262
+ const exp = auth?.tokenClaims?.exp;
263
+ const expiresAt =
264
+ typeof exp === "number" ? new Date(exp * 1000).toISOString() : null;
265
+ const nowSeconds = Math.floor(Date.now() / 1000);
266
+ return {
267
+ expires_at: expiresAt,
268
+ expired: typeof exp === "number" ? exp <= nowSeconds : null,
269
+ seconds_until_expiry: typeof exp === "number" ? exp - nowSeconds : null,
270
+ last_refresh: auth.lastRefresh,
271
+ };
272
+ }
273
+
274
+ function endpointPath(inputPath) {
275
+ if (!inputPath) throw new CliError("Request path is required", "BAD_ARGS");
276
+ if (/^https?:\/\//i.test(inputPath)) {
277
+ throw new CliError(
278
+ "Request path must be relative, such as /wham/rate-limit-reset-credits",
279
+ "BAD_ARGS",
280
+ );
281
+ }
282
+ return inputPath.startsWith("/") ? inputPath : `/${inputPath}`;
283
+ }
284
+
285
+ async function requestJson(auth, options, method, inputPath) {
286
+ const pathPart = endpointPath(inputPath);
287
+ const url = `${options.baseUrl}${pathPart}`;
288
+ const response = await fetch(url, {
289
+ method,
290
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
291
+ headers: {
292
+ Authorization: `Bearer ${auth.accessToken}`,
293
+ "OAI-Language": DEFAULT_LANGUAGE,
294
+ originator: DEFAULT_ORIGINATOR,
295
+ },
296
+ });
297
+ const text = await readResponseText(response);
298
+ const body = parseMaybeJson(text);
299
+
300
+ if (!response.ok) {
301
+ throw new ApiError(
302
+ `API request failed with ${response.status} ${response.statusText}`,
303
+ response.status,
304
+ {
305
+ path: pathPart,
306
+ body: redactBodyForError(body, auth.accessToken),
307
+ },
308
+ );
309
+ }
310
+
311
+ return body;
312
+ }
313
+
314
+ function parseMaybeJson(text) {
315
+ if (!text) return null;
316
+ try {
317
+ return JSON.parse(text);
318
+ } catch {
319
+ return text;
320
+ }
321
+ }
322
+
323
+ async function readResponseText(response, maxBytes = MAX_RESPONSE_BYTES) {
324
+ const reader = response.body?.getReader?.();
325
+ if (!reader) {
326
+ const text = await response.text();
327
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
328
+ throw new CliError(
329
+ `Response exceeded ${maxBytes} bytes`,
330
+ "RESPONSE_TOO_LARGE",
331
+ );
332
+ }
333
+ return text;
334
+ }
335
+
336
+ const chunks = [];
337
+ let received = 0;
338
+ while (true) {
339
+ const { done, value } = await reader.read();
340
+ if (done) break;
341
+ received += value.byteLength;
342
+ if (received > maxBytes) {
343
+ await reader.cancel();
344
+ throw new CliError(
345
+ `Response exceeded ${maxBytes} bytes`,
346
+ "RESPONSE_TOO_LARGE",
347
+ );
348
+ }
349
+ chunks.push(Buffer.from(value));
350
+ }
351
+
352
+ return Buffer.concat(chunks).toString("utf8");
353
+ }
354
+
355
+ function redactSensitiveString(value, knownSecret = "") {
356
+ let redacted = String(value);
357
+ if (knownSecret) {
358
+ redacted = redacted.split(knownSecret).join("[redacted]");
359
+ }
360
+ return redacted
361
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
362
+ .replace(
363
+ /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
364
+ "[redacted-jwt]",
365
+ );
366
+ }
367
+
368
+ function redactBodyForError(body, knownSecret = "") {
369
+ if (typeof body === "string")
370
+ return redactSensitiveString(body, knownSecret).slice(0, 500);
371
+ if (!body || typeof body !== "object") return body;
372
+ return JSON.parse(
373
+ JSON.stringify(body, (key, value) => {
374
+ if (/token|authorization|cookie|secret/i.test(key)) return "[redacted]";
375
+ if (typeof value === "string")
376
+ return redactSensitiveString(value, knownSecret);
377
+ return value;
378
+ }),
379
+ );
380
+ }
381
+
382
+ async function fetchCredits(auth, options) {
383
+ return requestJson(auth, options, "GET", "/wham/rate-limit-reset-credits");
384
+ }
385
+
386
+ function daysUntil(iso) {
387
+ if (!iso) return null;
388
+ const ms = new Date(iso).getTime() - Date.now();
389
+ return Math.round((ms / 86400000) * 10) / 10;
390
+ }
391
+
392
+ function formatDate(iso, timeZone) {
393
+ if (!iso) return null;
394
+ return new Intl.DateTimeFormat("en-US", {
395
+ timeZone,
396
+ year: "numeric",
397
+ month: "long",
398
+ day: "numeric",
399
+ hour: "numeric",
400
+ minute: "2-digit",
401
+ second: "2-digit",
402
+ timeZoneName: "short",
403
+ }).format(new Date(iso));
404
+ }
405
+
406
+ function publicCredit(credit, timeZone, options = {}) {
407
+ const result = {
408
+ reset_type: credit.reset_type,
409
+ status: credit.status,
410
+ title: credit.title,
411
+ granted_at: credit.granted_at || null,
412
+ expires_at: credit.expires_at || null,
413
+ expires_local: formatDate(credit.expires_at, timeZone),
414
+ expires_utc: formatDate(credit.expires_at, "UTC"),
415
+ days_until_expiry: daysUntil(credit.expires_at),
416
+ };
417
+ if (options.showIdentifiers) {
418
+ result.id = credit.id;
419
+ result.source = credit.profile_user_id || null;
420
+ }
421
+ return result;
422
+ }
423
+
424
+ function normalizeCredits(payload, timeZone, includeAll = false, options = {}) {
425
+ const credits = Array.isArray(payload?.credits) ? payload.credits : [];
426
+ return credits
427
+ .filter((credit) => includeAll || credit.status === "available")
428
+ .map((credit) => publicCredit(credit, timeZone, options))
429
+ .sort((a, b) => {
430
+ if (!a.expires_at && !b.expires_at) return 0;
431
+ if (!a.expires_at) return 1;
432
+ if (!b.expires_at) return -1;
433
+ return (
434
+ new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime()
435
+ );
436
+ });
437
+ }
438
+
439
+ function summarizePayload(payload, timeZone, options = {}) {
440
+ const credits = Array.isArray(payload?.credits) ? payload.credits : [];
441
+ const countsByStatus = credits.reduce((acc, credit) => {
442
+ const status = credit.status || "unknown";
443
+ acc[status] = (acc[status] || 0) + 1;
444
+ return acc;
445
+ }, {});
446
+ const available = normalizeCredits(payload, timeZone, false, options);
447
+ return {
448
+ available_count: Number.isFinite(payload?.available_count)
449
+ ? payload.available_count
450
+ : available.length,
451
+ total_earned_count: Number.isFinite(payload?.total_earned_count)
452
+ ? payload.total_earned_count
453
+ : null,
454
+ counts_by_status: countsByStatus,
455
+ next_expiring_credit: available[0] || null,
456
+ };
457
+ }
458
+
459
+ function printJson(value) {
460
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
461
+ }
462
+
463
+ function printHumanCredits(credits, timeZone) {
464
+ if (credits.length === 0) {
465
+ console.log("No matching reset credits found.");
466
+ return;
467
+ }
468
+ console.log(`Reset credits (${timeZone}):`);
469
+ credits.forEach((credit, index) => {
470
+ const source = credit.source ? ` from ${credit.source}` : "";
471
+ console.log(
472
+ `${index + 1}. ${credit.title || credit.reset_type || "Reset credit"} (${credit.status})${source}`,
473
+ );
474
+ console.log(` Expires: ${credit.expires_local}`);
475
+ console.log(` UTC: ${credit.expires_utc}`);
476
+ });
477
+ }
478
+
479
+ function printHumanSummary(summary, timeZone) {
480
+ console.log(`Available reset credits: ${summary.available_count}`);
481
+ if (summary.total_earned_count !== null)
482
+ console.log(`Total earned count: ${summary.total_earned_count}`);
483
+ console.log(`Counts by status: ${JSON.stringify(summary.counts_by_status)}`);
484
+ if (summary.next_expiring_credit) {
485
+ console.log(
486
+ `Next expiration (${timeZone}): ${summary.next_expiring_credit.expires_local}`,
487
+ );
488
+ }
489
+ }
490
+
491
+ function redactPathForOutput(filePath) {
492
+ if (!filePath) return filePath;
493
+ if (filePath === DEFAULT_AUTH_FILE) return "~/.codex/auth.json";
494
+ const home = os.homedir();
495
+ if (filePath === home) return "~";
496
+ if (filePath.startsWith(`${home}${path.sep}`))
497
+ return `~${filePath.slice(home.length)}`;
498
+ return filePath;
499
+ }
500
+
501
+ function authFileForOutput(filePath, options) {
502
+ if (options.showIdentifiers) return filePath;
503
+ if (filePath === DEFAULT_AUTH_FILE) return "~/.codex/auth.json";
504
+ return "[redacted auth file path]";
505
+ }
506
+
507
+ function authReport(auth, options) {
508
+ return {
509
+ source: "codex-default-auth-file",
510
+ auth_file: authFileForOutput(auth.authFile, options),
511
+ account_id: options.showIdentifiers ? auth.accountId : null,
512
+ access_token_available: Boolean(auth.accessToken),
513
+ token: tokenInfo(auth),
514
+ base_url: options.baseUrl,
515
+ };
516
+ }
517
+
518
+ async function commandDoctor(options) {
519
+ let auth;
520
+ try {
521
+ auth = readAuth(options.authFile);
522
+ } catch (error) {
523
+ const result = {
524
+ ok: false,
525
+ auth: {
526
+ source: "codex-default-auth-file",
527
+ auth_file: authFileForOutput(options.authFile, options),
528
+ access_token_available: false,
529
+ },
530
+ endpoint: { skipped: true },
531
+ error: errorToJson(error, options),
532
+ };
533
+ if (options.json) printJson(result);
534
+ else {
535
+ console.log("Codex auth: missing or invalid");
536
+ console.log(`Auth file: ${authFileForOutput(options.authFile, options)}`);
537
+ console.log(`Error: ${error.message}`);
538
+ }
539
+ process.exitCode = 1;
540
+ return;
541
+ }
542
+
543
+ try {
544
+ const payload = await fetchCredits(auth, options);
545
+ const summary = summarizePayload(payload, options.timeZone, options);
546
+ const result = {
547
+ ok: true,
548
+ auth: authReport(auth, options),
549
+ endpoint: {
550
+ reachable: true,
551
+ path: "/wham/rate-limit-reset-credits",
552
+ },
553
+ resets: summary,
554
+ };
555
+ if (options.json) printJson(result);
556
+ else {
557
+ console.log("Codex auth: OK");
558
+ console.log(`Auth file: ${authFileForOutput(auth.authFile, options)}`);
559
+ if (options.showIdentifiers)
560
+ console.log(`Account ID: ${auth.accountId || "unknown"}`);
561
+ console.log(
562
+ `Endpoint: OK (${options.baseUrl}/wham/rate-limit-reset-credits)`,
563
+ );
564
+ printHumanSummary(summary, options.timeZone);
565
+ }
566
+ } catch (error) {
567
+ const result = {
568
+ ok: false,
569
+ auth: authReport(auth, options),
570
+ endpoint: {
571
+ reachable: false,
572
+ path: "/wham/rate-limit-reset-credits",
573
+ },
574
+ error: errorToJson(error, options),
575
+ };
576
+ if (options.json) printJson(result);
577
+ else {
578
+ console.log("Codex auth: found");
579
+ if (options.showIdentifiers)
580
+ console.log(`Account ID: ${auth.accountId || "unknown"}`);
581
+ console.log(`Endpoint: failed: ${error.message}`);
582
+ }
583
+ process.exitCode = 1;
584
+ }
585
+ }
586
+
587
+ async function commandAccountCurrent(options) {
588
+ const auth = readAuth(options.authFile);
589
+ const result = {
590
+ ok: true,
591
+ account: {
592
+ account_id: options.showIdentifiers ? auth.accountId : null,
593
+ auth_file: authFileForOutput(auth.authFile, options),
594
+ token: tokenInfo(auth),
595
+ },
596
+ };
597
+ if (options.json) printJson(result);
598
+ else {
599
+ if (options.showIdentifiers)
600
+ console.log(`Account ID: ${auth.accountId || "unknown"}`);
601
+ console.log(`Auth file: ${authFileForOutput(auth.authFile, options)}`);
602
+ if (result.account.token.expires_at)
603
+ console.log(
604
+ `Token expires: ${formatDate(result.account.token.expires_at, options.timeZone)}`,
605
+ );
606
+ }
607
+ }
608
+
609
+ async function commandCredits(options, args) {
610
+ const subcommand = args[1];
611
+ const auth = readAuth(options.authFile);
612
+ const payload = await fetchCredits(auth, options);
613
+
614
+ if (subcommand === "list") {
615
+ const includeAll = args.includes("--all");
616
+ const credits = normalizeCredits(
617
+ payload,
618
+ options.timeZone,
619
+ includeAll,
620
+ options,
621
+ );
622
+ const result = {
623
+ ok: true,
624
+ account_id: options.showIdentifiers ? auth.accountId : null,
625
+ time_zone: options.timeZone,
626
+ available_count: Number.isFinite(payload?.available_count)
627
+ ? payload.available_count
628
+ : credits.filter((c) => c.status === "available").length,
629
+ total_earned_count: Number.isFinite(payload?.total_earned_count)
630
+ ? payload.total_earned_count
631
+ : null,
632
+ credits,
633
+ };
634
+ if (options.json) printJson(result);
635
+ else printHumanCredits(credits, options.timeZone);
636
+ return;
637
+ }
638
+
639
+ if (subcommand === "summary") {
640
+ const result = {
641
+ ok: true,
642
+ account_id: options.showIdentifiers ? auth.accountId : null,
643
+ time_zone: options.timeZone,
644
+ resets: summarizePayload(payload, options.timeZone, options),
645
+ };
646
+ if (options.json) printJson(result);
647
+ else printHumanSummary(result.resets, options.timeZone);
648
+ return;
649
+ }
650
+
651
+ throw new CliError("Expected credits list or credits summary", "BAD_ARGS");
652
+ }
653
+
654
+ async function commandRequest(options, args) {
655
+ const method = args[1];
656
+ const rawPath = args[2];
657
+ if (method !== "get")
658
+ throw new CliError("Only request get is supported", "BAD_ARGS");
659
+ const auth = readAuth(options.authFile);
660
+ const body = await requestJson(auth, options, "GET", rawPath);
661
+ if (options.json) {
662
+ printJson({ ok: true, path: endpointPath(rawPath), body });
663
+ } else if (typeof body === "string") {
664
+ console.log(body);
665
+ } else {
666
+ printJson(body);
667
+ }
668
+ }
669
+
670
+ function xmlEscape(value) {
671
+ return String(value)
672
+ .replace(/&/g, "&amp;")
673
+ .replace(/</g, "&lt;")
674
+ .replace(/>/g, "&gt;")
675
+ .replace(/"/g, "&quot;")
676
+ .replace(/'/g, "&apos;");
677
+ }
678
+
679
+ function parseFlagValue(args, flag, fallback) {
680
+ const index = args.indexOf(flag);
681
+ if (index === -1) return fallback;
682
+ if (!args[index + 1])
683
+ throw new CliError(`${flag} requires a value`, "BAD_ARGS");
684
+ return args[index + 1];
685
+ }
686
+
687
+ function validateLaunchdLabel(label) {
688
+ if (typeof label !== "string" || label.length < 1 || label.length > 128) {
689
+ throw new CliError("--label must be 1-128 characters", "BAD_ARGS");
690
+ }
691
+ if (
692
+ !/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(label) ||
693
+ label.includes("..")
694
+ ) {
695
+ throw new CliError(
696
+ "--label must be a reverse-DNS style label using letters, numbers, dots, or hyphens",
697
+ "BAD_ARGS",
698
+ );
699
+ }
700
+ return label;
701
+ }
702
+
703
+ function buildLaunchdPlist(args, executablePath) {
704
+ const intervalMinutes = Number(
705
+ parseFlagValue(args, "--interval-minutes", "360"),
706
+ );
707
+ if (!Number.isFinite(intervalMinutes) || intervalMinutes < 1) {
708
+ throw new CliError(
709
+ "--interval-minutes must be a positive number",
710
+ "BAD_ARGS",
711
+ );
712
+ }
713
+ const label = validateLaunchdLabel(
714
+ parseFlagValue(args, "--label", "com.codex-resets.check"),
715
+ );
716
+ const logDir = expandHome(
717
+ parseFlagValue(
718
+ args,
719
+ "--log-dir",
720
+ path.join(os.homedir(), ".codex", "resets"),
721
+ ),
722
+ );
723
+ const command = parseFlagValue(args, "--command", "credits summary")
724
+ .split(/\s+/)
725
+ .filter(Boolean);
726
+ const stdout = path.join(logDir, "codex-resets.log");
727
+ const stderr = path.join(logDir, "codex-resets.err");
728
+ const programArgs = path.isAbsolute(executablePath)
729
+ ? [executablePath, "--json", ...command]
730
+ : ["/usr/bin/env", executablePath, "--json", ...command];
731
+ const programArgsXml = programArgs
732
+ .map((arg) => ` <string>${xmlEscape(arg)}</string>`)
733
+ .join("\n");
734
+
735
+ return {
736
+ label,
737
+ interval_seconds: Math.round(intervalMinutes * 60),
738
+ stdout,
739
+ stderr,
740
+ plist_path: path.join(
741
+ os.homedir(),
742
+ "Library",
743
+ "LaunchAgents",
744
+ `${label}.plist`,
745
+ ),
746
+ plist: `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key>\n <string>${xmlEscape(label)}</string>\n <key>ProgramArguments</key>\n <array>\n${programArgsXml}\n </array>\n <key>StartInterval</key>\n <integer>${Math.round(intervalMinutes * 60)}</integer>\n <key>StandardOutPath</key>\n <string>${xmlEscape(stdout)}</string>\n <key>StandardErrorPath</key>\n <string>${xmlEscape(stderr)}</string>\n <key>RunAtLoad</key>\n <true/>\n</dict>\n</plist>\n`,
747
+ };
748
+ }
749
+
750
+ async function commandSchedule(options, args) {
751
+ if (args[1] !== "launchd-plist") {
752
+ throw new CliError("Expected schedule launchd-plist", "BAD_ARGS");
753
+ }
754
+ const scheduleArgs = args.slice(2);
755
+ const executablePath = parseFlagValue(
756
+ scheduleArgs,
757
+ "--executable",
758
+ process.env.CODEX_RESETS_EXECUTABLE || process.argv[1] || "codex-resets",
759
+ );
760
+ const schedule = buildLaunchdPlist(scheduleArgs, executablePath);
761
+ if (options.json) {
762
+ printJson({ ok: true, schedule });
763
+ } else {
764
+ console.log(schedule.plist);
765
+ }
766
+ }
767
+
768
+ function redactDetails(value, options) {
769
+ if (!value || typeof value !== "object") return value;
770
+ return JSON.parse(
771
+ JSON.stringify(value, (key, nestedValue) => {
772
+ if (/token|authorization|cookie|secret/i.test(key)) return "[redacted]";
773
+ if (key === "authFile" || key === "auth_file")
774
+ return authFileForOutput(nestedValue, options);
775
+ if (typeof nestedValue === "string")
776
+ return redactSensitiveString(nestedValue);
777
+ return nestedValue;
778
+ }),
779
+ );
780
+ }
781
+
782
+ function errorToJson(error, options = { showIdentifiers: false }) {
783
+ return {
784
+ message: error.message,
785
+ code: error.code || error.name || "ERROR",
786
+ status: error.status || undefined,
787
+ details: error.details ? redactDetails(error.details, options) : undefined,
788
+ };
789
+ }
790
+
791
+ async function main(argv = process.argv.slice(2)) {
792
+ const { options, args } = parseArgs(argv);
793
+ if (options.help || args.length === 0) {
794
+ if (options.json) printJson({ ok: true, help: HELP });
795
+ else process.stdout.write(HELP);
796
+ return;
797
+ }
798
+
799
+ const command = args[0];
800
+ if (command === "doctor") return commandDoctor(options);
801
+ if (command === "account" && args[1] === "current")
802
+ return commandAccountCurrent(options);
803
+ if (command === "credits") return commandCredits(options, args);
804
+ if (command === "request") return commandRequest(options, args);
805
+ if (command === "schedule") return commandSchedule(options, args);
806
+ throw new CliError(`Unknown command: ${command}`, "BAD_ARGS");
807
+ }
808
+
809
+ if (require.main === module) {
810
+ main().catch((error) => {
811
+ let options = { json: false };
812
+ try {
813
+ options = parseArgs(process.argv.slice(2)).options;
814
+ } catch {
815
+ // Keep fallback error reporting simple if argument parsing itself failed.
816
+ }
817
+ if (options.json)
818
+ printJson({ ok: false, error: errorToJson(error, options) });
819
+ else {
820
+ console.error(`Error: ${error.message}`);
821
+ if (error.code === "BAD_ARGS")
822
+ console.error("Run codex-resets --help for usage.");
823
+ }
824
+ process.exit(1);
825
+ });
826
+ }
827
+
828
+ module.exports = {
829
+ buildLaunchdPlist,
830
+ decodeJwt,
831
+ endpointPath,
832
+ errorToJson,
833
+ MAX_AUTH_FILE_BYTES,
834
+ MAX_RESPONSE_BYTES,
835
+ normalizeCredits,
836
+ normalizeBaseUrl,
837
+ parseArgs,
838
+ redactBodyForError,
839
+ readAuth,
840
+ readResponseText,
841
+ summarizePayload,
842
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "codex-resets",
3
+ "version": "0.1.0",
4
+ "description": "Check Codex Desktop reset credit expirations from the command line.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "codex-resets": "bin/codex-resets.js"
9
+ },
10
+ "scripts": {
11
+ "check": "node --check bin/codex-resets.js && node --test",
12
+ "prepublishOnly": "npm run check",
13
+ "test": "node --test"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/sourav-bhar/codex-resets.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/sourav-bhar/codex-resets/issues"
21
+ },
22
+ "homepage": "https://github.com/sourav-bhar/codex-resets#readme",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "keywords": [
27
+ "codex",
28
+ "openai",
29
+ "cli",
30
+ "rate-limit",
31
+ "developer-tools"
32
+ ],
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "README.md",
39
+ "LICENSE"
40
+ ]
41
+ }