diff --git a/src/cli/commands/config.zig b/src/cli/commands/config.zig index 14a6155..51ee8cf 100644 --- a/src/cli/commands/config.zig +++ b/src/cli/commands/config.zig @@ -12,9 +12,27 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa if (std.mem.eql(u8, scope, "live")) { return parseLive(allocator, args[1..]); } + if (std.mem.eql(u8, scope, "kill")) { + return parseKill(allocator, args[1..]); + } return common.usageErrorResult(allocator, .config, "unknown config section `{s}`.", .{scope}); } +fn parseKill(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 = .config } }; + } + if (args.len != 1) return common.usageErrorResult(allocator, .config, "`config kill` requires `on` or `off`.", .{}); + const value = std.mem.sliceTo(args[0], 0); + const enabled = if (std.mem.eql(u8, value, "on")) + true + else if (std.mem.eql(u8, value, "off")) + false + else + return common.usageErrorResult(allocator, .config, "`config kill` requires `on` or `off`.", .{}); + return .{ .command = .{ .config = .{ .kill = .{ .enabled = enabled } } } }; +} + fn parseLive(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 = .config } }; diff --git a/src/cli/commands/switch.zig b/src/cli/commands/switch.zig index 16f2eeb..a226bce 100644 --- a/src/cli/commands/switch.zig +++ b/src/cli/commands/switch.zig @@ -32,6 +32,28 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa } continue; } + if (std.mem.eql(u8, arg, "--kill")) { + if (opts.kill) |value| { + freeTarget(allocator, opts.target); + if (!value) { + return common.usageErrorResult(allocator, .switch_account, "`--kill` cannot be combined with `--no-kill` for `switch`.", .{}); + } + return common.usageErrorResult(allocator, .switch_account, "duplicate `--kill` for `switch`.", .{}); + } + opts.kill = true; + continue; + } + if (std.mem.eql(u8, arg, "--no-kill")) { + if (opts.kill) |value| { + freeTarget(allocator, opts.target); + if (value) { + return common.usageErrorResult(allocator, .switch_account, "`--kill` cannot be combined with `--no-kill` for `switch`.", .{}); + } + return common.usageErrorResult(allocator, .switch_account, "duplicate `--no-kill` for `switch`.", .{}); + } + opts.kill = false; + continue; + } if (std.mem.eql(u8, arg, "--skip-api")) { switch (opts.api_mode) { .default => opts.api_mode = .skip_api, diff --git a/src/cli/help.zig b/src/cli/help.zig index 70142ed..20aadcc 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -46,8 +46,8 @@ pub fn writeHelp( try writeCommandSummary(out, use_color, "export [] [--cpa]", "Export stored account auth files"); try writeCommandSummary(out, use_color, "switch", "Switch the active account"); try writeCommandDetail(out, use_color, "switch -"); - try writeCommandDetail(out, use_color, "switch [--live] [--api|--skip-api]"); - try writeCommandDetail(out, use_color, "switch "); + try writeCommandDetail(out, use_color, "switch [--live] [--api|--skip-api] [--kill|--no-kill]"); + try writeCommandDetail(out, use_color, "switch [--kill|--no-kill]"); try writeCommandSummary(out, use_color, "remove", "Remove one or more accounts"); try writeCommandDetail(out, use_color, "remove [--live] [--api|--skip-api]"); try writeCommandDetail(out, use_color, "remove ..."); @@ -59,6 +59,7 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "clean background"); try writeCommandSummary(out, use_color, "config", "Manage configuration"); try writeCommandDetail(out, use_color, "config live --interval "); + try writeCommandDetail(out, use_color, "config kill on|off"); try writeCommandSummary(out, use_color, "app", "Launch Codex App with CLI overrides"); try out.writeAll("\n"); @@ -149,7 +150,7 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .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.", + .config => "Manage live refresh and auto-kill configuration.", .app => "Launch Codex App with CLI overrides.", }; } @@ -208,9 +209,9 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth export --cpa []\n"); }, .switch_account => { - try out.writeAll(" codex-auth switch -\n"); - try out.writeAll(" codex-auth switch [--live] [--api|--skip-api]\n"); - try out.writeAll(" codex-auth switch \n"); + try out.writeAll(" codex-auth switch [-] [--kill|--no-kill]\n"); + try out.writeAll(" codex-auth switch [--live] [--api|--skip-api] [--kill|--no-kill]\n"); + try out.writeAll(" codex-auth switch [--kill|--no-kill]\n"); }, .remove_account => { try out.writeAll(" codex-auth remove [--live] [--api|--skip-api]\n"); @@ -227,6 +228,7 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { }, .config => { try out.writeAll(" codex-auth config live --interval \n"); + try out.writeAll(" codex-auth config kill on|off\n"); }, .app => { try out.writeAll(" codex-auth app [--id ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); @@ -281,6 +283,8 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" --live Open the live switch UI.\n"); try out.writeAll(" --api Load usage and account data from APIs.\n"); try out.writeAll(" --skip-api Load usage and account data from local data only (may be inaccurate).\n"); + try out.writeAll(" --kill Stop all running Codex processes (CLI and GUI) first; only switch if none remain.\n"); + try out.writeAll(" --no-kill Skip stopping Codex even when the `auto_kill` setting is on.\n"); try out.writeAll(" \n"); try out.writeAll(" Switch directly when the target resolves to one account.\n"); try out.writeAll(" - Switch to the previous active account.\n"); @@ -302,6 +306,8 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { .config => { try out.writeAll(" live --interval \n"); try out.writeAll(" Set the live TUI refresh interval from 5 to 3600 seconds.\n"); + try out.writeAll(" kill on|off\n"); + try out.writeAll(" Toggle stopping all Codex processes before every switch.\n"); }, .app => { try out.writeAll(" --id Windows package/AUMID or macOS bundle identifier.\n"); @@ -361,6 +367,9 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth switch john@example.com\n"); try out.writeAll(" codex-auth switch 02\n"); try out.writeAll(" codex-auth switch work\n"); + try out.writeAll(" codex-auth switch --kill\n"); + try out.writeAll(" codex-auth switch work --kill\n"); + try out.writeAll(" codex-auth switch --no-kill\n"); }, .remove_account => { try out.writeAll(" codex-auth remove\n"); @@ -384,6 +393,8 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { }, .config => { try out.writeAll(" codex-auth config live --interval 60\n"); + try out.writeAll(" codex-auth config kill on\n"); + try out.writeAll(" codex-auth config kill off\n"); }, .app => { try out.writeAll(" codex-auth app\n"); diff --git a/src/cli/output.zig b/src/cli/output.zig index df5054c..ab96149 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -238,6 +238,18 @@ pub fn printAccountNotFoundErrors(queries: []const []const u8) !void { try out.flush(); } +pub fn printCodexStillRunningError() !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + const out = stderr.out(); + const use_color = stderr.color_enabled; + try writeErrorPrefixTo(out, use_color); + try out.writeAll(" codex is still running; not switching accounts.\n"); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Close all Codex windows and processes, then retry (or switch without `--kill`).\n"); + try out.flush(); +} + pub fn printSwitchRequiresTtyError() !void { var stderr: io_util.Stderr = undefined; stderr.init(); diff --git a/src/cli/types.zig b/src/cli/types.zig index 3bf3ab6..431c84a 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -33,6 +33,8 @@ pub const SwitchOptions = struct { target: SwitchTarget = .picker, live: bool = false, api_mode: ApiMode = .default, + // null = use the registry `auto_kill` setting; true/false = per-run override. + kill: ?bool = null, }; pub const RemoveOptions = struct { selectors: [][]const u8, @@ -58,7 +60,13 @@ pub const CleanOptions = struct { pub const LiveOptions = struct { interval_seconds: u16, }; -pub const ConfigOptions = union(enum) { live: LiveOptions }; +pub const KillConfigOptions = struct { + enabled: bool, +}; +pub const ConfigOptions = union(enum) { + live: LiveOptions, + kill: KillConfigOptions, +}; pub const AppAction = enum { launch }; pub const AppPlatform = enum { win, wsl, mac }; pub const AppOptions = struct { diff --git a/src/registry/common.zig b/src/registry/common.zig index 7cc2c57..8c73607 100644 --- a/src/registry/common.zig +++ b/src/registry/common.zig @@ -89,6 +89,8 @@ pub const LiveConfig = struct { interval_seconds: u16 = default_live_refresh_interval_seconds, }; +pub const default_auto_kill: bool = false; + pub const AccountRecord = struct { account_key: []u8, chatgpt_account_id: []u8, @@ -139,6 +141,7 @@ pub const Registry = struct { active_account_activated_at_ms: ?i64, api: ApiConfig, live: LiveConfig = defaultLiveConfig(), + auto_kill: bool = default_auto_kill, accounts: std.ArrayList(AccountRecord), pub fn deinit(self: *Registry, allocator: std.mem.Allocator) void { diff --git a/src/registry/storage.zig b/src/registry/storage.zig index 66a0c39..013cecc 100644 --- a/src/registry/storage.zig +++ b/src/registry/storage.zig @@ -304,6 +304,7 @@ fn loadLegacyRegistryV2( } parseRegistryLiveConfig(®.live, root_obj); + parseRegistryAutoKill(®, root_obj); for (legacy_accounts.items) |*legacy| { try migrateLegacyRecord(allocator, codex_home, ®, legacy_active_email, legacy); @@ -352,6 +353,7 @@ fn loadCurrentRegistry(allocator: std.mem.Allocator, root_obj: std.json.ObjectMa } parseRegistryLiveConfig(®.live, root_obj); + parseRegistryAutoKill(®, root_obj); return reg; } @@ -383,6 +385,7 @@ fn currentLayoutNeedsRewrite(root_obj: std.json.ObjectMap) bool { return true; } if (root_obj.get("previous_active_account_key") == null) return true; + if (root_obj.get("auto_kill") == null) return true; return root_obj.get("active_account_key") != null and root_obj.get("active_account_activated_at_ms") == null; } @@ -398,6 +401,15 @@ fn parseRegistryLiveConfig(live: *LiveConfig, root_obj: std.json.ObjectMap) void } } +fn parseRegistryAutoKill(reg: *Registry, root_obj: std.json.ObjectMap) void { + if (root_obj.get("auto_kill")) |v| { + switch (v) { + .bool => |b| reg.auto_kill = b, + else => {}, + } + } +} + fn detectSchemaVersion(root_obj: std.json.ObjectMap) u32 { return schemaVersionFieldValue(root_obj) orelse if (root_obj.get("active_email") != null) 2 else current_schema_version; } diff --git a/src/registry/storage_write.zig b/src/registry/storage_write.zig index e56d79c..d696069 100644 --- a/src/registry/storage_write.zig +++ b/src/registry/storage_write.zig @@ -26,6 +26,7 @@ pub fn saveRegistry(allocator: std.mem.Allocator, codex_home: []const u8, reg: * .previous_active_account_key = reg.previous_active_account_key, .active_account_activated_at_ms = reg.active_account_activated_at_ms, .interval_seconds = reg.live.interval_seconds, + .auto_kill = reg.auto_kill, .accounts = reg.accounts.items, }; var aw: std.Io.Writer.Allocating = .init(allocator); @@ -106,5 +107,6 @@ const RegistryOut = struct { previous_active_account_key: ?[]const u8, active_account_activated_at_ms: ?i64, interval_seconds: u16, + auto_kill: bool, accounts: []const AccountRecord, }; diff --git a/src/workflows/config.zig b/src/workflows/config.zig index 216635d..4f15c33 100644 --- a/src/workflows/config.zig +++ b/src/workflows/config.zig @@ -6,9 +6,23 @@ const registry = @import("../registry/root.zig"); pub fn handleConfig(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.ConfigOptions) !void { switch (opts) { .live => |live_opts| try handleLiveCommand(allocator, codex_home, live_opts), + .kill => |kill_opts| try handleKillCommand(allocator, codex_home, kill_opts), } } +fn handleKillCommand(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.KillConfigOptions) !void { + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + reg.auto_kill = opts.enabled; + try registry.saveRegistry(allocator, codex_home, ®); + + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try out.print("Auto-kill codex on switch: {s}\n", .{if (opts.enabled) "on" else "off"}); + try out.flush(); +} + fn handleLiveCommand(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.LiveOptions) !void { var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); diff --git a/src/workflows/process_kill.zig b/src/workflows/process_kill.zig new file mode 100644 index 0000000..993f12b --- /dev/null +++ b/src/workflows/process_kill.zig @@ -0,0 +1,125 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const app_runtime = @import("../core/runtime.zig"); +const io_util = @import("../core/io_util.zig"); +const output = @import("../cli/output.zig"); + +// macOS GUI bundle id (mirrors `codex_app_bundle_id` in workflows/app.zig). +const mac_bundle_id = "com.openai.codex"; + +/// Terminate every running Codex process (CLI and GUI) before an account switch. +/// +/// Strategy: graceful request first (quit / SIGTERM / windowed close), a short +/// wait, then a hard kill for anything that survived. If a Codex process is +/// still alive afterwards the switch must not proceed, so `error.CodexStillRunning` +/// is returned (after printing a user-facing message). +/// +/// Exact process-name matching is used everywhere (`pkill -x codex`, +/// `taskkill /IM codex.exe`) so `codex-auth` itself is never targeted. +pub fn ensureCodexStoppedForSwitch(allocator: std.mem.Allocator) !void { + if (!isAnyCodexRunning(allocator)) return; + + gracefulKill(allocator); + sleepMs(700); + if (isAnyCodexRunning(allocator)) { + forceKill(allocator); + sleepMs(400); + } + if (isAnyCodexRunning(allocator)) { + try output.printCodexStillRunningError(); + return error.CodexStillRunning; + } + printStoppedNotice(); +} + +fn gracefulKill(allocator: std.mem.Allocator) void { + switch (builtin.os.tag) { + .windows => { + // `/T` also stops child processes of the launcher shims; no `/F` yet. + runIgnoringFailure(allocator, &[_][]const u8{ "taskkill", "/IM", "codex.exe", "/T" }); + }, + .macos => { + runIgnoringFailure(allocator, &[_][]const u8{ "osascript", "-e", "tell application id \"" ++ mac_bundle_id ++ "\" to quit" }); + runIgnoringFailure(allocator, &[_][]const u8{ "pkill", "-TERM", "-x", "codex" }); + runIgnoringFailure(allocator, &[_][]const u8{ "pkill", "-TERM", "-x", "Codex" }); + }, + .linux => { + runIgnoringFailure(allocator, &[_][]const u8{ "pkill", "-TERM", "-x", "codex" }); + runIgnoringFailure(allocator, &[_][]const u8{ "pkill", "-TERM", "-x", "Codex" }); + }, + else => {}, + } +} + +fn forceKill(allocator: std.mem.Allocator) void { + switch (builtin.os.tag) { + .windows => { + runIgnoringFailure(allocator, &[_][]const u8{ "taskkill", "/IM", "codex.exe", "/T", "/F" }); + }, + .macos, .linux => { + runIgnoringFailure(allocator, &[_][]const u8{ "pkill", "-KILL", "-x", "codex" }); + runIgnoringFailure(allocator, &[_][]const u8{ "pkill", "-KILL", "-x", "Codex" }); + }, + else => {}, + } +} + +fn isAnyCodexRunning(allocator: std.mem.Allocator) bool { + return switch (builtin.os.tag) { + .windows => windowsCodexRunning(allocator), + .macos, .linux => pgrepMatches(allocator, "codex") or pgrepMatches(allocator, "Codex"), + else => false, + }; +} + +fn pgrepMatches(allocator: std.mem.Allocator, name: []const u8) bool { + const result = std.process.run(allocator, app_runtime.io(), .{ + .argv = &[_][]const u8{ "pgrep", "-x", name }, + .stdout_limit = .limited(64 * 1024), + .stderr_limit = .limited(64 * 1024), + }) catch return false; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + return switch (result.term) { + .exited => |code| code == 0, + else => false, + }; +} + +fn windowsCodexRunning(allocator: std.mem.Allocator) bool { + // Exact image-name filter => never matches `codex-auth.exe`. + const result = std.process.run(allocator, app_runtime.io(), .{ + .argv = &[_][]const u8{ "tasklist", "/FI", "IMAGENAME eq codex.exe", "/NH" }, + .stdout_limit = .limited(64 * 1024), + .stderr_limit = .limited(64 * 1024), + }) catch return false; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); + if (trimmed.len == 0) return false; + // When no process matches, tasklist prints "INFO: No tasks are running ...". + if (std.mem.startsWith(u8, trimmed, "INFO:")) return false; + return std.ascii.indexOfIgnoreCase(trimmed, "codex.exe") != null; +} + +fn runIgnoringFailure(allocator: std.mem.Allocator, argv: []const []const u8) void { + const result = std.process.run(allocator, app_runtime.io(), .{ + .argv = argv, + .stdout_limit = .limited(1024 * 1024), + .stderr_limit = .limited(1024 * 1024), + }) catch return; + allocator.free(result.stdout); + allocator.free(result.stderr); +} + +fn sleepMs(ms: i64) void { + app_runtime.io().sleep(std.Io.Duration.fromMilliseconds(ms), .awake) catch {}; +} + +fn printStoppedNotice() void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + const out = stderr.out(); + out.writeAll("Stopped running codex processes before switching.\n") catch return; + out.flush() catch {}; +} diff --git a/src/workflows/switch.zig b/src/workflows/switch.zig index 1082840..8b00002 100644 --- a/src/workflows/switch.zig +++ b/src/workflows/switch.zig @@ -4,6 +4,7 @@ const registry = @import("../registry/root.zig"); const live_flow = @import("live.zig"); const preflight = @import("preflight.zig"); const query_mod = @import("query.zig"); +const process_kill = @import("process_kill.zig"); const ensureLiveTty = preflight.ensureLiveTty; const resolveSwitchQueryLocally = query_mod.resolveSwitchQueryLocally; @@ -17,6 +18,10 @@ const switchLiveRuntimeBuildStatusLine = live_flow.switchLiveRuntimeBuildStatusL const switchLiveRuntimeApplySelection = live_flow.switchLiveRuntimeApplySelection; pub fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.SwitchOptions) !void { + if (try shouldKillBeforeSwitch(allocator, codex_home, opts)) { + try process_kill.ensureCodexStoppedForSwitch(allocator); + } + switch (opts.target) { .query => |query| return handleSwitchQuery(allocator, codex_home, opts, query), .previous => return handleSwitchPrevious(allocator, codex_home, opts), @@ -107,6 +112,17 @@ pub fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: } } +fn shouldKillBeforeSwitch( + allocator: std.mem.Allocator, + codex_home: []const u8, + opts: cli.types.SwitchOptions, +) !bool { + if (opts.kill) |kill| return kill; + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + return reg.auto_kill; +} + fn handleSwitchQuery( allocator: std.mem.Allocator, codex_home: []const u8, diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index b76b4e6..06c43c2 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -518,7 +518,9 @@ test "Scenario: Given config help when rendering then live mode is explained" { try std.testing.expect(std.mem.indexOf(u8, config_help, "codex-auth config live --interval ") != null); try std.testing.expect(std.mem.indexOf(u8, config_help, "live --interval \n Set the live TUI refresh interval from 5 to 3600 seconds.") != null); try std.testing.expect(std.mem.indexOf(u8, config_help, "codex-auth config live --interval 60") != null); - try std.testing.expect(std.mem.indexOf(u8, config_help, "auto") == null); + try std.testing.expect(std.mem.indexOf(u8, config_help, "codex-auth config kill on|off") != null); + try std.testing.expect(std.mem.indexOf(u8, config_help, "kill on|off") != null); + try std.testing.expect(std.mem.indexOf(u8, config_help, "auto_switch") == null); } test "Scenario: Given scanned import report when rendering then stdout and stderr match the import format" { @@ -662,6 +664,7 @@ test "Scenario: Given config live interval when parsing then interval is preserv .command => |cmd| switch (cmd) { .config => |opts| switch (opts) { .live => |live_opts| try std.testing.expectEqual(@as(u16, 30), live_opts.interval_seconds), + else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, }, @@ -687,6 +690,122 @@ test "Scenario: Given config live unknown flag when parsing then usage error is try expectUsageError(result, .config, "unknown flag `--refresh` for `config live`."); } +fn expectConfigKill(result: cli.types.ParseResult, expected: bool) !void { + switch (result) { + .command => |cmd| switch (cmd) { + .config => |opts| switch (opts) { + .kill => |kill_opts| try std.testing.expectEqual(expected, kill_opts.enabled), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given config kill on when parsing then auto-kill is enabled" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "config", "kill", "on" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectConfigKill(result, true); +} + +test "Scenario: Given config kill off when parsing then auto-kill is disabled" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "config", "kill", "off" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectConfigKill(result, false); +} + +test "Scenario: Given config kill invalid value when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "config", "kill", "maybe" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .config, "`config kill` requires `on` or `off`."); +} + +fn expectSwitchKill(result: cli.types.ParseResult, expected: ?bool) !void { + switch (result) { + .command => |cmd| switch (cmd) { + .switch_account => |opts| try std.testing.expectEqual(expected, opts.kill), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given switch without kill flag when parsing then kill override is unset" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectSwitchKill(result, null); +} + +test "Scenario: Given switch with kill flag when parsing then kill override is true" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--kill" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectSwitchKill(result, true); +} + +test "Scenario: Given switch with no-kill flag when parsing then kill override is false" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--no-kill" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectSwitchKill(result, false); +} + +test "Scenario: Given switch target with kill flag when parsing then both are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "work", "--kill" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .switch_account => |opts| { + try std.testing.expectEqual(@as(?bool, true), opts.kill); + switch (opts.target) { + .query => |query| try std.testing.expectEqualStrings("work", query), + else => return error.TestExpectedEqual, + } + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given switch with duplicate kill flag when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--kill", "--kill" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .switch_account, "duplicate `--kill` for `switch`."); +} + +test "Scenario: Given switch with conflicting kill flags when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--kill", "--no-kill" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .switch_account, "`--kill` cannot be combined with `--no-kill` for `switch`."); +} + 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" }; diff --git a/tests/registry_test.zig b/tests/registry_test.zig index 8807304..9e74f98 100644 --- a/tests/registry_test.zig +++ b/tests/registry_test.zig @@ -511,6 +511,64 @@ test "registry load normalizes schema four without previous active account key" try std.testing.expect(std.mem.indexOf(u8, contents, "\"previous_active_account_key\": null") != null); } +test "registry save/load round-trips auto_kill enabled" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + + var reg = makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_kill = true; + try registry.saveRegistry(gpa, codex_home, ®); + + const registry_path = try fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); + defer gpa.free(registry_path); + const saved = try fixtures.readFileAlloc(gpa, registry_path); + defer gpa.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"auto_kill\": true") != null); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expect(loaded.auto_kill); +} + +test "registry load defaults missing auto_kill to false and rewrites file" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + try tmp.dir.writeFile(.{ + .sub_path = "accounts/registry.json", + .data = + \\{ + \\ "schema_version": 4, + \\ "active_account_key": null, + \\ "previous_active_account_key": null, + \\ "active_account_activated_at_ms": null, + \\ "interval_seconds": 60, + \\ "accounts": [] + \\} + , + }); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expect(!loaded.auto_kill); + + const registry_path = try fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); + defer gpa.free(registry_path); + const contents = try fixtures.readFileAlloc(gpa, registry_path); + defer gpa.free(contents); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"auto_kill\": false") != null); +} + test "registry save/load round-trips account_name string" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{});