Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error

| Command | Description |
|---------|-------------|
| `codex-auth list` | List all accounts |
| `codex-auth list [--debug]` | List all accounts |
| `codex-auth login [--device-auth]` | Run `codex login` (optionally with `--device-auth`), then add the current account |
| `codex-auth switch [<email>]` | Switch active account interactively or by partial match |
| `codex-auth remove` | Remove accounts with interactive multi-select |
Expand Down Expand Up @@ -125,6 +125,7 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error

```shell
codex-auth list
codex-auth list --debug
```

### Switch Account
Expand All @@ -135,7 +136,7 @@ Interactive: shows email, 5h, weekly, and last activity.
codex-auth switch
```

Before the picker opens, the current active account's usage is refreshed once so the selected row is not stale. The newly selected account is not refreshed after the switch completes.
Before the picker opens, `switch` refreshes usage for all stored accounts with a maximum concurrency of `3`, so the picker is based on a fresh cross-account snapshot. If a refresh returns a non-`200` HTTP status such as `401` or `403`, that row shows the status in both usage columns. If a stored account snapshot cannot make a ChatGPT usage request at all because the required auth fields are missing, that row shows `MissingAuth` instead of the previous usage values. No extra usage refresh is attempted after the switch completes.

![command switch](https://github.com/user-attachments/assets/48a86acf-2a6e-4206-a8c4-591989fdc0df)

Expand Down
8 changes: 5 additions & 3 deletions docs/api-refresh.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an

- `api.usage = true`: foreground refresh uses the usage API.
- `api.usage = false`: foreground refresh reads only the newest local `~/.codex/sessions/**/rollout-*.jsonl`.
- `list` refreshes only the current active account before rendering.
- `switch` refreshes only the current active account before showing the picker so the currently selected row is not stale.
- `switch` does not refresh usage for the newly selected account after the switch completes.
- when `api.usage = true`, both `list` and pre-selection `switch` refresh all stored accounts before rendering, using stored auth snapshots under `accounts/` with a maximum concurrency of `3`
- when one of those per-account foreground usage requests returns a non-`200` HTTP status, the corresponding `list` / `switch` row shows that response status in both usage columns until a later successful refresh replaces it
- when a stored account snapshot cannot make a ChatGPT usage request because it is missing the required ChatGPT auth fields, the corresponding `list` / `switch` row shows `MissingAuth` in both usage columns until a later successful refresh replaces it
- when `api.usage = false`, foreground refresh still uses only the active local rollout data because local session files do not identify the other stored accounts
- `switch` does not refresh usage again after the new account is activated
- the auto-switch daemon refreshes the current active account usage during each cycle when `auto_switch.enabled = true`
- the auto-switch daemon may also refresh a small number of non-active candidate accounts from stored snapshots so it can score switch candidates
- the daemon usage paths are cooldown-limited; see [docs/auto-switch.md](./auto-switch.md) for the broader runtime loop
Expand Down
4 changes: 3 additions & 1 deletion docs/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,9 @@ When switching:
2. The selected account’s `accounts/<account file key>.auth.json` is copied to `~/.codex/auth.json`.
3. The registry’s `active_account_key` is updated to that account’s `record_key`.

The switch command refreshes the current active account's usage once before rendering account choices, so the picker does not show stale data for the currently selected account. It does not refresh the newly selected account after the switch completes.
When `api.usage = true`, the switch command refreshes usage for all stored accounts before rendering account choices, using a maximum concurrency of `3`. When a per-account foreground usage request returns a non-`200` HTTP status, the picker shows that status in both usage columns for that row. When a stored account snapshot cannot make a ChatGPT usage request because the required ChatGPT auth fields are missing, the picker shows `MissingAuth` in both usage columns for that row. No extra usage refresh is attempted after the switch completes.

When `api.usage = false`, the switch command keeps the existing local-only behavior and can refresh only the active account from the newest local rollout data.

Grouped account-name metadata refresh, when needed, now runs in the same foreground pre-selection phase as that usage refresh; see [docs/api-refresh.md](./api-refresh.md).

Expand Down
161 changes: 141 additions & 20 deletions src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ fn stderrColorEnabled() bool {
return std.fs.File.stderr().isTty();
}

pub const ListOptions = struct {};
pub const ListOptions = struct {
debug: bool = false,
};
pub const LoginOptions = struct {
device_auth: bool = false,
};
Expand Down Expand Up @@ -130,7 +132,30 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars
}

if (std.mem.eql(u8, cmd, "list")) {
return try parseSimpleCommandArgs(allocator, "list", .list, .{ .list = .{} }, args[2..]);
if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) {
return .{ .command = .{ .help = .list } };
}

var opts: ListOptions = .{};
var i: usize = 2;
while (i < args.len) : (i += 1) {
const arg = std.mem.sliceTo(args[i], 0);
if (std.mem.eql(u8, arg, "--debug")) {
if (opts.debug) {
return usageErrorResult(allocator, .list, "duplicate `--debug` for `list`.", .{});
}
opts.debug = true;
continue;
}
if (isHelpFlag(arg)) {
return usageErrorResult(allocator, .list, "`--help` must be used by itself for `list`.", .{});
}
if (std.mem.startsWith(u8, arg, "-")) {
return usageErrorResult(allocator, .list, "unknown flag `{s}` for `list`.", .{arg});
}
return usageErrorResult(allocator, .list, "unexpected argument `{s}` for `list`.", .{arg});
}
return .{ .command = .{ .list = opts } };
}

if (std.mem.eql(u8, cmd, "login")) {
Expand Down Expand Up @@ -690,7 +715,7 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void {
try out.writeAll(" codex-auth --help\n");
try out.writeAll(" codex-auth help <command>\n");
},
.list => try out.writeAll(" codex-auth list\n"),
.list => try out.writeAll(" codex-auth list [--debug]\n"),
.status => try out.writeAll(" codex-auth status\n"),
.login => {
try out.writeAll(" codex-auth login\n");
Expand Down Expand Up @@ -734,7 +759,10 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void {
try out.writeAll(" codex-auth import /path/to/auth.json --alias personal\n");
try out.writeAll(" codex-auth config auto enable\n");
},
.list => try out.writeAll(" codex-auth list\n"),
.list => {
try out.writeAll(" codex-auth list\n");
try out.writeAll(" codex-auth list --debug\n");
},
.status => try out.writeAll(" codex-auth status\n"),
.login => {
try out.writeAll(" codex-auth login\n");
Expand Down Expand Up @@ -1053,19 +1081,36 @@ pub fn runCodexLogin(opts: LoginOptions) !void {
}

pub fn selectAccount(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]const u8 {
return selectAccountWithUsageOverrides(allocator, reg, null);
}

pub fn selectAccountWithUsageOverrides(
allocator: std.mem.Allocator,
reg: *registry.Registry,
usage_overrides: ?[]const ?[]const u8,
) !?[]const u8 {
return if (comptime builtin.os.tag == .windows)
selectWithNumbers(allocator, reg)
selectWithNumbers(allocator, reg, usage_overrides)
else
selectInteractive(allocator, reg) catch selectWithNumbers(allocator, reg);
selectInteractive(allocator, reg, usage_overrides) catch selectWithNumbers(allocator, reg, usage_overrides);
}

pub fn selectAccountFromIndices(allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize) !?[]const u8 {
return selectAccountFromIndicesWithUsageOverrides(allocator, reg, indices, null);
}

pub fn selectAccountFromIndicesWithUsageOverrides(
allocator: std.mem.Allocator,
reg: *registry.Registry,
indices: []const usize,
usage_overrides: ?[]const ?[]const u8,
) !?[]const u8 {
if (indices.len == 0) return null;
if (indices.len == 1) return reg.accounts.items[indices[0]].account_key;
return if (comptime builtin.os.tag == .windows)
selectWithNumbersFromIndices(allocator, reg, indices)
selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides)
else
selectInteractiveFromIndices(allocator, reg, indices) catch selectWithNumbersFromIndices(allocator, reg, indices);
selectInteractiveFromIndices(allocator, reg, indices, usage_overrides) catch selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides);
}

pub fn selectAccountsToRemove(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]usize {
Expand Down Expand Up @@ -1108,12 +1153,16 @@ fn accountIndexForSelectable(rows: *const SwitchRows, selectable_idx: usize) usi
return rows.items[row_idx].account_index.?;
}

fn selectWithNumbers(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]const u8 {
fn selectWithNumbers(
allocator: std.mem.Allocator,
reg: *registry.Registry,
usage_overrides: ?[]const ?[]const u8,
) !?[]const u8 {
var stdout: io_util.Stdout = undefined;
stdout.init();
const out = stdout.out();
if (reg.accounts.items.len == 0) return null;
var rows = try buildSwitchRows(allocator, reg);
var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides);
defer rows.deinit(allocator);
const use_color = colorEnabled();
const active_idx = activeSelectableIndex(&rows);
Expand All @@ -1138,13 +1187,18 @@ fn selectWithNumbers(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]
return accountIdForSelectable(&rows, reg, idx - 1);
}

fn selectWithNumbersFromIndices(allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize) !?[]const u8 {
fn selectWithNumbersFromIndices(
allocator: std.mem.Allocator,
reg: *registry.Registry,
indices: []const usize,
usage_overrides: ?[]const ?[]const u8,
) !?[]const u8 {
var stdout: io_util.Stdout = undefined;
stdout.init();
const out = stdout.out();
if (indices.len == 0) return null;

var rows = try buildSwitchRowsFromIndices(allocator, reg, indices);
var rows = try buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides);
defer rows.deinit(allocator);
const use_color = colorEnabled();
const active_idx = activeSelectableIndex(&rows);
Expand All @@ -1169,9 +1223,14 @@ fn selectWithNumbersFromIndices(allocator: std.mem.Allocator, reg: *registry.Reg
return accountIdForSelectable(&rows, reg, idx - 1);
}

fn selectInteractiveFromIndices(allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize) !?[]const u8 {
fn selectInteractiveFromIndices(
allocator: std.mem.Allocator,
reg: *registry.Registry,
indices: []const usize,
usage_overrides: ?[]const ?[]const u8,
) !?[]const u8 {
if (indices.len == 0) return null;
var rows = try buildSwitchRowsFromIndices(allocator, reg, indices);
var rows = try buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides);
defer rows.deinit(allocator);

var tty = try std.fs.cwd().openFile("/dev/tty", .{});
Expand Down Expand Up @@ -1344,9 +1403,13 @@ fn isStrictRemoveSelectionLine(line: []const u8) bool {
return true;
}

fn selectInteractive(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]const u8 {
fn selectInteractive(
allocator: std.mem.Allocator,
reg: *registry.Registry,
usage_overrides: ?[]const ?[]const u8,
) !?[]const u8 {
if (reg.accounts.items.len == 0) return null;
var rows = try buildSwitchRows(allocator, reg);
var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides);
defer rows.deinit(allocator);

var tty = try std.fs.cwd().openFile("/dev/tty", .{});
Expand Down Expand Up @@ -1806,7 +1869,33 @@ const SwitchRows = struct {
}
};

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 usageCellTextAlloc(
allocator: std.mem.Allocator,
window: ?registry.RateLimitWindow,
usage_override: ?[]const u8,
) ![]u8 {
if (usage_override) |value| return allocator.dupe(u8, value);
return formatRateLimitSwitchAlloc(allocator, window);
}

fn buildSwitchRows(allocator: std.mem.Allocator, reg: *registry.Registry) !SwitchRows {
return buildSwitchRowsWithUsageOverrides(allocator, reg, null);
}

fn buildSwitchRowsWithUsageOverrides(
allocator: std.mem.Allocator,
reg: *registry.Registry,
usage_overrides: ?[]const ?[]const u8,
) !SwitchRows {
var display = try display_rows.buildDisplayRows(allocator, reg, null);
defer display.deinit(allocator);
var rows = try allocator.alloc(SwitchRow, display.rows.len);
Expand All @@ -1824,8 +1913,9 @@ fn buildSwitchRows(allocator: std.mem.Allocator, reg: *registry.Registry) !Switc
const plan = if (registry.resolvePlan(&rec)) |p| @tagName(p) else "-";
const rate_5h = resolveRateWindow(rec.last_usage, 300, true);
const rate_week = resolveRateWindow(rec.last_usage, 10080, false);
const rate_5h_str = try formatRateLimitSwitchAlloc(allocator, rate_5h);
const rate_week_str = try formatRateLimitSwitchAlloc(allocator, rate_week);
const usage_override = usageOverrideForAccount(usage_overrides, account_idx);
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);
rows[i] = .{
.account_index = account_idx,
Expand Down Expand Up @@ -1870,6 +1960,15 @@ fn buildSwitchRowsFromIndices(
allocator: std.mem.Allocator,
reg: *registry.Registry,
indices: []const usize,
) !SwitchRows {
return buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, null);
}

fn buildSwitchRowsFromIndicesWithUsageOverrides(
allocator: std.mem.Allocator,
reg: *registry.Registry,
indices: []const usize,
usage_overrides: ?[]const ?[]const u8,
) !SwitchRows {
var display = try display_rows.buildDisplayRows(allocator, reg, indices);
defer display.deinit(allocator);
Expand All @@ -1888,8 +1987,9 @@ fn buildSwitchRowsFromIndices(
const plan = if (registry.resolvePlan(&rec)) |p| @tagName(p) else "-";
const rate_5h = resolveRateWindow(rec.last_usage, 300, true);
const rate_week = resolveRateWindow(rec.last_usage, 10080, false);
const rate_5h_str = try formatRateLimitSwitchAlloc(allocator, rate_5h);
const rate_week_str = try formatRateLimitSwitchAlloc(allocator, rate_week);
const usage_override = usageOverrideForAccount(usage_overrides, account_idx);
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);
rows[i] = .{
.account_index = account_idx,
Expand Down Expand Up @@ -2125,3 +2225,24 @@ test "Scenario: Given grouped accounts when rendering switch list then child row
try std.testing.expect(std.mem.indexOf(u8, output, "01 Als's Workspace") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "02 free") != null);
}

test "Scenario: Given usage overrides when rendering switch list then failed rows show response status in both usage columns" {
const gpa = std.testing.allocator;
var reg = makeTestRegistry();
defer reg.deinit(gpa);

try appendTestAccount(gpa, &reg, "user-1::acc-1", "user@example.com", "", .team);
try appendTestAccount(gpa, &reg, "user-1::acc-2", "user@example.com", "", .free);

const usage_overrides = [_]?[]const u8{ null, "401" };
var rows = try buildSwitchRowsWithUsageOverrides(gpa, &reg, &usage_overrides);
defer rows.deinit(gpa);

var buffer: [2048]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buffer);
const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len));
try renderSwitchList(&writer, &reg, rows.items, idx_width, rows.widths, null, false);

const output = writer.buffered();
try std.testing.expect(std.mem.count(u8, output, "401") >= 2);
}
Loading
Loading