Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ffaabba
wip: reset credits detail view in list --live
Loongphy Oct 16, 2026
0c1503f
wip: direct use (redeem) for the selected reset credit
Loongphy Oct 16, 2026
36380a6
adapt: read the merged reset_credit flat array in the panel
Loongphy Oct 22, 2026
474c8dc
fix: align reset-credit consume with upstream semantics
Loongphy Oct 22, 2026
312d332
feat: add a reset command for the soonest-expiring credit
Loongphy Oct 22, 2026
4513298
feat: compact reset command output
Loongphy Oct 22, 2026
94b9320
feat: render a missing reset-credit expiry as -
Loongphy Oct 22, 2026
be85b0b
test: cover the N (-) reset credits cell for undated cards
Loongphy Oct 22, 2026
e4bf3e0
fix(list): unify responsive column allocation with live details
Loongphy Oct 22, 2026
06172c0
feat: compact left-rail reset credits panel
Loongphy Oct 22, 2026
5bbc98c
feat: nest the reset rail, show Available N, and refresh after use
Loongphy Oct 22, 2026
6fd5173
fix: align the reset rail with the row separator
Loongphy Oct 22, 2026
142d99c
Merge remote-tracking branch 'origin/main' into wip/reset-credits-panel
Loongphy Oct 25, 2026
96c828b
fix: align live LAST header and reset rail with the shared width policy
Loongphy Oct 25, 2026
8c7f871
fix: pin the reset credits rail to one account and one credit
Loongphy Oct 25, 2026
a82b438
fix: json encode the reset credit consume body
Loongphy Oct 25, 2026
e7020ab
test: keep the table layout module free of inline tests
Loongphy Oct 25, 2026
c65150c
test: move the last inline tests out of src
Loongphy Oct 25, 2026
b9fa8f7
fix: keep the reset credits rail visible and its hint on the armed cr…
Loongphy Oct 25, 2026
b771edb
docs: name the two row index bases in the list renderer
Loongphy Oct 25, 2026
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command
| [`codex-auth remove [--live] [--api\|--skip-api]`](./docs/commands/remove.md) | Remove accounts interactively |
| [`codex-auth remove <query> [<query>...]`](./docs/commands/remove.md) | Remove accounts by selector |
| [`codex-auth remove --all`](./docs/commands/remove.md) | Remove all stored accounts |
| [`codex-auth reset <query>`](./docs/commands/reset.md) | Use the soonest-expiring reset credit for one account |
| [`codex-auth alias set <query> <alias>`](./docs/commands/alias.md) | Set an alias for an account |
| [`codex-auth alias clear <query>`](./docs/commands/alias.md) | Clear the alias for an account |

Expand Down
1 change: 1 addition & 0 deletions docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This directory documents command behavior by command. Use `codex-auth <command>
| `export` | [docs/commands/export.md](./export.md) |
| `switch` | [docs/commands/switch.md](./switch.md) |
| `remove` | [docs/commands/remove.md](./remove.md) |
| `reset` | [docs/commands/reset.md](./reset.md) |
| `alias` | [docs/commands/alias.md](./alias.md) |
| `clean` | [docs/commands/clean.md](./clean.md) |
| `config` | [docs/commands/config.md](./config.md) |
Expand Down
16 changes: 14 additions & 2 deletions docs/commands/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ codex-auth list --json
details may be cached.
- `--json` emits one machine-readable JSON document and cannot be combined with `--live`.

## Live Reset Credits

- With `--live`, `c` expands or collapses the reset-credit rail. The rail attaches to the row under the *rail cursor*, not to the highlighted switch target; the footer lists the keys (`Keys: ↑/↓ scroll, j/k row, c credits, PgUp/PgDn/Home/End page, Esc/q quit`).
- `j` and `k` move the rail cursor between rendered account rows. Group header rows (several accounts sharing one email) are not rows, so the rail always lands on the account drawn on that line.
- While the rail is open the list scrolls to keep that row visible, so a redeem is never confirmed or reported on a line outside the viewport.
- With the rail closed, `↑`/`↓` scroll the table. With it open they move the credit highlight inside the rail.
- The rail header reads `Available N · ⏎ select · esc close`.
- `⏎` selects the highlighted credit; pressing `⏎` again redeems it (two-step confirm). The confirmation is bound to the exact account and credit it was armed for: moving the credit highlight (`↑`/`↓`), moving the rail cursor (`j`/`k`), collapsing with `c`, closing the rail, or a refresh that changes which credit is highlighted all drop it, so a redeem only fires while the credit showing `⏎ again to use` is the one being confirmed. A failed redeem also drops it; retrying asks for a fresh `⏎` `⏎` pair and reuses the same idempotency key for that credit.
- While the rail is open, both `esc` and `q` close the rail instead of quitting the session.
- A successful redeem refreshes that account immediately, so the `5H`/`WEEKLY` cells and the remaining card list reflect the reset without waiting for the next refresh round.
- Rows show the card title, the local `MM-DD HH:MM` expiry, and a relative countdown; credits without an expiry render `-`.

When local-only refresh is active, only the active account can be updated from local rollout files. Non-active rows use the stored registry snapshot.

## Output Notes
Expand All @@ -52,12 +64,12 @@ When local-only refresh is active, only the active account can be updated from l
- In non-live output, `CREDITS` shows the integer part of the current `credits.balance` value.
- `PLAN EXPIRE` renders the local date from the stored `id_token` subscription claim.
Missing or past timestamps render as `-` in both plain and live tables.
- `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it. A zero count renders as `0`; a non-zero count with no known expiry renders as `N (-)`.
- `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it. A zero count renders as `0`; a non-zero count appends the earliest available credit expiry as `N (exp MMM-DD)`, or `N (-)` when no available credit has an expiry.
- JSON output includes the raw `plan_expires_at` timestamp and the returned reset-credit details.
The terminal column hides past plan-expiry timestamps, but the JSON value remains the
stored timestamp so callers can detect stale snapshots.
- Remote refresh failures can render row overlays such as `401`, `403`, `TimedOut`, or `MissingAuth`.
- `LAST ACTIVITY` is based on the last stored usage update time.
- `LAST ACTIVITY` is based on the last stored usage update time. Its header is shown in full when the column is at least 13 columns wide and collapses to `LAST` otherwise; the same rule applies to plain and live output.
- `--json` returns accounts in the same display order and includes the same row numbers shown by the table.
- JSON plan values are final product plans; callers do not map backend Team/Business identifiers.
- JSON usage keeps the displayable snapshot separate from the current invocation's refresh result, so cached values remain visible after refresh failure.
Expand Down
28 changes: 28 additions & 0 deletions docs/commands/reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# `codex-auth reset`

## Usage

```shell
codex-auth reset <alias|email|display-number|query>
```

## Behavior

`codex-auth reset` redeems one reset credit for a single stored account.

- The account selector accepts the same forms as `switch` and must resolve to exactly one account.
- The command picks the available credit (`status == "available"`) with the earliest `expires_at` from the stored `last_usage.reset_credit` snapshot. Credits without an expiry are used only when no dated credit is available.
- The chosen credit is redeemed through `POST wham/rate-limit-reset-credits/consume` with a freshly generated UUID v4 `redeem_request_id`.
- On success it prints two compact lines: the consumed credit (title and local expiry) and a remaining summary with the count and the card that expires next:

```text
used "Full reset (Weekly + 5 hr)" · expires 2026-11-01 08:00
remaining: 1 · next: (untitled) · -
```
- The registry file is not rewritten by this command. The consumed credit disappears from the account list on the next `codex-auth list --api` refresh; run that refresh when the backend state changed elsewhere.
- Exit code is `0` after a successful consume (including the backend reporting `already_redeemed`) and `1` when the account has no available credits, the backend reports `no_credit` or `nothing_to_reset`, or the request fails.

## Notes

- Only ChatGPT-auth accounts can redeem credits; API-key accounts fail with `MissingAuth`.
- Requests use the shared curl budget with a 5-second timeout.
8 changes: 7 additions & 1 deletion docs/dynamic-columns.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The order of each view is fixed. Unsupported columns are simply omitted.
| View | Visual order |
|---|---|
| `list` | `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `LAST ACTIVITY` |
| `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `RESET CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `LAST` |
| `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `RESET CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `LAST ACTIVITY` |
| `switch`, `remove` | `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST` |

## Shared Priority
Expand Down Expand Up @@ -127,6 +127,12 @@ Wide headers use their full label. Narrow columns use compact labels:
| `RESET CREDITS` | `RESET`, then `RC` |
| `AUTH EXPIRE` | `AUTH EXP`, then `AUTH` |
| `PLAN EXPIRE` | `PLAN EXP`, then `PLAN` |
| `LAST ACTIVITY` | `LAST` |

`LAST ACTIVITY` renders its full header at 13 columns or wider and falls back
to `LAST` below that. Plain `list` and `list --live` use the same allocator and
deferred order, so the abbreviation rule is identical; the exact terminal width
where it kicks in can differ because each view supports a different column set.

## Adding a Column

Expand Down
1 change: 1 addition & 0 deletions src/api/http.zig
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub const ChildCaptureResult = types.ChildCaptureResult;
pub const runGetJsonCommand = curl.runGetJsonCommand;
pub const runBearerGetJsonCommand = curl.runBearerGetJsonCommand;
pub const runGetJsonBatchCommand = curl.runGetJsonBatchCommand;
pub const runPostJsonCommand = curl.runCurlPostJsonCommand;
pub const ensureCurlExecutableAvailable = curl.ensureCurlExecutableAvailable;
pub const resolveCurlExecutableAlloc = curl.resolveCurlExecutableAlloc;

Expand Down
60 changes: 60 additions & 0 deletions src/api/http_curl.zig
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,66 @@ fn runCurlBearerGetJsonCommand(
return try runCurlJsonCommand(allocator, endpoint, &[_][]const u8{authorization});
}

pub fn runCurlPostJsonCommand(
allocator: std.mem.Allocator,
endpoint: []const u8,
access_token: []const u8,
account_id: []const u8,
body_json: []const u8,
) !HttpResult {
const curl_executable = try resolveCurlExecutableForLaunchAlloc(allocator);
defer allocator.free(curl_executable);

const authorization = try std.fmt.allocPrint(allocator, "Authorization: Bearer {s}", .{access_token});
defer allocator.free(authorization);
const account_header = try std.fmt.allocPrint(allocator, "ChatGPT-Account-Id: {s}", .{account_id});
defer allocator.free(account_header);

var argv = std.ArrayList([]const u8).empty;
defer argv.deinit(allocator);
try appendCurlBaseArgs(allocator, &argv, curl_executable);

var curl_config = std.ArrayList(u8).empty;
defer curl_config.deinit(allocator);
try appendCurlConfigLine(allocator, &curl_config, "url", endpoint);
try appendCurlConfigLine(allocator, &curl_config, "header", "User-Agent: " ++ user_agent);
try appendCurlConfigLine(allocator, &curl_config, "header", "Accept: application/json");
try appendCurlConfigLine(allocator, &curl_config, "header", authorization);
try appendCurlConfigLine(allocator, &curl_config, "header", account_header);
try appendCurlConfigLine(allocator, &curl_config, "header", "Content-Type: application/json");
try appendCurlConfigLine(allocator, &curl_config, "data", body_json);

const result = runChildCaptureWithInputAndOutputLimit(
allocator,
argv.items,
curl_config.items,
child_process_timeout_ms_value,
null,
default_max_output_bytes,
) catch |err| switch (err) {
error.OutOfMemory => return err,
error.FileNotFound => return error.CurlRequired,
else => return err,
};
defer result.deinit(allocator);

if (result.timed_out) return error.TimedOut;

switch (result.term) {
.exited => |code| if (code != 0) {
if (code == curl_timeout_exit_code) return error.TimedOut;
return error.RequestFailed;
},
else => return error.RequestFailed,
}

const parsed = try parseCurlHttpOutput(allocator, result.stdout);
return .{
.body = parsed.body,
.status_code = parsed.status_code,
};
}

fn runCurlGetJsonCommand(
allocator: std.mem.Allocator,
endpoint: []const u8,
Expand Down
154 changes: 154 additions & 0 deletions src/api/reset_credits.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
const std = @import("std");
const app_runtime = @import("../core/runtime.zig");
const registry = @import("../registry/root.zig");
const auth = @import("../auth/auth.zig");
const http = @import("http.zig");

pub const default_reset_credits_consume_endpoint =
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";

/// Outcome of one consume request, mirroring upstream's
/// `ConsumeRateLimitResetCreditCode` (`reset` / `nothing_to_reset` /
/// `no_credit` / `already_redeemed`).
pub const ConsumeOutcome = enum {
reset,
already_redeemed,
nothing_to_reset,
no_credit,
unexpected_response,
http_error,
request_error,

pub fn isSuccess(self: ConsumeOutcome) bool {
return self == .reset or self == .already_redeemed;
}
};

pub const ConsumeResult = struct {
outcome: ConsumeOutcome,
status_code: ?u16 = null,
error_name: ?[]const u8 = null,
body: ?[]const u8 = null,

pub fn deinit(self: *ConsumeResult, allocator: std.mem.Allocator) void {
if (self.error_name) |name| allocator.free(name);
if (self.body) |b| allocator.free(b);
self.* = undefined;
}
};

/// Maps the backend `code` field of `ConsumeRateLimitResetCreditResponse` to
/// the local outcome. Unknown or missing codes are unexpected responses.
pub fn parseConsumeOutcome(allocator: std.mem.Allocator, body: []const u8) ConsumeOutcome {
var parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .unexpected_response;
defer parsed.deinit();
const root = switch (parsed.value) {
.object => |obj| obj,
else => return .unexpected_response,
};
const code = switch (root.get("code") orelse return .unexpected_response) {
.string => |s| s,
else => return .unexpected_response,
};
if (std.mem.eql(u8, code, "reset")) return .reset;
if (std.mem.eql(u8, code, "already_redeemed")) return .already_redeemed;
if (std.mem.eql(u8, code, "nothing_to_reset")) return .nothing_to_reset;
if (std.mem.eql(u8, code, "no_credit")) return .no_credit;
return .unexpected_response;
}

/// Mirrors upstream: the client generates the idempotency key (UUID v4) and
/// sends it as `redeem_request_id` together with the chosen `credit_id`.
/// Callers must reuse the same key when retrying the same credit so the
/// backend deduplicates the request instead of consuming a second credit.
pub fn generateIdempotencyKeyAlloc(allocator: std.mem.Allocator) ![]u8 {
var raw: [16]u8 = undefined;
app_runtime.io().random(&raw);
raw[6] = (raw[6] & 0x0f) | 0x40;
raw[8] = (raw[8] & 0x3f) | 0x80;
const hex = "0123456789abcdef";
var out: [36]u8 = undefined;
var read: usize = 0;
var write: usize = 0;
while (read < 16) : (read += 1) {
if (write == 8 or write == 13 or write == 18 or write == 23) {
out[write] = '-';
write += 1;
}
out[write] = hex[raw[read] >> 4];
out[write + 1] = hex[raw[read] & 0x0f];
write += 2;
}
return allocator.dupe(u8, out[0..]);
}

/// Builds the consume request body. `credit_id` originates at the backend and
/// round-trips through the stored registry, so both fields are JSON-encoded
/// instead of interpolated into body text.
pub fn buildConsumeBodyAlloc(
allocator: std.mem.Allocator,
redeem_request_id: []const u8,
credit_id: []const u8,
) ![]u8 {
return std.json.Stringify.valueAlloc(allocator, .{
.redeem_request_id = redeem_request_id,
.credit_id = credit_id,
}, .{});
}

fn fail(allocator: std.mem.Allocator, name: []const u8) ConsumeResult {
return .{
.outcome = .request_error,
.status_code = null,
.error_name = allocator.dupe(u8, name) catch null,
.body = null,
};
}

/// Redeems one reset credit for the given account via the backend consume
/// endpoint, using `redeem_request_id` as the idempotency key. The account
/// must be a ChatGPT-auth account with a usable token.
pub fn consumeCreditForAccount(
allocator: std.mem.Allocator,
codex_home: []const u8,
account_key: []const u8,
redeem_request_id: []const u8,
credit_id: []const u8,
) !ConsumeResult {
const auth_path = try registry.accountAuthPath(allocator, codex_home, account_key);
defer allocator.free(auth_path);

const info = auth.parseAuthInfo(allocator, auth_path) catch |err| return fail(allocator, @errorName(err));
defer info.deinit(allocator);

if (info.auth_mode != .chatgpt) return fail(allocator, "MissingAuth");
const access_token = info.access_token orelse return fail(allocator, "MissingAuth");
const chatgpt_account_id = info.chatgpt_account_id orelse return fail(allocator, "MissingAuth");

const body = try buildConsumeBodyAlloc(allocator, redeem_request_id, credit_id);
defer allocator.free(body);

const http_result = http.runPostJsonCommand(
allocator,
default_reset_credits_consume_endpoint,
access_token,
chatgpt_account_id,
body,
) catch |err| return fail(allocator, @errorName(err));

const status_code = http_result.status_code;
if (status_code == null or status_code.? < 200 or status_code.? >= 300) {
return .{
.outcome = .http_error,
.status_code = status_code,
.error_name = null,
.body = http_result.body,
};
}
return .{
.outcome = parseConsumeOutcome(allocator, http_result.body),
.status_code = status_code,
.error_name = null,
.body = http_result.body,
};
}
27 changes: 27 additions & 0 deletions src/cli/commands/reset.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const std = @import("std");
const types = @import("../types.zig");
const common = @import("common.zig");

pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult {
if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) {
return .{ .command = .{ .help = .reset_credits } };
}

var selector: ?[]u8 = null;
for (args) |raw_arg| {
const arg = std.mem.sliceTo(raw_arg, 0);
if (std.mem.startsWith(u8, arg, "-")) {
if (selector) |value| allocator.free(value);
return common.usageErrorResult(allocator, .reset_credits, "unknown flag `{s}` for `reset`.", .{arg});
}
if (selector != null) {
allocator.free(selector.?);
return common.usageErrorResult(allocator, .reset_credits, "unexpected extra argument `{s}` for `reset`.", .{arg});
}
selector = try allocator.dupe(u8, arg);
}
if (selector == null) {
return common.usageErrorResult(allocator, .reset_credits, "`reset` requires an account selector (alias, email, display number, or partial query).", .{});
}
return .{ .command = .{ .reset_credits = .{ .selector = selector.? } } };
}
4 changes: 4 additions & 0 deletions src/cli/commands/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const import_auth = @import("import.zig");
const list = @import("list.zig");
const login = @import("login.zig");
const remove = @import("remove.zig");
const reset = @import("reset.zig");
const switch_account = @import("switch.zig");

pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult {
Expand Down Expand Up @@ -54,6 +55,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type
if (std.mem.eql(u8, cmd, "export")) return export_auth.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "switch")) return switch_account.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "remove")) return remove.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "reset")) return reset.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "alias")) return alias.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "clean")) return clean.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "config")) return config.parse(allocator, args[2..]);
Expand Down Expand Up @@ -84,6 +86,7 @@ fn freeCommand(allocator: std.mem.Allocator, cmd: *types.Command) void {
common.freeOwnedStringList(allocator, opts.selectors);
allocator.free(opts.selectors);
},
.reset_credits => |opts| allocator.free(opts.selector),
.alias => |opts| switch (opts) {
.set => |set_opts| {
allocator.free(set_opts.selector);
Expand Down Expand Up @@ -116,6 +119,7 @@ fn helpTopicForName(name: []const u8) ?types.HelpTopic {
if (std.mem.eql(u8, name, "export")) return .export_auth;
if (std.mem.eql(u8, name, "switch")) return .switch_account;
if (std.mem.eql(u8, name, "remove")) return .remove_account;
if (std.mem.eql(u8, name, "reset")) return .reset_credits;
if (std.mem.eql(u8, name, "alias")) return .alias;
if (std.mem.eql(u8, name, "clean")) return .clean;
if (std.mem.eql(u8, name, "config")) return .config;
Expand Down
Loading
Loading