diff --git a/README.md b/README.md index 201fdbec..6476217a 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | [`codex-auth remove --all`](./docs/commands/remove.md) | Remove all stored accounts | | [`codex-auth alias set `](./docs/commands/alias.md) | Set an alias for an account | | [`codex-auth alias clear `](./docs/commands/alias.md) | Clear the alias for an account | +| [`codex-auth reset --yes`](./docs/commands/reset.md) | Consume one rate-limit reset credit | ### Import and Maintenance @@ -101,6 +102,7 @@ codex-auth list --active codex-auth switch codex-auth switch 02 codex-auth remove work +codex-auth reset work --yes codex-auth import /path/to/auth.json --alias personal codex-auth list --skip-api ``` diff --git a/docs/commands/README.md b/docs/commands/README.md index 8dd102ee..fc01dae1 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -13,6 +13,7 @@ This directory documents command behavior by command. Use `codex-auth | `switch` | [docs/commands/switch.md](./switch.md) | | `remove` | [docs/commands/remove.md](./remove.md) | | `alias` | [docs/commands/alias.md](./alias.md) | +| `reset` | [docs/commands/reset.md](./reset.md) | | `clean` | [docs/commands/clean.md](./clean.md) | | `config` | [docs/commands/config.md](./config.md) | | `app` | [docs/commands/app.md](./app.md) | diff --git a/docs/commands/list.md b/docs/commands/list.md index 700f81dd..556ff00e 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -16,7 +16,7 @@ codex-auth list --skip-api - Syncs the current `auth.json` into the registry before rendering when the current auth file is parseable. - Shows selectable row numbers using the same ordering as `switch` and `remove`. - Groups rows by email when the same email owns multiple account snapshots. -- Shows `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, and `LAST ACTIVITY`. +- Shows `ACCOUNT`, `PLAN`, `RESETS`, `5H`, `WEEKLY`, and `LAST ACTIVITY`. ## Refresh Modes @@ -34,6 +34,7 @@ When local-only refresh is active, only the active account can be updated from l - Singleton rows with both alias and account name render as `alias(account name, email)`. - Grouped rows keep the shared email in the header; child rows with both alias and account name render as `alias(account name)`. - Usage cells show remaining percent and reset time when that data is known. +- `RESETS` shows the stored reset-credit count when remote usage refresh provides it. - 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. - Shared table layout policy is documented in [docs/table-layout.md](../table-layout.md). diff --git a/docs/commands/reset.md b/docs/commands/reset.md new file mode 100644 index 00000000..c2e1de24 --- /dev/null +++ b/docs/commands/reset.md @@ -0,0 +1,34 @@ +# `codex-auth reset` + +## Usage + +```shell +codex-auth reset --yes +``` + +## Behavior + +- Consumes one rate-limit reset credit for a stored ChatGPT account. +- Requires `--yes`; without it, the command refuses to consume a credit. +- Uses stored account auth from `CODEX_HOME/accounts`. +- Updates the stored reset-credit count when the account already has one. + +## Selector Rules + +`` resolves from stored local data only. It does not trigger API refresh. + +Selectors can match: + +- displayed row number, +- alias fragment, +- email fragment, or +- account name fragment. + +If multiple accounts match, use a displayed row number from `codex-auth list`. + +## Examples + +```shell +codex-auth reset 02 --yes +codex-auth reset work --yes +``` diff --git a/src/api/http.zig b/src/api/http.zig index bfb34d9d..78a946ca 100644 --- a/src/api/http.zig +++ b/src/api/http.zig @@ -21,6 +21,7 @@ pub const BatchHttpResult = types.BatchHttpResult; pub const ChildCaptureResult = types.ChildCaptureResult; pub const runGetJsonCommand = curl.runGetJsonCommand; +pub const runPostJsonCommand = curl.runPostJsonCommand; pub const runBearerGetJsonCommand = curl.runBearerGetJsonCommand; pub const runGetJsonBatchCommand = curl.runGetJsonBatchCommand; pub const ensureCurlExecutableAvailable = curl.ensureCurlExecutableAvailable; diff --git a/src/api/http_curl.zig b/src/api/http_curl.zig index 4fa84559..32441999 100644 --- a/src/api/http_curl.zig +++ b/src/api/http_curl.zig @@ -85,6 +85,16 @@ pub fn runGetJsonCommand( return runCurlGetJsonCommand(allocator, endpoint, access_token, account_id); } +pub fn runPostJsonCommand( + allocator: std.mem.Allocator, + endpoint: []const u8, + access_token: []const u8, + account_id: []const u8, + body: []const u8, +) !HttpResult { + return runCurlPostJsonCommand(allocator, endpoint, access_token, account_id, body); +} + pub fn runBearerGetJsonCommand( allocator: std.mem.Allocator, endpoint: []const u8, @@ -134,6 +144,30 @@ fn runCurlGetJsonCommand( return runCurlGetJsonCommandWithExecutable(allocator, curl_executable, endpoint, access_token, account_id); } +fn runCurlPostJsonCommand( + allocator: std.mem.Allocator, + endpoint: []const u8, + access_token: []const u8, + account_id: []const u8, + body: []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); + + return try runCurlJsonCommandWithExecutableAndBody( + allocator, + curl_executable, + endpoint, + &[_][]const u8{ authorization, account_header, "Content-Type: application/json" }, + body, + ); +} + fn runCurlGetJsonCommandWithExecutable( allocator: std.mem.Allocator, curl_executable: []const u8, @@ -165,6 +199,16 @@ fn runCurlJsonCommandWithExecutable( curl_executable: []const u8, endpoint: []const u8, headers: []const []const u8, +) !HttpResult { + return runCurlJsonCommandWithExecutableAndBody(allocator, curl_executable, endpoint, headers, null); +} + +fn runCurlJsonCommandWithExecutableAndBody( + allocator: std.mem.Allocator, + curl_executable: []const u8, + endpoint: []const u8, + headers: []const []const u8, + body: ?[]const u8, ) !HttpResult { const user_agent_header = "User-Agent: " ++ user_agent; @@ -180,6 +224,10 @@ fn runCurlJsonCommandWithExecutable( for (headers) |header| { try appendCurlConfigLine(allocator, &curl_config, "header", header); } + if (body) |value| { + try appendCurlConfigLine(allocator, &curl_config, "request", "POST"); + try appendCurlConfigLine(allocator, &curl_config, "data", value); + } const result = runChildCaptureWithInputAndOutputLimit( allocator, diff --git a/src/api/usage.zig b/src/api/usage.zig index 844fc16f..710965c6 100644 --- a/src/api/usage.zig +++ b/src/api/usage.zig @@ -4,6 +4,7 @@ const chatgpt_http = @import("http.zig"); const registry = @import("../registry/root.zig"); pub const default_usage_endpoint = "https://chatgpt.com/backend-api/wham/usage"; +pub const default_reset_consume_endpoint = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"; pub const UsageFetchResult = struct { snapshot: ?registry.RateLimitSnapshot, @@ -12,6 +13,18 @@ pub const UsageFetchResult = struct { missing_auth: bool = false, }; +pub const ResetConsumeResult = struct { + code: ?[]u8, + windows_reset: ?bool, + redeem_request_id: []u8, + + pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void { + if (self.code) |value| allocator.free(value); + allocator.free(self.redeem_request_id); + self.* = undefined; + } +}; + pub const max_response_error_code_bytes: usize = 64; pub const ResponseErrorCode = struct { @@ -207,6 +220,70 @@ pub fn fetchUsageForTokenDetailed( }; } +pub fn consumeResetForAuthPath( + allocator: std.mem.Allocator, + auth_path: []const u8, +) !ResetConsumeResult { + const info = try auth.parseAuthInfo(allocator, auth_path); + defer info.deinit(allocator); + + if (info.auth_mode == .apikey) return error.UnsupportedAuthMode; + if (info.auth_mode != .chatgpt) return error.MissingAuth; + const access_token = info.access_token orelse return error.MissingAuth; + const chatgpt_account_id = info.chatgpt_account_id orelse return error.MissingAuth; + + return try consumeResetForToken(allocator, default_reset_consume_endpoint, access_token, chatgpt_account_id); +} + +pub fn consumeResetForToken( + allocator: std.mem.Allocator, + endpoint: []const u8, + access_token: []const u8, + account_id: []const u8, +) !ResetConsumeResult { + const request_id = try randomRedeemRequestIdAlloc(allocator); + errdefer allocator.free(request_id); + + const body = try std.fmt.allocPrint(allocator, "{{\"redeem_request_id\":\"{s}\"}}", .{request_id}); + defer allocator.free(body); + + const http_result = try chatgpt_http.runPostJsonCommand(allocator, endpoint, access_token, account_id, body); + defer allocator.free(http_result.body); + if (http_result.body.len == 0 or isNonSuccessStatus(http_result.status_code)) return error.RequestFailed; + + var result = try parseResetConsumeResponse(allocator, http_result.body); + allocator.free(result.redeem_request_id); + result.redeem_request_id = request_id; + return result; +} + +pub fn parseResetConsumeResponse(allocator: std.mem.Allocator, body: []const u8) !ResetConsumeResult { + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const root_obj = switch (parsed.value) { + .object => |obj| obj, + else => return error.InvalidResponse, + }; + + const code = if (root_obj.get("code")) |value| switch (value) { + .string => |s| if (s.len == 0) null else try allocator.dupe(u8, s), + else => null, + } else null; + errdefer if (code) |value| allocator.free(value); + + const windows_reset = if (root_obj.get("windows_reset")) |value| switch (value) { + .bool => |b| b, + else => null, + } else null; + + return .{ + .code = code, + .windows_reset = windows_reset, + .redeem_request_id = try allocator.dupe(u8, ""), + }; +} + fn isNonSuccessStatus(status_code: ?u16) bool { const status = status_code orelse return false; return status < 200 or status > 299; @@ -261,6 +338,7 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi .primary = null, .secondary = null, .credits = null, + .reset_credits = null, .plan_type = null, }; @@ -270,6 +348,9 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi if (root_obj.get("credits")) |credits| { snapshot.credits = try parseCredits(allocator, credits); } + if (root_obj.get("rate_limit_reset_credits")) |reset_credits| { + snapshot.reset_credits = parseResetCredits(reset_credits); + } if (root_obj.get("rate_limit")) |rate_limit| { switch (rate_limit) { .object => |obj| { @@ -284,7 +365,7 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi } } - if (snapshot.primary == null and snapshot.secondary == null) { + if (snapshot.primary == null and snapshot.secondary == null and snapshot.reset_credits == null) { if (snapshot.credits) |*credits| { if (credits.balance) |balance| allocator.free(balance); } @@ -294,6 +375,17 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi return snapshot; } +fn parseResetCredits(v: std.json.Value) ?i64 { + const obj = switch (v) { + .object => |o| o, + else => return null, + }; + return switch (obj.get("available_count") orelse return null) { + .integer => |i| i, + else => null, + }; +} + fn parseWindow(v: std.json.Value) ?registry.RateLimitWindow { const obj = switch (v) { .object => |o| o, @@ -370,6 +462,21 @@ fn ceilMinutes(seconds: i64) ?i64 { return @divTrunc(seconds + 59, 60); } +fn randomRedeemRequestIdAlloc(allocator: std.mem.Allocator) ![]u8 { + var bytes: [16]u8 = undefined; + @import("../core/runtime.zig").io().random(&bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = std.fmt.bytesToHex(bytes, .lower); + return std.fmt.allocPrint(allocator, "{s}-{s}-{s}-{s}-{s}", .{ + hex[0..8], + hex[8..12], + hex[12..16], + hex[16..20], + hex[20..32], + }); +} + fn runUsageCommand( allocator: std.mem.Allocator, endpoint: []const u8, diff --git a/src/cli/commands/reset.zig b/src/cli/commands/reset.zig new file mode 100644 index 00000000..98e28b57 --- /dev/null +++ b/src/cli/commands/reset.zig @@ -0,0 +1,32 @@ +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 } }; + } + + var selector: ?[]u8 = null; + errdefer if (selector) |value| allocator.free(value); + var opts: types.ResetOptions = .{ + .selector = &.{}, + .yes = false, + }; + + for (args) |raw_arg| { + const arg = std.mem.sliceTo(raw_arg, 0); + if (std.mem.eql(u8, arg, "--yes")) { + if (opts.yes) return common.usageErrorResult(allocator, .reset, "duplicate `--yes` for `reset`.", .{}); + opts.yes = true; + continue; + } + if (std.mem.startsWith(u8, arg, "-")) return common.usageErrorResult(allocator, .reset, "unknown flag `{s}` for `reset`.", .{arg}); + if (selector != null) return common.usageErrorResult(allocator, .reset, "unexpected extra argument `{s}` for `reset`.", .{arg}); + selector = try allocator.dupe(u8, arg); + } + + opts.selector = selector orelse return common.usageErrorResult(allocator, .reset, "`reset` requires an account selector.", .{}); + selector = null; + return .{ .command = .{ .reset = opts } }; +} diff --git a/src/cli/commands/root.zig b/src/cli/commands/root.zig index 2a311420..7f76f8e9 100644 --- a/src/cli/commands/root.zig +++ b/src/cli/commands/root.zig @@ -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 { @@ -55,6 +56,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type 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, "alias")) return alias.parse(allocator, args[2..]); + if (std.mem.eql(u8, cmd, "reset")) return reset.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..]); if (std.mem.eql(u8, cmd, "app")) return app.parse(allocator, args[2..]); @@ -91,6 +93,7 @@ fn freeCommand(allocator: std.mem.Allocator, cmd: *types.Command) void { }, .clear => |clear_opts| allocator.free(clear_opts.selector), }, + .reset => |opts| allocator.free(opts.selector), else => {}, } cmd.* = undefined; @@ -117,6 +120,7 @@ fn helpTopicForName(name: []const u8) ?types.HelpTopic { 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, "alias")) return .alias; + if (std.mem.eql(u8, name, "reset")) return .reset; if (std.mem.eql(u8, name, "clean")) return .clean; if (std.mem.eql(u8, name, "config")) return .config; if (std.mem.eql(u8, name, "app")) return .app; diff --git a/src/cli/help.zig b/src/cli/help.zig index 70142ede..3f2526f3 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -55,6 +55,7 @@ pub fn writeHelp( try writeCommandSummary(out, use_color, "alias", "Set or clear account aliases"); try writeCommandDetail(out, use_color, "alias set "); try writeCommandDetail(out, use_color, "alias clear "); + try writeCommandSummary(out, use_color, "reset --yes", "Consume one reset credit"); try writeCommandSummary(out, use_color, "clean", "Delete backup and stale files under accounts/"); try writeCommandDetail(out, use_color, "clean background"); try writeCommandSummary(out, use_color, "config", "Manage configuration"); @@ -132,6 +133,7 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { .switch_account => "switch", .remove_account => "remove", .alias => "alias", + .reset => "reset", .clean => "clean", .config => "config", .app => "app", @@ -148,6 +150,7 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .switch_account => "Switch the active account by alias, email, display number, or partial query.", .remove_account => "Remove one or more accounts by alias, email, display number, or partial query.", .alias => "Set or clear an account alias by alias, email, display number, or partial query.", + .reset => "Consume one rate-limit reset credit for an account.", .clean => "Delete backup and stale files under accounts/.", .config => "Manage live refresh configuration.", .app => "Launch Codex App with CLI overrides.", @@ -156,21 +159,21 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { fn commandHelpHasExamples(topic: HelpTopic) bool { return switch (topic) { - .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config, .app => true, + .import_auth, .export_auth, .switch_account, .remove_account, .alias, .reset, .config, .app => true, else => false, }; } fn commandHelpHasOptions(topic: HelpTopic) bool { return switch (topic) { - .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config, .app => true, + .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .alias, .reset, .config, .app => true, else => false, }; } fn commandHelpHasNotes(topic: HelpTopic) bool { return switch (topic) { - .switch_account, .alias => true, + .switch_account, .alias, .reset => true, else => false, }; } @@ -221,6 +224,9 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth alias set \n"); try out.writeAll(" codex-auth alias clear \n"); }, + .reset => { + try out.writeAll(" codex-auth reset --yes\n"); + }, .clean => { try out.writeAll(" codex-auth clean\n"); try out.writeAll(" codex-auth clean background\n"); @@ -244,6 +250,7 @@ pub fn helpCommandForTopic(topic: HelpTopic) []const u8 { .switch_account => "codex-auth switch --help", .remove_account => "codex-auth remove --help", .alias => "codex-auth alias --help", + .reset => "codex-auth reset --help", .clean => "codex-auth clean --help", .config => "codex-auth config --help", .app => "codex-auth app --help", @@ -299,6 +306,11 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" clear \n"); try out.writeAll(" Remove one stored account alias without remote refresh.\n"); }, + .reset => { + try out.writeAll(" --yes Actually consume one reset credit.\n"); + try out.writeAll(" \n"); + try out.writeAll(" Select one stored ChatGPT account.\n"); + }, .config => { try out.writeAll(" live --interval \n"); try out.writeAll(" Set the live TUI refresh interval from 5 to 3600 seconds.\n"); @@ -378,6 +390,10 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth alias set old-name new-name\n"); try out.writeAll(" codex-auth alias clear work\n"); }, + .reset => { + try out.writeAll(" codex-auth reset 02 --yes\n"); + try out.writeAll(" codex-auth reset work --yes\n"); + }, .clean => { try out.writeAll(" codex-auth clean\n"); try out.writeAll(" codex-auth clean background\n"); @@ -403,6 +419,10 @@ fn writeNotesSectionStyled(out: *std.Io.Writer, use_color: bool, topic: HelpTopi try out.writeAll(" Alias targets can be aliases, emails, display numbers, or partial queries.\n"); try out.writeAll(" New aliases cannot be empty or only digits.\n"); }, + .reset => { + try out.writeAll(" Reset targets can be aliases, emails, display numbers, or partial queries.\n"); + try out.writeAll(" Consuming a reset credit calls ChatGPT's reset-credit consume endpoint.\n"); + }, else => {}, } } diff --git a/src/cli/output.zig b/src/cli/output.zig index df5054cd..bc14c95f 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const usage_api = @import("../api/usage.zig"); const display_rows = @import("../tui/display.zig"); const registry = @import("../registry/root.zig"); const io_util = @import("../core/io_util.zig"); @@ -216,6 +217,62 @@ pub fn printAliasAccountNotFoundError(query: []const u8) !void { try out.flush(); } +pub fn printResetAccountNotFoundError(query: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + const out = stderr.out(); + const use_color = stderr.color_enabled; + try writeErrorPrefixTo(out, use_color); + try out.print(" no reset target matches '{s}'.\n", .{query}); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Reset accepts one target: alias, email, display number, or partial query.\n"); + try out.flush(); +} + +pub fn printResetMultipleTargetsError(query: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + const out = stderr.out(); + const use_color = stderr.color_enabled; + try writeErrorPrefixTo(out, use_color); + try out.print(" reset target '{s}' matches multiple accounts.\n", .{query}); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Use a displayed row number from `codex-auth list`.\n"); + try out.flush(); +} + +pub fn printResetRequiresYesError() !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + const out = stderr.out(); + const use_color = stderr.color_enabled; + try writeErrorPrefixTo(out, use_color); + try out.writeAll(" refusing to consume a reset credit without `--yes`.\n"); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Run `codex-auth reset --yes`.\n"); + try out.flush(); +} + +pub fn printResetUnsupportedAuthModeError(email: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + const out = stderr.out(); + const use_color = stderr.color_enabled; + try writeErrorPrefixTo(out, use_color); + try out.print(" reset credits are only available for ChatGPT auth accounts: {s}.\n", .{email}); + try out.flush(); +} + +pub fn printResetConsumeFailedError(err_name: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + const out = stderr.out(); + const use_color = stderr.color_enabled; + try writeErrorPrefixTo(out, use_color); + try out.print(" failed to consume reset credit: {s}.\n", .{err_name}); + try out.flush(); +} + pub fn printAccountNotFoundErrors(queries: []const []const u8) !void { if (queries.len == 0) return; if (queries.len == 1) { @@ -450,6 +507,34 @@ pub fn printSwitchedAccount( try out.flush(); } +pub fn printResetConsumed( + allocator: std.mem.Allocator, + reg: *registry.Registry, + account_key: []const u8, + result: *const usage_api.ResetConsumeResult, +) !void { + const label = if (registry.findAccountIndexByAccountKey(reg, account_key)) |idx| + try display_rows.buildAccountIdentityLabelAlloc(allocator, ®.accounts.items[idx]) + else + try allocator.dupe(u8, account_key); + defer allocator.free(label); + + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + const use_color = stdout.color_enabled; + if (use_color) try out.writeAll(style.ansi.green); + try out.print("Consumed reset credit for {s}", .{label}); + if (result.code) |code| try out.print(": {s}", .{code}); + try out.writeAll("\n"); + if (result.windows_reset) |value| { + try out.print("windows_reset: {any}\n", .{value}); + } + try out.print("redeem_request_id: {s}\n", .{result.redeem_request_id}); + if (use_color) try out.writeAll(style.ansi.reset); + try out.flush(); +} + pub fn writeCodexLoginLaunchFailureHintTo(out: *std.Io.Writer, err_name: []const u8, use_color: bool) !void { try writeErrorPrefixTo(out, use_color); if (std.mem.eql(u8, err_name, "FileNotFound")) { diff --git a/src/cli/render.zig b/src/cli/render.zig index a455ab36..d7676cb7 100644 --- a/src/cli/render.zig +++ b/src/cli/render.zig @@ -333,6 +333,7 @@ fn liveAccountCells(row: SwitchRow) [table_layout.column_count]table_layout.Cell return .{ .{ .text = row.account, .indent = @as(usize, row.depth) * 2 }, .{ .text = row.plan }, + .{ .text = row.resets }, .{ .text = row.rate_5h }, .{ .text = row.rate_week }, .{ .text = row.last }, diff --git a/src/cli/rows.zig b/src/cli/rows.zig index 0e1d2321..32b5411e 100644 --- a/src/cli/rows.zig +++ b/src/cli/rows.zig @@ -11,6 +11,7 @@ const c = @cImport({ pub const SwitchWidths = struct { email: usize, plan: usize, + resets: usize = "RESETS".len, rate_5h: usize, rate_week: usize, last: usize, @@ -20,6 +21,7 @@ pub const SwitchRow = struct { account_index: ?usize, account: []u8, plan: []const u8, + resets: []const u8 = "", rate_5h: []u8, rate_week: []u8, last: []u8, @@ -30,6 +32,7 @@ pub const SwitchRow = struct { fn deinit(self: *SwitchRow, allocator: std.mem.Allocator) void { allocator.free(self.account); + if (self.resets.len != 0) allocator.free(@constCast(self.resets)); allocator.free(self.rate_5h); allocator.free(self.rate_week); allocator.free(self.last); @@ -84,6 +87,11 @@ fn usageCellTextAlloc( return formatRateLimitSwitchAlloc(allocator, window); } +fn resetCreditsCellAlloc(allocator: std.mem.Allocator, usage: ?registry.RateLimitSnapshot) ![]u8 { + const count = if (usage) |snapshot| snapshot.reset_credits else null; + return if (count) |value| std.fmt.allocPrint(allocator, "{d}", .{value}) else allocator.dupe(u8, "-"); +} + pub fn buildSwitchRows(allocator: std.mem.Allocator, reg: *registry.Registry) !SwitchRows { return buildSwitchRowsWithUsageOverrides(allocator, reg, null); } @@ -99,6 +107,7 @@ pub fn buildSwitchRowsWithUsageOverrides( var widths = SwitchWidths{ .email = "EMAIL".len, .plan = "PLAN".len, + .resets = "RESETS".len, .rate_5h = "5H".len, .rate_week = "WEEKLY".len, .last = "LAST".len, @@ -111,6 +120,7 @@ pub fn buildSwitchRowsWithUsageOverrides( const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); const usage_override = usageOverrideForAccount(usage_overrides, account_idx); + const resets_str = try resetCreditsCellAlloc(allocator, rec.last_usage); const rate_5h_str = try usageCellTextAlloc(allocator, rate_5h, usage_override); const rate_week_str = try usageCellTextAlloc(allocator, rate_week, usage_override); const last = try timefmt.formatRelativeTimeOrDashAlloc(allocator, rec.last_usage_at, now); @@ -118,6 +128,7 @@ pub fn buildSwitchRowsWithUsageOverrides( .account_index = account_idx, .account = try allocator.dupe(u8, display_row.account_cell), .plan = plan, + .resets = resets_str, .rate_5h = rate_5h_str, .rate_week = rate_week_str, .last = last, @@ -128,6 +139,7 @@ pub fn buildSwitchRowsWithUsageOverrides( }; widths.email = @max(widths.email, display_row.account_cell.len + (@as(usize, display_row.depth) * 2)); widths.plan = @max(widths.plan, plan.len); + widths.resets = @max(widths.resets, resets_str.len); widths.rate_5h = @max(widths.rate_5h, rate_5h_str.len); widths.rate_week = @max(widths.rate_week, rate_week_str.len); widths.last = @max(widths.last, last.len); @@ -136,6 +148,7 @@ pub fn buildSwitchRowsWithUsageOverrides( .account_index = null, .account = try allocator.dupe(u8, display_row.account_cell), .plan = "", + .resets = "", .rate_5h = try allocator.dupe(u8, ""), .rate_week = try allocator.dupe(u8, ""), .last = try allocator.dupe(u8, ""), @@ -175,6 +188,7 @@ pub fn buildSwitchRowsFromIndicesWithUsageOverrides( var widths = SwitchWidths{ .email = "EMAIL".len, .plan = "PLAN".len, + .resets = "RESETS".len, .rate_5h = "5H".len, .rate_week = "WEEKLY".len, .last = "LAST".len, @@ -187,6 +201,7 @@ pub fn buildSwitchRowsFromIndicesWithUsageOverrides( const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); const usage_override = usageOverrideForAccount(usage_overrides, account_idx); + const resets_str = try resetCreditsCellAlloc(allocator, rec.last_usage); const rate_5h_str = try usageCellTextAlloc(allocator, rate_5h, usage_override); const rate_week_str = try usageCellTextAlloc(allocator, rate_week, usage_override); const last = try timefmt.formatRelativeTimeOrDashAlloc(allocator, rec.last_usage_at, now); @@ -194,6 +209,7 @@ pub fn buildSwitchRowsFromIndicesWithUsageOverrides( .account_index = account_idx, .account = try allocator.dupe(u8, display_row.account_cell), .plan = plan, + .resets = resets_str, .rate_5h = rate_5h_str, .rate_week = rate_week_str, .last = last, @@ -204,6 +220,7 @@ pub fn buildSwitchRowsFromIndicesWithUsageOverrides( }; widths.email = @max(widths.email, display_row.account_cell.len + (@as(usize, display_row.depth) * 2)); widths.plan = @max(widths.plan, plan.len); + widths.resets = @max(widths.resets, resets_str.len); widths.rate_5h = @max(widths.rate_5h, rate_5h_str.len); widths.rate_week = @max(widths.rate_week, rate_week_str.len); widths.last = @max(widths.last, last.len); @@ -212,6 +229,7 @@ pub fn buildSwitchRowsFromIndicesWithUsageOverrides( .account_index = null, .account = try allocator.dupe(u8, display_row.account_cell), .plan = "", + .resets = "", .rate_5h = try allocator.dupe(u8, ""), .rate_week = try allocator.dupe(u8, ""), .last = try allocator.dupe(u8, ""), diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 24db81f5..14c25cf4 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -10,7 +10,7 @@ pub const LiveListViewport = struct { max_cols: ?usize = null, }; -pub const column_count = 5; +pub const column_count = 6; const live_account_ident_width: usize = 10; const live_account_suffix_min_width: usize = 10; const live_account_suffix_min_len: usize = 3; @@ -39,6 +39,7 @@ pub const LiveTable = struct { .{ .text = self.columns[2].header }, .{ .text = self.columns[3].header }, .{ .text = self.columns[4].header }, + .{ .text = self.columns[5].header }, }); try writer.reset(); try writer.writeAll("\n"); @@ -92,6 +93,7 @@ pub fn accountTable(widths: SwitchWidths, prefix_width: usize) LiveTable { .columns = .{ .{ .header = "ACCOUNT", .width = widths.email }, .{ .header = "PLAN", .width = widths.plan }, + .{ .header = "RESETS", .width = widths.resets }, .{ .header = "5H", .width = widths.rate_5h }, .{ .header = "WEEKLY", .width = widths.rate_week }, .{ .header = "LAST", .width = widths.last }, @@ -107,6 +109,7 @@ pub fn boundWidths(widths: SwitchWidths, prefix_width: usize, max_cols: ?usize) return .{ .email = 0, .plan = 0, + .resets = 0, .rate_5h = 0, .rate_week = 0, .last = 0, @@ -117,6 +120,7 @@ pub fn boundWidths(widths: SwitchWidths, prefix_width: usize, max_cols: ?usize) var bounded = SwitchWidths{ .email = 0, .plan = 0, + .resets = 0, .rate_5h = 0, .rate_week = 0, .last = 0, @@ -125,13 +129,13 @@ pub fn boundWidths(widths: SwitchWidths, prefix_width: usize, max_cols: ?usize) growBoundedWidth(&remaining, &bounded.email, @min(widths.email, live_account_ident_width)); growBoundedWidth(&remaining, &bounded.rate_5h, @min(widths.rate_5h, @max(@as(usize, 4), "5H".len))); growBoundedWidth(&remaining, &bounded.rate_week, @min(widths.rate_week, "WEEKLY".len)); - growBoundedWidth(&remaining, &bounded.plan, @min(widths.plan, "PLAN".len)); - growBoundedWidth(&remaining, &bounded.last, @min(widths.last, @as(usize, 3))); + growBoundedWidth(&remaining, &bounded.plan, widths.plan); + growBoundedWidth(&remaining, &bounded.last, widths.last); + growBoundedWidth(&remaining, &bounded.resets, @min(widths.resets, "RESETS".len)); growBoundedWidth(&remaining, &bounded.rate_5h, widths.rate_5h); growBoundedWidth(&remaining, &bounded.rate_week, widths.rate_week); - growBoundedWidth(&remaining, &bounded.plan, widths.plan); - growBoundedWidth(&remaining, &bounded.last, widths.last); + growBoundedWidth(&remaining, &bounded.resets, widths.resets); growBoundedWidth(&remaining, &bounded.email, widths.email); return bounded; diff --git a/src/cli/types.zig b/src/cli/types.zig index 3bf3ab64..2e83c3be 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -51,6 +51,10 @@ pub const AliasOptions = union(enum) { set: AliasSetOptions, clear: AliasClearOptions, }; +pub const ResetOptions = struct { + selector: []u8, + yes: bool = false, +}; pub const CleanTarget = enum { accounts, background }; pub const CleanOptions = struct { target: CleanTarget = .accounts, @@ -78,6 +82,7 @@ pub const HelpTopic = enum { switch_account, remove_account, alias, + reset, clean, config, app, @@ -91,6 +96,7 @@ pub const Command = union(enum) { switch_account: SwitchOptions, remove_account: RemoveOptions, alias: AliasOptions, + reset: ResetOptions, clean: CleanOptions, config: ConfigOptions, app: AppOptions, diff --git a/src/registry/common.zig b/src/registry/common.zig index 7cc2c571..27d43cc0 100644 --- a/src/registry/common.zig +++ b/src/registry/common.zig @@ -68,6 +68,7 @@ pub const RateLimitSnapshot = struct { primary: ?RateLimitWindow, secondary: ?RateLimitWindow, credits: ?CreditsSnapshot, + reset_credits: ?i64 = null, plan_type: ?PlanType, }; @@ -216,6 +217,7 @@ pub fn cloneRateLimitSnapshot(allocator: std.mem.Allocator, snapshot: RateLimitS .primary = snapshot.primary, .secondary = snapshot.secondary, .credits = cloned_credits, + .reset_credits = snapshot.reset_credits, .plan_type = snapshot.plan_type, }; } @@ -261,6 +263,7 @@ pub fn rateLimitSnapshotEqual(a: RateLimitSnapshot, b: RateLimitSnapshot) bool { return rateLimitWindowEqual(a.primary, b.primary) and rateLimitWindowEqual(a.secondary, b.secondary) and creditsEqual(a.credits, b.credits) and + a.reset_credits == b.reset_credits and a.plan_type == b.plan_type; } diff --git a/src/registry/parse.zig b/src/registry/parse.zig index 079808cc..842ad49a 100644 --- a/src/registry/parse.zig +++ b/src/registry/parse.zig @@ -32,7 +32,7 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value) ?RateLimitSna .object => |o| o, else => return null, }; - var snap = RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .plan_type = null }; + var snap = RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .reset_credits = null, .plan_type = null }; if (obj.get("plan_type")) |p| { switch (p) { @@ -43,6 +43,7 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value) ?RateLimitSna if (obj.get("primary")) |p| snap.primary = parseWindow(p); if (obj.get("secondary")) |p| snap.secondary = parseWindow(p); if (obj.get("credits")) |c| snap.credits = parseCredits(allocator, c); + snap.reset_credits = readInt(obj.get("reset_credits")); return snap; } diff --git a/src/session.zig b/src/session.zig index 3e3e2cd4..2b91db44 100644 --- a/src/session.zig +++ b/src/session.zig @@ -430,7 +430,7 @@ fn looksLikeUsageEventLine(line: []const u8) bool { } fn parseRateLimits(allocator: std.mem.Allocator, parsed: UsageRateLimitsJson) ?registry.RateLimitSnapshot { - var snap = registry.RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .plan_type = null }; + var snap = registry.RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .reset_credits = null, .plan_type = null }; if (parsed.primary) |p| snap.primary = parseWindow(p); if (parsed.secondary) |p| snap.secondary = parseWindow(p); if (parsed.credits) |c| snap.credits = parseCredits(allocator, c); diff --git a/src/tui/table.zig b/src/tui/table.zig index 37896bc6..e6f2ba95 100644 --- a/src/tui/table.zig +++ b/src/tui/table.zig @@ -76,19 +76,25 @@ fn usageCellFullTextAlloc( return formatRateLimitFullAlloc(window); } +fn resetCreditsCellAlloc(allocator: std.mem.Allocator, usage: ?registry.RateLimitSnapshot) ![]u8 { + const count = if (usage) |snapshot| snapshot.reset_credits else null; + return if (count) |value| std.fmt.allocPrint(allocator, "{d}", .{value}) else allocator.dupe(u8, "-"); +} + pub fn writeAccountsTableWithUsageOverrides( out: *std.Io.Writer, reg: *registry.Registry, use_color: bool, usage_overrides: ?[]const ?[]const u8, ) !void { - const headers = [_][]const u8{ "ACCOUNT", "PLAN", "5H", "WEEKLY", "LAST ACTIVITY" }; + const headers = [_][]const u8{ "ACCOUNT", "PLAN", "RESETS", "5H", "WEEKLY", "LAST ACTIVITY" }; var widths = [_]usize{ headers[0].len, headers[1].len, headers[2].len, headers[3].len, headers[4].len, + headers[5].len, }; const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); var display = try display_rows.buildDisplayRows(std.heap.page_allocator, reg, null); @@ -106,6 +112,8 @@ pub fn writeAccountsTableWithUsageOverrides( const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); const usage_override = usageOverrideForAccount(usage_overrides, account_idx); + const resets_str = try resetCreditsCellAlloc(std.heap.page_allocator, rec.last_usage); + defer std.heap.page_allocator.free(resets_str); const rate_5h_str = try usageCellFullTextAlloc(std.heap.page_allocator, rate_5h, usage_override); defer std.heap.page_allocator.free(rate_5h_str); const rate_week_str = try usageCellFullTextAlloc(std.heap.page_allocator, rate_week, usage_override); @@ -114,9 +122,10 @@ pub fn writeAccountsTableWithUsageOverrides( defer std.heap.page_allocator.free(last_str); widths[1] = @max(widths[1], plan.len); - widths[2] = @max(widths[2], rate_5h_str.len); - widths[3] = @max(widths[3], rate_week_str.len); - widths[4] = @max(widths[4], last_str.len); + widths[2] = @max(widths[2], resets_str.len); + widths[3] = @max(widths[3], rate_5h_str.len); + widths[4] = @max(widths[4], rate_week_str.len); + widths[5] = @max(widths[5], last_str.len); } } @@ -128,12 +137,14 @@ pub fn writeAccountsTableWithUsageOverrides( defer std.heap.page_allocator.free(h1); const h2 = try truncateAlloc(headers[2], widths[2]); defer std.heap.page_allocator.free(h2); - const header_week = if (widths[3] >= "WEEKLY".len) "WEEKLY" else if (widths[3] >= "WEEK".len) "WEEK" else "W"; - const h3 = try truncateAlloc(header_week, widths[3]); + const h3 = try truncateAlloc(headers[3], widths[3]); defer std.heap.page_allocator.free(h3); - const header_last = if (widths[4] >= "LAST ACTIVITY".len) "LAST ACTIVITY" else "LAST"; - const h4 = try truncateAlloc(header_last, widths[4]); + const header_week = if (widths[4] >= "WEEKLY".len) "WEEKLY" else if (widths[4] >= "WEEK".len) "WEEK" else "W"; + const h4 = try truncateAlloc(header_week, widths[4]); defer std.heap.page_allocator.free(h4); + const header_last = if (widths[5] >= "LAST ACTIVITY".len) "LAST ACTIVITY" else "LAST"; + const h5 = try truncateAlloc(header_last, widths[5]); + defer std.heap.page_allocator.free(h5); if (use_color) try out.writeAll(ansi.cyan); try writeRepeat(out, ' ', prefix_len); @@ -146,6 +157,8 @@ pub fn writeAccountsTableWithUsageOverrides( try writePadded(out, h3, widths[3]); try out.writeAll(" "); try writePadded(out, h4, widths[4]); + try out.writeAll(" "); + try writePadded(out, h5, widths[5]); try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); if (use_color) try out.writeAll(ansi.dim); @@ -161,9 +174,11 @@ pub fn writeAccountsTableWithUsageOverrides( const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); const usage_override = usageOverrideForAccount(usage_overrides, account_idx); - const rate_5h_str = try usageCellTextAlloc(std.heap.page_allocator, rate_5h, widths[2], usage_override); + const resets_str = try resetCreditsCellAlloc(std.heap.page_allocator, rec.last_usage); + defer std.heap.page_allocator.free(resets_str); + const rate_5h_str = try usageCellTextAlloc(std.heap.page_allocator, rate_5h, widths[3], usage_override); defer std.heap.page_allocator.free(rate_5h_str); - const rate_week_str = try usageCellTextAlloc(std.heap.page_allocator, rate_week, widths[3], usage_override); + const rate_week_str = try usageCellTextAlloc(std.heap.page_allocator, rate_week, widths[4], usage_override); defer std.heap.page_allocator.free(rate_week_str); const last = try timefmt.formatRelativeTimeOrDashAlloc(std.heap.page_allocator, rec.last_usage_at, now); defer std.heap.page_allocator.free(last); @@ -173,11 +188,13 @@ pub fn writeAccountsTableWithUsageOverrides( defer std.heap.page_allocator.free(account_cell); const plan_cell = try truncateAlloc(plan, widths[1]); defer std.heap.page_allocator.free(plan_cell); - const rate_5h_cell = try truncateAlloc(rate_5h_str, widths[2]); + const resets_cell = try truncateAlloc(resets_str, widths[2]); + defer std.heap.page_allocator.free(resets_cell); + const rate_5h_cell = try truncateAlloc(rate_5h_str, widths[3]); defer std.heap.page_allocator.free(rate_5h_cell); - const rate_week_cell = try truncateAlloc(rate_week_str, widths[3]); + const rate_week_cell = try truncateAlloc(rate_week_str, widths[4]); defer std.heap.page_allocator.free(rate_week_cell); - const last_cell = try truncateAlloc(last, widths[4]); + const last_cell = try truncateAlloc(last, widths[5]); defer std.heap.page_allocator.free(last_cell); if (use_color) { if (row.is_active) { @@ -194,11 +211,13 @@ pub fn writeAccountsTableWithUsageOverrides( try out.writeAll(" "); try writePadded(out, plan_cell, widths[1]); try out.writeAll(" "); - try writePadded(out, rate_5h_cell, widths[2]); + try writePadded(out, resets_cell, widths[2]); try out.writeAll(" "); - try writePadded(out, rate_week_cell, widths[3]); + try writePadded(out, rate_5h_cell, widths[3]); try out.writeAll(" "); - try writePadded(out, last_cell, widths[4]); + try writePadded(out, rate_week_cell, widths[4]); + try out.writeAll(" "); + try writePadded(out, last_cell, widths[5]); try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); selectable_counter += 1; @@ -282,14 +301,14 @@ fn writeRepeat(out: *std.Io.Writer, ch: u8, count: usize) !void { } } -fn listTotalWidth(widths: *const [5]usize, prefix_len: usize, sep_len: usize) usize { +fn listTotalWidth(widths: *const [6]usize, prefix_len: usize, sep_len: usize) usize { var sum: usize = prefix_len; for (widths) |w| sum += w; sum += sep_len * (widths.len - 1); return sum; } -fn adjustListWidths(widths: *[5]usize, prefix_len: usize, sep_len: usize) void { +fn adjustListWidths(widths: *[6]usize, prefix_len: usize, sep_len: usize) void { const term_cols = terminalWidth(); if (term_cols == 0) return; const total = listTotalWidth(widths, prefix_len, sep_len); @@ -297,6 +316,7 @@ fn adjustListWidths(widths: *[5]usize, prefix_len: usize, sep_len: usize) void { const min_email: usize = 10; const min_plan: usize = 4; + const min_resets: usize = 1; const min_rate: usize = 1; const min_last: usize = 4; @@ -319,8 +339,8 @@ fn adjustListWidths(widths: *[5]usize, prefix_len: usize, sep_len: usize) void { } if (over == 0) return; - if (widths[2] > min_rate) { - const reducible = widths[2] - min_rate; + if (widths[2] > min_resets) { + const reducible = widths[2] - min_resets; const reduce = @min(reducible, over); widths[2] -= reduce; over -= reduce; @@ -335,12 +355,20 @@ fn adjustListWidths(widths: *[5]usize, prefix_len: usize, sep_len: usize) void { } if (over == 0) return; - if (widths[4] > min_last) { - const reducible = widths[4] - min_last; + if (widths[4] > min_rate) { + const reducible = widths[4] - min_rate; const reduce = @min(reducible, over); widths[4] -= reduce; over -= reduce; } + if (over == 0) return; + + if (widths[5] > min_last) { + const reducible = widths[5] - min_last; + const reduce = @min(reducible, over); + widths[5] -= reduce; + over -= reduce; + } } fn adjustTableWidths(widths: []usize) void { diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index f6462867..b2f86aee 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -24,6 +24,11 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.CurlRequired or err == error.SwitchSelectionRequiresTty or err == error.AliasSelectionRequiresTty or + err == error.ResetRequiresConfirmation or + err == error.MultipleAccountsMatched or + err == error.UnsupportedAuthMode or + err == error.MissingAuth or + err == error.RequestFailed or err == error.InvalidAlias or err == error.DuplicateAlias or err == error.RemoveConfirmationUnavailable or diff --git a/src/workflows/reset.zig b/src/workflows/reset.zig new file mode 100644 index 00000000..417ef0c7 --- /dev/null +++ b/src/workflows/reset.zig @@ -0,0 +1,66 @@ +const std = @import("std"); +const chatgpt_http = @import("../api/http.zig"); +const usage_api = @import("../api/usage.zig"); +const cli = @import("../cli/root.zig"); +const registry = @import("../registry/root.zig"); +const query_mod = @import("query.zig"); + +pub fn handleReset(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.ResetOptions) !void { + if (!opts.yes) { + try cli.output.printResetRequiresYesError(); + return error.ResetRequiresConfirmation; + } + + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { + try registry.saveRegistry(allocator, codex_home, ®); + } + + const idx = try resolveResetTargetIndex(allocator, ®, opts.selector); + const rec = ®.accounts.items[idx]; + if (rec.auth_mode != null and rec.auth_mode.? == .apikey) { + try cli.output.printResetUnsupportedAuthModeError(rec.email); + return error.UnsupportedAuthMode; + } + try chatgpt_http.ensureCurlExecutableAvailable(allocator); + + const auth_path = try registry.accountAuthPath(allocator, codex_home, rec.account_key); + defer allocator.free(auth_path); + + var result = usage_api.consumeResetForAuthPath(allocator, auth_path) catch |err| { + try cli.output.printResetConsumeFailedError(@errorName(err)); + return err; + }; + defer result.deinit(allocator); + + if (rec.last_usage) |*snapshot| { + if (snapshot.reset_credits) |count| { + snapshot.reset_credits = @max(count - 1, 0); + } + } + try registry.saveRegistry(allocator, codex_home, ®); + try cli.output.printResetConsumed(allocator, ®, rec.account_key, &result); +} + +fn resolveResetTargetIndex( + allocator: std.mem.Allocator, + reg: *registry.Registry, + selector: []const u8, +) !usize { + var resolution = try query_mod.resolveSwitchQueryLocally(allocator, reg, selector); + defer resolution.deinit(allocator); + + const account_key = switch (resolution) { + .not_found => { + try cli.output.printResetAccountNotFoundError(selector); + return error.AccountNotFound; + }, + .direct => |key| key, + .multiple => { + try cli.output.printResetMultipleTargetsError(selector); + return error.MultipleAccountsMatched; + }, + }; + return registry.findAccountIndexByAccountKey(reg, account_key) orelse error.AccountNotFound; +} diff --git a/src/workflows/root.zig b/src/workflows/root.zig index 02f9bc5a..38f11ffb 100644 --- a/src/workflows/root.zig +++ b/src/workflows/root.zig @@ -24,6 +24,7 @@ const export_workflow = @import("export.zig"); const switch_workflow = @import("switch.zig"); const remove_workflow = @import("remove.zig"); const alias_workflow = @import("alias.zig"); +const reset_workflow = @import("reset.zig"); const workflow_env = @import("env.zig"); const targets = @import("targets.zig"); const usage_refresh = @import("usage.zig"); @@ -149,6 +150,7 @@ fn runMain(init: std.process.Init.Minimal) !void { .switch_account => |opts| try switch_workflow.handleSwitch(allocator, codex_home.?, opts), .remove_account => |opts| try remove_workflow.handleRemove(allocator, codex_home.?, opts), .alias => |opts| try alias_workflow.handleAlias(allocator, codex_home.?, opts), + .reset => |opts| try reset_workflow.handleReset(allocator, codex_home.?, opts), .clean => |opts| try clean_workflow.handleClean(allocator, codex_home.?, opts), } } diff --git a/tests/api_usage_test.zig b/tests/api_usage_test.zig index 2b5c7615..eda05c5d 100644 --- a/tests/api_usage_test.zig +++ b/tests/api_usage_test.zig @@ -46,6 +46,9 @@ test "parse usage api response maps live usage windows and plan" { \\ "approx_local_messages": null, \\ "approx_cloud_messages": null \\ }, + \\ "rate_limit_reset_credits": { + \\ "available_count": 3 + \\ }, \\ "promo": null \\} ; @@ -61,6 +64,23 @@ test "parse usage api response maps live usage windows and plan" { try std.testing.expect(snapshot.credits != null); try std.testing.expect(!snapshot.credits.?.has_credits); try std.testing.expect(snapshot.credits.?.balance == null); + try std.testing.expectEqual(@as(?i64, 3), snapshot.reset_credits); +} + +test "parse reset consume response maps code and windows reset" { + const gpa = std.testing.allocator; + const body = + \\{ + \\ "code": "success", + \\ "windows_reset": true + \\} + ; + + var result = try usage_api.parseResetConsumeResponse(gpa, body); + defer result.deinit(gpa); + + try std.testing.expectEqualStrings("success", result.code.?); + try std.testing.expectEqual(@as(?bool, true), result.windows_reset); } test "parse usage api response without windows is ignored" { diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index b76b4e66..00056553 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -225,6 +225,33 @@ test "Scenario: Given import cpa with purge when parsing then usage error is ret try expectUsageError(result, .import_auth, "`--purge`"); } +test "Scenario: Given reset selector and yes flag when parsing then reset options are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "reset", "02", "--yes" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .reset => |opts| { + try std.testing.expectEqualStrings("02", opts.selector); + try std.testing.expect(opts.yes); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given reset without selector when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "reset", "--yes" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .reset, "`reset` requires an account selector."); +} + test "Scenario: Given export directory when parsing then export options are preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "export", "/tmp/codex-backup" }; diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 4b7180b4..b8d498c5 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -312,9 +312,9 @@ test "Scenario: Given live switch table rows when rendering then table spacing a }, null, false); try std.testing.expectEqualStrings( - " ACCOUNT PLAN 5H WEEKLY LAST\n" ++ + " ACCOUNT PLAN RESETS 5H WEEKLY LAST\n" ++ " group \n" ++ - "* 01 child Team - - - \n", + "* 01 child Team - - - \n", writer.buffered(), ); } @@ -361,9 +361,9 @@ test "Scenario: Given live remove table rows when rendering then checkbox spacin }, 0, &checked, false); try std.testing.expectEqualStrings( - " ACCOUNT PLAN 5H WEEKLY LAST\n" ++ + " ACCOUNT PLAN RESETS 5H WEEKLY LAST\n" ++ " group \n" ++ - "> [x] 01 child Team - - - \n", + "> [x] 01 child Team - - - \n", writer.buffered(), ); } diff --git a/tests/table_layout_test.zig b/tests/table_layout_test.zig index feec7c16..e39fa757 100644 --- a/tests/table_layout_test.zig +++ b/tests/table_layout_test.zig @@ -117,7 +117,7 @@ test "Scenario: Given remaining table width when rendering then status plan and const output = writer.buffered(); try expectLinesWithin(output, 50); - try std.testing.expect(std.mem.indexOf(u8, output, "very-l.mple.com") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "very-l.com") != null); try std.testing.expect(std.mem.indexOf(u8, output, "Business") != null); try std.testing.expect(std.mem.indexOf(u8, output, "100%") != null); try std.testing.expect(std.mem.indexOf(u8, output, "42%") != null); diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index 8ebec328..a9874d0a 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -143,6 +143,29 @@ test "writeAccountsTable keeps usage headers short" { try std.testing.expect(std.mem.indexOf(u8, output, "USAGE") == null); } +test "writeAccountsTable shows reset credits column" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .plus); + reg.accounts.items[0].last_usage = .{ + .primary = null, + .secondary = null, + .credits = null, + .reset_credits = 2, + .plan_type = .plus, + }; + + var buffer: [2048]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try writeAccountsTable(&writer, ®, false); + + const output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "RESETS") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Plus 2") != null); +} + test "writeAccountsTable shows usage override statuses for failed refreshes" { const gpa = std.testing.allocator; var reg = makeTestRegistry();