From 4ccb8e935b5e4e750f0984184d2ee5bb1b8273dc Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 14:50:59 +0800 Subject: [PATCH 1/2] refactor: clean up CLI surface --- README.md | 2 - docs/implement.md | 1 - src/cli.zig | 477 +++++++++++++++++++++++++++------- src/format.zig | 122 +-------- src/main.zig | 58 +++-- src/tests/cli_bdd_test.zig | 508 ++++++++++++++++++++----------------- src/tests/main_test.zig | 2 +- 7 files changed, 700 insertions(+), 470 deletions(-) diff --git a/README.md b/README.md index c45bf7b..f624312 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,6 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error | `codex-auth remove` | Remove accounts with interactive multi-select | | `codex-auth status` | Show auto-switch, service, and usage status | -> `codex-auth add` is still accepted as a deprecated alias for `codex-auth login`. - ### Import | Command | Description | diff --git a/docs/implement.md b/docs/implement.md index c3cdf5d..2d1614c 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -36,7 +36,6 @@ This document describes how `codex-auth` stores accounts, synchronizes auth file - If `registry.json` is empty and `~/.codex/auth.json` exists, the tool auto-imports it into `accounts/.auth.json`. - If the registry is empty and there is no `auth.json`, `list` shows no accounts; use `codex-auth login` or `codex-auth import`. -- `codex-auth add` is still accepted as a deprecated alias for `codex-auth login`. ## Registry Compatibility diff --git a/src/cli.zig b/src/cli.zig index c9f0ed8..54ab47f 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -31,11 +31,8 @@ fn stderrColorEnabled() bool { return std.fs.File.stderr().isTty(); } -pub const OutputFormat = enum { table, json, csv, compact }; - pub const ListOptions = struct {}; -pub const LoginInvocation = enum { login, add_alias }; -pub const LoginOptions = struct { invocation: LoginInvocation }; +pub const LoginOptions = struct {}; pub const ImportSource = enum { standard, cpa }; pub const ImportOptions = struct { auth_path: ?[]u8, @@ -65,6 +62,18 @@ pub const ConfigOptions = union(enum) { }; pub const DaemonMode = enum { watch, once }; pub const DaemonOptions = struct { mode: DaemonMode }; +pub const HelpTopic = enum { + top_level, + list, + status, + login, + import_auth, + switch_account, + remove_account, + clean, + config, + daemon, +}; pub const Command = union(enum) { list: ListOptions, @@ -77,30 +86,60 @@ pub const Command = union(enum) { status: void, daemon: DaemonOptions, version: void, - help: void, + help: HelpTopic, +}; + +pub const UsageError = struct { + topic: HelpTopic, + message: []u8, }; -pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Command { - if (args.len < 2) return Command{ .help = {} }; +pub const ParseResult = union(enum) { + command: Command, + usage_error: UsageError, +}; + +pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !ParseResult { + if (args.len < 2) return .{ .command = .{ .help = .top_level } }; const cmd = std.mem.sliceTo(args[1], 0); + if (isHelpFlag(cmd)) { + if (args.len > 2) { + return usageErrorResult(allocator, .top_level, "unexpected argument after `{s}`: `{s}`.", .{ + cmd, + std.mem.sliceTo(args[2], 0), + }); + } + return .{ .command = .{ .help = .top_level } }; + } + + if (std.mem.eql(u8, cmd, "help")) { + return try parseHelpArgs(allocator, args[2..]); + } + if (std.mem.eql(u8, cmd, "--version") or std.mem.eql(u8, cmd, "-V")) { - if (args.len > 2) return Command{ .help = {} }; - return Command{ .version = {} }; + if (args.len > 2) { + return usageErrorResult(allocator, .top_level, "unexpected argument after `{s}`: `{s}`.", .{ + cmd, + std.mem.sliceTo(args[2], 0), + }); + } + return .{ .command = .{ .version = {} } }; } if (std.mem.eql(u8, cmd, "list")) { - if (args.len > 2) return Command{ .help = {} }; - return Command{ .list = .{} }; + return try parseSimpleCommandArgs(allocator, "list", .list, .{ .list = .{} }, args[2..]); } - if (std.mem.eql(u8, cmd, "login") or std.mem.eql(u8, cmd, "add")) { - if (args.len > 2) return Command{ .help = {} }; - const invocation: LoginInvocation = if (std.mem.eql(u8, cmd, "add")) .add_alias else .login; - return Command{ .login = .{ .invocation = invocation } }; + if (std.mem.eql(u8, cmd, "login")) { + return try parseSimpleCommandArgs(allocator, "login", .login, .{ .login = .{} }, args[2..]); } if (std.mem.eql(u8, cmd, "import")) { + if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { + return .{ .command = .{ .help = .import_auth } }; + } + var auth_path: ?[]u8 = null; var alias: ?[]u8 = null; var purge = false; @@ -108,68 +147,86 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Comm var i: usize = 2; while (i < args.len) : (i += 1) { const arg = std.mem.sliceTo(args[i], 0); - if (std.mem.eql(u8, arg, "--alias") and i + 1 < args.len) { - if (alias) |a| allocator.free(a); + if (std.mem.eql(u8, arg, "--alias")) { + if (i + 1 >= args.len) { + freeImportOptions(allocator, auth_path, alias); + return usageErrorResult(allocator, .import_auth, "missing value for `--alias`.", .{}); + } + if (alias != null) { + freeImportOptions(allocator, auth_path, alias); + return usageErrorResult(allocator, .import_auth, "duplicate `--alias` for `import`.", .{}); + } alias = try allocator.dupe(u8, std.mem.sliceTo(args[i + 1], 0)); i += 1; } else if (std.mem.eql(u8, arg, "--purge")) { + if (purge) { + freeImportOptions(allocator, auth_path, alias); + return usageErrorResult(allocator, .import_auth, "duplicate `--purge` for `import`.", .{}); + } purge = true; } else if (std.mem.eql(u8, arg, "--cpa")) { if (source == .cpa) { - if (auth_path) |p| allocator.free(p); - if (alias) |a| allocator.free(a); - return Command{ .help = {} }; + freeImportOptions(allocator, auth_path, alias); + return usageErrorResult(allocator, .import_auth, "duplicate `--cpa` for `import`.", .{}); } source = .cpa; + } else if (isHelpFlag(arg)) { + freeImportOptions(allocator, auth_path, alias); + return usageErrorResult(allocator, .import_auth, "`--help` must be used by itself for `import`.", .{}); } else if (std.mem.startsWith(u8, arg, "-")) { - if (auth_path) |p| allocator.free(p); - if (alias) |a| allocator.free(a); - return Command{ .help = {} }; + freeImportOptions(allocator, auth_path, alias); + return usageErrorResult(allocator, .import_auth, "unknown flag `{s}` for `import`.", .{arg}); } else { if (auth_path != null) { - if (auth_path) |p| allocator.free(p); - if (alias) |a| allocator.free(a); - return Command{ .help = {} }; + freeImportOptions(allocator, auth_path, alias); + return usageErrorResult(allocator, .import_auth, "unexpected extra path `{s}` for `import`.", .{arg}); } auth_path = try allocator.dupe(u8, arg); } } if (purge and source == .cpa) { - if (auth_path) |p| allocator.free(p); - if (alias) |a| allocator.free(a); - return Command{ .help = {} }; + freeImportOptions(allocator, auth_path, alias); + return usageErrorResult(allocator, .import_auth, "`--purge` cannot be combined with `--cpa`.", .{}); } if (auth_path == null and !purge and source == .standard) { - if (alias) |a| allocator.free(a); - return Command{ .help = {} }; + freeImportOptions(allocator, auth_path, alias); + return usageErrorResult(allocator, .import_auth, "`import` requires a path unless `--purge` or `--cpa` is used.", .{}); } - return Command{ .import_auth = .{ + return .{ .command = .{ .import_auth = .{ .auth_path = auth_path, .alias = alias, .purge = purge, .source = source, - } }; + } } }; } if (std.mem.eql(u8, cmd, "switch")) { + if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { + return .{ .command = .{ .help = .switch_account } }; + } + var query: ?[]u8 = null; var i: usize = 2; while (i < args.len) : (i += 1) { const arg = std.mem.sliceTo(args[i], 0); if (std.mem.startsWith(u8, arg, "-")) { if (query) |e| allocator.free(e); - return Command{ .help = {} }; + return usageErrorResult(allocator, .switch_account, "unknown flag `{s}` for `switch`.", .{arg}); } if (query != null) { if (query) |e| allocator.free(e); - return Command{ .help = {} }; + return usageErrorResult(allocator, .switch_account, "unexpected extra query `{s}` for `switch`.", .{arg}); } query = try allocator.dupe(u8, arg); } - return Command{ .switch_account = .{ .query = query } }; + return .{ .command = .{ .switch_account = .{ .query = query } } }; } if (std.mem.eql(u8, cmd, "remove")) { + if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { + return .{ .command = .{ .help = .remove_account } }; + } + var query: ?[]u8 = null; var all = false; var i: usize = 2; @@ -178,43 +235,50 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Comm if (std.mem.eql(u8, arg, "--all")) { if (all or query != null) { if (query) |q| allocator.free(q); - return Command{ .help = {} }; + return usageErrorResult(allocator, .remove_account, "`remove` cannot combine `--all` with another selector.", .{}); } all = true; continue; } if (std.mem.startsWith(u8, arg, "-")) { if (query) |q| allocator.free(q); - return Command{ .help = {} }; + return usageErrorResult(allocator, .remove_account, "unknown flag `{s}` for `remove`.", .{arg}); } if (query != null or all) { if (query) |q| allocator.free(q); - return Command{ .help = {} }; + if (all) { + return usageErrorResult(allocator, .remove_account, "`remove` cannot combine `--all` with another selector.", .{}); + } + return usageErrorResult(allocator, .remove_account, "unexpected extra selector `{s}` for `remove`.", .{arg}); } query = try allocator.dupe(u8, arg); } - return Command{ .remove_account = .{ .query = query, .all = all } }; + return .{ .command = .{ .remove_account = .{ .query = query, .all = all } } }; } if (std.mem.eql(u8, cmd, "clean")) { - if (args.len > 2) return Command{ .help = {} }; - return Command{ .clean = .{} }; + return try parseSimpleCommandArgs(allocator, "clean", .clean, .{ .clean = .{} }, args[2..]); } if (std.mem.eql(u8, cmd, "status")) { - if (args.len > 2) return Command{ .help = {} }; - return Command{ .status = {} }; + return try parseSimpleCommandArgs(allocator, "status", .status, .{ .status = {} }, args[2..]); } if (std.mem.eql(u8, cmd, "config")) { - if (args.len < 3) return Command{ .help = {} }; + if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { + return .{ .command = .{ .help = .config } }; + } + if (args.len < 3) return usageErrorResult(allocator, .config, "`config` requires a section.", .{}); const scope = std.mem.sliceTo(args[2], 0); if (std.mem.eql(u8, scope, "auto")) { + if (args.len == 4 and isHelpFlag(std.mem.sliceTo(args[3], 0))) { + return .{ .command = .{ .help = .config } }; + } if (args.len == 4) { const action = std.mem.sliceTo(args[3], 0); - if (std.mem.eql(u8, action, "enable")) return Command{ .config = .{ .auto_switch = .{ .action = .enable } } }; - if (std.mem.eql(u8, action, "disable")) return Command{ .config = .{ .auto_switch = .{ .action = .disable } } }; + if (std.mem.eql(u8, action, "enable")) return .{ .command = .{ .config = .{ .auto_switch = .{ .action = .enable } } } }; + if (std.mem.eql(u8, action, "disable")) return .{ .command = .{ .config = .{ .auto_switch = .{ .action = .disable } } } }; } var threshold_5h_percent: ?u8 = null; @@ -222,51 +286,74 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Comm var i: usize = 3; while (i < args.len) : (i += 1) { const arg = std.mem.sliceTo(args[i], 0); - if (std.mem.eql(u8, arg, "--5h") and i + 1 < args.len) { - if (threshold_5h_percent != null) return Command{ .help = {} }; - threshold_5h_percent = parsePercentArg(std.mem.sliceTo(args[i + 1], 0)) orelse return Command{ .help = {} }; + if (std.mem.eql(u8, arg, "--5h")) { + if (i + 1 >= args.len) return usageErrorResult(allocator, .config, "missing value for `--5h`.", .{}); + if (threshold_5h_percent != null) return usageErrorResult(allocator, .config, "duplicate `--5h` for `config auto`.", .{}); + threshold_5h_percent = parsePercentArg(std.mem.sliceTo(args[i + 1], 0)) orelse + return usageErrorResult(allocator, .config, "`--5h` must be an integer from 1 to 100.", .{}); i += 1; continue; } - if (std.mem.eql(u8, arg, "--weekly") and i + 1 < args.len) { - if (threshold_weekly_percent != null) return Command{ .help = {} }; - threshold_weekly_percent = parsePercentArg(std.mem.sliceTo(args[i + 1], 0)) orelse return Command{ .help = {} }; + if (std.mem.eql(u8, arg, "--weekly")) { + if (i + 1 >= args.len) return usageErrorResult(allocator, .config, "missing value for `--weekly`.", .{}); + if (threshold_weekly_percent != null) return usageErrorResult(allocator, .config, "duplicate `--weekly` for `config auto`.", .{}); + threshold_weekly_percent = parsePercentArg(std.mem.sliceTo(args[i + 1], 0)) orelse + return usageErrorResult(allocator, .config, "`--weekly` must be an integer from 1 to 100.", .{}); i += 1; continue; } - return Command{ .help = {} }; + if (std.mem.eql(u8, arg, "enable") or std.mem.eql(u8, arg, "disable")) { + return usageErrorResult(allocator, .config, "`config auto` cannot mix actions with threshold flags.", .{}); + } + return usageErrorResult(allocator, .config, "unknown argument `{s}` for `config auto`.", .{arg}); } - if (threshold_5h_percent == null and threshold_weekly_percent == null) return Command{ .help = {} }; - return Command{ .config = .{ .auto_switch = .{ .configure = .{ + if (threshold_5h_percent == null and threshold_weekly_percent == null) { + return usageErrorResult(allocator, .config, "`config auto` requires an action or threshold flags.", .{}); + } + return .{ .command = .{ .config = .{ .auto_switch = .{ .configure = .{ .threshold_5h_percent = threshold_5h_percent, .threshold_weekly_percent = threshold_weekly_percent, - } } } }; + } } } } }; } if (std.mem.eql(u8, scope, "api")) { - if (args.len != 4) return Command{ .help = {} }; + if (args.len == 4 and isHelpFlag(std.mem.sliceTo(args[3], 0))) { + return .{ .command = .{ .help = .config } }; + } + if (args.len != 4) return usageErrorResult(allocator, .config, "`config api` requires `enable` or `disable`.", .{}); const action = std.mem.sliceTo(args[3], 0); - if (std.mem.eql(u8, action, "enable")) return Command{ .config = .{ .api_usage = .enable } }; - if (std.mem.eql(u8, action, "disable")) return Command{ .config = .{ .api_usage = .disable } }; + if (std.mem.eql(u8, action, "enable")) return .{ .command = .{ .config = .{ .api_usage = .enable } } }; + if (std.mem.eql(u8, action, "disable")) return .{ .command = .{ .config = .{ .api_usage = .disable } } }; + return usageErrorResult(allocator, .config, "unknown action `{s}` for `config api`.", .{action}); } - return Command{ .help = {} }; + return usageErrorResult(allocator, .config, "unknown config section `{s}`.", .{scope}); } if (std.mem.eql(u8, cmd, "daemon")) { + if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { + return .{ .command = .{ .help = .daemon } }; + } if (args.len == 3 and std.mem.eql(u8, std.mem.sliceTo(args[2], 0), "--watch")) { - return Command{ .daemon = .{ .mode = .watch } }; + return .{ .command = .{ .daemon = .{ .mode = .watch } } }; } if (args.len == 3 and std.mem.eql(u8, std.mem.sliceTo(args[2], 0), "--once")) { - return Command{ .daemon = .{ .mode = .once } }; + return .{ .command = .{ .daemon = .{ .mode = .once } } }; } - return Command{ .help = {} }; + return usageErrorResult(allocator, .daemon, "`daemon` requires `--watch` or `--once`.", .{}); } - return Command{ .help = {} }; + return usageErrorResult(allocator, .top_level, "unknown command `{s}`.", .{cmd}); +} + +pub fn freeParseResult(allocator: std.mem.Allocator, result: *ParseResult) void { + switch (result.*) { + .command => |*cmd| freeCommand(allocator, cmd), + .usage_error => |*usage_err| allocator.free(usage_err.message), + } } -pub fn freeCommand(allocator: std.mem.Allocator, cmd: *Command) void { +fn freeCommand(allocator: std.mem.Allocator, cmd: *Command) void { switch (cmd.*) { .import_auth => |*opts| { if (opts.auth_path) |path| allocator.free(path); @@ -282,6 +369,73 @@ pub fn freeCommand(allocator: std.mem.Allocator, cmd: *Command) void { } } +fn isHelpFlag(arg: []const u8) bool { + return std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"); +} + +fn usageErrorResult( + allocator: std.mem.Allocator, + topic: HelpTopic, + comptime fmt: []const u8, + args: anytype, +) !ParseResult { + return .{ .usage_error = .{ + .topic = topic, + .message = try std.fmt.allocPrint(allocator, fmt, args), + } }; +} + +fn parseSimpleCommandArgs( + allocator: std.mem.Allocator, + command_name: []const u8, + topic: HelpTopic, + command: Command, + rest: []const [:0]const u8, +) !ParseResult { + if (rest.len == 0) return .{ .command = command }; + if (rest.len == 1 and isHelpFlag(std.mem.sliceTo(rest[0], 0))) { + return .{ .command = .{ .help = topic } }; + } + const arg = std.mem.sliceTo(rest[0], 0); + if (std.mem.startsWith(u8, arg, "-")) { + return usageErrorResult(allocator, topic, "unknown flag `{s}` for `{s}`.", .{ arg, command_name }); + } + return usageErrorResult(allocator, topic, "unexpected argument `{s}` for `{s}`.", .{ arg, command_name }); +} + +fn parseHelpArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !ParseResult { + if (rest.len == 0) return .{ .command = .{ .help = .top_level } }; + if (rest.len > 1) { + return usageErrorResult(allocator, .top_level, "unexpected argument after `help`: `{s}`.", .{ + std.mem.sliceTo(rest[1], 0), + }); + } + + const topic = helpTopicForName(std.mem.sliceTo(rest[0], 0)) orelse + return usageErrorResult(allocator, .top_level, "unknown help topic `{s}`.", .{ + std.mem.sliceTo(rest[0], 0), + }); + return .{ .command = .{ .help = topic } }; +} + +fn helpTopicForName(name: []const u8) ?HelpTopic { + if (std.mem.eql(u8, name, "list")) return .list; + if (std.mem.eql(u8, name, "status")) return .status; + if (std.mem.eql(u8, name, "login")) return .login; + if (std.mem.eql(u8, name, "import")) return .import_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, "clean")) return .clean; + if (std.mem.eql(u8, name, "config")) return .config; + if (std.mem.eql(u8, name, "daemon")) return .daemon; + return null; +} + +fn freeImportOptions(allocator: std.mem.Allocator, auth_path: ?[]u8, alias: ?[]u8) void { + if (auth_path) |path| allocator.free(path); + if (alias) |value| allocator.free(value); +} + pub fn printHelp(auto_cfg: *const registry.AutoSwitchConfig, api_cfg: *const registry.ApiConfig) !void { var stdout: io_util.Stdout = undefined; stdout.init(); @@ -382,7 +536,7 @@ pub fn writeHelp( try out.writeAll("Notes:"); if (use_color) try out.writeAll(ansi.reset); try out.writeAll("\n\n"); - try out.writeAll(" `add` is accepted as a deprecated alias for `login` and will be removed in the next release.\n"); + try out.writeAll(" Run `codex-auth --help` for command-specific usage and examples.\n"); try out.writeAll(" `config api enable` may trigger OpenAI account restrictions or suspension in some environments.\n"); } @@ -432,6 +586,167 @@ fn writeHelpEntry( try out.writeAll("\n"); } +pub fn printCommandHelp(topic: HelpTopic) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try writeCommandHelp(out, colorEnabled(), topic); + try out.flush(); +} + +pub fn writeCommandHelp(out: *std.Io.Writer, use_color: bool, topic: HelpTopic) !void { + try writeCommandHelpHeader(out, use_color, topic); + try out.writeAll("\n"); + try writeUsageSection(out, topic); + try out.writeAll("\n"); + try writeExamplesSection(out, topic); +} + +fn writeCommandHelpHeader(out: *std.Io.Writer, use_color: bool, topic: HelpTopic) !void { + if (use_color) try out.writeAll(ansi.bold); + try out.print("codex-auth {s}", .{commandNameForTopic(topic)}); + if (use_color) try out.writeAll(ansi.reset); + try out.writeAll("\n"); + try out.print("{s}\n", .{commandDescriptionForTopic(topic)}); +} + +fn commandNameForTopic(topic: HelpTopic) []const u8 { + return switch (topic) { + .top_level => "", + .list => "list", + .status => "status", + .login => "login", + .import_auth => "import", + .switch_account => "switch", + .remove_account => "remove", + .clean => "clean", + .config => "config", + .daemon => "daemon", + }; +} + +fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { + return switch (topic) { + .top_level => "Command-line account management for Codex.", + .list => "List available accounts in the default table view.", + .status => "Show auto-switch, service, and usage API status.", + .login => "Run `codex login`, then add the current account.", + .import_auth => "Import auth files or rebuild the registry.", + .switch_account => "Switch the active account interactively or by query.", + .remove_account => "Remove one or more accounts.", + .clean => "Delete backup and stale files under accounts/.", + .config => "Manage auto-switch and usage API configuration.", + .daemon => "Run the background auto-switch daemon.", + }; +} + +fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { + try out.writeAll("Usage:\n"); + switch (topic) { + .top_level => { + try out.writeAll(" codex-auth \n"); + try out.writeAll(" codex-auth --help\n"); + try out.writeAll(" codex-auth help \n"); + }, + .list => try out.writeAll(" codex-auth list\n"), + .status => try out.writeAll(" codex-auth status\n"), + .login => try out.writeAll(" codex-auth login\n"), + .import_auth => { + try out.writeAll(" codex-auth import [--alias ]\n"); + try out.writeAll(" codex-auth import --cpa [] [--alias ]\n"); + try out.writeAll(" codex-auth import --purge []\n"); + }, + .switch_account => { + try out.writeAll(" codex-auth switch\n"); + try out.writeAll(" codex-auth switch \n"); + }, + .remove_account => { + try out.writeAll(" codex-auth remove\n"); + try out.writeAll(" codex-auth remove \n"); + try out.writeAll(" codex-auth remove --all\n"); + }, + .clean => try out.writeAll(" codex-auth clean\n"), + .config => { + try out.writeAll(" codex-auth config auto enable\n"); + try out.writeAll(" codex-auth config auto disable\n"); + try out.writeAll(" codex-auth config auto --5h [--weekly ]\n"); + try out.writeAll(" codex-auth config auto --weekly \n"); + try out.writeAll(" codex-auth config api enable\n"); + try out.writeAll(" codex-auth config api disable\n"); + }, + .daemon => { + try out.writeAll(" codex-auth daemon --watch\n"); + try out.writeAll(" codex-auth daemon --once\n"); + }, + } +} + +fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { + try out.writeAll("Examples:\n"); + switch (topic) { + .top_level => { + try out.writeAll(" codex-auth list\n"); + 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"), + .status => try out.writeAll(" codex-auth status\n"), + .login => try out.writeAll(" codex-auth login\n"), + .import_auth => { + try out.writeAll(" codex-auth import /path/to/auth.json --alias personal\n"); + try out.writeAll(" codex-auth import --cpa /path/to/token.json --alias work\n"); + try out.writeAll(" codex-auth import --purge\n"); + }, + .switch_account => { + try out.writeAll(" codex-auth switch\n"); + try out.writeAll(" codex-auth switch john@example.com\n"); + }, + .remove_account => { + try out.writeAll(" codex-auth remove\n"); + try out.writeAll(" codex-auth remove john@example.com\n"); + try out.writeAll(" codex-auth remove --all\n"); + }, + .clean => try out.writeAll(" codex-auth clean\n"), + .config => { + try out.writeAll(" codex-auth config auto --5h 12 --weekly 8\n"); + try out.writeAll(" codex-auth config api enable\n"); + }, + .daemon => { + try out.writeAll(" codex-auth daemon --watch\n"); + try out.writeAll(" codex-auth daemon --once\n"); + }, + } +} + +pub fn printUsageError(usage_err: *const UsageError) !void { + var buffer: [2048]u8 = undefined; + var writer = std.fs.File.stderr().writer(&buffer); + const out = &writer.interface; + const use_color = stderrColorEnabled(); + try writeErrorPrefixTo(out, use_color); + try out.print(" {s}\n\n", .{usage_err.message}); + try writeUsageSection(out, usage_err.topic); + try out.writeAll("\n"); + try writeHintPrefixTo(out, use_color); + try out.print(" Run `{s}` for examples.\n", .{helpCommandForTopic(usage_err.topic)}); + try out.flush(); +} + +fn helpCommandForTopic(topic: HelpTopic) []const u8 { + return switch (topic) { + .top_level => "codex-auth --help", + .list => "codex-auth list --help", + .status => "codex-auth status --help", + .login => "codex-auth login --help", + .import_auth => "codex-auth import --help", + .switch_account => "codex-auth switch --help", + .remove_account => "codex-auth remove --help", + .clean => "codex-auth clean --help", + .config => "codex-auth config --help", + .daemon => "codex-auth daemon --help", + }; +} + pub fn printVersion() !void { var stdout: io_util.Stdout = undefined; stdout.init(); @@ -499,19 +814,6 @@ pub fn writeImportReport( } } -pub fn warnDeprecatedLoginAlias(opts: LoginOptions) void { - if (opts.invocation != .add_alias) return; - writeDeprecatedLoginAliasWarning("codex-auth login", stderrColorEnabled()) catch {}; -} - -fn writeDeprecatedLoginAliasWarning(replacement: []const u8, use_color: bool) !void { - var buffer: [512]u8 = undefined; - var writer = std.fs.File.stderr().writer(&buffer); - const out = &writer.interface; - try writeDeprecatedLoginAliasWarningTo(out, replacement, use_color); - try out.flush(); -} - pub fn writeErrorPrefixTo(out: *std.Io.Writer, use_color: bool) !void { if (use_color) try out.writeAll(ansi.bold_red); try out.writeAll("error:"); @@ -643,21 +945,6 @@ pub fn printRemoveSummary(labels: []const []const u8) !void { try out.flush(); } -pub fn writeDeprecatedLoginAliasWarningTo(out: *std.Io.Writer, replacement: []const u8, use_color: bool) !void { - if (use_color) try out.writeAll(ansi.bold_red); - try out.writeAll("warning:"); - if (use_color) try out.writeAll(ansi.reset); - try out.writeAll(" "); - if (use_color) try out.writeAll(ansi.bold); - try out.writeAll("`add`"); - if (use_color) try out.writeAll(ansi.reset); - try out.writeAll(" is deprecated; use "); - if (use_color) try out.writeAll(ansi.bold_green); - try out.print("`{s}`", .{replacement}); - if (use_color) try out.writeAll(ansi.reset); - try out.writeAll("\n"); -} - fn writeCodexLoginLaunchFailureHint(err_name: []const u8, use_color: bool) !void { var buffer: [512]u8 = undefined; var writer = std.fs.File.stderr().writer(&buffer); diff --git a/src/format.zig b/src/format.zig index 2431c9e..664746b 100644 --- a/src/format.zig +++ b/src/format.zig @@ -2,7 +2,6 @@ const std = @import("std"); const builtin = @import("builtin"); const display_rows = @import("display_rows.zig"); const registry = @import("registry.zig"); -const cli = @import("cli.zig"); const io_util = @import("io_util.zig"); const timefmt = @import("timefmt.zig"); const c = @cImport({ @@ -24,14 +23,8 @@ fn planDisplay(rec: *const registry.AccountRecord, missing: []const u8) []const return missing; } -pub fn printAccounts(allocator: std.mem.Allocator, reg: *registry.Registry, fmt: cli.OutputFormat) !void { - switch (fmt) { - .table => try printAccountsTable(reg), - .json => try printAccountsJson(reg), - .csv => try printAccountsCsv(reg), - .compact => try printAccountsCompact(reg), - } - _ = allocator; +pub fn printAccounts(reg: *registry.Registry) !void { + try printAccountsTable(reg); } fn printAccountsTable(reg: *registry.Registry) !void { @@ -167,82 +160,6 @@ fn printAccountsTable(reg: *registry.Registry) !void { try out.flush(); } -fn printAccountsJson(reg: *registry.Registry) !void { - var stdout: io_util.Stdout = undefined; - stdout.init(); - const out = stdout.out(); - const dump = RegistryOut{ - .schema_version = reg.schema_version, - .active_account_key = reg.active_account_key, - .auto_switch = reg.auto_switch, - .api = reg.api, - .accounts = reg.accounts.items, - }; - try std.json.Stringify.value(dump, .{ .whitespace = .indent_2 }, out); - try out.writeAll("\n"); - try out.flush(); -} - -fn printAccountsCsv(reg: *registry.Registry) !void { - var stdout: io_util.Stdout = undefined; - stdout.init(); - const out = stdout.out(); - try out.writeAll("active,account_key,chatgpt_account_id,chatgpt_user_id,email,plan,limit_5h,limit_weekly,last_used\n"); - for (reg.accounts.items) |rec| { - const active = if (reg.active_account_key) |k| std.mem.eql(u8, k, rec.account_key) else false; - const email = rec.email; - const account_key = rec.account_key; - const chatgpt_account_id = rec.chatgpt_account_id; - const chatgpt_user_id = rec.chatgpt_user_id; - const plan = planDisplay(&rec, ""); - const rate_5h = resolveRateWindow(rec.last_usage, 300, true); - const rate_week = resolveRateWindow(rec.last_usage, 10080, false); - const rate_5h_str = try formatRateLimitStatusAlloc(rate_5h); - defer std.heap.page_allocator.free(rate_5h_str); - const rate_week_str = try formatRateLimitStatusAlloc(rate_week); - defer std.heap.page_allocator.free(rate_week_str); - const last = if (rec.last_used_at) |t| try std.fmt.allocPrint(std.heap.page_allocator, "{d}", .{t}) else ""; - defer if (rec.last_used_at != null) std.heap.page_allocator.free(last) else {}; - try out.print( - "{s},{s},{s},{s},{s},{s},{s},{s},{s}\n", - .{ if (active) "1" else "0", account_key, chatgpt_account_id, chatgpt_user_id, email, plan, rate_5h_str, rate_week_str, last }, - ); - } - try out.flush(); -} - -fn printAccountsCompact(reg: *registry.Registry) !void { - var stdout: io_util.Stdout = undefined; - stdout.init(); - const out = stdout.out(); - for (reg.accounts.items) |rec| { - const active = if (reg.active_account_key) |k| std.mem.eql(u8, k, rec.account_key) else false; - const email = rec.email; - const plan = planDisplay(&rec, "-"); - const rate_5h = resolveRateWindow(rec.last_usage, 300, true); - const rate_week = resolveRateWindow(rec.last_usage, 10080, false); - const rate_5h_str = try formatRateLimitStatusAlloc(rate_5h); - defer std.heap.page_allocator.free(rate_5h_str); - const rate_week_str = try formatRateLimitStatusAlloc(rate_week); - defer std.heap.page_allocator.free(rate_week_str); - const last = if (rec.last_used_at) |t| try formatTimestampAlloc(t) else "-"; - defer if (rec.last_used_at != null) std.heap.page_allocator.free(last) else {}; - try out.print( - "{s}{s} ({s}) 5h:{s} week:{s} last:{s}\n", - .{ if (active) "* " else " ", email, plan, rate_5h_str, rate_week_str, last }, - ); - } - try out.flush(); -} - -const RegistryOut = struct { - schema_version: u32, - active_account_key: ?[]const u8, - auto_switch: registry.AutoSwitchConfig, - api: registry.ApiConfig, - accounts: []const registry.AccountRecord, -}; - fn resolveRateWindow(usage: ?registry.RateLimitSnapshot, minutes: i64, fallback_primary: bool) ?registry.RateLimitWindow { if (usage == null) return null; if (usage.?.primary) |p| { @@ -254,20 +171,6 @@ fn resolveRateWindow(usage: ?registry.RateLimitSnapshot, minutes: i64, fallback_ return if (fallback_primary) usage.?.primary else usage.?.secondary; } -fn formatRateLimitStatusAlloc(window: ?registry.RateLimitWindow) ![]u8 { - if (window == null) return try std.fmt.allocPrint(std.heap.page_allocator, "-", .{}); - if (window.?.resets_at == null) return try std.fmt.allocPrint(std.heap.page_allocator, "-", .{}); - const now = std.time.timestamp(); - const reset_at = window.?.resets_at.?; - if (now >= reset_at) { - return try std.fmt.allocPrint(std.heap.page_allocator, "100%", .{}); - } - const remaining = remainingPercent(window.?.used_percent); - const time_str = try formatResetTimeAlloc(reset_at, now); - defer std.heap.page_allocator.free(time_str); - return std.fmt.allocPrint(std.heap.page_allocator, "{d}% {s}", .{ remaining, time_str }); -} - const ResetParts = struct { time: []u8, date: []u8, @@ -667,27 +570,6 @@ fn truncateAlloc(value: []const u8, max_len: usize) ![]u8 { return std.fmt.allocPrint(std.heap.page_allocator, "{s}.", .{value[0 .. max_len - 1]}); } -fn formatTimestampAlloc(ts: i64) ![]u8 { - if (ts < 0) return try std.fmt.allocPrint(std.heap.page_allocator, "-", .{}); - var tm: c.struct_tm = undefined; - if (!localtimeCompat(ts, &tm)) { - return try std.fmt.allocPrint(std.heap.page_allocator, "-", .{}); - } - - const year = @as(u32, @intCast(tm.tm_year + 1900)); - const month = @as(u32, @intCast(tm.tm_mon + 1)); - const day = @as(u32, @intCast(tm.tm_mday)); - const hour = @as(u32, @intCast(tm.tm_hour)); - const min = @as(u32, @intCast(tm.tm_min)); - const sec = @as(u32, @intCast(tm.tm_sec)); - - return std.fmt.allocPrint( - std.heap.page_allocator, - "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}", - .{ year, month, day, hour, min, sec }, - ); -} - test "printTableRow handles long cells without underflow" { var buffer: [256]u8 = undefined; var writer: std.Io.Writer = .fixed(&buffer); diff --git a/src/main.zig b/src/main.zig index 9324955..72db2b5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,7 +10,9 @@ const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; pub fn main() !void { var exit_code: u8 = 0; runMain() catch |err| { - if (isHandledCliError(err)) { + if (err == error.InvalidCliUsage) { + exit_code = 2; + } else if (isHandledCliError(err)) { exit_code = 1; } else { return err; @@ -27,31 +29,47 @@ fn runMain() !void { const args = try std.process.argsAlloc(allocator); defer std.process.argsFree(allocator, args); - var cmd = try cli.parseArgs(allocator, args); - defer cli.freeCommand(allocator, &cmd); + var parsed = try cli.parseArgs(allocator, args); + defer cli.freeParseResult(allocator, &parsed); - const codex_home = try registry.resolveCodexHome(allocator); - defer allocator.free(codex_home); + const cmd = switch (parsed) { + .command => |command| command, + .usage_error => |usage_err| { + try cli.printUsageError(&usage_err); + return error.InvalidCliUsage; + }, + }; + + const needs_codex_home = switch (cmd) { + .version => false, + .help => |topic| topic == .top_level, + else => true, + }; + const codex_home = if (needs_codex_home) try registry.resolveCodexHome(allocator) else null; + defer if (codex_home) |path| allocator.free(path); switch (cmd) { .version => try cli.printVersion(), - .help => try handleHelp(allocator, codex_home), - .status => try auto.printStatus(allocator, codex_home), + .help => |topic| switch (topic) { + .top_level => try handleTopLevelHelp(allocator, codex_home.?), + else => try cli.printCommandHelp(topic), + }, + .status => try auto.printStatus(allocator, codex_home.?), .daemon => |opts| switch (opts.mode) { - .watch => try auto.runDaemon(allocator, codex_home), - .once => try auto.runDaemonOnce(allocator, codex_home), + .watch => try auto.runDaemon(allocator, codex_home.?), + .once => try auto.runDaemonOnce(allocator, codex_home.?), }, - .config => |opts| try handleConfig(allocator, codex_home, opts), - .list => |opts| try handleList(allocator, codex_home, opts), - .login => |opts| try handleLogin(allocator, codex_home, opts), - .import_auth => |opts| try handleImport(allocator, codex_home, opts), - .switch_account => |opts| try handleSwitch(allocator, codex_home, opts), - .remove_account => |opts| try handleRemove(allocator, codex_home, opts), - .clean => |_| try handleClean(allocator, codex_home), + .config => |opts| try handleConfig(allocator, codex_home.?, opts), + .list => |opts| try handleList(allocator, codex_home.?, opts), + .login => |opts| try handleLogin(allocator, codex_home.?, opts), + .import_auth => |opts| try handleImport(allocator, codex_home.?, opts), + .switch_account => |opts| try handleSwitch(allocator, codex_home.?, opts), + .remove_account => |opts| try handleRemove(allocator, codex_home.?, opts), + .clean => |_| try handleClean(allocator, codex_home.?), } if (shouldReconcileManagedService(cmd)) { - try auto.reconcileManagedService(allocator, codex_home); + try auto.reconcileManagedService(allocator, codex_home.?); } } @@ -175,11 +193,11 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li } } try maybeRefreshForegroundUsage(allocator, codex_home, ®, .list); - try format.printAccounts(allocator, ®, .table); + try format.printAccounts(®); } fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.LoginOptions) !void { - cli.warnDeprecatedLoginAlias(opts); + _ = opts; try cli.runCodexLogin(allocator); const auth_path = try registry.activeAuthPath(allocator, codex_home); defer allocator.free(auth_path); @@ -475,7 +493,7 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. try cli.printRemoveSummary(removed_labels.items); } -fn handleHelp(allocator: std.mem.Allocator, codex_home: []const u8) !void { +fn handleTopLevelHelp(allocator: std.mem.Allocator, codex_home: []const u8) !void { const help_cfg = loadHelpConfig(allocator, codex_home); try cli.printHelp(&help_cfg.auto_switch, &help_cfg.api); } diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 9095c6e..47f74dc 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -2,22 +2,23 @@ const std = @import("std"); const cli = @import("../cli.zig"); const registry = @import("../registry.zig"); -fn isHelp(cmd: cli.Command) bool { - return switch (cmd) { - .help => true, - else => false, - }; +fn expectHelp(result: cli.ParseResult, topic: cli.HelpTopic) !void { + switch (result) { + .command => |cmd| switch (cmd) { + .help => |actual| try std.testing.expectEqual(topic, actual), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } } -test "Scenario: Given add alias when parsing then legacy invocation is preserved" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "add" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .login => |opts| { - try std.testing.expect(opts.invocation == .add_alias); +fn expectUsageError(result: cli.ParseResult, topic: cli.HelpTopic, contains: ?[]const u8) !void { + switch (result) { + .usage_error => |usage_err| { + try std.testing.expectEqual(topic, usage_err.topic); + if (contains) |needle| { + try std.testing.expect(std.mem.indexOf(u8, usage_err.message, needle) != null); + } }, else => return error.TestExpectedEqual, } @@ -26,16 +27,19 @@ test "Scenario: Given add alias when parsing then legacy invocation is preserved test "Scenario: Given import path and alias when parsing then import options are preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "import", "/tmp/auth.json", "--alias", "personal" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .import_auth => |opts| { - try std.testing.expect(opts.auth_path != null); - try std.testing.expect(std.mem.eql(u8, opts.auth_path.?, "/tmp/auth.json")); - try std.testing.expect(opts.alias != null); - try std.testing.expect(std.mem.eql(u8, opts.alias.?, "personal")); - try std.testing.expect(!opts.purge); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .import_auth => |opts| { + try std.testing.expect(opts.auth_path != null); + try std.testing.expect(std.mem.eql(u8, opts.auth_path.?, "/tmp/auth.json")); + try std.testing.expect(opts.alias != null); + try std.testing.expect(std.mem.eql(u8, opts.alias.?, "personal")); + try std.testing.expect(!opts.purge); + }, + else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, } @@ -44,14 +48,17 @@ test "Scenario: Given import path and alias when parsing then import options are test "Scenario: Given import purge without path when parsing then purge mode is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "import", "--purge" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .import_auth => |opts| { - try std.testing.expect(opts.auth_path == null); - try std.testing.expect(opts.alias == null); - try std.testing.expect(opts.purge); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .import_auth => |opts| { + try std.testing.expect(opts.auth_path == null); + try std.testing.expect(opts.alias == null); + try std.testing.expect(opts.purge); + }, + else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, } @@ -60,84 +67,87 @@ test "Scenario: Given import purge without path when parsing then purge mode is test "Scenario: Given import cpa without path when parsing then cpa mode is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "import", "--cpa" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .import_auth => |opts| { - try std.testing.expect(opts.auth_path == null); - try std.testing.expect(opts.alias == null); - try std.testing.expect(!opts.purge); - try std.testing.expect(opts.source == .cpa); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .import_auth => |opts| { + try std.testing.expect(opts.auth_path == null); + try std.testing.expect(opts.alias == null); + try std.testing.expect(!opts.purge); + try std.testing.expectEqual(cli.ImportSource.cpa, opts.source); + }, + else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, } } -test "Scenario: Given import cpa with purge when parsing then help command is returned" { +test "Scenario: Given import cpa with purge when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "import", "--cpa", "--purge" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .import_auth, "`--purge`"); } -test "Scenario: Given import unknown short purge flag when parsing then help command is returned" { +test "Scenario: Given import unknown short purge flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "import", "-P", "/tmp/auth.json" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .import_auth, "unknown flag"); } -test "Scenario: Given import alias without path when parsing then help command is returned without leaks" { +test "Scenario: Given import alias without path when parsing then usage error is returned without leaks" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "import", "--alias", "personal" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .import_auth, "requires a path"); } -test "Scenario: Given list with extra args when parsing then help command is returned" { +test "Scenario: Given list with extra args when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "list", "unexpected" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .list, "unexpected argument"); } -test "Scenario: Given login with removed no-login flag when parsing then help command is returned" { +test "Scenario: Given login with removed no-login flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "login", "--no-login" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .login, "unknown flag"); } -test "Scenario: Given add alias with removed no-login flag when parsing then help command is returned" { +test "Scenario: Given login with unknown flag when parsing then usage error is returned" { const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "add", "--no-login" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + const args = [_][:0]const u8{ "codex-auth", "login", "--bad-flag" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .login, "unknown flag"); } -test "Scenario: Given login with unknown flag when parsing then help command is returned" { +test "Scenario: Given command help selector when parsing then command-specific help is preserved" { const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "login", "--bad-flag" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + const args = [_][:0]const u8{ "codex-auth", "help", "list" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectHelp(result, .list); } -test "Scenario: Given help when rendering then login and compatibility notes are shown" { +test "Scenario: Given help when rendering then login and command help notes are shown" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); defer aw.deinit(); @@ -154,11 +164,9 @@ test "Scenario: Given help when rendering then login and compatibility notes are try std.testing.expect(std.mem.indexOf(u8, help, "Auto Switch: ON (5h<12%, weekly<8%)") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Usage API: ON (api)") != null); try std.testing.expect(std.mem.indexOf(u8, help, "--cpa []") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "Warning: Usage refresh is currently using the ChatGPT usage API") == null); - try std.testing.expect(std.mem.indexOf(u8, help, "`codex-auth config api disable`") == null); + try std.testing.expect(std.mem.indexOf(u8, help, "Run `codex-auth --help` for command-specific usage and examples.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "`config api enable` may trigger OpenAI account restrictions or suspension in some environments.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "login") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "add [--no-login]") == null); try std.testing.expect(std.mem.indexOf(u8, help, "clean") != null); try std.testing.expect(std.mem.indexOf(u8, help, "remove [|--all]") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Delete backup and stale files under accounts/") != null); @@ -171,7 +179,19 @@ test "Scenario: Given help when rendering then login and compatibility notes are try std.testing.expect(std.mem.indexOf(u8, help, "api disable") != null); try std.testing.expect(std.mem.indexOf(u8, help, "auto ...") == null); try std.testing.expect(std.mem.indexOf(u8, help, "migrate") == null); - try std.testing.expect(std.mem.indexOf(u8, help, "`add` is accepted as a deprecated alias for `login` and will be removed in the next release.") != null); +} + +test "Scenario: Given command help when rendering then usage and examples are shown" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.writeCommandHelp(&aw.writer, false, .list); + + const help = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth list") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list\n") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Examples:\n codex-auth list\n") != null); } test "Scenario: Given scanned import report when rendering then stdout and stderr match the import format" { @@ -229,11 +249,14 @@ test "Scenario: Given single-file skipped import report when rendering then summ test "Scenario: Given status when parsing then status command is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "status" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - switch (cmd) { - .status => {}, + switch (result) { + .command => |cmd| switch (cmd) { + .status => {}, + else => return error.TestExpectedEqual, + }, else => return error.TestExpectedEqual, } } @@ -241,16 +264,19 @@ test "Scenario: Given status when parsing then status command is preserved" { test "Scenario: Given config auto 5h threshold when parsing then threshold configuration is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--5h", "12" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .config => |opts| switch (opts) { - .auto_switch => |auto_opts| switch (auto_opts) { - .configure => |cfg| { - try std.testing.expect(cfg.threshold_5h_percent != null); - try std.testing.expect(cfg.threshold_5h_percent.? == 12); - try std.testing.expect(cfg.threshold_weekly_percent == null); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .config => |opts| switch (opts) { + .auto_switch => |auto_opts| switch (auto_opts) { + .configure => |cfg| { + try std.testing.expect(cfg.threshold_5h_percent != null); + try std.testing.expect(cfg.threshold_5h_percent.? == 12); + try std.testing.expect(cfg.threshold_weekly_percent == null); + }, + else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, }, @@ -263,17 +289,20 @@ test "Scenario: Given config auto 5h threshold when parsing then threshold confi test "Scenario: Given config auto thresholds together when parsing then both window thresholds are preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--5h", "12", "--weekly", "8" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .config => |opts| switch (opts) { - .auto_switch => |auto_opts| switch (auto_opts) { - .configure => |cfg| { - try std.testing.expect(cfg.threshold_5h_percent != null); - try std.testing.expect(cfg.threshold_5h_percent.? == 12); - try std.testing.expect(cfg.threshold_weekly_percent != null); - try std.testing.expect(cfg.threshold_weekly_percent.? == 8); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .config => |opts| switch (opts) { + .auto_switch => |auto_opts| switch (auto_opts) { + .configure => |cfg| { + try std.testing.expect(cfg.threshold_5h_percent != null); + try std.testing.expect(cfg.threshold_5h_percent.? == 12); + try std.testing.expect(cfg.threshold_weekly_percent != null); + try std.testing.expect(cfg.threshold_weekly_percent.? == 8); + }, + else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, }, @@ -286,13 +315,16 @@ test "Scenario: Given config auto thresholds together when parsing then both win test "Scenario: Given config auto enable when parsing then auto action is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto", "enable" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .config => |opts| switch (opts) { - .auto_switch => |auto_opts| switch (auto_opts) { - .action => |action| try std.testing.expect(action == .enable), + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .config => |opts| switch (opts) { + .auto_switch => |auto_opts| switch (auto_opts) { + .action => |action| try std.testing.expectEqual(cli.AutoAction.enable, action), + else => return error.TestExpectedEqual, + }, else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, @@ -304,12 +336,15 @@ test "Scenario: Given config auto enable when parsing then auto action is preser test "Scenario: Given config api enable when parsing then api action is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "api", "enable" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - switch (cmd) { - .config => |opts| switch (opts) { - .api_usage => |action| try std.testing.expect(action == .enable), + switch (result) { + .command => |cmd| switch (cmd) { + .config => |opts| switch (opts) { + .api_usage => |action| try std.testing.expectEqual(cli.ApiUsageAction.enable, action), + else => return error.TestExpectedEqual, + }, else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, @@ -319,76 +354,82 @@ test "Scenario: Given config api enable when parsing then api action is preserve test "Scenario: Given config api disable when parsing then api disable action is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "api", "disable" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - switch (cmd) { - .config => |opts| switch (opts) { - .api_usage => |action| try std.testing.expect(action == .disable), + switch (result) { + .command => |cmd| switch (cmd) { + .config => |opts| switch (opts) { + .api_usage => |action| try std.testing.expectEqual(cli.ApiUsageAction.disable, action), + else => return error.TestExpectedEqual, + }, else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, } } -test "Scenario: Given config auto action mixed with threshold flags when parsing then help command is returned" { +test "Scenario: Given config auto action mixed with threshold flags when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto", "enable", "--5h", "12" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .config, "cannot mix actions"); } -test "Scenario: Given config auto threshold percent out of range when parsing then help command is returned" { +test "Scenario: Given config auto threshold percent out of range when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--weekly", "0" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .config, "`--weekly` must be an integer from 1 to 100."); } -test "Scenario: Given config auto repeated threshold flag when parsing then help command is returned" { +test "Scenario: Given config auto repeated threshold flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--5h", "12", "--5h", "15" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .config, "duplicate `--5h`"); } -test "Scenario: Given config auto threshold without value when parsing then help command is returned" { +test "Scenario: Given config auto threshold without value when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--weekly" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .config, "missing value for `--weekly`"); } -test "Scenario: Given config auto threshold command without flags when parsing then help command is returned" { +test "Scenario: Given config auto threshold command without flags when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .config, "requires an action or threshold flags"); } test "Scenario: Given config auto threshold with weekly only when parsing then single-window config is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--weekly", "9" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .config => |opts| switch (opts) { - .auto_switch => |auto_opts| switch (auto_opts) { - .configure => |cfg| { - try std.testing.expect(cfg.threshold_5h_percent == null); - try std.testing.expect(cfg.threshold_weekly_percent != null); - try std.testing.expect(cfg.threshold_weekly_percent.? == 9); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .config => |opts| switch (opts) { + .auto_switch => |auto_opts| switch (auto_opts) { + .configure => |cfg| { + try std.testing.expect(cfg.threshold_5h_percent == null); + try std.testing.expect(cfg.threshold_weekly_percent != null); + try std.testing.expect(cfg.threshold_weekly_percent.? == 9); + }, + else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, }, @@ -398,50 +439,53 @@ test "Scenario: Given config auto threshold with weekly only when parsing then s } } -test "Scenario: Given removed top-level auto command when parsing then help command is returned" { +test "Scenario: Given removed top-level auto command when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "auto", "enable" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .top_level, "unknown command `auto`"); } -test "Scenario: Given config api unknown action when parsing then help command is returned" { +test "Scenario: Given config api unknown action when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "api", "status" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .config, "unknown action `status`"); } -test "Scenario: Given status with extra args when parsing then help command is returned" { +test "Scenario: Given status with extra args when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "status", "extra" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .status, "unexpected argument"); } -test "Scenario: Given migrate when parsing then help command is returned" { +test "Scenario: Given migrate when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "migrate" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .top_level, "unknown command `migrate`"); } test "Scenario: Given clean when parsing then clean command is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "clean" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - switch (cmd) { - .clean => {}, + switch (result) { + .command => |cmd| switch (cmd) { + .clean => {}, + else => return error.TestExpectedEqual, + }, else => return error.TestExpectedEqual, } } @@ -449,11 +493,14 @@ test "Scenario: Given clean when parsing then clean command is preserved" { test "Scenario: Given daemon watch when parsing then daemon command is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "daemon", "--watch" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - switch (cmd) { - .daemon => |opts| try std.testing.expect(opts.mode == .watch), + switch (result) { + .command => |cmd| switch (cmd) { + .daemon => |opts| try std.testing.expectEqual(cli.DaemonMode.watch, opts.mode), + else => return error.TestExpectedEqual, + }, else => return error.TestExpectedEqual, } } @@ -461,28 +508,18 @@ test "Scenario: Given daemon watch when parsing then daemon command is preserved test "Scenario: Given daemon once when parsing then one-shot daemon command is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "daemon", "--once" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - switch (cmd) { - .daemon => |opts| try std.testing.expect(opts.mode == .once), + switch (result) { + .command => |cmd| switch (cmd) { + .daemon => |opts| try std.testing.expectEqual(cli.DaemonMode.once, opts.mode), + else => return error.TestExpectedEqual, + }, else => return error.TestExpectedEqual, } } -test "Scenario: Given deprecated add alias warning when rendering then colorized replacement is included" { - const gpa = std.testing.allocator; - var aw: std.Io.Writer.Allocating = .init(gpa); - defer aw.deinit(); - - try cli.writeDeprecatedLoginAliasWarningTo(&aw.writer, "codex-auth login", true); - - const warning = aw.written(); - try std.testing.expect(std.mem.indexOf(u8, warning, "\x1b[1;31mwarning:\x1b[0m") != null); - try std.testing.expect(std.mem.indexOf(u8, warning, "\x1b[1m`add`\x1b[0m") != null); - try std.testing.expect(std.mem.indexOf(u8, warning, "\x1b[1;32m`codex-auth login`\x1b[0m") != null); -} - test "Scenario: Given codex login access denied when rendering then plain English retry hint is included" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); @@ -511,47 +548,53 @@ test "Scenario: Given codex login client missing when rendering then detection h test "Scenario: Given switch with positional query when parsing then non-interactive target is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "switch", "user@example.com" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .switch_account => |opts| { - try std.testing.expect(opts.query != null); - try std.testing.expect(std.mem.eql(u8, opts.query.?, "user@example.com")); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .switch_account => |opts| { + try std.testing.expect(opts.query != null); + try std.testing.expect(std.mem.eql(u8, opts.query.?, "user@example.com")); + }, + else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, } } -test "Scenario: Given switch with duplicate target when parsing then help command is returned" { +test "Scenario: Given switch with duplicate target when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "switch", "a@example.com", "b@example.com" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .switch_account, "unexpected extra query"); } -test "Scenario: Given switch with unexpected flag when parsing then help command is returned" { +test "Scenario: Given switch with unexpected flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "switch", "--email", "a@example.com" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .switch_account, "unknown flag"); } test "Scenario: Given remove with positional query when parsing then query mode is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "remove", "user@example.com" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .remove_account => |opts| { - try std.testing.expect(opts.query != null); - try std.testing.expect(std.mem.eql(u8, opts.query.?, "user@example.com")); - try std.testing.expect(!opts.all); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .remove_account => |opts| { + try std.testing.expect(opts.query != null); + try std.testing.expect(std.mem.eql(u8, opts.query.?, "user@example.com")); + try std.testing.expect(!opts.all); + }, + else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, } @@ -560,43 +603,46 @@ test "Scenario: Given remove with positional query when parsing then query mode test "Scenario: Given remove with all flag when parsing then all mode is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "remove", "--all" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); - - switch (cmd) { - .remove_account => |opts| { - try std.testing.expect(opts.query == null); - try std.testing.expect(opts.all); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .remove_account => |opts| { + try std.testing.expect(opts.query == null); + try std.testing.expect(opts.all); + }, + else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, } } -test "Scenario: Given remove with duplicate targets when parsing then help command is returned" { +test "Scenario: Given remove with duplicate targets when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "remove", "a@example.com", "b@example.com" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .remove_account, "unexpected extra selector"); } -test "Scenario: Given remove with unexpected flag when parsing then help command is returned" { +test "Scenario: Given remove with unexpected flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "remove", "--email" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .remove_account, "unknown flag"); } -test "Scenario: Given remove with all and query when parsing then help command is returned" { +test "Scenario: Given remove with all and query when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "remove", "--all", "a@example.com" }; - var cmd = try cli.parseArgs(gpa, &args); - defer cli.freeCommand(gpa, &cmd); + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - try std.testing.expect(isHelp(cmd)); + try expectUsageError(result, .remove_account, "cannot combine `--all`"); } test "Scenario: Given multiple removed accounts when rendering summary then emails are joined on one line" { diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index ff1601b..ab9167f 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -90,7 +90,7 @@ test "Scenario: Given foreground commands when checking reconcile policy then co .threshold_weekly_percent = null, } } } })); try std.testing.expect(main_mod.shouldReconcileManagedService(.{ .config = .{ .api_usage = .enable } })); - try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .help = {} })); + try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .help = .top_level })); try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .status = {} })); try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .version = {} })); try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .daemon = .{ .mode = .once } })); From 63ef466d644f38d56650557e3a60569bfde03bc9 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 14:58:47 +0800 Subject: [PATCH 2/2] refactor: simplify command help output --- src/cli.zig | 17 +++++++++++++---- src/tests/cli_bdd_test.zig | 20 +++++++++++++++++--- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 54ab47f..9214324 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -536,7 +536,7 @@ pub fn writeHelp( try out.writeAll("Notes:"); if (use_color) try out.writeAll(ansi.reset); try out.writeAll("\n\n"); - try out.writeAll(" Run `codex-auth --help` for command-specific usage and examples.\n"); + try out.writeAll(" Run `codex-auth --help` for command-specific usage details.\n"); try out.writeAll(" `config api enable` may trigger OpenAI account restrictions or suspension in some environments.\n"); } @@ -598,8 +598,10 @@ pub fn writeCommandHelp(out: *std.Io.Writer, use_color: bool, topic: HelpTopic) try writeCommandHelpHeader(out, use_color, topic); try out.writeAll("\n"); try writeUsageSection(out, topic); - try out.writeAll("\n"); - try writeExamplesSection(out, topic); + if (commandHelpHasExamples(topic)) { + try out.writeAll("\n\n"); + try writeExamplesSection(out, topic); + } } fn writeCommandHelpHeader(out: *std.Io.Writer, use_color: bool, topic: HelpTopic) !void { @@ -628,7 +630,7 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { return switch (topic) { .top_level => "Command-line account management for Codex.", - .list => "List available accounts in the default table view.", + .list => "List available accounts.", .status => "Show auto-switch, service, and usage API status.", .login => "Run `codex login`, then add the current account.", .import_auth => "Import auth files or rebuild the registry.", @@ -640,6 +642,13 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { }; } +fn commandHelpHasExamples(topic: HelpTopic) bool { + return switch (topic) { + .import_auth, .switch_account, .remove_account, .config, .daemon => true, + else => false, + }; +} + fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll("Usage:\n"); switch (topic) { diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 47f74dc..883763d 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -164,7 +164,7 @@ test "Scenario: Given help when rendering then login and command help notes are try std.testing.expect(std.mem.indexOf(u8, help, "Auto Switch: ON (5h<12%, weekly<8%)") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Usage API: ON (api)") != null); try std.testing.expect(std.mem.indexOf(u8, help, "--cpa []") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "Run `codex-auth --help` for command-specific usage and examples.") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Run `codex-auth --help` for command-specific usage details.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "`config api enable` may trigger OpenAI account restrictions or suspension in some environments.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "login") != null); try std.testing.expect(std.mem.indexOf(u8, help, "clean") != null); @@ -181,7 +181,7 @@ test "Scenario: Given help when rendering then login and command help notes are try std.testing.expect(std.mem.indexOf(u8, help, "migrate") == null); } -test "Scenario: Given command help when rendering then usage and examples are shown" { +test "Scenario: Given simple command help when rendering then examples are omitted" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); defer aw.deinit(); @@ -190,8 +190,22 @@ test "Scenario: Given command help when rendering then usage and examples are sh const help = aw.written(); try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth list") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "List available accounts.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list\n") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "Examples:\n codex-auth list\n") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Examples:") == null); +} + +test "Scenario: Given complex command help when rendering then examples are shown" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.writeCommandHelp(&aw.writer, false, .import_auth); + + const help = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth import") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth import [--alias ]") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Examples:\n codex-auth import /path/to/auth.json --alias personal\n") != null); } test "Scenario: Given scanned import report when rendering then stdout and stderr match the import format" {