From d9da5d11e0e7069d8139432e0e78f26bee15f880 Mon Sep 17 00:00:00 2001 From: bjspi Date: Wed, 1 Jul 2026 09:10:30 +0200 Subject: [PATCH 1/2] feat(switch): add --kill/--no-kill and persistent auto_kill setting Stop all running Codex processes (CLI and GUI) before switching accounts, so the new auth.json takes effect without a manual Codex restart. - `codex-auth switch --kill` / `--no-kill` override per run; the persistent `auto_kill` registry setting (toggle via `codex-auth config kill on|off`) applies otherwise. Resolution: opts.kill orelse reg.auto_kill. - Kill is graceful-first (quit / SIGTERM / windowed close), then a hard kill for survivors; the switch is aborted with `error.CodexStillRunning` if any Codex process remains. Exact name matching (pkill -x codex, taskkill /IM codex.exe) never targets codex-auth itself. - Cross-platform (Windows taskkill/tasklist, macOS osascript+pkill, Linux pkill/pgrep) in new src/workflows/process_kill.zig, hooked once at the top of handleSwitch (covers picker/auto/query/previous/live). - auto_kill is an additive optional registry field (no schema bump); parsed tolerantly and materialized into old files via currentLayoutNeedsRewrite. - Adds parser, config, and registry round-trip tests; updates help text. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/commands/config.zig | 18 +++++ src/cli/commands/switch.zig | 22 ++++++ src/cli/help.zig | 18 +++-- src/cli/output.zig | 12 ++++ src/cli/types.zig | 10 ++- src/registry/common.zig | 3 + src/registry/storage.zig | 12 ++++ src/registry/storage_write.zig | 2 + src/workflows/config.zig | 14 ++++ src/workflows/process_kill.zig | 125 +++++++++++++++++++++++++++++++++ src/workflows/switch.zig | 16 +++++ tests/cli_behavior_test.zig | 121 ++++++++++++++++++++++++++++++- tests/registry_test.zig | 58 +++++++++++++++ 13 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 src/workflows/process_kill.zig diff --git a/src/cli/commands/config.zig b/src/cli/commands/config.zig index 14a61555..51ee8cf3 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 16f2eebd..a226bce2 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 70142ede..8bf498f2 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -149,7 +149,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 +208,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 +227,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 +282,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 +305,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 +366,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 +392,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 df5054cd..ab961492 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 3bf3ab64..431c84a4 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 7cc2c571..8c736071 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 66a0c398..013ceccd 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 e56d79cb..d696069f 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 216635d5..4f15c33c 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 00000000..993f12b6 --- /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 1082840e..8b000027 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 b76b4e66..06c43c28 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 88073049..9e74f989 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(.{}); From 8abe2ad062b0c7ffa96c414f695b255ee0e63534 Mon Sep 17 00:00:00 2001 From: bjspi Date: Wed, 1 Jul 2026 09:21:48 +0200 Subject: [PATCH 2/2] docs(help): surface --kill/--no-kill and config kill in top-level help Add the kill flags and `config kill on|off` to the top-level `--help` overview so the feature is discoverable there, not only in the per-command help. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/help.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/help.zig b/src/cli/help.zig index 8bf498f2..20aadccb 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");