Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command
|---------|-------------|
| [`codex-auth list [--live] [--active] [--api\|--skip-api]`](./docs/commands/list.md) | List stored accounts and usage state |
| [`codex-auth login [--device-auth]`](./docs/commands/login.md) | Run `codex login`, then add the current account |
| [`codex-auth switch [--live] [--api\|--skip-api]`](./docs/commands/switch.md) | Switch the active account interactively |
| [`codex-auth switch [--auto\|--live] [--api\|--skip-api]`](./docs/commands/switch.md) | Switch the active account interactively or automatically |
| [`codex-auth switch <query>`](./docs/commands/switch.md) | Switch directly by row number or account selector |
| [`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 |
Expand Down
12 changes: 12 additions & 0 deletions docs/commands/switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

```shell
codex-auth switch -
codex-auth switch --auto [--api|--skip-api]
codex-auth switch [--api|--skip-api]
codex-auth switch --live [--api|--skip-api]
codex-auth switch <query>
Expand All @@ -27,6 +28,17 @@ codex-auth switch <query>
- `--api` forces foreground remote refresh before rendering.
- `--skip-api` renders from stored data and local-only active-account refresh where available.

## Automatic One-Shot Switch

`codex-auth switch --auto` switches once without opening a picker.

- The command refreshes usage the same way as interactive switch unless `--skip-api` is set.
- It selects an inactive account only when both 5h and weekly remaining limits are greater than `0%`.
- When multiple inactive accounts are eligible, it chooses the account with the earliest 5h reset.
- Accounts with foreground refresh errors are not eligible for that run.
- The command exits after one successful switch and does not start a background process.
- `--auto` cannot be combined with `--live`, `-`, or a query target.

## Live Switch

`codex-auth switch --live` keeps the picker open after each successful switch.
Expand Down
21 changes: 19 additions & 2 deletions src/cli/commands/switch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa
opts.live = true;
continue;
}
if (std.mem.eql(u8, arg, "--auto")) {
if (opts.auto) {
freeTarget(allocator, opts.target);
return common.usageErrorResult(allocator, .switch_account, "duplicate `--auto` for `switch`.", .{});
}
opts.auto = true;
continue;
}
if (std.mem.eql(u8, arg, "--api")) {
switch (opts.api_mode) {
.default => opts.api_mode = .force_api,
Expand Down Expand Up @@ -59,12 +67,21 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa
else
.{ .query = try allocator.dupe(u8, arg) };
}
if (opts.target != .picker and (opts.api_mode != .default or opts.live)) {
if (opts.auto and opts.live) {
freeTarget(allocator, opts.target);
return common.usageErrorResult(
allocator,
.switch_account,
"`--auto` cannot be combined with `--live` for `switch`.",
.{},
);
}
if (opts.target != .picker and (opts.api_mode != .default or opts.live or opts.auto)) {
freeTarget(allocator, opts.target);
return common.usageErrorResult(
allocator,
.switch_account,
"`switch -|<alias|email|display-number|query>` does not support `--live`, `--api`, or `--skip-api`.",
"`switch -|<alias|email|display-number|query>` does not support `--auto`, `--live`, `--api`, or `--skip-api`.",
.{},
);
}
Expand Down
6 changes: 5 additions & 1 deletion src/cli/help.zig
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub fn writeHelp(
try writeCommandSummary(out, use_color, "export [<dir>] [--cpa]", "Export stored account auth files");
try writeCommandSummary(out, use_color, "switch", "Switch the active account");
try writeCommandDetail(out, use_color, "switch -");
try writeCommandDetail(out, use_color, "switch [--live] [--api|--skip-api]");
try writeCommandDetail(out, use_color, "switch [--auto|--live] [--api|--skip-api]");
try writeCommandDetail(out, use_color, "switch <alias|email|display-number|query>");
try writeCommandSummary(out, use_color, "remove", "Remove one or more accounts");
try writeCommandDetail(out, use_color, "remove [--live] [--api|--skip-api]");
Expand Down Expand Up @@ -209,6 +209,7 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void {
},
.switch_account => {
try out.writeAll(" codex-auth switch -\n");
try out.writeAll(" codex-auth switch --auto [--api|--skip-api]\n");
try out.writeAll(" codex-auth switch [--live] [--api|--skip-api]\n");
try out.writeAll(" codex-auth switch <alias|email|display-number|query>\n");
},
Expand Down Expand Up @@ -278,6 +279,7 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void {
try out.writeAll(" --cpa Export CPA flat token JSON. Without this, exports Codex auth snapshots.\n");
},
.switch_account => {
try out.writeAll(" --auto Switch once to an inactive account with both 5h and weekly limits remaining.\n");
try out.writeAll(" --live Open the live switch UI.\n");
try out.writeAll(" --api Load usage and account data from APIs.\n");
try out.writeAll(" --skip-api Load usage and account data from local data only (may be inaccurate).\n");
Expand Down Expand Up @@ -353,6 +355,7 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void {
},
.switch_account => {
try out.writeAll(" codex-auth switch\n");
try out.writeAll(" codex-auth switch --auto\n");
try out.writeAll(" codex-auth switch -\n");
try out.writeAll(" codex-auth switch --live\n");
try out.writeAll(" codex-auth switch --api\n");
Expand Down Expand Up @@ -398,6 +401,7 @@ fn writeNotesSectionStyled(out: *std.Io.Writer, use_color: bool, topic: HelpTopi
switch (topic) {
.switch_account => {
try out.writeAll(" Targets can be `-`, aliases, emails, display numbers, or partial queries.\n");
try out.writeAll(" `--auto` selects the eligible inactive account with the earliest 5h reset.\n");
},
.alias => {
try out.writeAll(" Alias targets can be aliases, emails, display numbers, or partial queries.\n");
Expand Down
12 changes: 12 additions & 0 deletions src/cli/output.zig
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,18 @@ pub fn printSwitchRequiresTtyError() !void {
try out.flush();
}

pub fn printNoAutoSwitchCandidateError() !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(" no inactive account has both 5h and weekly limits remaining.\n");
try writeHintPrefixTo(out, use_color);
try out.writeAll(" Run `codex-auth list --api` to refresh usage, or add another account with available limits.\n");
try out.flush();
}

pub fn printListRequiresTtyError() !void {
var stderr: io_util.Stderr = undefined;
stderr.init();
Expand Down
2 changes: 1 addition & 1 deletion src/cli/picker_auto.zig
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ fn autoSwitchCandidateIsBetter(candidate: *const registry.AccountRecord, best: *

const candidate_weekly_reset = resetDistanceForMinutes(candidate.last_usage, 10080, false, now);
const best_weekly_reset = resetDistanceForMinutes(best.last_usage, 10080, false, now);
return candidate_weekly_reset < best_weekly_reset;
return candidate_weekly_reset > best_weekly_reset;
}

fn bestAutoSwitchCandidateSelectableIndex(
Expand Down
1 change: 1 addition & 0 deletions src/cli/types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub const SwitchTarget = union(enum) {
pub const SwitchOptions = struct {
target: SwitchTarget = .picker,
live: bool = false,
auto: bool = false,
api_mode: ApiMode = .default,
};
pub const RemoveOptions = struct {
Expand Down
66 changes: 66 additions & 0 deletions src/registry/account_ops.zig
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,72 @@ pub fn selectBestAccountIndexByUsage(reg: *Registry) ?usize {
return best_idx;
}

pub fn selectAutoSwitchAccountIndexByLimits(reg: *Registry) ?usize {
const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds();
return selectAutoSwitchAccountIndexByLimitsWithUsageOverridesAt(reg, null, now);
}

pub fn selectAutoSwitchAccountIndexByLimitsWithUsageOverrides(
reg: *Registry,
usage_overrides: ?[]const ?[]const u8,
) ?usize {
const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds();
return selectAutoSwitchAccountIndexByLimitsWithUsageOverridesAt(reg, usage_overrides, now);
}

pub fn selectAutoSwitchAccountIndexByLimitsAt(reg: *Registry, now: i64) ?usize {
return selectAutoSwitchAccountIndexByLimitsWithUsageOverridesAt(reg, null, now);
}

fn selectAutoSwitchAccountIndexByLimitsWithUsageOverridesAt(
reg: *Registry,
usage_overrides: ?[]const ?[]const u8,
now: i64,
) ?usize {
var best_idx: ?usize = null;
for (reg.accounts.items, 0..) |*rec, idx| {
if (reg.active_account_key) |active_key| {
if (std.mem.eql(u8, rec.account_key, active_key)) continue;
}
if (usageOverrideForAccount(usage_overrides, idx) != null) continue;
if (!accountHasPositiveFiveHourAndWeeklyLimits(rec, now)) continue;

if (best_idx == null or autoSwitchAccountIsBetter(rec, &reg.accounts.items[best_idx.?], now)) {
best_idx = idx;
}
}
return best_idx;
}

fn usageOverrideForAccount(usage_overrides: ?[]const ?[]const u8, account_idx: usize) ?[]const u8 {
const overrides = usage_overrides orelse return null;
if (account_idx >= overrides.len) return null;
return overrides[account_idx];
}

fn accountHasPositiveFiveHourAndWeeklyLimits(rec: *const AccountRecord, now: i64) bool {
const rate_5h = resolveRateWindow(rec.last_usage, 300, true);
const rate_week = resolveRateWindow(rec.last_usage, 10080, false);
const rem_5h = remainingPercentAt(rate_5h, now) orelse return false;
const rem_week = remainingPercentAt(rate_week, now) orelse return false;
return rem_5h > 0 and rem_week > 0;
}

fn autoSwitchAccountIsBetter(candidate: *const AccountRecord, best: *const AccountRecord, now: i64) bool {
const candidate_5h_reset = resetDistanceForWindow(resolveRateWindow(candidate.last_usage, 300, true), now);
const best_5h_reset = resetDistanceForWindow(resolveRateWindow(best.last_usage, 300, true), now);
if (candidate_5h_reset != best_5h_reset) return candidate_5h_reset < best_5h_reset;

const candidate_weekly_reset = resetDistanceForWindow(resolveRateWindow(candidate.last_usage, 10080, false), now);
const best_weekly_reset = resetDistanceForWindow(resolveRateWindow(best.last_usage, 10080, false), now);
return candidate_weekly_reset > best_weekly_reset;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

fn resetDistanceForWindow(window: ?RateLimitWindow, now: i64) i64 {
const resets_at = if (window) |value| value.resets_at orelse return std.math.maxInt(i64) else return std.math.maxInt(i64);
return @max(resets_at - now, 0);
}

pub fn usageScoreAt(usage: ?RateLimitSnapshot, now: i64) ?i64 {
const rate_5h = resolveRateWindow(usage, 300, true);
const rate_week = resolveRateWindow(usage, 10080, false);
Expand Down
3 changes: 3 additions & 0 deletions src/registry/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ pub fn syncActiveAccountFromAuth(allocator: std.mem.Allocator, codex_home: []con
}
pub const removeAccounts = account_ops.removeAccounts;
pub const selectBestAccountIndexByUsage = account_ops.selectBestAccountIndexByUsage;
pub const selectAutoSwitchAccountIndexByLimits = account_ops.selectAutoSwitchAccountIndexByLimits;
pub const selectAutoSwitchAccountIndexByLimitsWithUsageOverrides = account_ops.selectAutoSwitchAccountIndexByLimitsWithUsageOverrides;
pub const selectAutoSwitchAccountIndexByLimitsAt = account_ops.selectAutoSwitchAccountIndexByLimitsAt;
pub const usageScoreAt = account_ops.usageScoreAt;
pub const remainingPercentAt = account_ops.remainingPercentAt;
pub const resolveRateWindow = account_ops.resolveRateWindow;
Expand Down
1 change: 1 addition & 0 deletions src/workflows/preflight.zig
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub fn isHandledCliError(err: anyerror) bool {
err == error.TuiOutputUnavailable or
err == error.CurlRequired or
err == error.SwitchSelectionRequiresTty or
err == error.NoAutoSwitchCandidate or
err == error.AliasSelectionRequiresTty or
err == error.InvalidAlias or
err == error.DuplicateAlias or
Expand Down
41 changes: 41 additions & 0 deletions src/workflows/switch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts:
.previous => return handleSwitchPrevious(allocator, codex_home, opts),
.picker => {},
}
if (opts.auto) return handleSwitchAuto(allocator, codex_home, opts);

{
if (!opts.live) {
Expand Down Expand Up @@ -107,6 +108,46 @@ pub fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts:
}
}

fn handleSwitchAuto(
allocator: std.mem.Allocator,
codex_home: []const u8,
opts: cli.types.SwitchOptions,
) !void {
std.debug.assert(opts.target == .picker);
std.debug.assert(!opts.live);
std.debug.assert(opts.auto);

var loaded = if (opts.api_mode == .skip_api)
try loadStoredSwitchSelectionDisplay(
allocator,
codex_home,
.switch_account,
opts.api_mode,
)
else
try loadSwitchSelectionDisplay(
allocator,
codex_home,
opts.api_mode,
.switch_account,
true,
);
defer loaded.display.deinit(allocator);
defer if (loaded.refresh_error_name) |name| allocator.free(name);

const selected_account_idx = registry.selectAutoSwitchAccountIndexByLimitsWithUsageOverrides(
&loaded.display.reg,
loaded.display.usage_overrides,
) orelse {
try cli.output.printNoAutoSwitchCandidateError();
return error.NoAutoSwitchCandidate;
};
const selected_account_key = loaded.display.reg.accounts.items[selected_account_idx].account_key;
try registry.activateAccountByKey(allocator, codex_home, &loaded.display.reg, selected_account_key);
try registry.saveRegistry(allocator, codex_home, &loaded.display.reg);
try cli.output.printSwitchedAccount(allocator, &loaded.display.reg, selected_account_key);
}

fn handleSwitchQuery(
allocator: std.mem.Allocator,
codex_home: []const u8,
Expand Down
34 changes: 23 additions & 11 deletions tests/cli_behavior_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ test "Scenario: Given help when rendering then login and command help notes are
const help = aw.written();
try std.testing.expect(std.mem.indexOf(u8, help, "Commands:") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "list [--live] [--active] [--api|--skip-api]") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "switch [--live] [--api|--skip-api]") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "switch [--auto|--live] [--api|--skip-api]") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "alias set <alias|email|display-number|query> <alias>") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "config live --interval <seconds>") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "auto enable") == null);
Expand Down Expand Up @@ -474,7 +474,8 @@ test "Scenario: Given switch command help when rendering then target forms and m
try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth switch john@example.com") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth switch 02") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth switch work") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "Options:\n --live Open the live switch UI.") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "Options:\n --auto Switch once to an inactive account with both 5h and weekly limits remaining.") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "--live Open the live switch UI.") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "Switch directly when the target resolves to one account.") != null);
try std.testing.expect(std.mem.indexOf(u8, help, "If a target is ambiguous") == null);
}
Expand Down Expand Up @@ -1077,22 +1078,33 @@ test "Scenario: Given switch interactive with live flag when parsing then live m
}
}

test "Scenario: Given switch with removed auto flag when parsing then usage error is returned" {
test "Scenario: Given switch auto when parsing then auto mode is preserved" {
const gpa = std.testing.allocator;
const args = [_][:0]const u8{ "codex-auth", "switch", "--live", "--auto" };
const args = [_][:0]const u8{ "codex-auth", "switch", "--auto" };
var result = try cli.commands.parseArgs(gpa, &args);
defer cli.commands.freeParseResult(gpa, &result);

try expectUsageError(result, .switch_account, "unknown flag `--auto`");
switch (result) {
.command => |cmd| switch (cmd) {
.switch_account => |opts| {
try std.testing.expect(opts.auto);
try std.testing.expect(!opts.live);
try std.testing.expectEqual(cli.types.SwitchTarget.picker, opts.target);
try std.testing.expectEqual(cli.types.ApiMode.default, opts.api_mode);
},
else => return error.TestExpectedEqual,
},
else => return error.TestExpectedEqual,
}
}

test "Scenario: Given switch with removed auto flag without live when parsing then usage error is returned" {
test "Scenario: Given switch auto with live when parsing then usage error is returned" {
const gpa = std.testing.allocator;
const args = [_][:0]const u8{ "codex-auth", "switch", "--auto" };
const args = [_][:0]const u8{ "codex-auth", "switch", "--live", "--auto" };
var result = try cli.commands.parseArgs(gpa, &args);
defer cli.commands.freeParseResult(gpa, &result);

try expectUsageError(result, .switch_account, "unknown flag `--auto`");
try expectUsageError(result, .switch_account, "`--auto` cannot be combined with `--live`");
}

test "Scenario: Given switch query with live flag when parsing then usage error is returned" {
Expand Down Expand Up @@ -1176,13 +1188,13 @@ test "Scenario: Given switch dash with skip-api flag when parsing then usage err
try expectUsageError(result, .switch_account, "switch -|<alias|email|display-number|query>");
}

test "Scenario: Given switch query with removed auto flag when parsing then usage error is returned" {
test "Scenario: Given switch query with auto flag when parsing then usage error is returned" {
const gpa = std.testing.allocator;
const args = [_][:0]const u8{ "codex-auth", "switch", "--live", "--auto", "02" };
const args = [_][:0]const u8{ "codex-auth", "switch", "--auto", "02" };
var result = try cli.commands.parseArgs(gpa, &args);
defer cli.commands.freeParseResult(gpa, &result);

try expectUsageError(result, .switch_account, "unknown flag `--auto`");
try expectUsageError(result, .switch_account, "does not support `--auto`");
}

test "Scenario: Given switch with duplicate target when parsing then usage error is returned" {
Expand Down
Loading