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(.{});