diff --git a/docs/commands/README.md b/docs/commands/README.md index 63e31005..8d3cae45 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -12,6 +12,7 @@ This directory documents command behavior by command. Use `codex-auth | `export` | [docs/commands/export.md](./export.md) | | `switch` | [docs/commands/switch.md](./switch.md) | | `remove` | [docs/commands/remove.md](./remove.md) | +| `alias` | [docs/commands/alias.md](./alias.md) | | `clean` | [docs/commands/clean.md](./clean.md) | | `config` | [docs/commands/config.md](./config.md) | diff --git a/docs/commands/alias.md b/docs/commands/alias.md new file mode 100644 index 00000000..07e2a252 --- /dev/null +++ b/docs/commands/alias.md @@ -0,0 +1,36 @@ +# `codex-auth alias` + +## Usage + +```shell +codex-auth alias set +codex-auth alias clear +``` + +## 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 one account matches, the command updates that account immediately. If multiple accounts match, the command falls back to interactive selection in a TTY. + +## Set Alias + +`codex-auth alias set ` stores an alias in `registry.json` for the matched account. + +- Empty aliases are rejected. +- All-digit aliases are rejected because numeric selectors already refer to displayed row numbers. +- Alias comparison is case-insensitive for duplicate detection. +- Changing an alias updates only stored registry metadata. + +## Clear Alias + +`codex-auth alias clear ` removes the stored alias for the matched account. + +If the alias is already empty, the command reports that state and leaves the registry unchanged. diff --git a/docs/commands/list.md b/docs/commands/list.md index 14df4a7e..700f81dd 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -30,7 +30,9 @@ When local-only refresh is active, only the active account can be updated from l ## Output Notes -- Alias labels render before the email when an alias exists. +- Singleton rows with aliases render as `alias(email)`. +- 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. - 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. diff --git a/docs/commands/switch.md b/docs/commands/switch.md index cf2eb532..893a94f3 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -46,3 +46,4 @@ 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)`. diff --git a/src/cli/commands/alias.zig b/src/cli/commands/alias.zig new file mode 100644 index 00000000..91faea0f --- /dev/null +++ b/src/cli/commands/alias.zig @@ -0,0 +1,40 @@ +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 = .alias } }; + } + if (args.len == 0) { + return common.usageErrorResult(allocator, .alias, "`alias` requires `set` or `clear`.", .{}); + } + + const subcommand = std.mem.sliceTo(args[0], 0); + if (std.mem.eql(u8, subcommand, "set")) { + if (args.len < 3) return common.usageErrorResult(allocator, .alias, "`alias set` requires a selector and alias.", .{}); + if (args.len > 3) return common.usageErrorResult(allocator, .alias, "unexpected extra argument `{s}` for `alias set`.", .{std.mem.sliceTo(args[3], 0)}); + + const selector = try allocator.dupe(u8, std.mem.sliceTo(args[1], 0)); + errdefer allocator.free(selector); + const alias_value = try allocator.dupe(u8, std.mem.sliceTo(args[2], 0)); + return .{ .command = .{ .alias = .{ .set = .{ + .selector = selector, + .alias = alias_value, + } } } }; + } + if (std.mem.eql(u8, subcommand, "clear")) { + if (args.len < 2) return common.usageErrorResult(allocator, .alias, "`alias clear` requires a selector.", .{}); + if (args.len > 2) return common.usageErrorResult(allocator, .alias, "unexpected extra argument `{s}` for `alias clear`.", .{std.mem.sliceTo(args[2], 0)}); + return .{ .command = .{ .alias = .{ .clear = .{ + .selector = try allocator.dupe(u8, std.mem.sliceTo(args[1], 0)), + } } } }; + } + if (common.isHelpFlag(subcommand)) { + return common.usageErrorResult(allocator, .alias, "`--help` must be used by itself for `alias`.", .{}); + } + if (std.mem.startsWith(u8, subcommand, "-")) { + return common.usageErrorResult(allocator, .alias, "unknown flag `{s}` for `alias`.", .{subcommand}); + } + return common.usageErrorResult(allocator, .alias, "unknown alias subcommand `{s}`.", .{subcommand}); +} diff --git a/src/cli/commands/root.zig b/src/cli/commands/root.zig index 341cc2d8..70e2b1ca 100644 --- a/src/cli/commands/root.zig +++ b/src/cli/commands/root.zig @@ -2,6 +2,7 @@ const std = @import("std"); const types = @import("../types.zig"); const common = @import("common.zig"); +const alias = @import("alias.zig"); const clean = @import("clean.zig"); const config = @import("config.zig"); const export_auth = @import("export.zig"); @@ -43,6 +44,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type if (std.mem.eql(u8, cmd, "export")) return export_auth.parse(allocator, args[2..]); 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, "clean")) return clean.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "config")) return config.parse(allocator, args[2..]); @@ -70,6 +72,13 @@ fn freeCommand(allocator: std.mem.Allocator, cmd: *types.Command) void { common.freeOwnedStringList(allocator, opts.selectors); allocator.free(opts.selectors); }, + .alias => |opts| switch (opts) { + .set => |set_opts| { + allocator.free(set_opts.selector); + allocator.free(set_opts.alias); + }, + .clear => |clear_opts| allocator.free(clear_opts.selector), + }, else => {}, } cmd.* = undefined; @@ -95,6 +104,7 @@ fn helpTopicForName(name: []const u8) ?types.HelpTopic { if (std.mem.eql(u8, name, "export")) return .export_auth; 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, "clean")) return .clean; if (std.mem.eql(u8, name, "config")) return .config; return null; diff --git a/src/cli/help.zig b/src/cli/help.zig index 950b5cf6..c7cb9702 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -50,6 +50,9 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "remove [--live] [--api|--skip-api]"); try writeCommandDetail(out, use_color, "remove ..."); try writeCommandDetail(out, use_color, "remove --all"); + 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, "clean", "Delete backup and stale files under accounts/"); try writeCommandDetail(out, use_color, "clean background"); try writeCommandSummary(out, use_color, "config", "Manage configuration"); @@ -125,6 +128,7 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { .export_auth => "export", .switch_account => "switch", .remove_account => "remove", + .alias => "alias", .clean => "clean", .config => "config", }; @@ -139,6 +143,7 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .export_auth => "Export stored account auth files.", .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.", .clean => "Delete backup and stale files under accounts/.", .config => "Manage live refresh configuration.", }; @@ -146,20 +151,21 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { fn commandHelpHasExamples(topic: HelpTopic) bool { return switch (topic) { - .import_auth, .export_auth, .switch_account, .remove_account, .config => true, + .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config => true, else => false, }; } fn commandHelpHasOptions(topic: HelpTopic) bool { return switch (topic) { - .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .config => true, + .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config => true, else => false, }; } fn commandHelpHasNotes(topic: HelpTopic) bool { return switch (topic) { + .switch_account, .alias => true, else => false, }; } @@ -204,6 +210,10 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth remove ...\n"); try out.writeAll(" codex-auth remove --all\n"); }, + .alias => { + try out.writeAll(" codex-auth alias set \n"); + try out.writeAll(" codex-auth alias clear \n"); + }, .clean => { try out.writeAll(" codex-auth clean\n"); try out.writeAll(" codex-auth clean background\n"); @@ -223,6 +233,7 @@ pub fn helpCommandForTopic(topic: HelpTopic) []const u8 { .export_auth => "codex-auth export --help", .switch_account => "codex-auth switch --help", .remove_account => "codex-auth remove --help", + .alias => "codex-auth alias --help", .clean => "codex-auth clean --help", .config => "codex-auth config --help", }; @@ -270,6 +281,12 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" ...\n"); try out.writeAll(" Remove one or more matching accounts.\n"); }, + .alias => { + try out.writeAll(" set \n"); + try out.writeAll(" Set one stored account alias without remote refresh.\n"); + try out.writeAll(" clear \n"); + try out.writeAll(" Remove one stored account alias without remote refresh.\n"); + }, .config => { try out.writeAll(" live --interval \n"); try out.writeAll(" Set the live TUI refresh interval from 5 to 3600 seconds.\n"); @@ -332,6 +349,12 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth remove john@example.com jane@example.com\n"); try out.writeAll(" codex-auth remove --all\n"); }, + .alias => { + try out.writeAll(" codex-auth alias set 02 work\n"); + try out.writeAll(" codex-auth alias set john@example.com personal\n"); + try out.writeAll(" codex-auth alias set old-name new-name\n"); + try out.writeAll(" codex-auth alias clear work\n"); + }, .clean => { try out.writeAll(" codex-auth clean\n"); try out.writeAll(" codex-auth clean background\n"); @@ -349,6 +372,10 @@ fn writeNotesSectionStyled(out: *std.Io.Writer, use_color: bool, topic: HelpTopi .switch_account => { 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"); + try out.writeAll(" New aliases cannot be empty or only digits.\n"); + }, else => {}, } } diff --git a/src/cli/output.zig b/src/cli/output.zig index 24b1168e..0d70a797 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -138,6 +138,18 @@ pub fn printSwitchAccountNotFoundError(query: []const u8) !void { try out.flush(); } +pub fn printAliasAccountNotFoundError(query: []const u8) !void { + var buffer: [768]u8 = undefined; + var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + const use_color = style.stderrColorEnabled(); + try writeErrorPrefixTo(out, use_color); + try out.print(" no alias target matches '{s}'.\n", .{query}); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Alias targets accept one account: alias, email, display number, or partial query.\n"); + try out.flush(); +} + pub fn printAccountNotFoundErrors(queries: []const []const u8) !void { if (queries.len == 0) return; if (queries.len == 1) { @@ -196,6 +208,64 @@ pub fn printRemoveRequiresTtyError() !void { try out.flush(); } +pub fn printAliasRequiresTtyError() !void { + var buffer: [512]u8 = undefined; + var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + const use_color = style.stderrColorEnabled(); + try writeErrorPrefixTo(out, use_color); + try out.writeAll(" multiple alias targets require a TTY.\n"); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Narrow the selector or use a displayed row number.\n"); + try out.flush(); +} + +pub fn printInvalidAliasError(reason: []const u8) !void { + var buffer: [768]u8 = undefined; + var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + const use_color = style.stderrColorEnabled(); + try writeErrorPrefixTo(out, use_color); + try out.print(" invalid alias: {s}\n", .{reason}); + try out.flush(); +} + +pub fn printDuplicateAliasError(alias_value: []const u8, email: []const u8) !void { + var buffer: [768]u8 = undefined; + var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + const use_color = style.stderrColorEnabled(); + try writeErrorPrefixTo(out, use_color); + try out.print(" alias '{s}' is already used by {s}.\n", .{ alias_value, email }); + try out.flush(); +} + +pub fn printAliasSet(rec: *const registry.AccountRecord, old_alias: []const u8) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + if (old_alias.len == 0) { + try out.print("Set alias for {s}: {s}\n", .{ rec.email, rec.alias }); + } else if (std.mem.eql(u8, old_alias, rec.alias)) { + try out.print("Alias already set for {s}: {s}\n", .{ rec.email, rec.alias }); + } else { + try out.print("Updated alias for {s}: {s} -> {s}\n", .{ rec.email, old_alias, rec.alias }); + } + try out.flush(); +} + +pub fn printAliasCleared(rec: *const registry.AccountRecord, old_alias: []const u8) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + if (old_alias.len == 0) { + try out.print("Alias already empty for {s}.\n", .{rec.email}); + } else { + try out.print("Cleared alias for {s}: {s}\n", .{ rec.email, old_alias }); + } + try out.flush(); +} + pub fn printInvalidRemoveSelectionError() !void { var buffer: [512]u8 = undefined; var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); @@ -231,15 +301,7 @@ pub fn buildRemoveLabels( const label = if (row.depth == 0 or current_header == null) blk: { const rec = ®.accounts.items[row.account_index.?]; - if (std.mem.eql(u8, row.account_cell, rec.email)) { - const preferred = try display_rows.buildPreferredAccountLabelAlloc(allocator, rec, rec.email); - defer allocator.free(preferred); - if (std.mem.eql(u8, preferred, rec.email)) { - break :blk try allocator.dupe(u8, row.account_cell); - } - break :blk try std.fmt.allocPrint(allocator, "{s} / {s}", .{ rec.email, preferred }); - } - break :blk try std.fmt.allocPrint(allocator, "{s} / {s}", .{ rec.email, row.account_cell }); + break :blk try display_rows.buildAccountIdentityLabelAlloc(allocator, rec); } else try std.fmt.allocPrint(allocator, "{s} / {s}", .{ current_header.?, row.account_cell }); try labels.append(allocator, label); } @@ -307,7 +369,7 @@ pub fn printSwitchedAccount( account_key: []const u8, ) !void { const label = if (registry.findAccountIndexByAccountKey(reg, account_key)) |idx| - try display_rows.buildPreferredAccountLabelAlloc(allocator, ®.accounts.items[idx], reg.accounts.items[idx].email) + try display_rows.buildAccountIdentityLabelAlloc(allocator, ®.accounts.items[idx]) else try allocator.dupe(u8, account_key); defer allocator.free(label); diff --git a/src/cli/types.zig b/src/cli/types.zig index 35c9c6de..c6103f39 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -35,6 +35,17 @@ pub const RemoveOptions = struct { live: bool = false, api_mode: ApiMode = .default, }; +pub const AliasSetOptions = struct { + selector: []u8, + alias: []u8, +}; +pub const AliasClearOptions = struct { + selector: []u8, +}; +pub const AliasOptions = union(enum) { + set: AliasSetOptions, + clear: AliasClearOptions, +}; pub const CleanTarget = enum { accounts, background }; pub const CleanOptions = struct { target: CleanTarget = .accounts, @@ -51,6 +62,7 @@ pub const HelpTopic = enum { export_auth, switch_account, remove_account, + alias, clean, config, }; @@ -62,6 +74,7 @@ pub const Command = union(enum) { export_auth: ExportOptions, switch_account: SwitchOptions, remove_account: RemoveOptions, + alias: AliasOptions, clean: CleanOptions, config: ConfigOptions, version: void, diff --git a/src/tui/display.zig b/src/tui/display.zig index cd625af2..e6b17bdb 100644 --- a/src/tui/display.zig +++ b/src/tui/display.zig @@ -155,7 +155,7 @@ fn isActive(reg: *const registry.Registry, account_idx: usize) bool { } fn singletonAccountCellAlloc(allocator: std.mem.Allocator, rec: *const registry.AccountRecord) ![]u8 { - return allocator.dupe(u8, rec.email); + return buildAccountIdentityLabelAlloc(allocator, rec); } fn groupedAccountCellAlloc( @@ -201,7 +201,7 @@ pub fn buildPreferredAccountLabelAlloc( defer if (api_key_label) |value| allocator.free(value); if (alias != null and api_key_label != null) { - return std.fmt.allocPrint(allocator, "{s} ({s})", .{ alias.?, api_key_label.? }); + return std.fmt.allocPrint(allocator, "{s}({s})", .{ alias.?, api_key_label.? }); } if (alias != null) return allocator.dupe(u8, alias.?); if (api_key_label != null) return allocator.dupe(u8, api_key_label.?); @@ -209,13 +209,32 @@ pub fn buildPreferredAccountLabelAlloc( return allocator.dupe(u8, fallback); } if (alias != null and account_name != null) { - return std.fmt.allocPrint(allocator, "{s} ({s})", .{ alias.?, account_name.? }); + return std.fmt.allocPrint(allocator, "{s}({s})", .{ alias.?, account_name.? }); } if (alias != null) return allocator.dupe(u8, alias.?); if (account_name != null) return allocator.dupe(u8, account_name.?); return allocator.dupe(u8, fallback); } +pub fn buildAccountIdentityLabelAlloc( + allocator: std.mem.Allocator, + rec: *const registry.AccountRecord, +) ![]u8 { + const alias = if (rec.alias.len != 0) rec.alias else null; + const account_name = normalizedAccountName(rec); + + if (alias != null and account_name != null) { + return std.fmt.allocPrint(allocator, "{s}({s}, {s})", .{ alias.?, account_name.?, rec.email }); + } + if (alias != null) { + return std.fmt.allocPrint(allocator, "{s}({s})", .{ alias.?, rec.email }); + } + if (account_name != null) { + return std.fmt.allocPrint(allocator, "{s}({s})", .{ account_name.?, rec.email }); + } + return allocator.dupe(u8, rec.email); +} + fn normalizedAccountName(rec: *const registry.AccountRecord) ?[]const u8 { const account_name = rec.account_name orelse return null; if (account_name.len == 0) return null; diff --git a/src/workflows/alias.zig b/src/workflows/alias.zig new file mode 100644 index 00000000..b460eb78 --- /dev/null +++ b/src/workflows/alias.zig @@ -0,0 +1,93 @@ +const std = @import("std"); +const cli = @import("../cli/root.zig"); +const registry = @import("../registry/root.zig"); +const query_mod = @import("query.zig"); + +pub fn handleAlias(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.AliasOptions) !void { + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + + switch (opts) { + .set => |set_opts| { + const idx = (try resolveAliasTargetIndex(allocator, ®, set_opts.selector)) orelse return; + try validateAlias(®, set_opts.alias, idx); + const old_alias = try allocator.dupe(u8, reg.accounts.items[idx].alias); + defer allocator.free(old_alias); + try replaceAlias(allocator, ®.accounts.items[idx], set_opts.alias); + try registry.saveRegistry(allocator, codex_home, ®); + try cli.output.printAliasSet(®.accounts.items[idx], old_alias); + }, + .clear => |clear_opts| { + const idx = (try resolveAliasTargetIndex(allocator, ®, clear_opts.selector)) orelse return; + const old_alias = try allocator.dupe(u8, reg.accounts.items[idx].alias); + defer allocator.free(old_alias); + try replaceAlias(allocator, ®.accounts.items[idx], ""); + try registry.saveRegistry(allocator, codex_home, ®); + try cli.output.printAliasCleared(®.accounts.items[idx], old_alias); + }, + } +} + +fn resolveAliasTargetIndex( + 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.printAliasAccountNotFoundError(selector); + return error.AccountNotFound; + }, + .direct => |key| key, + .multiple => |matches| blk: { + const selected_account_key = cli.picker.selectAccountFromIndicesWithUsageOverrides( + allocator, + reg, + matches.items, + null, + ) catch |err| { + if (err == error.TuiRequiresTty) { + try cli.output.printAliasRequiresTtyError(); + return error.AliasSelectionRequiresTty; + } + return err; + }; + if (selected_account_key == null) return null; + break :blk selected_account_key.?; + }, + }; + return registry.findAccountIndexByAccountKey(reg, account_key) orelse error.AccountNotFound; +} + +fn replaceAlias(allocator: std.mem.Allocator, rec: *registry.AccountRecord, alias_value: []const u8) !void { + const owned_alias = try allocator.dupe(u8, alias_value); + allocator.free(rec.alias); + rec.alias = owned_alias; +} + +fn validateAlias(reg: *registry.Registry, alias_value: []const u8, selected_idx: usize) !void { + if (alias_value.len == 0) { + try cli.output.printInvalidAliasError("alias cannot be empty; use `codex-auth alias clear ` to remove one."); + return error.InvalidAlias; + } + if (query_mod.parseDisplayNumber(alias_value) != null) { + try cli.output.printInvalidAliasError("alias cannot be only digits because numbers select displayed rows."); + return error.InvalidAlias; + } + for (alias_value) |ch| { + if (ch < 0x20 or ch == 0x7f) { + try cli.output.printInvalidAliasError("alias cannot contain control characters."); + return error.InvalidAlias; + } + } + for (reg.accounts.items, 0..) |rec, idx| { + if (idx == selected_idx) continue; + if (rec.alias.len != 0 and std.ascii.eqlIgnoreCase(rec.alias, alias_value)) { + try cli.output.printDuplicateAliasError(alias_value, rec.email); + return error.DuplicateAlias; + } + } +} diff --git a/src/workflows/live_runtime.zig b/src/workflows/live_runtime.zig index 27db2328..9f36b224 100644 --- a/src/workflows/live_runtime.zig +++ b/src/workflows/live_runtime.zig @@ -300,11 +300,7 @@ pub fn accountLabelForKeyAlloc( account_key: []const u8, ) ![]u8 { const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return error.AccountNotFound; - return display_rows.buildPreferredAccountLabelAlloc( - allocator, - ®.accounts.items[idx], - reg.accounts.items[idx].email, - ); + return display_rows.buildAccountIdentityLabelAlloc(allocator, ®.accounts.items[idx]); } pub fn buildRemoveSummaryMessageAlloc(allocator: std.mem.Allocator, labels: []const []const u8) ![]u8 { diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index a3d64c37..c2a0d742 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -20,6 +20,9 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.TuiOutputUnavailable or err == error.NodeJsRequired or err == error.SwitchSelectionRequiresTty or + err == error.AliasSelectionRequiresTty or + err == error.InvalidAlias or + err == error.DuplicateAlias or err == error.RemoveConfirmationUnavailable or err == error.RemoveSelectionRequiresTty or err == error.InvalidRemoveSelectionInput; diff --git a/src/workflows/root.zig b/src/workflows/root.zig index 1bb7b8e4..3e26dcc3 100644 --- a/src/workflows/root.zig +++ b/src/workflows/root.zig @@ -22,6 +22,7 @@ const import_workflow = @import("import.zig"); 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 workflow_env = @import("env.zig"); const targets = @import("targets.zig"); const usage_refresh = @import("usage.zig"); @@ -144,6 +145,7 @@ fn runMain(init: std.process.Init.Minimal) !void { .export_auth => |opts| try export_workflow.handleExport(allocator, codex_home.?, opts), .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), .clean => |opts| try clean_workflow.handleClean(allocator, codex_home.?, opts), } } diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index f690a66e..2f183b92 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -338,6 +338,7 @@ test "Scenario: Given help when rendering then login and command help notes are 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, "alias set ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "config live --interval ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "auto enable") == null); } @@ -419,6 +420,20 @@ test "Scenario: Given remove command help when rendering then options explain li try std.testing.expect(std.mem.indexOf(u8, help, "Remove one or more matching accounts.") != null); } +test "Scenario: Given alias command help when rendering then set and clear examples are shown" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.help.writeCommandHelp(&aw.writer, false, .alias); + + const help = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth alias set ") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth alias clear ") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth alias set 02 work") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "New aliases cannot be empty or only digits.") != null); +} + test "Scenario: Given config help when rendering then live mode is explained" { const gpa = std.testing.allocator; var config_aw: std.Io.Writer.Allocating = .init(gpa); @@ -548,6 +563,63 @@ test "Scenario: Given config live unknown flag when parsing then usage error is try expectUsageError(result, .config, "unknown flag `--refresh` for `config live`."); } +test "Scenario: Given alias set when parsing then selector and alias are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "alias", "set", "john@example.com", "work" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .alias => |opts| switch (opts) { + .set => |set_opts| { + try std.testing.expectEqualStrings("john@example.com", set_opts.selector); + try std.testing.expectEqualStrings("work", set_opts.alias); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given alias clear when parsing then selector is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "alias", "clear", "work" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .alias => |opts| switch (opts) { + .clear => |clear_opts| try std.testing.expectEqualStrings("work", clear_opts.selector), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given alias set missing value when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "alias", "set", "work" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .alias, "`alias set` requires a selector and alias."); +} + +test "Scenario: Given alias unknown subcommand when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "alias", "rename", "work", "personal" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .alias, "unknown alias subcommand `rename`"); +} + test "Scenario: Given migrate when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "migrate" }; @@ -996,8 +1068,8 @@ test "Scenario: Given singleton aliases from different emails when building remo } try std.testing.expectEqual(@as(usize, 2), labels.items.len); - try std.testing.expectEqualStrings("alpha@example.com / work", labels.items[0]); - try std.testing.expectEqualStrings("beta@example.com / work", labels.items[1]); + try std.testing.expectEqualStrings("work(alpha@example.com)", labels.items[0]); + try std.testing.expectEqualStrings("work(beta@example.com)", labels.items[1]); } test "Scenario: Given singleton account names from different emails when building remove labels then each label keeps email context" { @@ -1018,8 +1090,8 @@ test "Scenario: Given singleton account names from different emails when buildin } try std.testing.expectEqual(@as(usize, 2), labels.items.len); - try std.testing.expectEqualStrings("alpha@example.com / Workspace", labels.items[0]); - try std.testing.expectEqualStrings("beta@example.com / Workspace", labels.items[1]); + try std.testing.expectEqualStrings("Workspace(alpha@example.com)", labels.items[0]); + try std.testing.expectEqualStrings("Workspace(beta@example.com)", labels.items[1]); } test "Scenario: Given selector environment when deciding switch or remove UI then only non-tty streams use the numbered selector" { diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 473943f4..54ed25f9 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -1697,7 +1697,7 @@ test "Scenario: Given switch query with a direct local match when running switch defer gpa.free(result.stderr); try expectSuccess(result); - try std.testing.expectEqualStrings("Switched to backup\n", result.stdout); + try std.testing.expectEqualStrings("Switched to backup(backup@example.com)\n", result.stdout); try std.testing.expectEqualStrings("", result.stderr); const auth_after = try fixtures.readFileAlloc(gpa, active_auth_path); @@ -1710,6 +1710,129 @@ test "Scenario: Given switch query with a direct local match when running switch try std.testing.expect(std.mem.eql(u8, loaded.active_account_key.?, backup_key)); } +test "Scenario: Given alias set with a direct local match when running alias then registry alias is updated" { + 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, "active@example.com", &[_]SeedAccount{ + .{ .email = "active@example.com", .alias = "active" }, + .{ .email = "backup@example.com", .alias = "backup" }, + }); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + + const result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "alias", "set", "backup@", "work" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + try std.testing.expectEqualStrings("Updated alias for backup@example.com: backup -> work\n", result.stdout); + try std.testing.expectEqualStrings("", result.stderr); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + const backup_key = try fixtures.accountKeyForEmailAlloc(gpa, "backup@example.com"); + defer gpa.free(backup_key); + const idx = registry.findAccountIndexByAccountKey(&loaded, backup_key) orelse return error.TestExpectedEqual; + try std.testing.expectEqualStrings("work", loaded.accounts.items[idx].alias); +} + +test "Scenario: Given alias clear with display number when running alias then registry alias is removed" { + 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, "active@example.com", &[_]SeedAccount{ + .{ .email = "active@example.com", .alias = "active" }, + .{ .email = "backup@example.com", .alias = "backup" }, + }); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + + const result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "alias", "clear", "02" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + try std.testing.expectEqualStrings("Cleared alias for backup@example.com: backup\n", result.stdout); + try std.testing.expectEqualStrings("", result.stderr); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + const backup_key = try fixtures.accountKeyForEmailAlloc(gpa, "backup@example.com"); + defer gpa.free(backup_key); + const idx = registry.findAccountIndexByAccountKey(&loaded, backup_key) orelse return error.TestExpectedEqual; + try std.testing.expectEqualStrings("", loaded.accounts.items[idx].alias); +} + +test "Scenario: Given alias set with duplicate alias when running alias then it fails without changing registry" { + 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, "active@example.com", &[_]SeedAccount{ + .{ .email = "active@example.com", .alias = "active" }, + .{ .email = "backup@example.com", .alias = "backup" }, + }); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + + const result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "alias", "set", "backup@", "ACTIVE" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stdout); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "alias 'ACTIVE' is already used by active@example.com.") != null); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + const backup_key = try fixtures.accountKeyForEmailAlloc(gpa, "backup@example.com"); + defer gpa.free(backup_key); + const idx = registry.findAccountIndexByAccountKey(&loaded, backup_key) orelse return error.TestExpectedEqual; + try std.testing.expectEqualStrings("backup", loaded.accounts.items[idx].alias); +} + test "Scenario: Given switch query with multiple matches when running switch then it asks for one account and switches only that account" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); @@ -1771,7 +1894,7 @@ test "Scenario: Given switch query with multiple matches when running switch the try std.testing.expect(std.mem.indexOf(u8, result.stdout, "alpha@example.com") != null); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "beta@example.com") != null); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "solo@example.com") == null); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Switched to team-b") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Switched to team-b(beta@example.com)") != null); try std.testing.expectEqualStrings("", result.stderr); const auth_after = try fixtures.readFileAlloc(gpa, active_auth_path); @@ -2070,7 +2193,7 @@ test "Scenario: Given switch with skip-api when running interactively then it do try expectSuccess(result); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Select account to activate:") != null); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Switched to backup") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Switched to backup(backup@example.com)") != null); try std.testing.expectEqualStrings("", result.stderr); const auth_after = try fixtures.readFileAlloc(gpa, active_auth_path); @@ -2831,8 +2954,8 @@ test "Scenario: Given remove query with multiple matches in non-tty mode when ru try std.testing.expectEqualStrings("", result.stdout); try std.testing.expectEqualStrings( "Matched multiple accounts:\n" ++ - "- alpha@example.com / team-a\n" ++ - "- beta@example.com / team-b\n" ++ + "- team-a(alpha@example.com)\n" ++ + "- team-b(beta@example.com)\n" ++ "error: multiple accounts match the query in non-interactive mode.\n" ++ "hint: Refine the query to match one account, or run the command in a TTY.\n", result.stderr, @@ -2871,8 +2994,8 @@ test "Scenario: Given remove fuzzy selector with multiple matches when running r try std.testing.expectEqualStrings("", result.stdout); try std.testing.expectEqualStrings( "Matched multiple accounts:\n" ++ - "- east@example.com / ops-east\n" ++ - "- west@example.com / ops-west\n" ++ + "- ops-east(east@example.com)\n" ++ + "- ops-west(west@example.com)\n" ++ "error: multiple accounts match the query in non-interactive mode.\n" ++ "hint: Refine the query to match one account, or run the command in a TTY.\n", result.stderr, diff --git a/tests/tui_display_test.zig b/tests/tui_display_test.zig index 8fd63800..83c7c9c3 100644 --- a/tests/tui_display_test.zig +++ b/tests/tui_display_test.zig @@ -162,10 +162,10 @@ test "Scenario: Given same-email accounts filtered down to one row when building defer singleton_rows.deinit(gpa); try std.testing.expectEqual(@as(usize, 1), singleton_rows.rows.len); try std.testing.expect(singleton_rows.rows[0].account_index != null); - try std.testing.expect(std.mem.eql(u8, singleton_rows.rows[0].account_cell, "user@example.com")); + try std.testing.expect(std.mem.eql(u8, singleton_rows.rows[0].account_cell, "work(Primary Workspace, user@example.com)")); } -test "Scenario: Given singleton accounts with alias and account name combinations when building display rows then email labels are preserved" { +test "Scenario: Given singleton accounts with alias and account name combinations when building display rows then preferred labels render before emails" { const gpa = std.testing.allocator; var reg = makeRegistry(); defer reg.deinit(gpa); @@ -181,13 +181,13 @@ test "Scenario: Given singleton accounts with alias and account name combination defer rows.deinit(gpa); try std.testing.expectEqual(@as(usize, 4), rows.rows.len); - try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "alias-name@example.com")); - try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "alias-only@example.com")); + try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "work(Primary Workspace, alias-name@example.com)")); + try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "backup(alias-only@example.com)")); try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "fallback@example.com")); - try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "name-only@example.com")); + try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "Sandbox(name-only@example.com)")); } -test "Scenario: Given mixed singleton and grouped accounts when building display rows then singleton rows keep email while grouped rows keep preferred labels" { +test "Scenario: Given mixed singleton and grouped accounts when building display rows then singleton rows include preferred labels while grouped rows keep child labels" { const gpa = std.testing.allocator; var reg = makeRegistry(); defer reg.deinit(gpa); @@ -202,10 +202,10 @@ test "Scenario: Given mixed singleton and grouped accounts when building display defer rows.deinit(gpa); try std.testing.expectEqual(@as(usize, 4), rows.rows.len); - try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "solo@example.com")); + try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "solo(Solo Workspace, solo@example.com)")); try std.testing.expect(rows.rows[1].account_index == null); try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "user@example.com")); - try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "work (Primary Workspace)")); + try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "work(Primary Workspace)")); try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "Plus")); } @@ -225,10 +225,10 @@ test "Scenario: Given grouped accounts with account names when building display try std.testing.expectEqual(@as(usize, 4), rows.rows.len); try std.testing.expect( - (std.mem.eql(u8, rows.rows[1].account_cell, "work (Primary Workspace)") and + (std.mem.eql(u8, rows.rows[1].account_cell, "work(Primary Workspace)") and std.mem.eql(u8, rows.rows[2].account_cell, "Backup Workspace")) or (std.mem.eql(u8, rows.rows[1].account_cell, "Backup Workspace") and - std.mem.eql(u8, rows.rows[2].account_cell, "work (Primary Workspace)")), + std.mem.eql(u8, rows.rows[2].account_cell, "work(Primary Workspace)")), ); try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "Plus")); } diff --git a/tests/workflows_live_test.zig b/tests/workflows_live_test.zig index 952f5322..1e64e317 100644 --- a/tests/workflows_live_test.zig +++ b/tests/workflows_live_test.zig @@ -338,7 +338,7 @@ test "switch live action patches the current display after switching" { owned_display.deinit(gpa); } - try std.testing.expectEqualStrings("Switched to Registry Beta", outcome.action_message.?); + try std.testing.expectEqualStrings("Switched to Registry Beta(beta@example.com)", outcome.action_message.?); try std.testing.expectEqualStrings(beta_key, outcome.updated_display.reg.active_account_key.?); try std.testing.expectEqual(@as(usize, 2), outcome.updated_display.reg.accounts.items.len); try std.testing.expectEqualStrings("Registry Beta", outcome.updated_display.reg.accounts.items[beta_idx].account_name.?); @@ -531,7 +531,7 @@ test "remove live action patches the current display after deleting the active a owned_display.deinit(gpa); } - try std.testing.expectEqualStrings("Removed 1 account(s): alpha@example.com / Registry Alpha", outcome.action_message.?); + try std.testing.expectEqualStrings("Removed 1 account(s): Registry Alpha(alpha@example.com)", outcome.action_message.?); try std.testing.expectEqual(@as(usize, 1), outcome.updated_display.reg.accounts.items.len); try std.testing.expect(findAccountIndexByAccountKeyConst(&outcome.updated_display.reg, alpha_key) == null); try std.testing.expectEqualStrings(beta_key, outcome.updated_display.reg.active_account_key.?);