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
147 changes: 147 additions & 0 deletions docs/brainstorm/2026-05-31-previous-account-switch-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Previous Account Switch Design

## Goal

Add a `cd -` style shortcut for returning to the previous active account.

The shortcut must support both:

```shell
codex-auth -
codex-auth switch -
```

Both commands must have the same behavior.

## User-Facing Behavior

`codex-auth -` and `codex-auth switch -` switch to the previous active account. On success, output stays consistent with the existing switch command:

```text
Switched to <label>
```

If there is no previous active account, the command fails with:

```text
error: no previous account to switch to.
```

If the recorded previous account no longer exists, the command fails with:

```text
error: previous account is no longer available.
```

No hint line is needed for these errors.

`codex-auth switch - --api`, `codex-auth switch - --skip-api`, and `codex-auth switch - --live` keep the same usage-error style as query mode. Help and usage text should list `switch -` explicitly so the syntax is visible.

## State Model

Add a top-level field to `registry.json`:

```json
"previous_active_account_key": "..."
```

The in-memory registry gets a matching nullable field:

```zig
previous_active_account_key: ?[]u8
```

When loading an older registry without this field, treat it as `null`.

## Update Rules

Account activation should remain centralized in the registry layer.

When the active account changes from A to B:

```text
previous_active_account_key = A
active_account_key = B
```

This applies to successful switch paths and other existing paths that call the shared active-account setter.

If there is no current active account, setting an active account does not create a previous account.

If the target account is already active, keep the current behavior: the command succeeds and prints the standard switch success message, but the previous account is not changed.

## Previous Switch Flow

The previous-account switch should:

1. Load and sync the registry the same way query switch does.
2. Read `previous_active_account_key`.
3. Fail if it is missing.
4. Fail if it does not point to an account in the registry.
5. Reuse the normal account activation path for the target previous account.
6. Save the registry and print the normal switched-account message.

Because normal activation updates the previous field, successful previous switches naturally alternate between two accounts.

## Remove Behavior

Removing an account must not leave `previous_active_account_key` pointing to a deleted account.

Rules:

- If the removed account is the recorded previous account, clear `previous_active_account_key`.
- If the removed account is the active account and the existing remove flow automatically selects a replacement active account, keep the existing previous account if it still exists.
- Do not record the deleted active account as previous during automatic replacement.
- If neither the active nor previous account is removed, leave previous unchanged.

The automatic active-account replacement after removal is treated as cleanup, not as a user-initiated switch.

## Parsing And Help

Top-level parsing should recognize:

```shell
codex-auth -
```

as the same command as:

```shell
codex-auth switch -
```

Switch parsing should treat a lone `-` as previous-account mode, not as a normal query selector. `switch -` with API or live flags should keep the existing query-mode usage error pattern.

Help should show:

```text
codex-auth -
codex-auth switch -
```

The switch command documentation should explain that `-` returns to the previous active account.

## Testing

Add focused tests for:

- parsing `codex-auth -`;
- parsing `codex-auth switch -`;
- rejecting `switch -` combined with `--api`, `--skip-api`, or `--live`;
- loading old registry data without `previous_active_account_key`;
- writing the new registry field;
- updating previous when active changes from A to B;
- preserving previous on same-account activation;
- switching back and forth with `switch -`;
- failing when no previous account exists;
- failing when the previous account no longer exists;
- clearing previous when the previous account is removed;
- preserving previous when active is removed and a replacement active account is selected.

After changing Zig files, run:

```shell
zig build run -- list
```

Broader tests should cover the parser, registry behavior, and CLI integration paths touched by this change.
13 changes: 12 additions & 1 deletion docs/commands/switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@
## Usage

```shell
codex-auth switch -
codex-auth switch [--api|--skip-api]
codex-auth switch --live [--api|--skip-api]
codex-auth switch <query>
```

## Previous Switch

`codex-auth switch -` switches to the previous active account.

- `codex-auth -` is a shortcut for the same behavior.
- The command fails when no previous account has been recorded.
- The command fails when the recorded previous account was removed.
- `switch -` does not accept `--live`, `--api`, or `--skip-api`.

## Interactive Switch

`codex-auth switch` opens the account picker and exits after one successful switch.
Expand Down Expand Up @@ -46,4 +56,5 @@ When switching succeeds:
1. `auth.json` is backed up when its contents would change.
2. The selected account snapshot is copied to `~/.codex/auth.json`.
3. `active_account_key` is updated in `registry.json`.
4. The success message uses the same identity label as singleton rows, for example `Switched to me(test@example.com)`.
4. `previous_active_account_key` records the account that was active before the switch, when one exists.
5. The success message uses the same identity label as singleton rows, for example `Switched to me(test@example.com)`.
4 changes: 2 additions & 2 deletions docs/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ Managed files:

- `registry.json.schema_version` is the on-disk migration gate.
- `version = 2` registries using `active_email` and email-keyed snapshots are migrated to the current schema.
- Current-layout files that still use the top-level `version = 3` key are rewritten to `schema_version = 4`.
- Current-layout files that still use the top-level `version = 3` key are rewritten to the current schema.
- Loading a supported older schema performs the migration in memory and rewrites `registry.json` in the current format.
- Loading a newer `schema_version` is rejected with `UnsupportedRegistryVersion`.
- Saving always rewrites `registry.json` into the current schema `4` field set.
- Saving always rewrites `registry.json` into the current schema field set.

See [docs/schema-migration.md](./schema-migration.md) for versioning policy and migration rules.

Expand Down
2 changes: 2 additions & 0 deletions docs/schema-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r
- User-visible behavior is always “upgrade directly to the latest supported schema”.
- Internally, migrations are implemented as a chain of `Vn -> Vn+1` steps.
- In the current code, supported automatic migration is `version = 2 -> schema_version = 4`; older current-layout schema `3` files are rewritten once as schema `4`.
- Current-layout schema `4` files are also rewritten once when they are missing normalized current fields such as `previous_active_account_key`.
- Users are not expected to install intermediate `codex-auth` versions.

## Released Schemas
Expand All @@ -41,6 +42,7 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r
- Same account layout as schema `3`
- Live refresh interval stored as top-level `interval_seconds`
- Older `live.interval_seconds` and removed `auto_switch` blocks are omitted on rewrite
- Adds top-level `previous_active_account_key` for `codex-auth -` and `codex-auth switch -`

## When To Bump `schema_version`

Expand Down
14 changes: 12 additions & 2 deletions src/cli/commands/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type
return .{ .command = .{ .version = {} } };
}

if (std.mem.eql(u8, cmd, "-")) {
if (args.len > 2) {
return common.usageErrorResult(allocator, .top_level, "unexpected argument after `-`: `{s}`.", .{
std.mem.sliceTo(args[2], 0),
});
}
return .{ .command = .{ .switch_account = .{ .target = .previous } } };
}

if (std.mem.eql(u8, cmd, "list")) return list.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "login")) return login.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "import")) return import_auth.parse(allocator, args[2..]);
Expand Down Expand Up @@ -67,8 +76,9 @@ fn freeCommand(allocator: std.mem.Allocator, cmd: *types.Command) void {
.export_auth => |opts| {
if (opts.dest_path) |path| allocator.free(path);
},
.switch_account => |opts| {
if (opts.query) |query| allocator.free(query);
.switch_account => |opts| switch (opts.target) {
.query => |query| allocator.free(query),
else => {},
},
.remove_account => |opts| {
common.freeOwnedStringList(allocator, opts.selectors);
Expand Down
38 changes: 24 additions & 14 deletions src/cli/commands/switch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa
return .{ .command = .{ .help = .switch_account } };
}

var opts: types.SwitchOptions = .{ .query = null };
var opts: types.SwitchOptions = .{};
for (args) |raw_arg| {
const arg = std.mem.sliceTo(raw_arg, 0);
if (std.mem.eql(u8, arg, "--live")) {
if (opts.live) {
if (opts.query) |query| allocator.free(query);
freeTarget(allocator, opts.target);
return common.usageErrorResult(allocator, .switch_account, "duplicate `--live` for `switch`.", .{});
}
opts.live = true;
Expand All @@ -22,11 +22,11 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa
switch (opts.api_mode) {
.default => opts.api_mode = .force_api,
.force_api => {
if (opts.query) |query| allocator.free(query);
freeTarget(allocator, opts.target);
return common.usageErrorResult(allocator, .switch_account, "duplicate `--api` for `switch`.", .{});
},
.skip_api => {
if (opts.query) |query| allocator.free(query);
freeTarget(allocator, opts.target);
return common.usageErrorResult(allocator, .switch_account, "`--api` cannot be combined with `--skip-api` for `switch`.", .{});
},
}
Expand All @@ -36,34 +36,44 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa
switch (opts.api_mode) {
.default => opts.api_mode = .skip_api,
.skip_api => {
if (opts.query) |query| allocator.free(query);
freeTarget(allocator, opts.target);
return common.usageErrorResult(allocator, .switch_account, "duplicate `--skip-api` for `switch`.", .{});
},
.force_api => {
if (opts.query) |query| allocator.free(query);
freeTarget(allocator, opts.target);
return common.usageErrorResult(allocator, .switch_account, "`--skip-api` cannot be combined with `--api` for `switch`.", .{});
},
}
continue;
}
if (std.mem.startsWith(u8, arg, "-")) {
if (opts.query) |query| allocator.free(query);
if (std.mem.startsWith(u8, arg, "-") and !std.mem.eql(u8, arg, "-")) {
freeTarget(allocator, opts.target);
return common.usageErrorResult(allocator, .switch_account, "unknown flag `{s}` for `switch`.", .{arg});
}
if (opts.query != null) {
if (opts.query) |query| allocator.free(query);
if (opts.target != .picker) {
freeTarget(allocator, opts.target);
return common.usageErrorResult(allocator, .switch_account, "unexpected extra query `{s}` for `switch`.", .{arg});
}
opts.query = try allocator.dupe(u8, arg);
opts.target = if (std.mem.eql(u8, arg, "-"))
.previous
else
.{ .query = try allocator.dupe(u8, arg) };
}
if (opts.query != null and (opts.api_mode != .default or opts.live)) {
if (opts.query) |query| allocator.free(query);
if (opts.target != .picker and (opts.api_mode != .default or opts.live)) {
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 `--live`, `--api`, or `--skip-api`.",
.{},
);
}
return .{ .command = .{ .switch_account = opts } };
}

fn freeTarget(allocator: std.mem.Allocator, target: types.SwitchTarget) void {
switch (target) {
.query => |query| allocator.free(query),
else => {},
}
}
8 changes: 7 additions & 1 deletion src/cli/help.zig
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub fn writeHelp(
try writeCommandSummary(out, use_color, "--help, -h", "Show this help");
try writeCommandSummary(out, use_color, "help <command>", "Show command-specific help");
try writeCommandSummary(out, use_color, "--version, -V", "Show version");
try writeCommandSummary(out, use_color, "-", "Switch to the previous active account");
try writeCommandSummary(out, use_color, "list [--live] [--active] [--api|--skip-api]", "List available accounts");
try writeCommandSummary(out, use_color, "login [--device-auth]", "Login and add the current account");
try writeCommandSummary(out, use_color, "import", "Import auth files or rebuild registry");
Expand All @@ -44,6 +45,7 @@ pub fn writeHelp(
try writeCommandDetail(out, use_color, "import --purge [<path>]");
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 <alias|email|display-number|query>");
try writeCommandSummary(out, use_color, "remove", "Remove one or more accounts");
Expand Down Expand Up @@ -187,6 +189,7 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void {
switch (topic) {
.top_level => {
try out.writeAll(" codex-auth <command>\n");
try out.writeAll(" codex-auth -\n");
try out.writeAll(" codex-auth --help\n");
try out.writeAll(" codex-auth help <command>\n");
},
Expand All @@ -205,6 +208,7 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void {
try out.writeAll(" codex-auth export --cpa [<dir>]\n");
},
.switch_account => {
try out.writeAll(" codex-auth switch -\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 @@ -279,6 +283,7 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void {
try out.writeAll(" --skip-api Load usage and account data from local data only (may be inaccurate).\n");
try out.writeAll(" <alias|email|display-number|query>\n");
try out.writeAll(" Switch directly when the target resolves to one account.\n");
try out.writeAll(" - Switch to the previous active account.\n");
},
.remove_account => {
try out.writeAll(" --live Open the live remove UI.\n");
Expand Down Expand Up @@ -348,6 +353,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 -\n");
try out.writeAll(" codex-auth switch --live\n");
try out.writeAll(" codex-auth switch --api\n");
try out.writeAll(" codex-auth switch --skip-api\n");
Expand Down Expand Up @@ -391,7 +397,7 @@ fn writeNotesSectionStyled(out: *std.Io.Writer, use_color: bool, topic: HelpTopi
try out.writeAll("\n");
switch (topic) {
.switch_account => {
try out.writeAll(" Targets can be aliases, emails, display numbers, or partial queries.\n");
try out.writeAll(" Targets can be `-`, aliases, emails, display numbers, or partial queries.\n");
},
.alias => {
try out.writeAll(" Alias targets can be aliases, emails, display numbers, or partial queries.\n");
Expand Down
Loading
Loading