diff --git a/docs/commands/reset.md b/docs/commands/reset.md index fcd27d0e..42984315 100644 --- a/docs/commands/reset.md +++ b/docs/commands/reset.md @@ -3,7 +3,7 @@ ## Usage ```shell -codex-auth reset +codex-auth reset [--credit ] [--json] ``` ## Behavior @@ -11,12 +11,13 @@ codex-auth reset `codex-auth reset` redeems one reset credit for a single stored account. - The account selector accepts the same alias, email, display-number, and query forms as `switch`, and must resolve to exactly one account. Two differences from `switch`: a flag-like selector (a word longer than one character starting with `-`) is rejected as an unknown flag, and a query matching several accounts fails instead of opening the interactive picker. -- 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. +- By default 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. `--credit ` (or `--credit=`) instead redeems the available card whose `id` matches; an id that names no available card fails without a request. - The chosen credit is redeemed through `POST wham/rate-limit-reset-credits/consume` with a freshly generated UUID v4 `redeem_request_id`. See [docs/api.md](../api.md) for the full request. - On success it prints one compact line reporting that a credit was redeemed, how many are left, and the next card's expiry — or that none are left when the redeemed card was the last one. The backend answering `already_redeemed` is reported in the same spot and counts as success. The redeemed card is neither named nor dated in the line; `list --json` and the stored snapshot keep the card detail. Exact wording is covered by tests and follows the shared separator rules in [docs/windows.md](../windows.md). +- `--json` emits one machine-readable success document instead of the summary line: `schema_version`, `command: "reset"`, `redeemed` (the account object shared with `list --json`), `outcome` (`"reset"` or `"already_redeemed"`), `remaining_credits`, and `next_expires_at` (the stored ISO 8601 expiry of the soonest-expiring card still available, or `null`). `remaining_credits` and `next_expires_at` describe the stored card list after the consume. See [docs/json-api.md](../json-api.md). - The remaining count is derived from the stored card list, so it can be lower than the `RESETS` cell of `codex-auth list`, which prefers the server's `available_count`. Server card lists may be capped; run `codex-auth list --api` to reconcile them. - 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`), `1` when the account has no available credits, the backend reports `no_credit` or `nothing_to_reset`, or the request fails, and `2` for a usage error such as a missing selector or an unknown flag. +- Exit code is `0` after a successful consume (including the backend reporting `already_redeemed`), `1` when the account has no available credits, `--credit` names no available card, the backend reports `no_credit` or `nothing_to_reset`, or the request fails, and `2` for a usage error such as a missing selector, a missing `--credit` value, or an unknown flag. With `--json`, failures are reported as JSON error documents on stdout (codes `account_not_found`, `ambiguous_query`, `no_reset_credit`, `reset_credit_not_found`, `nothing_to_reset`, `reset_failed`, `registry_error`, `usage`); see [docs/json-api.md](../json-api.md). ## Notes diff --git a/docs/json-api.md b/docs/json-api.md index c574400f..bae63af7 100644 --- a/docs/json-api.md +++ b/docs/json-api.md @@ -31,6 +31,7 @@ codex-auth switch --json codex-auth - --json codex-auth remove [...] --json codex-auth remove --all --json +codex-auth reset [--credit ] --json ``` Interactive and live paths are not supported. `codex-auth - --json` emits the @@ -188,6 +189,26 @@ accompany exit code 1 when the daemon restart fails. } ``` +### Reset + +```json +{ + "schema_version": 1, + "command": "reset", + "redeemed": {}, + "outcome": "reset", + "remaining_credits": 1, + "next_expires_at": "2026-11-01T00:00:00Z" +} +``` + +`redeemed` is the account object of the account the credit was redeemed for. +`outcome` is `"reset"` or `"already_redeemed"`; both count as success. +`remaining_credits` and `next_expires_at` describe the stored reset-credit +card list after the consume: `next_expires_at` is the stored ISO 8601 expiry +of the soonest-expiring card still available, or `null` when no dated card +remains. + ## Error Documents ```json @@ -247,10 +268,14 @@ Resolution status values are `resolved`, `ambiguous`, and `not_found`. | Error code | Meaning | |------------|---------| -| `account_not_found` | Switch query has no match | -| `ambiguous_query` | Switch query has multiple matches | +| `account_not_found` | Switch query or reset selector has no match | +| `ambiguous_query` | Switch query or reset selector has multiple matches | | `no_previous_account` | `codex-auth -` ran with no stored previous account | | `selector_resolution_failed` | Remove selector resolution failed atomically | +| `no_reset_credit` | Account has no available reset credit, or the backend reports the credit spent | +| `reset_credit_not_found` | `reset --credit` names no available reset-credit card | +| `nothing_to_reset` | Backend reports the account's usage does not need a reset | +| `reset_failed` | The reset-credit consume request failed | | `curl_unavailable` | Required API refresh cannot find curl | | `registry_error` | State failed before mutation began | | `state_uncertain` | Persistence failed after mutation began; run `list --json` before retrying | diff --git a/src/cli/commands/reset.zig b/src/cli/commands/reset.zig index f4389d26..46a55c05 100644 --- a/src/cli/commands/reset.zig +++ b/src/cli/commands/reset.zig @@ -3,25 +3,65 @@ const types = @import("../types.zig"); const common = @import("common.zig"); pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { + const json_requested = common.argsContainFlag(args, "--json"); 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); + defer if (selector) |value| allocator.free(value); + var credit: ?[]u8 = null; + defer if (credit) |value| allocator.free(value); + var json = false; + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = std.mem.sliceTo(args[i], 0); + if (std.mem.eql(u8, arg, "--json")) { + if (json) { + return common.usageErrorResultWithJson(allocator, .reset_credits, true, "duplicate `--json` for `reset`.", .{}); + } + json = true; + continue; + } + if (std.mem.eql(u8, arg, "--credit") or common.inlineFlagValue(arg, "--credit") != null) { + const inline_value = common.inlineFlagValue(arg, "--credit"); + if (inline_value == null and i + 1 >= args.len) { + return common.usageErrorResultWithJson(allocator, .reset_credits, json_requested, "missing value for `--credit`.", .{}); + } + if (credit != null) { + return common.usageErrorResultWithJson(allocator, .reset_credits, json_requested, "duplicate `--credit` for `reset`.", .{}); + } + const value = inline_value orelse blk: { + // A flag-like next word is not a card id: it is the value the + // user forgot, e.g. `--credit --json`. + const next = std.mem.sliceTo(args[i + 1], 0); + if (next.len > 1 and next[0] == '-') { + return common.usageErrorResultWithJson(allocator, .reset_credits, json_requested, "missing value for `--credit`.", .{}); + } + i += 1; + break :blk std.mem.sliceTo(args[i], 0); + }; + credit = try allocator.dupe(u8, value); + continue; + } if (arg.len > 1 and std.mem.startsWith(u8, arg, "-")) { - if (selector) |value| allocator.free(value); - return common.usageErrorResult(allocator, .reset_credits, "unknown flag `{s}` for `reset`.", .{arg}); + return common.usageErrorResultWithJson(allocator, .reset_credits, json_requested, "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}); + return common.usageErrorResultWithJson(allocator, .reset_credits, json_requested, "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 common.usageErrorResultWithJson(allocator, .reset_credits, json_requested, "`reset` requires an account selector (alias, email, display number, or partial query).", .{}); } - return .{ .command = .{ .reset_credits = .{ .selector = selector.? } } }; + const opts: types.ResetOptions = .{ + .selector = selector.?, + .credit = credit, + .json = json, + }; + // Ownership moved into `opts`; the defers above must not release it. + selector = null; + credit = null; + return .{ .command = .{ .reset_credits = opts } }; } diff --git a/src/cli/commands/root.zig b/src/cli/commands/root.zig index ee806fe0..9843e6a6 100644 --- a/src/cli/commands/root.zig +++ b/src/cli/commands/root.zig @@ -83,7 +83,10 @@ 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), + .reset_credits => |opts| { + allocator.free(opts.selector); + if (opts.credit) |credit| allocator.free(credit); + }, .alias => |opts| switch (opts) { .set => |set_opts| { allocator.free(set_opts.selector); diff --git a/src/cli/completion.zig b/src/cli/completion.zig index 46ba3b24..a2b51e72 100644 --- a/src/cli/completion.zig +++ b/src/cli/completion.zig @@ -334,6 +334,10 @@ const remove_flags = [_]FlagSpec{ .{ .name = "--all", .desc = "Remove every stored account" }, .{ .name = "--json", .desc = "Emit one machine-readable JSON document for non-interactive remove" }, }; +const reset_flags = [_]FlagSpec{ + .{ .name = "--credit", .desc = "Redeem the reset-credit card with this id", .value = .freeform }, + .{ .name = "--json", .desc = "Emit one machine-readable JSON document" }, +}; const app_flags = [_]FlagSpec{ .{ .name = "--id", .desc = "Set the Windows package/AUMID or macOS bundle identifier", .value = .freeform }, .{ .name = "--codex-cli-path", .desc = "Set CODEX_CLI_PATH for the app launch", .value = .path }, @@ -386,7 +390,7 @@ const command_specs = [_]CmdSpec{ .{ .name = "export", .desc = "Export stored account auth files", .flags = &export_flags, .positionals = &export_positionals }, .{ .name = "switch", .desc = "Switch the active account", .flags = &switch_flags, .positionals = &switch_positionals, .pos_blocks_flags = &.{ "--live", "--api", "--skip-api" } }, .{ .name = "remove", .desc = "Remove one or more accounts", .flags = &remove_flags, .positionals = &remove_positionals, .pos_blocks_flags = &.{ "--live", "--api", "--skip-api", "--all" } }, - .{ .name = "reset", .desc = "Use the available reset credit that expires first", .positionals = &reset_positionals }, + .{ .name = "reset", .desc = "Use the available reset credit that expires first", .flags = &reset_flags, .positionals = &reset_positionals }, .{ .name = "alias", .desc = "Set or clear account aliases", .subs = &alias_subs }, .{ .name = "clean", .desc = "Delete backup and stale files under accounts/", .positionals = &clean_positionals }, .{ .name = "completion", .desc = "Generate shell completion scripts", .positionals = &completion_positionals }, diff --git a/src/cli/help.zig b/src/cli/help.zig index 1a038efb..10a4fe0f 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -226,7 +226,7 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth remove --all\n"); try out.writeAll(" codex-auth remove (...|--all) --json\n"); }, - .reset_credits => try out.writeAll(" codex-auth reset \n"), + .reset_credits => try out.writeAll(" codex-auth reset [--credit ] [--json]\n"), .alias => { try out.writeAll(" codex-auth alias set \n"); try out.writeAll(" codex-auth alias clear \n"); @@ -319,6 +319,9 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" Remove one or more matching accounts.\n"); }, .reset_credits => { + try out.writeAll(" --credit Redeem the stored reset-credit card with this id instead of the\n"); + try out.writeAll(" soonest-expiring one.\n"); + try out.writeAll(" --json Emit one machine-readable JSON document.\n"); try out.writeAll(" \n"); try out.writeAll(" Select one stored account with an available reset credit.\n"); }, diff --git a/src/cli/json_output.zig b/src/cli/json_output.zig index 00bb81c2..684980cb 100644 --- a/src/cli/json_output.zig +++ b/src/cli/json_output.zig @@ -32,6 +32,15 @@ pub fn printRemoveResult(result: *const results.RemoveResult) !void { try out.flush(); } +pub fn printResetResult(result: *const results.ResetResult) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try writeResetResult(out, result); + try out.writeAll("\n"); + try out.flush(); +} + pub fn printError(code: []const u8, message: []const u8, candidates: ?[]const results.AccountView) !void { var stdout: io_util.Stdout = undefined; stdout.init(); @@ -93,6 +102,23 @@ pub fn writeRemoveResult(out: *std.Io.Writer, result: *const results.RemoveResul try jw.endObject(); } +pub fn writeResetResult(out: *std.Io.Writer, result: *const results.ResetResult) !void { + var jw: std.json.Stringify = .{ .writer = out, .options = .{} }; + try jw.beginObject(); + try writeSchemaVersion(&jw); + try jw.objectField("command"); + try jw.write("reset"); + try jw.objectField("redeemed"); + try writeAccount(&jw, &result.redeemed); + try jw.objectField("outcome"); + try jw.write(@tagName(result.outcome)); + try jw.objectField("remaining_credits"); + try jw.write(result.remaining_credits); + try jw.objectField("next_expires_at"); + try jw.write(result.next_expires_at); + try jw.endObject(); +} + pub fn writeError( out: *std.Io.Writer, code: []const u8, diff --git a/src/cli/types.zig b/src/cli/types.zig index 77a14294..3b5f3481 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -46,6 +46,8 @@ pub const RemoveOptions = struct { }; pub const ResetOptions = struct { selector: []u8, + credit: ?[]u8 = null, + json: bool = false, }; pub const AliasSetOptions = struct { selector: []u8, diff --git a/src/workflows/reset.zig b/src/workflows/reset.zig index 3943d7d0..e674af46 100644 --- a/src/workflows/reset.zig +++ b/src/workflows/reset.zig @@ -4,10 +4,36 @@ const registry = @import("../registry/root.zig"); const reset_credits_api = @import("../api/reset_credits.zig"); const transport_mod = @import("../api/transport.zig"); const query_mod = @import("query.zig"); +const results = @import("results.zig"); const panel = cli.reset_credits_panel; pub fn handleReset(allocator: std.mem.Allocator, transport: transport_mod.Transport, codex_home: []const u8, opts: cli.types.ResetOptions) !void { + if (opts.json) return handleResetJson(allocator, transport, codex_home, opts); + return handleResetInteractive(allocator, transport, codex_home, opts); +} + +/// `collectAvailable` sorts by expiry ascending (no expiry last), so the +/// default redemption target is the first card. `--credit` picks by id +/// instead; a null return means the requested card is not available. +fn selectCardIndex(cards: []const panel.AvailableCard, credit: ?[]const u8) ?usize { + const wanted = credit orelse return if (cards.len == 0) null else 0; + for (cards, 0..) |card, index| { + if (std.mem.eql(u8, card.id, wanted)) return index; + } + return null; +} + +/// Index of the soonest-expiring card still available once `redeemed_index` +/// leaves the list, or null when none remains. +fn nextCardIndex(cards: []const panel.AvailableCard, redeemed_index: usize) ?usize { + for (cards, 0..) |_, index| { + if (index != redeemed_index) return index; + } + return null; +} + +fn handleResetInteractive(allocator: std.mem.Allocator, transport: transport_mod.Transport, codex_home: []const u8, opts: cli.types.ResetOptions) !void { var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); @@ -38,9 +64,11 @@ pub fn handleReset(allocator: std.mem.Allocator, transport: transport_mod.Transp return error.ResetFailed; } - // collectAvailable sorts by expiry ascending (no expiry last), so the - // first card is the one that expires soonest. - const card = cards[0]; + const card_index = selectCardIndex(cards, opts.credit) orelse { + try cli.output.printResetError("no available reset credit `{s}` for {s}.", .{ opts.credit.?, rec.email }); + return error.ResetFailed; + }; + const card = cards[card_index]; const redeem_key = try reset_credits_api.generateIdempotencyKeyAlloc(allocator); defer allocator.free(redeem_key); var result = try reset_credits_api.consumeCreditForAccount( @@ -56,11 +84,12 @@ pub fn handleReset(allocator: std.mem.Allocator, transport: transport_mod.Transp switch (result.outcome) { .reset, .already_redeemed => { // The redeemed card's own expiry is spent, so the summary reports only - // what is left and when the next card expires. `collectAvailable` sorts - // by expiry ascending, so `cards[1]` is that card. + // what is left and when the next card expires. var next_expiry: ?[]u8 = null; - if (cards.len > 1 and cards[1].epoch != null) { - next_expiry = try cli.rows.formatCompactTimestampAlloc(allocator, cards[1].epoch); + if (nextCardIndex(cards, card_index)) |next_index| { + if (cards[next_index].epoch != null) { + next_expiry = try cli.rows.formatCompactTimestampAlloc(allocator, cards[next_index].epoch); + } } defer if (next_expiry) |text| allocator.free(text); try cli.output.printResetSummary( @@ -96,3 +125,128 @@ pub fn handleReset(allocator: std.mem.Allocator, transport: transport_mod.Transp }, } } + +fn handleResetJson(allocator: std.mem.Allocator, transport: transport_mod.Transport, codex_home: []const u8, opts: cli.types.ResetOptions) !void { + var reg = registry.loadRegistry(allocator, codex_home) catch |err| return printJsonWorkflowError(err); + defer reg.deinit(allocator); + + var resolution = query_mod.resolveSwitchQueryLocally(allocator, ®, opts.selector) catch |err| return printJsonWorkflowError(err); + defer resolution.deinit(allocator); + + const account_key = switch (resolution) { + .not_found => { + const message = try allocator.print("no account matches \"{s}\"", .{opts.selector}); + defer allocator.free(message); + try cli.json_output.printError("account_not_found", message, null); + return error.AccountNotFound; + }, + .direct => |key| key, + .multiple => |matches| { + const candidates = results.buildAccountViewsForIndices(allocator, ®, null, matches.items) catch |err| return printJsonWorkflowError(err); + defer results.deinitAccountViews(allocator, candidates); + const message = try allocator.print("query \"{s}\" matches multiple accounts", .{opts.selector}); + defer allocator.free(message); + try cli.json_output.printError("ambiguous_query", message, candidates); + return error.AmbiguousQuery; + }, + }; + const account_idx = registry.findAccountIndexByAccountKey(®, account_key) orelse { + const message = try allocator.print("no account matches \"{s}\"", .{opts.selector}); + defer allocator.free(message); + try cli.json_output.printError("account_not_found", message, null); + return error.AccountNotFound; + }; + const rec = ®.accounts.items[account_idx]; + + const cards = panel.collectAvailable(allocator, rec, &.{}) catch |err| return printJsonWorkflowError(err); + defer allocator.free(cards); + + const card_index = selectCardIndex(cards, opts.credit) orelse { + if (opts.credit) |credit_id| { + const message = try allocator.print("no available reset credit \"{s}\" for {s}", .{ credit_id, rec.email }); + defer allocator.free(message); + try cli.json_output.printError("reset_credit_not_found", message, null); + } else { + const message = try allocator.print("no available reset credits for {s}", .{rec.email}); + defer allocator.free(message); + try cli.json_output.printError("no_reset_credit", message, null); + } + return error.ResetFailed; + }; + const card = cards[card_index]; + const redeem_key = try reset_credits_api.generateIdempotencyKeyAlloc(allocator); + defer allocator.free(redeem_key); + var consume = reset_credits_api.consumeCreditForAccount( + allocator, + transport, + codex_home, + rec.account_key, + redeem_key, + card.id, + ) catch |err| return printJsonWorkflowError(err); + defer consume.deinit(allocator); + + switch (consume.outcome) { + .reset, .already_redeemed => { + const next_index = nextCardIndex(cards, card_index); + var result = results.buildResetResult( + allocator, + ®, + rec.account_key, + consume.outcome, + cards.len - 1, + if (next_index) |idx| cards[idx].expires_at else null, + ) catch |err| return printJsonWorkflowError(err); + defer result.deinit(allocator); + try cli.json_output.printResetResult(&result); + }, + .no_credit => { + try cli.json_output.printError("no_reset_credit", "reset credit is no longer available; run `codex-auth list --api` to refresh.", null); + return error.ResetFailed; + }, + .nothing_to_reset => { + try cli.json_output.printError("nothing_to_reset", "usage does not need a reset right now.", null); + return error.ResetFailed; + }, + .unexpected_response => { + const message = try allocator.print("reset failed: unexpected consume response (HTTP {?d}).", .{consume.status_code}); + defer allocator.free(message); + try cli.json_output.printError("reset_failed", message, null); + return error.ResetFailed; + }, + .http_error => { + const message = try allocator.print("reset failed (HTTP {?d}).", .{consume.status_code}); + defer allocator.free(message); + try cli.json_output.printError("reset_failed", message, null); + return error.ResetFailed; + }, + .request_error => { + const detail = if (consume.error_name) |name| + reset_credits_api.describeRequestError(name) + else + "request error"; + const message = try allocator.print("reset failed: {s}.", .{detail}); + defer allocator.free(message); + try cli.json_output.printError("reset_failed", message, null); + return error.ResetFailed; + }, + } +} + +fn printJsonWorkflowError(err: anyerror) anyerror { + switch (err) { + error.OutOfMemory => return err, + error.CurlRequired => { + try cli.json_output.printError( + "curl_unavailable", + "curl is required for API requests. Install curl and retry.", + null, + ); + return err; + }, + else => { + try cli.json_output.printError("registry_error", @errorName(err), null); + return error.RegistryError; + }, + } +} diff --git a/src/workflows/results.zig b/src/workflows/results.zig index 705aaa24..d742a50e 100644 --- a/src/workflows/results.zig +++ b/src/workflows/results.zig @@ -2,6 +2,7 @@ const std = @import("std"); const app_runtime = @import("../core/runtime.zig"); const display_rows = @import("../tui/display.zig"); const registry = @import("../registry/root.zig"); +const reset_credits_api = @import("../api/reset_credits.zig"); const usage_refresh = @import("usage.zig"); const codex_daemon = @import("codex_daemon.zig"); @@ -120,6 +121,19 @@ pub const SwitchResult = struct { } }; +pub const ResetResult = struct { + redeemed: AccountView, + outcome: reset_credits_api.ConsumeOutcome, + remaining_credits: usize, + next_expires_at: ?[]u8, + + pub fn deinit(self: *ResetResult, allocator: std.mem.Allocator) void { + self.redeemed.deinit(allocator); + if (self.next_expires_at) |text| allocator.free(text); + self.* = undefined; + } +}; + pub const RemoveResult = struct { removed: []AccountView, new_active_account_key: ?[]u8, @@ -200,6 +214,30 @@ pub fn buildSwitchResult( }; } +/// `next_expires_at` is the stored ISO 8601 expiry of the soonest-expiring +/// card still available after the consume, or null when no dated card remains. +pub fn buildResetResult( + allocator: std.mem.Allocator, + reg: *const registry.Registry, + account_key: []const u8, + outcome: reset_credits_api.ConsumeOutcome, + remaining_credits: usize, + next_expires_at: ?[]const u8, +) !ResetResult { + var redeemed = try buildAccountViewForKey(allocator, reg, null, account_key); + errdefer redeemed.deinit(allocator); + + const next = try dupeOptional(allocator, next_expires_at); + errdefer if (next) |text| allocator.free(text); + + return .{ + .redeemed = redeemed, + .outcome = outcome, + .remaining_credits = remaining_credits, + .next_expires_at = next, + }; +} + pub fn buildAccountViewsForIndices( allocator: std.mem.Allocator, reg: *const registry.Registry, diff --git a/src/workflows/root.zig b/src/workflows/root.zig index ef1a3876..0c29173f 100644 --- a/src/workflows/root.zig +++ b/src/workflows/root.zig @@ -140,6 +140,7 @@ fn commandWantsJson(cmd: *const cli.types.Command) bool { .list => |opts| opts.json, .switch_account => |opts| opts.json, .remove_account => |opts| opts.json, + .reset_credits => |opts| opts.json, else => false, }; } diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 96ee648f..29c5e744 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -2596,6 +2596,132 @@ test "Scenario: Given reset help when parsing then the reset credits topic is re try expectHelp(result, .reset_credits); } +test "Scenario: Given reset with json flag when parsing then the flag is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "reset", "work@example.com", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .reset_credits => |opts| { + try std.testing.expectEqualStrings("work@example.com", opts.selector); + try std.testing.expect(opts.json); + try std.testing.expect(opts.credit == null); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given reset with a credit flag when parsing then the card id is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "reset", "work@example.com", "--credit", "credit-123" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .reset_credits => |opts| try std.testing.expectEqualStrings("credit-123", opts.credit.?), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given reset with an inline credit flag when parsing then the card id is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "reset", "work@example.com", "--credit=credit-123", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .reset_credits => |opts| { + try std.testing.expectEqualStrings("credit-123", opts.credit.?); + try std.testing.expect(opts.json); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given reset with a missing credit value when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "reset", "work@example.com", "--credit" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .reset_credits, "missing value for `--credit`"); +} + +test "Scenario: Given reset with a flag in the credit position when parsing then json usage error is returned" { + const gpa = std.testing.allocator; + // `--json` after `--credit` is the forgotten value, not the card id; the + // automation caller keeps the JSON error contract. + const args = [_][:0]const u8{ "codex-auth", "reset", "work@example.com", "--credit", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .usage_error => |usage_err| { + try std.testing.expect(usage_err.json); + try std.testing.expect(std.mem.find(u8, usage_err.message, "missing value for `--credit`") != null); + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given reset with duplicate credit flags when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "reset", "work@example.com", "--credit", "a", "--credit=b" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .reset_credits, "duplicate `--credit`"); +} + +test "Scenario: Given reset with duplicate json flags when parsing then json usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "reset", "work@example.com", "--json", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .usage_error => |usage_err| { + try std.testing.expect(usage_err.json); + try std.testing.expect(std.mem.find(u8, usage_err.message, "duplicate `--json`") != null); + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given reset with an unknown flag when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "reset", "work@example.com", "--bogus" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .reset_credits, "unknown flag `--bogus`"); +} + +test "Scenario: Given reset json without a selector when parsing then json usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "reset", "--json" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .usage_error => |usage_err| { + try std.testing.expect(usage_err.json); + try std.testing.expect(std.mem.find(u8, usage_err.message, "requires an account selector") != null); + }, + else => return error.TestExpectedEqual, + } +} + test "reset summary line matches the documented examples on both platforms" { const gpa = std.testing.allocator; diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 9cb1d93f..6aa0f3dc 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -5592,6 +5592,177 @@ test "Scenario: Given no available reset credits when running reset then it repo try std.testing.expect(std.mem.find(u8, result.stderr, "no available reset credits") != null); } +test "Scenario: Given reset credits when running reset json then the success document reports the consume" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + const email = "json-reset@example.com"; + try seedResetCreditsFixture(gpa, home_root, email); + try writeResetCreditsAuthFile(gpa, home_root, email); + + const result = try runCliWithMockBackend(gpa, project_root, home_root, tmp.dir, "ok", &[_][]const u8{ "reset", email, "--json" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + logRunResultIfFailed("reset --json", result); + try expectSuccess(result); + try std.testing.expectEqualStrings("", result.stderr); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const root = parsed.value.object; + try std.testing.expectEqual(@as(i64, 1), root.get("schema_version").?.integer); + try std.testing.expectEqualStrings("reset", root.get("command").?.string); + try std.testing.expectEqualStrings(email, root.get("redeemed").?.object.get("email").?.string); + try std.testing.expectEqualStrings("reset", root.get("outcome").?.string); + try std.testing.expectEqual(@as(i64, 1), root.get("remaining_credits").?.integer); + try std.testing.expectEqualStrings("2027-01-01T00:00:00Z", root.get("next_expires_at").?.string); + try expectConsumeRequestLogged(gpa, tmp.dir, "credit-soon"); +} + +test "Scenario: Given a named credit when running reset json then that card is consumed and the next expiry shifts" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + const email = "json-credit-reset@example.com"; + try seedResetCreditsFixture(gpa, home_root, email); + try writeResetCreditsAuthFile(gpa, home_root, email); + + const result = try runCliWithMockBackend(gpa, project_root, home_root, tmp.dir, "ok", &[_][]const u8{ "reset", email, "--credit", "credit-later", "--json" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + logRunResultIfFailed("reset --credit --json", result); + try expectSuccess(result); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const root = parsed.value.object; + try std.testing.expectEqualStrings("reset", root.get("command").?.string); + try std.testing.expectEqualStrings("reset", root.get("outcome").?.string); + try std.testing.expectEqual(@as(i64, 1), root.get("remaining_credits").?.integer); + try std.testing.expectEqualStrings("2026-11-01T00:00:00Z", root.get("next_expires_at").?.string); + try expectConsumeRequestLogged(gpa, tmp.dir, "credit-later"); +} + +test "Scenario: Given a named credit when running reset then the named card is consumed" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + const email = "named-credit-reset@example.com"; + try seedResetCreditsFixture(gpa, home_root, email); + try writeResetCreditsAuthFile(gpa, home_root, email); + + const result = try runCliWithMockBackend(gpa, project_root, home_root, tmp.dir, "ok", &[_][]const u8{ "reset", email, "--credit=credit-later" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + logRunResultIfFailed("reset --credit", result); + try expectSuccess(result); + try std.testing.expect(std.mem.find(u8, result.stdout, "reset credit redeemed") != null); + try std.testing.expect(std.mem.find(u8, result.stdout, "1 left (next exp ") != null); + try expectConsumeRequestLogged(gpa, tmp.dir, "credit-later"); +} + +test "Scenario: Given an unknown credit id when running reset json then reset_credit_not_found is returned" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + const email = "unknown-credit-reset@example.com"; + try seedResetCreditsFixture(gpa, home_root, email); + try writeResetCreditsAuthFile(gpa, home_root, email); + + const result = try runCliWithMockBackend(gpa, project_root, home_root, tmp.dir, "ok", &[_][]const u8{ "reset", email, "--credit", "credit-gone", "--json" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectExitCode(result, 1); + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const err = parsed.value.object.get("error").?.object; + try std.testing.expectEqualStrings("reset_credit_not_found", err.get("code").?.string); +} + +test "Scenario: Given a missing account when running reset json then account_not_found is returned" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + try seedRegistryWithAccounts(gpa, home_root, "present@example.com", &.{ + .{ .email = "present@example.com", .alias = "present" }, + }); + + const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ "reset", "missing@example.com", "--json" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectExitCode(result, 1); + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const err = parsed.value.object.get("error").?.object; + try std.testing.expectEqualStrings("account_not_found", err.get("code").?.string); +} + +test "Scenario: Given a spent credit when running reset json then no_reset_credit is returned" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + const email = "json-gone-reset@example.com"; + try seedResetCreditsFixture(gpa, home_root, email); + try writeResetCreditsAuthFile(gpa, home_root, email); + + const result = try runCliWithMockBackend(gpa, project_root, home_root, tmp.dir, "no_credit", &[_][]const u8{ "reset", email, "--json" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectExitCode(result, 1); + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const err = parsed.value.object.get("error").?.object; + try std.testing.expectEqualStrings("no_reset_credit", err.get("code").?.string); +} + fn writeMockFixtureNamed( gpa: std.mem.Allocator, tmp_dir: fs.Dir,