From ac52fa70637046605a2d8aa16036008a1373bd4b Mon Sep 17 00:00:00 2001 From: Mouaad SK Date: Mon, 27 Apr 2026 08:46:54 +0100 Subject: [PATCH 1/2] Add account groups with isolated CODEX_HOME support --- README.md | 21 +- docs/account-groups.md | 105 ++++++++++ docs/implement.md | 1 + src/cli.zig | 406 ++++++++++++++++++++++++++++++++++++- src/group_manager.zig | 292 ++++++++++++++++++++++++++ src/main.zig | 308 +++++++++++++++++++++++++++- src/tests/cli_bdd_test.zig | 84 ++++++++ 7 files changed, 1214 insertions(+), 3 deletions(-) create mode 100644 docs/account-groups.md create mode 100644 src/group_manager.zig diff --git a/README.md b/README.md index 324276d9..2ca281bd 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error | Command | Description | |---------|-------------| | `codex-auth list [--live] [--api\|--skip-api]` | List all accounts. `--live` keeps refreshing the terminal view; `--api` forces remote refresh, while `--skip-api` forbids remote API use for this command. | -| `codex-auth login [--device-auth]` | Run `codex login` (optionally with `--device-auth`), then add the current account | +| `codex-auth login [--device-auth] [--group ]` | Run `codex login` (optionally with `--device-auth`), then add the current account to the default account set or a named group | | `codex-auth switch [--live] [--auto] [--api\|--skip-api]` | Switch the active account interactively. Without `--live` it exits after one switch; with `--live` it stays open and keeps refreshing. `--auto` requires `--live` and auto-switches away from the current account when the live view shows it as exhausted or returns a non-200 usage API status. | | `codex-auth switch ` | Switch the active account directly by row number, alias, or fuzzy match using stored local data only. | | `codex-auth remove [--live] [--api\|--skip-api]` | Interactive remove. `--live` keeps the picker open after each deletion; `--api` forces remote refresh and `--skip-api` forbids remote API use for this command. | @@ -103,6 +103,22 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error | `codex-auth remove --all` | Remove all stored accounts. | | `codex-auth status` | Show auto-switch, service, and usage status | +### Account Groups + +Groups keep separate Codex account sets with their own `CODEX_HOME`. The `default` group is the normal Codex home. Named groups can be used to keep workspaces such as `work`, `trading`, or `personal` separate. + +See [docs/account-groups.md](./docs/account-groups.md) for the full command reference. + +| Command | Description | +|---------|-------------| +| `codex-auth group list` | List configured groups and account counts | +| `codex-auth group create [...]` | Create a group and optionally copy existing accounts into it | +| `codex-auth group list [--live] [--api\|--skip-api]` | List accounts in one group | +| `codex-auth group login [--device-auth]` | Login and add the account directly to one group | +| `codex-auth group add [...]` | Copy accounts from another group into this group | +| `codex-auth group switch [--live] [--auto] [--api\|--skip-api]` | Switch the active account inside one group | +| `codex-auth group launch [-- ...]` | Launch `codext` with this group's `CODEX_HOME` | + ### Import | Command | Description | @@ -226,8 +242,11 @@ Add the currently logged-in Codex account: ```shell codex-auth login codex-auth login --device-auth +codex-auth login --group work --device-auth ``` +`--group ` creates the group when needed and stores the logged-in account in that group's Codex home. + ### Import #### Single File diff --git a/docs/account-groups.md b/docs/account-groups.md new file mode 100644 index 00000000..4229d6d6 --- /dev/null +++ b/docs/account-groups.md @@ -0,0 +1,105 @@ +# Account Groups + +Account groups let one `codex-auth` installation manage multiple isolated Codex homes. +Each group has its own `auth.json`, account registry, account snapshots, and sessions. + +The `default` group is the normal Codex home at `~/.codex`. Named groups are created under `~/codex-auth/groups/`. + +## Create and Inspect Groups + +Create an empty group: + +```shell +codex-auth group create work +``` + +List all groups: + +```shell +codex-auth group list +``` + +Print a group's Codex home: + +```shell +codex-auth group work path +codex-auth group path work +``` + +Group names may contain letters, numbers, `_`, and `-`. + +## Login Into a Group + +Login directly into a group: + +```shell +codex-auth group work login +codex-auth group work login --device-auth +``` + +The top-level login command can also target a group: + +```shell +codex-auth login --group work +codex-auth login --group work --device-auth +``` + +If the group does not exist, login creates it first. + +## Copy or Import Accounts + +Copy existing accounts into a group by row number, alias, email, account name, or account key: + +```shell +codex-auth group work add 01 +codex-auth group work add jane@example.com personal +``` + +The source account remains in its original group. When the selector matches the `default` group, that match is used first. Other groups are searched only when `default` has no match. + +Create a group and copy accounts in one command: + +```shell +codex-auth group create work 01 jane@example.com +``` + +Import an auth file directly into a group: + +```shell +codex-auth group work import /path/to/auth.json --alias work-main +``` + +Remove accounts from one group: + +```shell +codex-auth group work remove 01 +codex-auth group work remove jane@example.com +``` + +Removing an account from one group does not remove it from other groups. + +## Use a Group + +List accounts in a group: + +```shell +codex-auth group work list +codex-auth group work list --skip-api +``` + +Switch the active account inside a group: + +```shell +codex-auth group work switch +codex-auth group work switch 02 +codex-auth group work switch --live --auto +``` + +Launch `codext` with a group's `CODEX_HOME`: + +```shell +codex-auth group work launch +codex-auth group work launch -- --model gpt-5.4 +``` + +Arguments after `--` are passed to `codext`. diff --git a/docs/implement.md b/docs/implement.md index d6abd34c..32c38a71 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -16,6 +16,7 @@ This document describes how `codex-auth` stores accounts, synchronizes auth file - `/accounts/auth.json.bak.YYYYMMDD-hhmmss[.N]` - `/accounts/registry.json.bak.YYYYMMDD-hhmmss[.N]` - `/sessions/...` +- Account group behavior is documented in [docs/account-groups.md](./account-groups.md). ## File Permissions diff --git a/src/cli.zig b/src/cli.zig index fab3e0cc..76c0b87c 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -643,6 +643,7 @@ pub const ListOptions = struct { }; pub const LoginOptions = struct { device_auth: bool = false, + group_name: ?[]u8 = null, }; pub const ImportSource = enum { standard, cpa }; pub const ImportOptions = struct { @@ -680,6 +681,29 @@ pub const ConfigOptions = union(enum) { }; pub const DaemonMode = enum { watch, once }; pub const DaemonOptions = struct { mode: DaemonMode }; +pub const GroupMutationOptions = struct { + name: []u8, + selectors: [][]const u8, +}; +pub const GroupScopedAction = union(enum) { + list: ListOptions, + login: LoginOptions, + add: [][]const u8, + remove: [][]const u8, + import_auth: ImportOptions, + switch_account: SwitchOptions, + launch: [][]const u8, +}; +pub const GroupScopedOptions = struct { + name: []u8, + action: GroupScopedAction, +}; +pub const GroupOptions = union(enum) { + list: void, + create: GroupMutationOptions, + path: []u8, + scoped: GroupScopedOptions, +}; pub const HelpTopic = enum { top_level, list, @@ -691,6 +715,7 @@ pub const HelpTopic = enum { clean, config, daemon, + group, }; pub const Command = union(enum) { @@ -703,6 +728,7 @@ pub const Command = union(enum) { config: ConfigOptions, status: void, daemon: DaemonOptions, + group: GroupOptions, version: void, help: HelpTopic, }; @@ -804,12 +830,28 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars opts.device_auth = true; continue; } + if (std.mem.eql(u8, arg, "--group")) { + if (i + 1 >= args.len) { + if (opts.group_name) |name| allocator.free(name); + return usageErrorResult(allocator, .login, "missing value for `--group`.", .{}); + } + if (opts.group_name != null) { + if (opts.group_name) |name| allocator.free(name); + return usageErrorResult(allocator, .login, "duplicate `--group` for `login`.", .{}); + } + opts.group_name = try allocator.dupe(u8, std.mem.sliceTo(args[i + 1], 0)); + i += 1; + continue; + } if (isHelpFlag(arg)) { + if (opts.group_name) |name| allocator.free(name); return usageErrorResult(allocator, .login, "`--help` must be used by itself for `login`.", .{}); } if (std.mem.startsWith(u8, arg, "-")) { + if (opts.group_name) |name| allocator.free(name); return usageErrorResult(allocator, .login, "unknown flag `{s}` for `login`.", .{arg}); } + if (opts.group_name) |name| allocator.free(name); return usageErrorResult(allocator, .login, "unexpected argument `{s}` for `login`.", .{arg}); } return .{ .command = .{ .login = opts } }; @@ -1033,6 +1075,13 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars return try parseSimpleCommandArgs(allocator, "status", .status, .{ .status = {} }, args[2..]); } + if (std.mem.eql(u8, cmd, "group")) { + if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { + return .{ .command = .{ .help = .group } }; + } + return try parseGroupArgs(allocator, args[2..]); + } + if (std.mem.eql(u8, cmd, "config")) { if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { return .{ .command = .{ .help = .config } }; @@ -1135,6 +1184,48 @@ fn freeCommand(allocator: std.mem.Allocator, cmd: *Command) void { freeOwnedStringList(allocator, opts.selectors); allocator.free(opts.selectors); }, + .login => |*opts| { + if (opts.group_name) |name| allocator.free(name); + }, + .group => |*opts| freeGroupOptions(allocator, opts), + else => {}, + } +} + +fn freeGroupMutationOptions(allocator: std.mem.Allocator, opts: *GroupMutationOptions) void { + allocator.free(opts.name); + freeOwnedStringList(allocator, opts.selectors); + allocator.free(opts.selectors); +} + +fn freeGroupScopedAction(allocator: std.mem.Allocator, action: *GroupScopedAction) void { + switch (action.*) { + .login => |*opts| { + if (opts.group_name) |name| allocator.free(name); + }, + .add, .remove, .launch => |items| { + freeOwnedStringList(allocator, items); + allocator.free(items); + }, + .import_auth => |*opts| { + if (opts.auth_path) |path| allocator.free(path); + if (opts.alias) |alias| allocator.free(alias); + }, + .switch_account => |*opts| { + if (opts.query) |query| allocator.free(query); + }, + else => {}, + } +} + +fn freeGroupOptions(allocator: std.mem.Allocator, opts: *GroupOptions) void { + switch (opts.*) { + .create => |*mutation| freeGroupMutationOptions(allocator, mutation), + .path => |name| allocator.free(name), + .scoped => |*scoped| { + allocator.free(scoped.name); + freeGroupScopedAction(allocator, &scoped.action); + }, else => {}, } } @@ -1188,6 +1279,263 @@ fn parseHelpArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !Pars return .{ .command = .{ .help = topic } }; } +fn parseGroupNameAlloc(allocator: std.mem.Allocator, raw: [:0]const u8) ![]u8 { + const name = std.mem.sliceTo(raw, 0); + if (name.len == 0 or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return error.InvalidCliUsage; + for (name) |ch| { + switch (ch) { + 'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {}, + else => return error.InvalidCliUsage, + } + } + return try allocator.dupe(u8, name); +} + +fn parseGroupSelectorsAlloc(allocator: std.mem.Allocator, rest: []const [:0]const u8) ![][]const u8 { + var selectors = std.ArrayList([]const u8).empty; + errdefer freeOwnedStringList(allocator, selectors.items); + defer selectors.deinit(allocator); + for (rest) |arg_raw| { + const arg = std.mem.sliceTo(arg_raw, 0); + if (isHelpFlag(arg) or std.mem.startsWith(u8, arg, "-")) return error.InvalidCliUsage; + try selectors.append(allocator, try allocator.dupe(u8, arg)); + } + return try selectors.toOwnedSlice(allocator); +} + +fn parseGroupMutationArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !GroupMutationOptions { + const name = try parseGroupNameAlloc(allocator, rest[1]); + errdefer allocator.free(name); + const selectors = try parseGroupSelectorsAlloc(allocator, rest[2..]); + errdefer { + freeOwnedStringList(allocator, selectors); + allocator.free(selectors); + } + return .{ .name = name, .selectors = selectors }; +} + +fn parseGroupLoginArgs(rest: []const [:0]const u8) !LoginOptions { + var opts: LoginOptions = .{}; + for (rest) |arg_raw| { + const arg = std.mem.sliceTo(arg_raw, 0); + if (std.mem.eql(u8, arg, "--device-auth")) { + if (opts.device_auth) return error.InvalidCliUsage; + opts.device_auth = true; + continue; + } + return error.InvalidCliUsage; + } + return opts; +} + +fn parseGroupImportArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !ImportOptions { + var auth_path: ?[]u8 = null; + var alias: ?[]u8 = null; + errdefer freeImportOptions(allocator, auth_path, alias); + var i: usize = 0; + while (i < rest.len) : (i += 1) { + const arg = std.mem.sliceTo(rest[i], 0); + if (std.mem.eql(u8, arg, "--alias")) { + if (i + 1 >= rest.len or alias != null) return error.InvalidCliUsage; + alias = try allocator.dupe(u8, std.mem.sliceTo(rest[i + 1], 0)); + i += 1; + continue; + } + if (isHelpFlag(arg) or std.mem.startsWith(u8, arg, "-")) return error.InvalidCliUsage; + if (auth_path != null) return error.InvalidCliUsage; + auth_path = try allocator.dupe(u8, arg); + } + if (auth_path == null) return error.InvalidCliUsage; + return .{ + .auth_path = auth_path, + .alias = alias, + .purge = false, + .source = .standard, + }; +} + +fn parseGroupLaunchArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) ![][]const u8 { + var args = std.ArrayList([]const u8).empty; + errdefer freeOwnedStringList(allocator, args.items); + defer args.deinit(allocator); + + var i: usize = 0; + if (i < rest.len and std.mem.eql(u8, std.mem.sliceTo(rest[i], 0), "--")) i += 1; + while (i < rest.len) : (i += 1) { + try args.append(allocator, try allocator.dupe(u8, std.mem.sliceTo(rest[i], 0))); + } + return try args.toOwnedSlice(allocator); +} + +fn parseGroupListOptions(rest: []const [:0]const u8) !ListOptions { + var opts: ListOptions = .{}; + for (rest) |arg_raw| { + const arg = std.mem.sliceTo(arg_raw, 0); + if (std.mem.eql(u8, arg, "--live")) { + if (opts.live) return error.InvalidCliUsage; + opts.live = true; + continue; + } + if (std.mem.eql(u8, arg, "--api")) { + switch (opts.api_mode) { + .default => opts.api_mode = .force_api, + else => return error.InvalidCliUsage, + } + continue; + } + if (std.mem.eql(u8, arg, "--skip-api")) { + switch (opts.api_mode) { + .default => opts.api_mode = .skip_api, + else => return error.InvalidCliUsage, + } + continue; + } + return error.InvalidCliUsage; + } + return opts; +} + +fn parseGroupSwitchOptions(allocator: std.mem.Allocator, rest: []const [:0]const u8) !SwitchOptions { + var opts: SwitchOptions = .{ .query = null }; + errdefer if (opts.query) |query| allocator.free(query); + for (rest) |arg_raw| { + const arg = std.mem.sliceTo(arg_raw, 0); + if (std.mem.eql(u8, arg, "--live")) { + if (opts.live) return error.InvalidCliUsage; + opts.live = true; + continue; + } + if (std.mem.eql(u8, arg, "--auto")) { + if (opts.auto) return error.InvalidCliUsage; + opts.auto = true; + continue; + } + if (std.mem.eql(u8, arg, "--api")) { + switch (opts.api_mode) { + .default => opts.api_mode = .force_api, + else => return error.InvalidCliUsage, + } + continue; + } + if (std.mem.eql(u8, arg, "--skip-api")) { + switch (opts.api_mode) { + .default => opts.api_mode = .skip_api, + else => return error.InvalidCliUsage, + } + continue; + } + if (isHelpFlag(arg) or std.mem.startsWith(u8, arg, "-")) return error.InvalidCliUsage; + if (opts.query != null) return error.InvalidCliUsage; + opts.query = try allocator.dupe(u8, arg); + } + if (opts.auto and !opts.live) return error.InvalidCliUsage; + if (opts.query != null and (opts.api_mode != .default or opts.live or opts.auto)) return error.InvalidCliUsage; + return opts; +} + +fn parseNamedGroupAction(allocator: std.mem.Allocator, name: []u8, rest: []const [:0]const u8) !ParseResult { + errdefer allocator.free(name); + if (rest.len == 0) { + allocator.free(name); + return usageErrorResult(allocator, .group, "`group ` requires an action.", .{}); + } + const action = std.mem.sliceTo(rest[0], 0); + if (std.mem.eql(u8, action, "list")) { + const opts = parseGroupListOptions(rest[1..]) catch + { + allocator.free(name); + return usageErrorResult(allocator, .group, "invalid `group list` arguments.", .{}); + }; + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .list = opts } } } } }; + } + if (std.mem.eql(u8, action, "login")) { + const opts = parseGroupLoginArgs(rest[1..]) catch + { + allocator.free(name); + return usageErrorResult(allocator, .group, "invalid `group login` arguments.", .{}); + }; + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .login = opts } } } } }; + } + if (std.mem.eql(u8, action, "add")) { + if (rest.len < 2) { + allocator.free(name); + return usageErrorResult(allocator, .group, "`group add` requires at least one account selector.", .{}); + } + const selectors = parseGroupSelectorsAlloc(allocator, rest[1..]) catch + { + allocator.free(name); + return usageErrorResult(allocator, .group, "invalid `group add` arguments.", .{}); + }; + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .add = selectors } } } } }; + } + if (std.mem.eql(u8, action, "remove")) { + if (rest.len < 2) { + allocator.free(name); + return usageErrorResult(allocator, .group, "`group remove` requires at least one account selector.", .{}); + } + const selectors = parseGroupSelectorsAlloc(allocator, rest[1..]) catch + { + allocator.free(name); + return usageErrorResult(allocator, .group, "invalid `group remove` arguments.", .{}); + }; + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .remove = selectors } } } } }; + } + if (std.mem.eql(u8, action, "import")) { + const opts = parseGroupImportArgs(allocator, rest[1..]) catch + { + allocator.free(name); + return usageErrorResult(allocator, .group, "invalid `group import` arguments.", .{}); + }; + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .import_auth = opts } } } } }; + } + if (std.mem.eql(u8, action, "switch")) { + const opts = parseGroupSwitchOptions(allocator, rest[1..]) catch + { + allocator.free(name); + return usageErrorResult(allocator, .group, "invalid `group switch` arguments.", .{}); + }; + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .switch_account = opts } } } } }; + } + if (std.mem.eql(u8, action, "launch")) { + const launch_args = try parseGroupLaunchArgs(allocator, rest[1..]); + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .launch = launch_args } } } } }; + } + if (std.mem.eql(u8, action, "path")) { + if (rest.len != 1) { + allocator.free(name); + return usageErrorResult(allocator, .group, "`group path` does not take arguments.", .{}); + } + return .{ .command = .{ .group = .{ .path = name } } }; + } + allocator.free(name); + return usageErrorResult(allocator, .group, "unknown group action `{s}`.", .{action}); +} + +fn parseGroupArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !ParseResult { + if (rest.len == 0) return usageErrorResult(allocator, .group, "`group` requires an action.", .{}); + const action = std.mem.sliceTo(rest[0], 0); + if (isHelpFlag(action)) return .{ .command = .{ .help = .group } }; + if (std.mem.eql(u8, action, "list")) { + if (rest.len == 1) return .{ .command = .{ .group = .{ .list = {} } } }; + if (rest.len < 2) return usageErrorResult(allocator, .group, "`group list` takes an optional group name.", .{}); + if (rest.len > 2) return usageErrorResult(allocator, .group, "`group list ` does not take extra arguments.", .{}); + const name = try parseGroupNameAlloc(allocator, rest[1]); + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .list = .{} } } } } }; + } + if (std.mem.eql(u8, action, "create")) { + if (rest.len < 2) return usageErrorResult(allocator, .group, "`group create` requires a name.", .{}); + const opts = parseGroupMutationArgs(allocator, rest) catch + return usageErrorResult(allocator, .group, "invalid `group create` arguments.", .{}); + return .{ .command = .{ .group = .{ .create = opts } } }; + } + if (std.mem.eql(u8, action, "path")) { + if (rest.len != 2) return usageErrorResult(allocator, .group, "`group path` requires exactly one name.", .{}); + return .{ .command = .{ .group = .{ .path = try parseGroupNameAlloc(allocator, rest[1]) } } }; + } + const name = try parseGroupNameAlloc(allocator, rest[0]); + return try parseNamedGroupAction(allocator, name, rest[1..]); +} + fn helpTopicForName(name: []const u8) ?HelpTopic { if (std.mem.eql(u8, name, "list")) return .list; if (std.mem.eql(u8, name, "status")) return .status; @@ -1198,6 +1546,7 @@ fn helpTopicForName(name: []const u8) ?HelpTopic { 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; + if (std.mem.eql(u8, name, "group")) return .group; return null; } @@ -1272,6 +1621,7 @@ pub fn writeHelp( .{ .name = "switch [--live] [--auto] [--api|--skip-api] | switch ", .description = "Switch the active account" }, .{ .name = "remove [--live] [--api|--skip-api] | remove [...] | remove --all", .description = "Remove one or more accounts" }, .{ .name = "clean", .description = "Delete backup and stale files under accounts/" }, + .{ .name = "group", .description = "Manage separate account groups" }, .{ .name = "config", .description = "Manage configuration" }, }; const import_details = [_]HelpEntry{ @@ -1307,6 +1657,7 @@ pub fn writeHelp( try writeHelpEntry(out, use_color, parent_indent, command_col, commands[6].name, commands[6].description); try writeHelpEntry(out, use_color, parent_indent, command_col, commands[7].name, commands[7].description); try writeHelpEntry(out, use_color, parent_indent, command_col, commands[8].name, commands[8].description); + try writeHelpEntry(out, use_color, parent_indent, command_col, commands[9].name, commands[9].description); try writeHelpEntry(out, use_color, child_indent, config_detail_col, config_details[0].name, config_details[0].description); try writeHelpEntry(out, use_color, child_indent, config_detail_col, config_details[1].name, config_details[1].description); try writeHelpEntry(out, use_color, child_indent, config_detail_col, config_details[2].name, config_details[2].description); @@ -1406,6 +1757,7 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { .clean => "clean", .config => "config", .daemon => "daemon", + .group => "group", }; } @@ -1421,12 +1773,13 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .clean => "Delete backup and stale files under accounts/.", .config => "Manage auto-switch and usage API configuration.", .daemon => "Run the background auto-switch daemon.", + .group => "Create groups and run account commands against a group's CODEX_HOME.", }; } fn commandHelpHasExamples(topic: HelpTopic) bool { return switch (topic) { - .import_auth, .switch_account, .remove_account, .config, .daemon => true, + .import_auth, .switch_account, .remove_account, .config, .daemon, .group => true, else => false, }; } @@ -1444,6 +1797,7 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { .login => { try out.writeAll(" codex-auth login\n"); try out.writeAll(" codex-auth login --device-auth\n"); + try out.writeAll(" codex-auth login --group [--device-auth]\n"); }, .import_auth => { try out.writeAll(" codex-auth import [--alias ]\n"); @@ -1472,6 +1826,20 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth daemon --watch\n"); try out.writeAll(" codex-auth daemon --once\n"); }, + .group => { + try out.writeAll(" codex-auth group list\n"); + try out.writeAll(" codex-auth group create [...]\n"); + try out.writeAll(" codex-auth group list [--live] [--api|--skip-api]\n"); + try out.writeAll(" codex-auth group login [--device-auth]\n"); + try out.writeAll(" codex-auth group add [...]\n"); + try out.writeAll(" codex-auth group remove [...]\n"); + try out.writeAll(" codex-auth group import [--alias ]\n"); + try out.writeAll(" codex-auth group switch [--live] [--auto] [--api|--skip-api]\n"); + try out.writeAll(" codex-auth group switch \n"); + try out.writeAll(" codex-auth group launch [-- ...]\n"); + try out.writeAll(" codex-auth group path\n"); + try out.writeAll(" codex-auth group path \n"); + }, } } @@ -1493,6 +1861,7 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { .login => { try out.writeAll(" codex-auth login\n"); try out.writeAll(" codex-auth login --device-auth\n"); + try out.writeAll(" codex-auth login --group work --device-auth\n"); }, .import_auth => { try out.writeAll(" codex-auth import /path/to/auth.json --alias personal\n"); @@ -1527,6 +1896,14 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth daemon --watch\n"); try out.writeAll(" codex-auth daemon --once\n"); }, + .group => { + try out.writeAll(" codex-auth group create work\n"); + try out.writeAll(" codex-auth group work login --device-auth\n"); + try out.writeAll(" codex-auth group work add 01 jane@example.com\n"); + try out.writeAll(" codex-auth group work list --skip-api\n"); + try out.writeAll(" codex-auth group work switch 02\n"); + try out.writeAll(" codex-auth group work launch -- --model gpt-5.4\n"); + }, } } @@ -1556,6 +1933,7 @@ fn helpCommandForTopic(topic: HelpTopic) []const u8 { .clean => "codex-auth clean --help", .config => "codex-auth config --help", .daemon => "codex-auth daemon --help", + .group => "codex-auth group --help", }; } @@ -1887,6 +2265,32 @@ pub fn runCodexLogin(opts: LoginOptions) !void { try ensureCodexLoginSucceeded(term); } +pub fn runCodexLoginWithCodexHome( + allocator: std.mem.Allocator, + opts: LoginOptions, + codex_home: []const u8, +) !void { + var env_map = try app_runtime.currentEnviron().createMap(allocator); + defer env_map.deinit(); + try env_map.put("CODEX_HOME", codex_home); + + var child = std.process.spawn(app_runtime.io(), .{ + .argv = codexLoginArgs(opts), + .environ_map = &env_map, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }) catch |err| { + writeCodexLoginLaunchFailureHint(@errorName(err), stderrColorEnabled()) catch {}; + return err; + }; + const term = child.wait(app_runtime.io()) catch |err| { + writeCodexLoginLaunchFailureHint(@errorName(err), stderrColorEnabled()) catch {}; + return err; + }; + try ensureCodexLoginSucceeded(term); +} + pub fn selectAccount(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]const u8 { return selectAccountWithUsageOverrides(allocator, reg, null); } diff --git a/src/group_manager.zig b/src/group_manager.zig new file mode 100644 index 00000000..f17e27d3 --- /dev/null +++ b/src/group_manager.zig @@ -0,0 +1,292 @@ +const std = @import("std"); +const app_runtime = @import("runtime.zig"); +const registry = @import("registry.zig"); + +pub const default_group_name = "default"; +pub const manager_dir_name = "codex-auth"; +pub const groups_dir_name = "groups"; +pub const config_file_name = "groups.json"; +pub const config_schema_version: u32 = 1; + +pub const GroupRef = struct { + name: []u8, + codex_home: []u8, + managed: bool, + + pub fn deinit(self: *GroupRef, allocator: std.mem.Allocator) void { + allocator.free(self.name); + allocator.free(self.codex_home); + } +}; + +pub const GroupList = struct { + items: std.ArrayList(GroupRef) = .empty, + + pub fn deinit(self: *GroupList, allocator: std.mem.Allocator) void { + for (self.items.items) |*item| item.deinit(allocator); + self.items.deinit(allocator); + } +}; + +const ConfigGroupOut = struct { + name: []const u8, + codex_home: []const u8, + managed: bool, +}; + +const ConfigOut = struct { + schema_version: u32, + groups: []const ConfigGroupOut, +}; + +fn readFileAlloc(file: std.Io.File, allocator: std.mem.Allocator, max_bytes: usize) ![]u8 { + var read_buffer: [4096]u8 = undefined; + var file_reader = file.reader(app_runtime.io(), &read_buffer); + return try file_reader.interface.allocRemaining(allocator, .limited(max_bytes)); +} + +pub fn validateGroupName(name: []const u8) !void { + if (name.len == 0) return error.InvalidGroupName; + if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return error.InvalidGroupName; + for (name) |ch| { + switch (ch) { + 'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {}, + else => return error.InvalidGroupName, + } + } +} + +pub fn managerRootAlloc(allocator: std.mem.Allocator) ![]u8 { + const home = try registry.resolveUserHome(allocator); + defer allocator.free(home); + return try std.fs.path.join(allocator, &[_][]const u8{ home, manager_dir_name }); +} + +pub fn groupsRootAlloc(allocator: std.mem.Allocator) ![]u8 { + const root = try managerRootAlloc(allocator); + defer allocator.free(root); + return try std.fs.path.join(allocator, &[_][]const u8{ root, groups_dir_name }); +} + +pub fn configPathAlloc(allocator: std.mem.Allocator) ![]u8 { + const root = try managerRootAlloc(allocator); + defer allocator.free(root); + return try std.fs.path.join(allocator, &[_][]const u8{ root, config_file_name }); +} + +pub fn defaultCodexHomeAlloc(allocator: std.mem.Allocator) ![]u8 { + const home = try registry.resolveUserHome(allocator); + defer allocator.free(home); + return try std.fs.path.join(allocator, &[_][]const u8{ home, ".codex" }); +} + +pub fn managedGroupCodexHomeAlloc(allocator: std.mem.Allocator, name: []const u8) ![]u8 { + try validateGroupName(name); + const groups_root = try groupsRootAlloc(allocator); + defer allocator.free(groups_root); + return try std.fs.path.join(allocator, &[_][]const u8{ groups_root, name }); +} + +fn appendGroupRef( + allocator: std.mem.Allocator, + list: *GroupList, + name: []const u8, + codex_home: []const u8, + managed: bool, +) !void { + if (findGroupIndex(list, name) != null) return; + try list.items.append(allocator, .{ + .name = try allocator.dupe(u8, name), + .codex_home = try allocator.dupe(u8, codex_home), + .managed = managed, + }); +} + +pub fn findGroupIndex(list: *const GroupList, name: []const u8) ?usize { + for (list.items.items, 0..) |item, idx| { + if (std.mem.eql(u8, item.name, name)) return idx; + } + return null; +} + +pub fn findGroupIndexByCodexHome(list: *const GroupList, codex_home: []const u8) ?usize { + for (list.items.items, 0..) |item, idx| { + if (std.mem.eql(u8, item.codex_home, codex_home)) return idx; + } + return null; +} + +fn parseConfigGroups(allocator: std.mem.Allocator, list: *GroupList, root_obj: std.json.ObjectMap) !void { + const groups_value = root_obj.get("groups") orelse return; + const groups = switch (groups_value) { + .array => |arr| arr, + else => return, + }; + for (groups.items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + const name = switch (obj.get("name") orelse continue) { + .string => |s| s, + else => continue, + }; + if (std.mem.eql(u8, name, default_group_name)) continue; + validateGroupName(name) catch continue; + const codex_home = switch (obj.get("codex_home") orelse continue) { + .string => |s| s, + else => continue, + }; + const managed = switch (obj.get("managed") orelse std.json.Value{ .bool = true }) { + .bool => |value| value, + else => true, + }; + try appendGroupRef(allocator, list, name, codex_home, managed); + } +} + +fn discoverManagedGroupFolders(allocator: std.mem.Allocator, list: *GroupList) !void { + const groups_root = try groupsRootAlloc(allocator); + defer allocator.free(groups_root); + + var dir = std.Io.Dir.cwd().openDir(app_runtime.io(), groups_root, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer dir.close(app_runtime.io()); + + var it = dir.iterate(); + while (try it.next(app_runtime.io())) |entry| { + if (entry.kind != .directory) continue; + validateGroupName(entry.name) catch continue; + if (std.mem.eql(u8, entry.name, default_group_name)) continue; + const codex_home = try std.fs.path.join(allocator, &[_][]const u8{ groups_root, entry.name }); + defer allocator.free(codex_home); + if (findGroupIndex(list, entry.name) != null) continue; + if (findGroupIndexByCodexHome(list, codex_home) != null) continue; + try appendGroupRef(allocator, list, entry.name, codex_home, true); + } +} + +fn loadConfiguredGroups(allocator: std.mem.Allocator) !GroupList { + var list: GroupList = .{}; + errdefer list.deinit(allocator); + + const default_codex_home = try defaultCodexHomeAlloc(allocator); + defer allocator.free(default_codex_home); + try appendGroupRef(allocator, &list, default_group_name, default_codex_home, false); + + const config_path = try configPathAlloc(allocator); + defer allocator.free(config_path); + const data = blk: { + var file = std.Io.Dir.cwd().openFile(app_runtime.io(), config_path, .{}) catch |err| switch (err) { + error.FileNotFound => break :blk null, + else => return err, + }; + defer file.close(app_runtime.io()); + break :blk try readFileAlloc(file, allocator, 1024 * 1024); + }; + if (data) |bytes| { + defer allocator.free(bytes); + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, bytes, .{}); + defer parsed.deinit(); + switch (parsed.value) { + .object => |obj| try parseConfigGroups(allocator, &list, obj), + else => {}, + } + } + + return list; +} + +pub fn loadGroups(allocator: std.mem.Allocator) !GroupList { + var list = try loadConfiguredGroups(allocator); + errdefer list.deinit(allocator); + try discoverManagedGroupFolders(allocator, &list); + return list; +} + +pub fn saveGroups(allocator: std.mem.Allocator, list: *const GroupList) !void { + const root = try managerRootAlloc(allocator); + defer allocator.free(root); + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), root); + + var out_groups = std.ArrayList(ConfigGroupOut).empty; + defer out_groups.deinit(allocator); + for (list.items.items) |item| { + if (std.mem.eql(u8, item.name, default_group_name)) continue; + try out_groups.append(allocator, .{ + .name = item.name, + .codex_home = item.codex_home, + .managed = item.managed, + }); + } + + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + try std.json.Stringify.value(ConfigOut{ + .schema_version = config_schema_version, + .groups = out_groups.items, + }, .{ .whitespace = .indent_2 }, &aw.writer); + + const config_path = try configPathAlloc(allocator); + defer allocator.free(config_path); + var file = try std.Io.Dir.cwd().createFile(app_runtime.io(), config_path, .{ .truncate = true }); + defer file.close(app_runtime.io()); + try file.writeStreamingAll(app_runtime.io(), aw.written()); +} + +pub fn resolveGroupAlloc(allocator: std.mem.Allocator, name: []const u8) !GroupRef { + var groups = try loadGroups(allocator); + defer groups.deinit(allocator); + const idx = findGroupIndex(&groups, name) orelse return error.GroupNotFound; + return .{ + .name = try allocator.dupe(u8, groups.items.items[idx].name), + .codex_home = try allocator.dupe(u8, groups.items.items[idx].codex_home), + .managed = groups.items.items[idx].managed, + }; +} + +pub fn ensureManagedGroupAlloc(allocator: std.mem.Allocator, name: []const u8) !GroupRef { + try validateGroupName(name); + if (std.mem.eql(u8, name, default_group_name)) { + return try resolveGroupAlloc(allocator, default_group_name); + } + + var groups = try loadGroups(allocator); + defer groups.deinit(allocator); + if (findGroupIndex(&groups, name)) |idx| { + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), groups.items.items[idx].codex_home); + return .{ + .name = try allocator.dupe(u8, groups.items.items[idx].name), + .codex_home = try allocator.dupe(u8, groups.items.items[idx].codex_home), + .managed = groups.items.items[idx].managed, + }; + } + + const codex_home = try managedGroupCodexHomeAlloc(allocator, name); + defer allocator.free(codex_home); + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), codex_home); + try appendGroupRef(allocator, &groups, name, codex_home, true); + try saveGroups(allocator, &groups); + + return .{ + .name = try allocator.dupe(u8, name), + .codex_home = try allocator.dupe(u8, codex_home), + .managed = true, + }; +} + +test "group names allow simple shell-safe identifiers" { + try validateGroupName("work"); + try validateGroupName("team_1"); + try validateGroupName("personal-alpha"); +} + +test "group names reject path-like or empty identifiers" { + try std.testing.expectError(error.InvalidGroupName, validateGroupName("")); + try std.testing.expectError(error.InvalidGroupName, validateGroupName(".")); + try std.testing.expectError(error.InvalidGroupName, validateGroupName("..")); + try std.testing.expectError(error.InvalidGroupName, validateGroupName("../work")); + try std.testing.expectError(error.InvalidGroupName, validateGroupName("work/group")); +} diff --git a/src/main.zig b/src/main.zig index 08ff59fe..9eb534db 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,6 +5,7 @@ const account_name_refresh = @import("account_name_refresh.zig"); const cli = @import("cli.zig"); const chatgpt_http = @import("chatgpt_http.zig"); const display_rows = @import("display_rows.zig"); +const group_manager = @import("group_manager.zig"); const registry = @import("registry.zig"); const auth = @import("auth.zig"); const auto = @import("auto.zig"); @@ -178,6 +179,7 @@ fn runMain(init: std.process.Init.Minimal) !void { .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), + .group => |opts| try handleGroup(allocator, opts), .clean => try handleClean(allocator, codex_home.?), } @@ -192,6 +194,10 @@ fn isHandledCliError(err: anyerror) bool { err == error.ListLiveRequiresTty or err == error.TuiOutputUnavailable or err == error.NodeJsRequired or + err == error.GroupNotFound or + err == error.GroupAlreadyExists or + err == error.InvalidGroupName or + err == error.CodextLaunchFailed or err == error.SwitchSelectionRequiresTty or err == error.RemoveConfirmationUnavailable or err == error.RemoveSelectionRequiresTty or @@ -201,7 +207,7 @@ fn isHandledCliError(err: anyerror) bool { pub fn shouldReconcileManagedService(cmd: cli.Command) bool { if (hasNonEmptyEnvVar(skip_service_reconcile_env)) return false; return switch (cmd) { - .help, .version, .status, .daemon => false, + .help, .version, .status, .daemon, .group => false, else => true, }; } @@ -1301,6 +1307,11 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li } fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.LoginOptions) !void { + if (opts.group_name) |group_name| { + try handleManagedGroupLogin(allocator, group_name, opts); + return; + } + try cli.runCodexLogin(opts); const auth_path = try registry.activeAuthPath(allocator, codex_home); defer allocator.free(auth_path); @@ -2564,6 +2575,300 @@ fn findAccountIndexByDisplayNumber( return display.rows[row_idx].account_index; } +fn printGroupMessage(comptime fmt: []const u8, args: anytype) !void { + var buffer: [1024]u8 = undefined; + var stdout = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + try stdout.interface.print(fmt ++ "\n", args); + try stdout.interface.flush(); +} + +fn printGroupError(comptime fmt: []const u8, args: anytype) !void { + var buffer: [1024]u8 = undefined; + var stderr = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + try stderr.interface.print("error: " ++ fmt ++ "\n", args); + try stderr.interface.flush(); +} + +fn handleManagedGroupLogin(allocator: std.mem.Allocator, group_name: []const u8, opts: cli.LoginOptions) !void { + var group = try group_manager.ensureManagedGroupAlloc(allocator, group_name); + defer group.deinit(allocator); + try handleLoginInCodexHome(allocator, group.codex_home, opts); + try printGroupMessage("Logged in account to group `{s}` ({s}).", .{ group.name, group.codex_home }); +} + +fn handleLoginInCodexHome(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.LoginOptions) !void { + try cli.runCodexLoginWithCodexHome(allocator, opts, codex_home); + const auth_path = try registry.activeAuthPath(allocator, codex_home); + defer allocator.free(auth_path); + + const info = try auth.parseAuthInfo(allocator, auth_path); + defer info.deinit(allocator); + + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + + const email = info.email orelse return error.MissingEmail; + _ = email; + const record_key = info.record_key orelse return error.MissingChatgptUserId; + const dest = try registry.accountAuthPath(allocator, codex_home, record_key); + defer allocator.free(dest); + + try registry.ensureAccountsDir(allocator, codex_home); + try registry.copyManagedFile(auth_path, dest); + + const record = try registry.accountFromAuth(allocator, "", &info); + try registry.upsertAccount(allocator, ®, record); + try registry.setActiveAccountKey(allocator, ®, record_key); + _ = try refreshAccountNamesAfterLogin(allocator, ®, &info, defaultAccountFetcher); + try registry.saveRegistry(allocator, codex_home, ®); +} + +fn groupAccountCount(allocator: std.mem.Allocator, codex_home: []const u8) !usize { + var reg = registry.loadRegistry(allocator, codex_home) catch |err| switch (err) { + error.FileNotFound, error.UnsupportedRegistryVersion => return 0, + else => return err, + }; + defer reg.deinit(allocator); + return reg.accounts.items.len; +} + +fn printGroupListAll(allocator: std.mem.Allocator) !void { + var groups = try group_manager.loadGroups(allocator); + defer groups.deinit(allocator); + + var buffer: [4096]u8 = undefined; + var stdout = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &stdout.interface; + try out.writeAll("GROUP ACCOUNTS CODEX_HOME\n"); + try out.writeAll("------------------------------\n"); + for (groups.items.items) |group| { + const count = try groupAccountCount(allocator, group.codex_home); + try out.print("{s:<8} {d:>8} {s}\n", .{ group.name, count, group.codex_home }); + } + try out.flush(); +} + +fn printManagedGroupPath(allocator: std.mem.Allocator, name: []const u8) !void { + var group = try group_manager.resolveGroupAlloc(allocator, name); + defer group.deinit(allocator); + try printGroupMessage("{s}", .{group.codex_home}); +} + +const ManagedGroupAccountMatch = struct { + source_group_name: []u8, + source_codex_home: []u8, + snapshot_path: []u8, + label: []u8, + alias: []u8, + + fn deinit(self: *ManagedGroupAccountMatch, allocator: std.mem.Allocator) void { + allocator.free(self.source_group_name); + allocator.free(self.source_codex_home); + allocator.free(self.snapshot_path); + allocator.free(self.label); + allocator.free(self.alias); + } +}; + +fn managedGroupAccountMatchForRecord( + allocator: std.mem.Allocator, + source: *const group_manager.GroupRef, + rec: *const registry.AccountRecord, +) !ManagedGroupAccountMatch { + const snapshot_path = try registry.accountAuthPath(allocator, source.codex_home, rec.account_key); + errdefer allocator.free(snapshot_path); + const label = try display_rows.buildPreferredAccountLabelAlloc(allocator, rec, rec.email); + errdefer allocator.free(label); + return .{ + .source_group_name = try allocator.dupe(u8, source.name), + .source_codex_home = try allocator.dupe(u8, source.codex_home), + .snapshot_path = snapshot_path, + .label = label, + .alias = try allocator.dupe(u8, rec.alias), + }; +} + +fn appendManagedGroupMatchesFromGroup( + allocator: std.mem.Allocator, + result: *std.ArrayList(ManagedGroupAccountMatch), + selector: []const u8, + source: *const group_manager.GroupRef, +) !void { + var reg = registry.loadRegistry(allocator, source.codex_home) catch |err| switch (err) { + error.FileNotFound, error.UnsupportedRegistryVersion => return, + else => return err, + }; + defer reg.deinit(allocator); + + var matches = std.ArrayList(usize).empty; + defer matches.deinit(allocator); + if (try findAccountIndexByDisplayNumber(allocator, ®, selector)) |account_idx| { + try matches.append(allocator, account_idx); + } else { + var fuzzy = try findMatchingAccountsForRemove(allocator, ®, selector); + defer fuzzy.deinit(allocator); + try matches.appendSlice(allocator, fuzzy.items); + } + + for (matches.items) |account_idx| { + try result.append(allocator, try managedGroupAccountMatchForRecord(allocator, source, ®.accounts.items[account_idx])); + } +} + +fn collectManagedGroupMatches( + allocator: std.mem.Allocator, + selector: []const u8, + target_group_name: []const u8, +) !std.ArrayList(ManagedGroupAccountMatch) { + var result = std.ArrayList(ManagedGroupAccountMatch).empty; + errdefer { + for (result.items) |*match| match.deinit(allocator); + result.deinit(allocator); + } + + var groups = try group_manager.loadGroups(allocator); + defer groups.deinit(allocator); + + if (!std.mem.eql(u8, target_group_name, group_manager.default_group_name)) { + if (group_manager.findGroupIndex(&groups, group_manager.default_group_name)) |idx| { + try appendManagedGroupMatchesFromGroup(allocator, &result, selector, &groups.items.items[idx]); + if (result.items.len != 0) return result; + } + } + + for (groups.items.items) |source| { + if (std.mem.eql(u8, source.name, target_group_name)) continue; + if (std.mem.eql(u8, source.name, group_manager.default_group_name)) continue; + try appendManagedGroupMatchesFromGroup(allocator, &result, selector, &source); + } + return result; +} + +fn importManagedMatchIntoGroup( + allocator: std.mem.Allocator, + target: *const group_manager.GroupRef, + target_reg: *registry.Registry, + match: *const ManagedGroupAccountMatch, +) !registry.ImportOutcome { + const alias: ?[]const u8 = if (match.alias.len == 0) null else match.alias; + var report = try registry.importAuthPath(allocator, target.codex_home, target_reg, match.snapshot_path, alias); + defer report.deinit(allocator); + if (report.failure) |err| return err; + if (report.imported > 0) return .imported; + if (report.updated > 0) return .updated; + return .skipped; +} + +fn handleManagedGroupAdd(allocator: std.mem.Allocator, target_group_name: []const u8, selectors: []const []const u8) !void { + var target = try group_manager.resolveGroupAlloc(allocator, target_group_name); + defer target.deinit(allocator); + var target_reg = try registry.loadRegistry(allocator, target.codex_home); + defer target_reg.deinit(allocator); + + var imported: usize = 0; + var updated: usize = 0; + for (selectors) |selector| { + var matches = try collectManagedGroupMatches(allocator, selector, target.name); + defer { + for (matches.items) |*match| match.deinit(allocator); + matches.deinit(allocator); + } + if (matches.items.len == 0) { + try cli.printAccountNotFoundError(selector); + return error.AccountNotFound; + } + for (matches.items) |*match| { + switch (try importManagedMatchIntoGroup(allocator, &target, &target_reg, match)) { + .imported => imported += 1, + .updated => updated += 1, + .skipped => {}, + } + } + } + + try registry.saveRegistry(allocator, target.codex_home, &target_reg); + try printGroupMessage("Group `{s}` updated: {d} imported, {d} refreshed.", .{ target.name, imported, updated }); +} + +fn handleManagedGroupCreate(allocator: std.mem.Allocator, opts: cli.GroupMutationOptions) !void { + var group = try group_manager.ensureManagedGroupAlloc(allocator, opts.name); + defer group.deinit(allocator); + try printGroupMessage("Group `{s}` uses {s}.", .{ group.name, group.codex_home }); + if (opts.selectors.len != 0) { + try handleManagedGroupAdd(allocator, group.name, opts.selectors); + } +} + +fn handleManagedGroupRemove(allocator: std.mem.Allocator, target_group_name: []const u8, selectors: []const []const u8) !void { + var target = try group_manager.resolveGroupAlloc(allocator, target_group_name); + defer target.deinit(allocator); + try handleRemove(allocator, target.codex_home, .{ + .selectors = @constCast(selectors), + .all = false, + .live = false, + .api_mode = .default, + }); +} + +fn handleManagedGroupLaunch(allocator: std.mem.Allocator, name: []const u8, argv_tail: []const []const u8) !void { + var group = try group_manager.resolveGroupAlloc(allocator, name); + defer group.deinit(allocator); + + var argv = std.ArrayList([]const u8).empty; + defer argv.deinit(allocator); + try argv.append(allocator, "codext"); + try argv.appendSlice(allocator, argv_tail); + + var env_map = try getEnvMap(allocator); + defer env_map.deinit(); + try env_map.put("CODEX_HOME", group.codex_home); + + var child = std.process.spawn(app_runtime.io(), .{ + .argv = argv.items, + .environ_map = &env_map, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }) catch |err| { + try printGroupError("failed to launch `codext` for group `{s}`: {s}.", .{ name, @errorName(err) }); + return err; + }; + const term = try child.wait(app_runtime.io()); + switch (term) { + .exited => |code| if (code == 0) return, + else => {}, + } + return error.CodextLaunchFailed; +} + +fn handleGroup(allocator: std.mem.Allocator, opts: cli.GroupOptions) !void { + switch (opts) { + .list => try printGroupListAll(allocator), + .create => |mutation| try handleManagedGroupCreate(allocator, mutation), + .path => |name| try printManagedGroupPath(allocator, name), + .scoped => |scoped| { + switch (scoped.action) { + .login => |login_opts| { + try handleManagedGroupLogin(allocator, scoped.name, login_opts); + return; + }, + else => {}, + } + var group = try group_manager.resolveGroupAlloc(allocator, scoped.name); + defer group.deinit(allocator); + switch (scoped.action) { + .list => |list_opts| try handleList(allocator, group.codex_home, list_opts), + .login => unreachable, + .add => |selectors| try handleManagedGroupAdd(allocator, group.name, selectors), + .remove => |selectors| try handleManagedGroupRemove(allocator, group.name, selectors), + .import_auth => |import_opts| try handleImport(allocator, group.codex_home, import_opts), + .switch_account => |switch_opts| try handleSwitch(allocator, group.codex_home, switch_opts), + .launch => |argv| try handleManagedGroupLaunch(allocator, group.name, argv), + } + }, + } +} + const CurrentAuthState = struct { record_key: ?[]u8, syncable: bool, @@ -3647,6 +3952,7 @@ test { _ = @import("cli.zig"); _ = @import("compat_fs.zig"); _ = @import("format.zig"); + _ = @import("group_manager.zig"); _ = @import("timefmt.zig"); _ = @import("tests/auth_test.zig"); _ = @import("tests/sessions_test.zig"); diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 6ffb908f..387305e2 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -262,6 +262,89 @@ test "Scenario: Given login with duplicate device auth flag when parsing then us try expectUsageError(result, .login, "duplicate `--device-auth`"); } +test "Scenario: Given login with group when parsing then group name and device auth are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "login", "--group", "work", "--device-auth" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .login => |opts| { + try std.testing.expect(opts.device_auth); + try std.testing.expect(opts.group_name != null); + try std.testing.expectEqualStrings("work", opts.group_name.?); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given group create with account selectors when parsing then group mutation is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "create", "work", "01", "jane@example.com" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |opts| switch (opts) { + .create => |mutation| { + try std.testing.expectEqualStrings("work", mutation.name); + try std.testing.expectEqual(@as(usize, 2), mutation.selectors.len); + try std.testing.expectEqualStrings("01", mutation.selectors[0]); + try std.testing.expectEqualStrings("jane@example.com", mutation.selectors[1]); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given scoped group commands when parsing then delegated options are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "work", "switch", "--live", "--auto", "--skip-api" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |opts| switch (opts) { + .scoped => |scoped| { + try std.testing.expectEqualStrings("work", scoped.name); + switch (scoped.action) { + .switch_account => |switch_opts| { + try std.testing.expect(switch_opts.live); + try std.testing.expect(switch_opts.auto); + try std.testing.expectEqual(cli.ApiMode.skip_api, switch_opts.api_mode); + }, + else => return error.TestExpectedEqual, + } + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given group help when rendering then core group usage is shown" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.writeCommandHelp(&aw.writer, false, .group); + + const help = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group create [...]") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group login [--device-auth]") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group launch [-- ...]") != null); +} + 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", "help", "list" }; @@ -294,6 +377,7 @@ test "Scenario: Given help when rendering then login and command help notes are 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); + try std.testing.expect(std.mem.indexOf(u8, help, "group") != null); try std.testing.expect(std.mem.indexOf(u8, help, "switch [--live] [--auto] [--api|--skip-api] | switch ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "remove [--live] [--api|--skip-api] | remove [...] | remove --all") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Delete backup and stale files under accounts/") != null); From d93348203e8781fd1d2ecd4c060dd619cb0eaadd Mon Sep 17 00:00:00 2001 From: Mouaad SK Date: Mon, 27 Apr 2026 08:56:20 +0100 Subject: [PATCH 2/2] Extend account groups with manager and launch workflows --- README.md | 9 +- docs/account-groups.md | 174 ++-- docs/auto-switch.md | 40 +- docs/implement.md | 9 +- docs/schema-migration.md | 10 +- docs/test.md | 4 +- src/auto.zig | 1269 ++++++++++++++++++++++++++-- src/chatgpt_http.zig | 2 +- src/cli.zig | 739 +++++++++++----- src/format.zig | 450 ++++++++-- src/group_manager.zig | 423 +++++++++- src/main.zig | 1578 +++++++++++++++++++++++++++++------ src/registry.zig | 392 ++++++++- src/sessions.zig | 311 +++++++ src/tests/auto_test.zig | 262 +++++- src/tests/cli_bdd_test.zig | 396 ++++++++- src/tests/e2e_cli_test.zig | 605 ++++++++++++++ src/tests/purge_test.zig | 4 +- src/tests/registry_test.zig | 8 +- src/tests/sessions_test.zig | 98 +++ src/windows_auto_main.zig | 34 +- 21 files changed, 6069 insertions(+), 748 deletions(-) diff --git a/README.md b/README.md index 2ca281bd..c424691e 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,15 @@ See [docs/account-groups.md](./docs/account-groups.md) for the full command refe | `codex-auth group list [--live] [--api\|--skip-api]` | List accounts in one group | | `codex-auth group login [--device-auth]` | Login and add the account directly to one group | | `codex-auth group add [...]` | Copy accounts from another group into this group | +| `codex-auth group copy [...]` | Copy accounts into this group; without selectors, choose interactively | +| `codex-auth group move [...]` | Move accounts into this group; without selectors, choose interactively | | `codex-auth group switch [--live] [--auto] [--api\|--skip-api]` | Switch the active account inside one group | -| `codex-auth group launch [-- ...]` | Launch `codext` with this group's `CODEX_HOME` | +| `codex-auth group auto enable\|disable` | Enable or disable background auto-switching for one group | +| `codex-auth group config api enable\|disable` | Enable or disable usage and account APIs for one group | +| `codex-auth group status` | Show auto-switch and usage status for one group | +| `codex-auth group launch [resume [session]] [-- ...]` | Launch `codext` with this group's `CODEX_HOME` | +| `codex-auth project set-group ` | Remember a group for the current project directory | +| `codex-auth launch [resume [session]] [-- ...]` | Launch `codext` with the remembered project group, or `default` | ### Import diff --git a/docs/account-groups.md b/docs/account-groups.md index 4229d6d6..8f8e80a3 100644 --- a/docs/account-groups.md +++ b/docs/account-groups.md @@ -1,105 +1,169 @@ # Account Groups -Account groups let one `codex-auth` installation manage multiple isolated Codex homes. -Each group has its own `auth.json`, account registry, account snapshots, and sessions. +Account groups provide separate Codex homes for separate account pools. -The `default` group is the normal Codex home at `~/.codex`. Named groups are created under `~/codex-auth/groups/`. +The managed layout is: -## Create and Inspect Groups +- `default` uses `~/.codex`, which is the normal Codex home. +- every other managed group uses a folder under `~/codex-auth/groups/`. +- `~/codex-auth/groups.json` stores mappings when a group name points at a folder with a different name. +- `~/codex-auth/projects.json` remembers the preferred group for project directories. +- archived group folders move under `~/codex-auth/archive/`. -Create an empty group: +For example, a `work` group normally uses: -```shell -codex-auth group create work +```sh +~/codex-auth/groups/work ``` -List all groups: +## Managed Group Commands -```shell +```sh +codex-auth list codex-auth group list +codex-auth group status +codex-auth group create [...] +codex-auth group login [--device-auth] +codex-auth group add [...] +codex-auth group copy [...] +codex-auth group move [...] +codex-auth group remove [...] +codex-auth group list [--live] [--api|--skip-api] +codex-auth group import [--alias ] +codex-auth group switch [--live] [--auto] [--api|--skip-api] +codex-auth group switch +codex-auth group auto enable|disable +codex-auth group auto --5h [--weekly ] +codex-auth group launch [resume [session]] [-- ...] +codex-auth group archive +codex-auth group delete --force +codex-auth group path +codex-auth project show +codex-auth project set-group +codex-auth project clear +codex-auth launch [resume [session]] [-- ...] ``` -Print a group's Codex home: +`group list `, `group status `, and `group path ` are also accepted as aliases for the preferred `group ...` forms. -```shell -codex-auth group work path -codex-auth group path work -``` +`list` shows the cross-group account dashboard from the default `CODEX_HOME`, separated into group sections and including a `GROUP` column for each account row. In color terminals, `default` uses gray and managed groups use their assigned display colors; active rows and group separators are bold/darker, while inactive rows use lighter tones from the same group color. -Group names may contain letters, numbers, `_`, and `-`. +Use `group list --skip-api` to print one group's accounts using local data. Use `group switch` to open the interactive switcher inside one group, or `group switch ` to switch directly. Use `group status` for service and auto-switch status, and `group path` for the group's `CODEX_HOME`. -## Login Into a Group +## Logging In To A Group -Login directly into a group: +Use group login when the account should be added directly to one pool: -```shell +```sh codex-auth group work login codex-auth group work login --device-auth ``` -The top-level login command can also target a group: +The command runs `codex login` with `CODEX_HOME` set to the `work` group folder, then imports that group's new `auth.json` into the same group registry. + +Top-level login can target a group too: -```shell -codex-auth login --group work +```sh codex-auth login --group work --device-auth ``` -If the group does not exist, login creates it first. +The group must already exist. Create it first with `codex-auth group create work` if you want to choose or create the backing folder. -## Copy or Import Accounts +## Creating a Group -Copy existing accounts into a group by row number, alias, email, account name, or account key: +`group create work` creates or reuses the managed Codex home for `work`. -```shell -codex-auth group work add 01 -codex-auth group work add jane@example.com personal -``` +In an interactive terminal, if there are unused folders under `~/codex-auth/groups/`, the command shows them and lets the user attach `work` to one of those folders. If no unused folder is selected, it asks for a new folder name and defaults to `work`. + +In non-interactive mode, `group create work` uses `~/codex-auth/groups/work`. -The source account remains in its original group. When the selector matches the `default` group, that match is used first. Other groups are searched only when `default` has no match. +## Adding Existing Accounts -Create a group and copy accounts in one command: +Account selectors can be display numbers, aliases, email fragments, account names, or account keys. -```shell -codex-auth group create work 01 jane@example.com +When adding an account to a managed group, the command searches all known groups: + +```sh +codex-auth group work add personal@example.com ``` -Import an auth file directly into a group: +If the match is in `default`, it is copied directly from `~/.codex` into the `work` Codex home. + +If the match is in another non-default group, the command tells the user where it was found and asks for confirmation before copying it into the target group. + +For explicit transfer commands: -```shell -codex-auth group work import /path/to/auth.json --alias work-main +```sh +codex-auth group trading copy beta@example.com +codex-auth group trading move work-only@example.com ``` -Remove accounts from one group: +`copy` leaves the source group unchanged, so a copied account can appear under both group sections in `codex-auth list`. That is expected: each group has its own `CODEX_HOME`, registry, sessions, and active account. -```shell -codex-auth group work remove 01 -codex-auth group work remove jane@example.com +`move` imports the account into the target group, then removes it from the source group. It is the command to use when the account should belong to the new pool instead of the old pool. + +With no account selector, `copy` and `move` open an interactive picker showing accounts from all other groups: + +```sh +codex-auth group trading copy +codex-auth group trading move ``` -Removing an account from one group does not remove it from other groups. +## Launching Codext + +Use `group launch` instead of manually prefixing every command with `CODEX_HOME=...`: -## Use a Group +```sh +codex-auth group work launch +codex-auth group work launch -- --model gpt-5.4 +codex-auth group work launch resume +codex-auth group work launch resume 019db67d-2190-7563-a899-ce3082e491cf +``` -List accounts in a group: +The launched `codext` process receives `CODEX_HOME` for that group. Extra launch arguments are passed through to `codext`, so `launch resume` has the same behavior as running `codext resume` inside that group. The optional session argument can be any selector that `codext resume` normally accepts. Changing `CODEX_HOME` in another terminal does not affect a `codext` process that was already launched. -```shell -codex-auth group work list -codex-auth group work list --skip-api +`group launch` also remembers `` for the current project directory. After that, plain launch uses the remembered group: + +```sh +codex-auth launch +codex-auth launch -- --model gpt-5.4 +codex-auth launch resume +codex-auth launch resume 019db67d-2190-7563-a899-ce3082e491cf ``` -Switch the active account inside a group: +To manage the remembered project group directly: -```shell -codex-auth group work switch -codex-auth group work switch 02 -codex-auth group work switch --live --auto +```sh +codex-auth project show +codex-auth project set-group work +codex-auth project clear ``` -Launch `codext` with a group's `CODEX_HOME`: +If no group is remembered for the project, `launch` uses `default`, which maps to `~/.codex`. -```shell -codex-auth group work launch -codex-auth group work launch -- --model gpt-5.4 +## Per-Group Auto-Switch Settings + +Each managed group has its own auto-switch and API settings in that group's `CODEX_HOME`: + +```sh +codex-auth group work auto enable +codex-auth group work auto --5h 12 --weekly 8 +codex-auth group work config api enable +codex-auth group work auto disable ``` -Arguments after `--` are passed to `codext`. +The background runtime is one manager service for all enabled groups. It reads the group config, then checks each enabled group's own `CODEX_HOME` independently. Older per-group service identities are removed during enable/reconcile. + +Use `group status` for a dashboard of all groups and `group status` for the standard auto-switch status of one group. + +## Archive And Delete + +`group archive ` moves the managed group folder to `~/codex-auth/archive/-` and removes the group/project mapping. It is meant as the reversible cleanup path before deleting a group permanently. + +`group delete --force` permanently removes the managed group folder and its mapping. The `default` group cannot be archived or deleted. + +## Legacy Registry Groups + +`group use |none` still exists for the lower-level registry grouping inside the current `CODEX_HOME`. It stores an active group in that one registry and scopes plain `list`, `switch`, and auto-switch candidate selection inside that registry. + +For separate account pools and separate Codex sessions, prefer the managed group commands above. diff --git a/docs/auto-switch.md b/docs/auto-switch.md index 8ab145e3..05e4348c 100644 --- a/docs/auto-switch.md +++ b/docs/auto-switch.md @@ -13,6 +13,11 @@ User-facing commands: - `codex-auth config auto [--5h ] [--weekly ]` - `codex-auth config api enable` - `codex-auth config api disable` +- `codex-auth group auto enable` +- `codex-auth group auto disable` +- `codex-auth group auto [--5h ] [--weekly ]` +- `codex-auth group config api enable` +- `codex-auth group config api disable` Stored registry fields: @@ -25,21 +30,22 @@ The feature is off by default. ## Runtime Model -When enabled, managed services run the long-lived watcher mode: +When enabled, the managed service runs the long-lived manager mode: -- `codex-auth daemon --watch` +- `codex-auth daemon --manager` -The watcher keeps a single process alive and runs roughly once per second. -Each cycle: +The manager keeps a single process alive, loads all managed groups, and runs roughly once per second. +For every group whose own registry has `auto_switch.enabled = true`, each cycle: 1. keeps an in-memory candidate index for all non-active accounts, keyed by the same candidate score used for switching 2. reloads `registry.json` only when the on-disk file changed, then rebuilds that in-memory index 3. syncs the currently active `auth.json` into the in-memory registry when the active auth snapshot changed 4. tries to refresh usage from the newest local rollout event first -5. if no new local rollout event is available, or the newest event has no usable rate-limit windows, and `api.usage = true`, falls back to the ChatGPT usage API at most once per minute for the current active account -6. keeps the candidate index warm with a bounded candidate upkeep pass instead of batch-refreshing every candidate -7. if the active account should switch, revalidates only the top few stale candidates before making the final switch decision -8. writes `registry.json` only when state changed +5. scans that group's rollout files for a fresh "hit your usage limit" message and marks the currently active account exhausted immediately when the message is newer than that account's activation time +6. if no new local rollout event is available, or the newest event has no usable rate-limit windows, and `api.usage = true`, falls back to the ChatGPT usage API at most once per minute for the current active account +7. keeps the candidate index warm with a bounded candidate upkeep pass instead of batch-refreshing every candidate +8. if the active account should switch, revalidates only the top few stale candidates before making the final switch decision +9. writes `registry.json` only when state changed The watcher also emits English-only service logs for debugging: @@ -47,13 +53,14 @@ The watcher also emits English-only service logs for debugging: - local rollout captures show the parsed window labels first, then the local-time event timestamp, then the real rollout basename; when the newest local event has no usable usage windows the same `[local]` line also marks `fallback-to-api` - API refresh logs are reduced to `refresh usage | status=...`, where `status` is the HTTP status when available, `MissingAuth` when the active auth cannot call the ChatGPT usage API, or the direct transport error name such as `TimedOut` / `RequestFailed` -`daemon --once` still exists for tests and one-shot validation, but the managed service path uses `daemon --watch`. +`daemon --watch` and `daemon --once` still exist for tests, legacy service cleanup, and one-CODEX_HOME validation. The managed service path uses `daemon --manager`; `daemon --manager-once` runs one manager pass. ## Data Source Priority The background watcher is intentionally not API-only, even when `api.usage = true`. - Local rollout events are preferred because they arrive much faster than periodic usage API polling. +- Local limit-message detection is even more direct: it watches each enabled group's own `/sessions/**/rollout-*.jsonl` tree, so project directories and terminal tabs do not matter as long as the session uses that group `CODEX_HOME`. - API refresh remains useful as a slower fallback and calibration path when rollout data is missing or stale. - When `api.usage = false`, the watcher uses local rollout data only and makes no usage API requests. - When a new rollout event arrives but its `rate_limits` payload is `null`, `{}`, or otherwise lacks usable 5h/weekly windows, the watcher keeps the previous `last_usage` snapshot and relies on the API fallback path instead of overwriting usage with empty data. @@ -68,13 +75,14 @@ Local rollout attribution rules are unchanged: - a newer `token_count` event with unusable `rate_limits` is still treated as a fresh signal for API fallback, but it does not overwrite the stored usage snapshot - the event is applied only when `event_timestamp_ms >= active_account_activated_at_ms` - each account remembers its own last consumed local rollout signature `(path, event_timestamp_ms)` so the same local event is not reapplied +- limit-message events also use the same activation-time guard; after a switch, the old limit message is older than the newly active account activation and will not immediately exhaust the replacement account ## Switching Rules -The watcher switches without foreground CLI output when the active account drops below either threshold: +The watcher switches without foreground CLI output when the active account reaches or drops below either threshold: -- `5h remaining < auto_switch.threshold_5h_percent` -- `weekly remaining < auto_switch.threshold_weekly_percent` +- `5h remaining <= auto_switch.threshold_5h_percent` +- `weekly remaining <= auto_switch.threshold_weekly_percent` There is one extra near-real-time safety rule for free plans: @@ -101,12 +109,14 @@ Platform bootstrap: - Linux/WSL: `systemd --user` persistent service - macOS: `LaunchAgent` with `KeepAlive` -- Windows: user scheduled task with an `ONLOGON` trigger, restart-on-failure settings, and an unlimited execution time for `codex-auth-auto.exe`, plus an immediate `schtasks /Run` during enablement +- Windows: user scheduled task with an `ONLOGON` trigger, restart-on-failure settings, and an unlimited execution time for `codex-auth-auto.exe`, plus an immediate task run during enablement -Service definition files stay in the platform-standard per-user locations. The managed watcher process uses the current `codex_home` root, so when `CODEX_HOME` is set during enablement the watcher keeps reading and writing that override after it starts in the background. +Service definition files stay in the platform-standard per-user locations. The managed manager process does not use one fixed `CODEX_HOME`; it reads `~/codex-auth/groups.json`, then runs each enabled group against that group's configured `CODEX_HOME`. The `default` group still maps to the normal `~/.codex`. Foreground commands other than `help`, `version`, `status`, and `daemon` still reconcile the managed service definition after they complete. `config auto enable` also prints a short usage-mode note so the user can see whether switching is currently running with default API-backed usage data or local-only fallback semantics. -When migrating from older Linux/WSL timer-based installs, enable/reconcile also removes the legacy `codex-auth-autoswitch.timer` unit file instead of leaving the old minute timer behind. +The manager uses one service identity for all enabled groups: `codex-auth-manager.service` on Linux, `com.loongphy.codex-auth.manager` on macOS, and `CodexAuthManager` on Windows. +When the manager service is installed or reconciled, the CLI resolves the current usable Node executable from `CODEX_AUTH_NODE_EXECUTABLE` or `PATH` and writes it into the service environment as `CODEX_AUTH_NODE_EXECUTABLE`. This avoids hard-coding a user-specific path while still letting macOS LaunchAgent/systemd run API refreshes under their limited default `PATH`. +When migrating from older service layouts, enable/reconcile also removes legacy one-CODEX_HOME and per-group service identities such as `codex-auth-autoswitch.service`, `com.loongphy.codex-auth.auto`, `codex-auth-autoswitch-work.service`, and `com.loongphy.codex-auth.auto.work`. ## Limits diff --git a/docs/implement.md b/docs/implement.md index 32c38a71..650c6e85 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -47,12 +47,13 @@ File-permission behavior is documented in [docs/permissions.md](./permissions.md - `registry.json.schema_version` is the on-disk migration gate. - The current binary supports all released schemas: - - `schema_version = 3` is the current layout with record-keyed snapshots, active-account activation timestamps, and per-account local rollout dedupe. - - `version = 2` legacy registries using `active_email` and email-keyed snapshots are auto-migrated to schema `3`. -- The current binary also accepts current-layout files that still use the legacy top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them once to the normalized `schema_version = 3` format. + - `schema_version = 4` is the current layout with record-keyed snapshots, active-account activation timestamps, per-account local rollout dedupe, and optional registry-local account groups. + - `schema_version = 3` registries are auto-migrated to schema `4`. + - `version = 2` legacy registries using `active_email` and email-keyed snapshots are auto-migrated to schema `4`. +- The current binary also accepts current-layout files that still use the legacy top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them once to the normalized `schema_version = 4` format. - Loading a supported older schema performs the migration in memory and then rewrites `registry.json` in the current format. - Loading a newer `schema_version` is rejected with `UnsupportedRegistryVersion`; older binaries must not silently rewrite newer registry files. -- Saving always rewrites `registry.json` into the current field set with `schema_version = 3`. +- Saving always rewrites `registry.json` into the current field set with `schema_version = 4`. - Unknown extra fields are still ignored on load and dropped on save, so additive compatibility is only guaranteed for schemas explicitly supported by the current binary. - See `docs/schema-migration.md` for the versioning policy and migration rules. diff --git a/docs/schema-migration.md b/docs/schema-migration.md index 4a6a8122..99251d5e 100644 --- a/docs/schema-migration.md +++ b/docs/schema-migration.md @@ -13,15 +13,16 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r - `codex-auth` keeps a single `registry.json`; feature state such as `auto_switch` and `api` stays in that file. - The latest binary supports every released schema. Right now that means: - legacy `version = 2` - - current `schema_version = 3` -- The current binary also accepts current-layout files that still use the old top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them once to normalized `schema_version = 3`. + - `schema_version = 3` + - current `schema_version = 4` +- The current binary also accepts current-layout files that still use the old top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them once to normalized `schema_version = 4`. - If the binary sees a newer `schema_version` than it understands, it fails with `UnsupportedRegistryVersion` and must not write the file. ## Upgrade Behavior - User-visible behavior is always “upgrade directly to the latest supported schema”. - Internally, migrations are implemented as a chain of `Vn -> Vn+1` steps. -- In the current code, supported automatic migration is `version = 2 -> schema_version = 3`, then the file is rewritten once as schema `3`. +- In the current code, supported automatic migration is `version = 2 -> schema_version = 3 -> schema_version = 4`, then the file is rewritten once as schema `4`. - Users are not expected to install intermediate `codex-auth` versions. ## Released Schemas @@ -39,6 +40,9 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r - Current top-level `api` block - Per-account `account_key` - Each account also stores `chatgpt_account_id` and `chatgpt_user_id` +- `schema_version = 4` + - Adds optional registry-local account groups for the legacy `group use` scope + - Adds optional `active_group` to scope plain `list`, `switch`, and background candidate selection inside one `CODEX_HOME` ## When To Bump `schema_version` diff --git a/docs/test.md b/docs/test.md index 025ae73e..bb9d7370 100644 --- a/docs/test.md +++ b/docs/test.md @@ -90,7 +90,7 @@ This scenario is accepted when all of the following are true: - `list` exits with code `0`. - `list` creates `accounts/registry.json`. - `list` creates exactly one `accounts/*.auth.json` snapshot keyed by the imported `record_key`. -- `registry.json` is written in the current layout with `schema_version = 3`. +- `registry.json` is written in the current layout with `schema_version = 4`. - `active_account_key` matches the imported `record_key` from `auth.json`. - `switch ` exits with code `0`. - default `status` shows `usage: api` before any `config api` changes. @@ -192,7 +192,7 @@ This scenario is accepted when all of the following are true: - the copied pre-run `registry.json` is a legacy schema `2` registry - the first `list` exits with code `0` -- after `list`, `registry.json` is rewritten to the current layout with `schema_version = 3` +- after `list`, `registry.json` is rewritten to the current layout with `schema_version = 4` - after `list`, `active_account_key` exists and there is no `active_email` - the migrated `accounts` array still contains the expected accounts - the legacy email-keyed snapshots are replaced by current account-id-keyed snapshots diff --git a/src/auto.zig b/src/auto.zig index fc162892..7c785260 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -7,8 +7,10 @@ const builtin = @import("builtin"); const c_time = @cImport({ @cInclude("time.h"); }); +const chatgpt_http = @import("chatgpt_http.zig"); const cli = @import("cli.zig"); const io_util = @import("io_util.zig"); +const group_manager = @import("group_manager.zig"); const registry = @import("registry.zig"); const sessions = @import("sessions.zig"); const terminal_color = @import("terminal_color.zig"); @@ -20,6 +22,9 @@ const linux_service_name = "codex-auth-autoswitch.service"; const linux_timer_name = "codex-auth-autoswitch.timer"; const mac_label = "com.loongphy.codex-auth.auto"; const windows_task_name = "CodexAuthAutoSwitch"; +const manager_linux_service_name = "codex-auth-manager.service"; +const manager_mac_label = "com.loongphy.codex-auth.manager"; +const manager_windows_task_name = "CodexAuthManager"; const windows_helper_name = "codex-auth-auto.exe"; const windows_task_trigger_kind = "LogonTrigger"; const windows_task_restart_count = "999"; @@ -27,6 +32,7 @@ const windows_task_restart_count_value: i32 = 999; const windows_task_restart_interval_xml = "PT1M"; const windows_task_execution_time_limit_xml = "PT0S"; const lock_file_name = "auto-switch.lock"; +const manager_lock_file_name = "auto-switch-manager.lock"; const watch_poll_interval_ns = 1 * std.time.ns_per_s; const api_refresh_interval_ns = 60 * std.time.ns_per_s; const free_plan_realtime_guard_5h_percent: i64 = 35; @@ -39,8 +45,73 @@ pub const Status = struct { threshold_weekly_percent: u8, api_usage_enabled: bool, api_account_enabled: bool, + codex_home: []const u8 = "", + managed_group: ?[]u8 = null, + active_group: ?[]u8 = null, + + pub fn deinit(self: *Status, allocator: std.mem.Allocator) void { + if (self.managed_group) |name| allocator.free(name); + if (self.active_group) |name| allocator.free(name); + self.managed_group = null; + self.active_group = null; + } +}; + +pub const ServiceTarget = struct { + linux_service_name: []const u8, + linux_timer_name: ?[]const u8 = null, + mac_label: []const u8, + windows_task_name: []const u8, + owned: bool = false, + + pub fn deinit(self: *ServiceTarget, allocator: std.mem.Allocator) void { + if (!self.owned) return; + allocator.free(self.linux_service_name); + if (self.linux_timer_name) |name| allocator.free(name); + allocator.free(self.mac_label); + allocator.free(self.windows_task_name); + self.* = undefined; + } }; +fn defaultServiceTarget() ServiceTarget { + return .{ + .linux_service_name = linux_service_name, + .linux_timer_name = linux_timer_name, + .mac_label = mac_label, + .windows_task_name = windows_task_name, + }; +} + +pub fn managerServiceTarget() ServiceTarget { + return .{ + .linux_service_name = manager_linux_service_name, + .linux_timer_name = null, + .mac_label = manager_mac_label, + .windows_task_name = manager_windows_task_name, + }; +} + +pub fn groupServiceTargetAlloc(allocator: std.mem.Allocator, group_name: []const u8) !ServiceTarget { + try group_manager.validateGroupName(group_name); + if (std.mem.eql(u8, group_name, group_manager.default_group_name)) { + return defaultServiceTarget(); + } + const group_linux_service_name = try std.fmt.allocPrint(allocator, "codex-auth-autoswitch-{s}.service", .{group_name}); + errdefer allocator.free(group_linux_service_name); + const group_mac_label = try std.fmt.allocPrint(allocator, "com.loongphy.codex-auth.auto.{s}", .{group_name}); + errdefer allocator.free(group_mac_label); + const group_windows_task_name = try std.fmt.allocPrint(allocator, "CodexAuthAutoSwitch-{s}", .{group_name}); + errdefer allocator.free(group_windows_task_name); + return .{ + .linux_service_name = group_linux_service_name, + .linux_timer_name = null, + .mac_label = group_mac_label, + .windows_task_name = group_windows_task_name, + .owned = true, + }; +} + const service_version_env_name = "CODEX_AUTH_VERSION"; const codex_home_env_name = "CODEX_HOME"; @@ -79,6 +150,7 @@ const CandidateIndex = struct { self.deinit(allocator); const active = reg.active_account_key; for (reg.accounts.items) |*rec| { + if (!registry.accountInActiveGroup(reg, rec.account_key)) continue; if (active) |account_key| { if (std.mem.eql(u8, rec.account_key, account_key)) continue; } @@ -140,6 +212,12 @@ const CandidateIndex = struct { } } + if (!registry.accountInActiveGroup(reg, account_key)) { + self.remove(account_key); + self.refreshNextScoreChangeAt(reg, now); + return; + } + const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse { self.remove(account_key); self.refreshNextScoreChangeAt(reg, now); @@ -175,6 +253,7 @@ const CandidateIndex = struct { const active = reg.active_account_key; var next_score_change_at: ?i64 = null; for (reg.accounts.items) |*rec| { + if (!registry.accountInActiveGroup(reg, rec.account_key)) continue; if (active) |account_key| { if (std.mem.eql(u8, rec.account_key, account_key)) continue; } @@ -250,6 +329,9 @@ const CandidateIndex = struct { pub const DaemonRefreshState = struct { last_api_refresh_at_ns: i128 = 0, last_api_refresh_account_key: ?[]u8 = null, + active_usage_refresh_account_key: ?[]u8 = null, + active_usage_refresh_available: ?bool = null, + confirmed_limit_account_key: ?[]u8 = null, last_account_name_refresh_at_ns: i128 = 0, last_account_name_refresh_account_key: ?[]u8 = null, pending_bad_account_key: ?[]u8 = null, @@ -261,9 +343,12 @@ pub const DaemonRefreshState = struct { candidate_check_times: std.StringHashMapUnmanaged(i128) = .empty, candidate_rejections: std.StringHashMapUnmanaged(bool) = .empty, rollout_scan_cache: sessions.RolloutScanCache = .{}, + limit_scan_cache: sessions.LimitScanCache = .{}, pub fn deinit(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { self.clearApiRefresh(allocator); + self.clearActiveUsageRefresh(allocator); + self.clearConfirmedLimit(allocator); self.clearAccountNameRefresh(allocator); self.clearPending(allocator); if (self.current_reg) |*reg| { @@ -278,6 +363,7 @@ pub const DaemonRefreshState = struct { self.candidate_rejections.deinit(allocator); } self.rollout_scan_cache.deinit(allocator); + self.limit_scan_cache.deinit(allocator); } fn clearApiRefresh(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { @@ -288,6 +374,57 @@ pub const DaemonRefreshState = struct { self.last_api_refresh_at_ns = 0; } + fn clearActiveUsageRefresh(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { + if (self.active_usage_refresh_account_key) |account_key| { + allocator.free(account_key); + } + self.active_usage_refresh_account_key = null; + self.active_usage_refresh_available = null; + } + + fn markActiveUsageRefresh( + self: *DaemonRefreshState, + allocator: std.mem.Allocator, + account_key: []const u8, + available: bool, + ) !void { + if (self.active_usage_refresh_account_key) |stored| { + if (std.mem.eql(u8, stored, account_key)) { + self.active_usage_refresh_available = available; + return; + } + } + self.clearActiveUsageRefresh(allocator); + self.active_usage_refresh_account_key = try allocator.dupe(u8, account_key); + self.active_usage_refresh_available = available; + } + + fn activeUsageRefreshUnavailable(self: *const DaemonRefreshState, account_key: []const u8) bool { + const stored = self.active_usage_refresh_account_key orelse return false; + if (!std.mem.eql(u8, stored, account_key)) return false; + return self.active_usage_refresh_available == false; + } + + fn clearConfirmedLimit(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { + if (self.confirmed_limit_account_key) |account_key| { + allocator.free(account_key); + } + self.confirmed_limit_account_key = null; + } + + fn setConfirmedLimit(self: *DaemonRefreshState, allocator: std.mem.Allocator, account_key: []const u8) !void { + if (self.confirmed_limit_account_key) |stored| { + if (std.mem.eql(u8, stored, account_key)) return; + } + self.clearConfirmedLimit(allocator); + self.confirmed_limit_account_key = try allocator.dupe(u8, account_key); + } + + fn confirmedLimitMatches(self: *const DaemonRefreshState, account_key: []const u8) bool { + const stored = self.confirmed_limit_account_key orelse return false; + return std.mem.eql(u8, stored, account_key); + } + fn clearAccountNameRefresh(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { if (self.last_account_name_refresh_account_key) |account_key| { allocator.free(account_key); @@ -510,22 +647,46 @@ fn colorEnabled() bool { } pub fn printStatus(allocator: std.mem.Allocator, codex_home: []const u8) !void { - const status = try getStatus(allocator, codex_home); + var status = try getStatus(allocator, codex_home); + defer status.deinit(allocator); var stdout: io_util.Stdout = undefined; stdout.init(); try writeStatusWithColor(stdout.out(), status, colorEnabled()); } pub fn getStatus(allocator: std.mem.Allocator, codex_home: []const u8) !Status { + const target = managerServiceTarget(); + return getStatusForTarget(allocator, codex_home, &target); +} + +pub fn printStatusForGroup(allocator: std.mem.Allocator, group_name: []const u8, codex_home: []const u8) !void { + var status = try getStatusForGroup(allocator, group_name, codex_home); + defer status.deinit(allocator); + var stdout: io_util.Stdout = undefined; + stdout.init(); + try writeStatusWithColor(stdout.out(), status, colorEnabled()); +} + +pub fn getStatusForGroup(allocator: std.mem.Allocator, group_name: []const u8, codex_home: []const u8) !Status { + const target = managerServiceTarget(); + var status = try getStatusForTarget(allocator, codex_home, &target); + errdefer status.deinit(allocator); + status.managed_group = try allocator.dupe(u8, group_name); + return status; +} + +fn getStatusForTarget(allocator: std.mem.Allocator, codex_home: []const u8, target: *const ServiceTarget) !Status { var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); return .{ .enabled = reg.auto_switch.enabled, - .runtime = queryRuntimeState(allocator), + .runtime = queryRuntimeStateForTarget(allocator, target), .threshold_5h_percent = reg.auto_switch.threshold_5h_percent, .threshold_weekly_percent = reg.auto_switch.threshold_weekly_percent, .api_usage_enabled = reg.api.usage, .api_account_enabled = reg.api.account, + .codex_home = codex_home, + .active_group = if (reg.active_group_name) |name| try allocator.dupe(u8, name) else null, }; } @@ -539,13 +700,19 @@ fn writeStatusWithColor(out: *std.Io.Writer, status: Status, use_color: bool) !v try out.writeAll(helpStateLabel(status.enabled)); try out.writeAll("\n"); + if (status.managed_group) |group_name| { + try out.writeAll("managed group: "); + try out.writeAll(group_name); + try out.writeAll("\n"); + } + try out.writeAll("service: "); try out.writeAll(@tagName(status.runtime)); try out.writeAll("\n"); try out.writeAll("thresholds: "); try out.print( - "5h<{d}%, weekly<{d}%", + "5h left<={d}%, weekly left<={d}%", .{ status.threshold_5h_percent, status.threshold_weekly_percent }, ); try out.writeAll("\n"); @@ -558,6 +725,17 @@ fn writeStatusWithColor(out: *std.Io.Writer, status: Status, use_color: bool) !v try out.writeAll(if (status.api_account_enabled) "api" else "disabled"); try out.writeAll("\n"); + try out.writeAll("account scope: "); + if (status.active_group) |group_name| { + try out.print("legacy group {s}", .{group_name}); + } else { + try out.writeAll("all accounts"); + } + if (status.codex_home.len != 0) { + try out.print(" in CODEX_HOME: {s}", .{status.codex_home}); + } + try out.writeAll("\n"); + try out.flush(); } @@ -733,12 +911,45 @@ fn fieldSeparator() []const u8 { } pub fn handleAutoCommand(allocator: std.mem.Allocator, codex_home: []const u8, cmd: cli.AutoOptions) !void { + try handleManagedAutoCommand(allocator, null, codex_home, cmd); +} + +pub fn handleGroupAutoCommand( + allocator: std.mem.Allocator, + group_name: []const u8, + codex_home: []const u8, + cmd: cli.AutoOptions, +) !void { + try handleManagedAutoCommand(allocator, group_name, codex_home, cmd); +} + +fn handleAutoCommandForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + target: *const ServiceTarget, + cmd: cli.AutoOptions, +) !void { + switch (cmd) { + .action => |action| switch (action) { + .enable => try enableWithTarget(allocator, codex_home, target), + .disable => try disableWithTarget(allocator, codex_home, target), + }, + .configure => |opts| try configureThresholdsForTarget(allocator, codex_home, target, opts), + } +} + +fn handleManagedAutoCommand( + allocator: std.mem.Allocator, + group_name: ?[]const u8, + codex_home: []const u8, + cmd: cli.AutoOptions, +) !void { switch (cmd) { .action => |action| switch (action) { - .enable => try enable(allocator, codex_home), - .disable => try disable(allocator, codex_home), + .enable => try enableManagedAuto(allocator, codex_home), + .disable => try disableManagedAuto(allocator, codex_home), }, - .configure => |opts| try configureThresholds(allocator, codex_home, opts), + .configure => |opts| try configureManagedAutoThresholds(allocator, group_name, codex_home, opts), } } @@ -764,27 +975,110 @@ pub fn supportsManagedServiceOnPlatform(os_tag: std.Target.Os.Tag) bool { } pub fn reconcileManagedService(allocator: std.mem.Allocator, codex_home: []const u8) !void { + _ = codex_home; + try reconcileManagedServiceManager(allocator); +} + +pub fn reconcileManagedServiceForGroup( + allocator: std.mem.Allocator, + group_name: []const u8, + codex_home: []const u8, +) !void { + _ = group_name; + _ = codex_home; + try reconcileManagedServiceManager(allocator); +} + +fn reconcileManagedServiceForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + target: *const ServiceTarget, +) !void { if (!supportsManagedServiceOnPlatform(builtin.os.tag)) return; var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); if (!reg.auto_switch.enabled) { - try uninstallService(allocator, codex_home); + try uninstallServiceForTarget(allocator, codex_home, target); return; } if (builtin.os.tag == .linux and !linuxUserSystemdAvailable(allocator)) return; - const runtime = queryRuntimeState(allocator); + const runtime = queryRuntimeStateForTarget(allocator, target); const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); defer allocator.free(self_exe); const managed_self_exe = try managedServiceSelfExePath(allocator, self_exe); defer allocator.free(managed_self_exe); - const definition_matches = try currentServiceDefinitionMatches(allocator, codex_home, managed_self_exe); + const definition_matches = try currentServiceDefinitionMatchesForTarget(allocator, codex_home, managed_self_exe, target); if (!shouldEnsureManagedService(reg.auto_switch.enabled, runtime, definition_matches)) return; - try installService(allocator, codex_home, managed_self_exe); + try installServiceForTarget(allocator, codex_home, managed_self_exe, target); +} + +fn anyAutoSwitchEnabled(allocator: std.mem.Allocator) !bool { + var groups = try group_manager.loadGroups(allocator); + defer groups.deinit(allocator); + + for (groups.items.items) |item| { + var reg = registry.loadRegistry(allocator, item.codex_home) catch |err| switch (err) { + error.FileNotFound, error.UnsupportedRegistryVersion => continue, + else => return err, + }; + defer reg.deinit(allocator); + if (reg.auto_switch.enabled) return true; + } + return false; +} + +fn cleanupLegacyAutoServices(allocator: std.mem.Allocator) void { + var groups = group_manager.loadGroups(allocator) catch return; + defer groups.deinit(allocator); + + for (groups.items.items) |item| { + var target = groupServiceTargetAlloc(allocator, item.name) catch continue; + defer target.deinit(allocator); + uninstallServiceForTarget(allocator, item.codex_home, &target) catch {}; + } +} + +fn managerNodeExecutableForServiceAlloc(allocator: std.mem.Allocator) !?[]u8 { + return chatgpt_http.resolveNodeExecutableForLaunchAlloc(allocator) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => null, + }; +} + +fn ensureManagerServiceInstalled(allocator: std.mem.Allocator) !void { + if (!supportsManagedServiceOnPlatform(builtin.os.tag)) return; + if (builtin.os.tag == .linux and !linuxUserSystemdAvailable(allocator)) return; + + const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); + defer allocator.free(self_exe); + const managed_self_exe = try managedServiceSelfExePath(allocator, self_exe); + defer allocator.free(managed_self_exe); + + const target = managerServiceTarget(); + const runtime = queryRuntimeStateForTarget(allocator, &target); + const definition_matches = try currentManagerServiceDefinitionMatches(allocator, managed_self_exe); + if (!shouldEnsureManagedService(true, runtime, definition_matches)) return; + + try installManagerService(allocator, managed_self_exe); + cleanupLegacyAutoServices(allocator); +} + +fn reconcileManagedServiceManager(allocator: std.mem.Allocator) !void { + if (!supportsManagedServiceOnPlatform(builtin.os.tag)) return; + + if (!(try anyAutoSwitchEnabled(allocator))) { + cleanupLegacyAutoServices(allocator); + try uninstallManagerService(allocator); + return; + } + + try ensureManagerServiceInstalled(allocator); + cleanupLegacyAutoServices(allocator); } pub fn runDaemon(allocator: std.mem.Allocator, codex_home: []const u8) !void { @@ -814,6 +1108,154 @@ pub fn runDaemonOnce(allocator: std.mem.Allocator, codex_home: []const u8) !void _ = try daemonCycle(allocator, codex_home, &refresh_state); } +const ManagerLock = struct { + file: std.Io.File, + + fn acquire(allocator: std.mem.Allocator) !?ManagerLock { + const root = try group_manager.managerRootAlloc(allocator); + defer allocator.free(root); + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), root); + + const path = try std.fs.path.join(allocator, &[_][]const u8{ root, manager_lock_file_name }); + defer allocator.free(path); + var file = try std.Io.Dir.cwd().createFile(app_runtime.io(), path, .{ .read = true, .truncate = false }); + errdefer file.close(app_runtime.io()); + if (!(try tryExclusiveLock(file))) { + file.close(app_runtime.io()); + return null; + } + return .{ .file = file }; + } + + fn release(self: *ManagerLock) void { + self.file.unlock(app_runtime.io()); + self.file.close(app_runtime.io()); + } +}; + +const ManagerGroupRuntimeState = struct { + name: []u8, + codex_home: []u8, + refresh_state: DaemonRefreshState = .{}, + seen: bool = false, + + fn deinit(self: *ManagerGroupRuntimeState, allocator: std.mem.Allocator) void { + allocator.free(self.name); + allocator.free(self.codex_home); + self.refresh_state.deinit(allocator); + self.* = undefined; + } +}; + +const ManagerDaemonState = struct { + groups: std.ArrayList(ManagerGroupRuntimeState) = .empty, + + fn deinit(self: *ManagerDaemonState, allocator: std.mem.Allocator) void { + for (self.groups.items) |*item| item.deinit(allocator); + self.groups.deinit(allocator); + } + + fn resetSeen(self: *ManagerDaemonState) void { + for (self.groups.items) |*item| item.seen = false; + } + + fn ensureGroup( + self: *ManagerDaemonState, + allocator: std.mem.Allocator, + name: []const u8, + codex_home: []const u8, + ) !*ManagerGroupRuntimeState { + for (self.groups.items) |*item| { + if (!std.mem.eql(u8, item.name, name)) continue; + if (!std.mem.eql(u8, item.codex_home, codex_home)) { + const next_codex_home = try allocator.dupe(u8, codex_home); + item.refresh_state.deinit(allocator); + allocator.free(item.codex_home); + item.codex_home = next_codex_home; + item.refresh_state = .{}; + } + item.seen = true; + return item; + } + + const name_copy = try allocator.dupe(u8, name); + errdefer allocator.free(name_copy); + const codex_home_copy = try allocator.dupe(u8, codex_home); + errdefer allocator.free(codex_home_copy); + try self.groups.append(allocator, .{ + .name = name_copy, + .codex_home = codex_home_copy, + .seen = true, + }); + return &self.groups.items[self.groups.items.len - 1]; + } + + fn pruneUnseen(self: *ManagerDaemonState, allocator: std.mem.Allocator) void { + var idx: usize = 0; + while (idx < self.groups.items.len) { + if (self.groups.items[idx].seen) { + idx += 1; + continue; + } + self.groups.items[idx].deinit(allocator); + _ = self.groups.orderedRemove(idx); + } + } +}; + +fn managerDaemonCycle(allocator: std.mem.Allocator, state: *ManagerDaemonState) !void { + state.resetSeen(); + defer state.pruneUnseen(allocator); + + var groups = try group_manager.loadGroups(allocator); + defer groups.deinit(allocator); + + for (groups.items.items) |item| { + var reg = registry.loadRegistry(allocator, item.codex_home) catch |err| switch (err) { + error.FileNotFound, error.UnsupportedRegistryVersion => continue, + else => return err, + }; + const enabled = reg.auto_switch.enabled; + reg.deinit(allocator); + if (!enabled) continue; + + try registry.ensureAccountsDir(allocator, item.codex_home); + { + var group_lock = (try DaemonLock.acquire(allocator, item.codex_home)) orelse continue; + defer group_lock.release(); + + var runtime_state = try state.ensureGroup(allocator, item.name, item.codex_home); + const keep_running = daemonCycle(allocator, item.codex_home, &runtime_state.refresh_state) catch |err| blk: { + emitTaggedDaemonLog(.err, item.name, "auto manager cycle failed: {s}", .{@errorName(err)}); + break :blk true; + }; + if (!keep_running) runtime_state.seen = false; + } + } +} + +pub fn runManagerDaemon(allocator: std.mem.Allocator) !void { + var manager_lock = (try ManagerLock.acquire(allocator)) orelse return; + defer manager_lock.release(); + + var state = ManagerDaemonState{}; + defer state.deinit(allocator); + + while (true) { + try managerDaemonCycle(allocator, &state); + try std.Io.sleep(app_runtime.io(), .fromNanoseconds(watch_poll_interval_ns), .awake); + } +} + +pub fn runManagerDaemonOnce(allocator: std.mem.Allocator) !void { + var manager_lock = (try ManagerLock.acquire(allocator)) orelse return; + defer manager_lock.release(); + + var state = ManagerDaemonState{}; + defer state.deinit(allocator); + try managerDaemonCycle(allocator, &state); +} + pub fn refreshActiveUsage(allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry) !bool { return refreshActiveUsageWithApiFetcher(allocator, codex_home, reg, usage_api.fetchActiveUsage); } @@ -1024,6 +1466,16 @@ fn refreshActiveUsageForDaemonWithDetailedApiFetcher( api_fetcher: anytype, ) !bool { const account_key = reg.active_account_key orelse return false; + if (refresh_state.confirmed_limit_account_key) |stored| { + if (!std.mem.eql(u8, stored, account_key)) { + refresh_state.clearConfirmedLimit(allocator); + } + } + if (refresh_state.active_usage_refresh_account_key) |stored| { + if (!std.mem.eql(u8, stored, account_key)) { + refresh_state.clearActiveUsageRefresh(allocator); + } + } refresh_state.clearPendingIfAccountChanged(allocator, account_key); try refresh_state.resetApiCooldownIfAccountChanged(allocator, account_key); const active_idx = registry.findAccountIndexByAccountKey(reg, account_key); @@ -1040,6 +1492,7 @@ fn refreshActiveUsageForDaemonWithDetailedApiFetcher( refresh_state.last_api_refresh_at_ns = now_ns; const fetch_result = api_fetcher(allocator, codex_home) catch |err| { + try refresh_state.markActiveUsageRefresh(allocator, account_key, false); emitTaggedDaemonLog(.warning, "api", "refresh usage{s}status={s}", .{ fieldSeparator(), @errorName(err), @@ -1052,6 +1505,7 @@ fn refreshActiveUsageForDaemonWithDetailedApiFetcher( const missing_auth = fetch_result.missing_auth; var status_buf: [24]u8 = undefined; if (latest_usage == null) { + try refresh_state.markActiveUsageRefresh(allocator, account_key, false); emitTaggedDaemonLog(.warning, "api", "refresh usage{s}status={s}", .{ fieldSeparator(), apiStatusLabel(&status_buf, status_code, false, missing_auth), @@ -1064,6 +1518,7 @@ fn refreshActiveUsageForDaemonWithDetailedApiFetcher( defer if (!snapshot_consumed) registry.freeRateLimitSnapshot(allocator, &latest); if (active_idx == null) { + try refresh_state.markActiveUsageRefresh(allocator, account_key, true); emitTaggedDaemonLog(.debug, "api", "refresh usage{s}status={s}", .{ fieldSeparator(), apiStatusLabel(&status_buf, status_code, true, missing_auth), @@ -1071,16 +1526,20 @@ fn refreshActiveUsageForDaemonWithDetailedApiFetcher( return false; } if (registry.rateLimitSnapshotsEqual(reg.accounts.items[active_idx.?].last_usage, latest)) { + try refresh_state.markActiveUsageRefresh(allocator, account_key, true); emitTaggedDaemonLog(.debug, "api", "refresh usage{s}status={s}", .{ fieldSeparator(), apiStatusLabel(&status_buf, status_code, true, missing_auth), }); refresh_state.clearPending(allocator); + refresh_state.clearConfirmedLimit(allocator); return false; } registry.updateUsage(allocator, reg, account_key, latest); snapshot_consumed = true; + try refresh_state.markActiveUsageRefresh(allocator, account_key, true); + refresh_state.clearConfirmedLimit(allocator); emitTaggedDaemonLog(.info, "api", "refresh usage{s}status={s}", .{ fieldSeparator(), apiStatusLabel(&status_buf, status_code, true, missing_auth), @@ -1097,6 +1556,16 @@ pub fn refreshActiveUsageForDaemonWithApiFetcher( api_fetcher: anytype, ) !bool { const account_key = reg.active_account_key orelse return false; + if (refresh_state.confirmed_limit_account_key) |stored| { + if (!std.mem.eql(u8, stored, account_key)) { + refresh_state.clearConfirmedLimit(allocator); + } + } + if (refresh_state.active_usage_refresh_account_key) |stored| { + if (!std.mem.eql(u8, stored, account_key)) { + refresh_state.clearActiveUsageRefresh(allocator); + } + } refresh_state.clearPendingIfAccountChanged(allocator, account_key); try refresh_state.resetApiCooldownIfAccountChanged(allocator, account_key); if (try refreshActiveUsageFromSessionsForDaemon(allocator, codex_home, reg, refresh_state)) { @@ -1112,16 +1581,21 @@ pub fn refreshActiveUsageForDaemonWithApiFetcher( return switch (try refreshActiveUsageFromApi(allocator, codex_home, reg, api_fetcher)) { .updated => blk: { + try refresh_state.markActiveUsageRefresh(allocator, account_key, true); + refresh_state.clearConfirmedLimit(allocator); emitTaggedDaemonLog(.info, "api", "refresh usage{s}status=200", .{fieldSeparator()}); refresh_state.clearPending(allocator); break :blk true; }, .unchanged => blk: { + try refresh_state.markActiveUsageRefresh(allocator, account_key, true); + refresh_state.clearConfirmedLimit(allocator); emitTaggedDaemonLog(.debug, "api", "refresh usage{s}status=200", .{fieldSeparator()}); refresh_state.clearPending(allocator); break :blk false; }, .unavailable => blk: { + try refresh_state.markActiveUsageRefresh(allocator, account_key, false); emitTaggedDaemonLog(.warning, "api", "refresh usage{s}status=NoUsageLimitsWindow", .{fieldSeparator()}); break :blk false; }, @@ -1170,6 +1644,7 @@ fn refreshActiveUsageFromSessionsForDaemon( activated_at_ms, )) { refresh_state.clearPending(allocator); + refresh_state.clearConfirmedLimit(allocator); return true; } if (refresh_state.pendingMatches(account_key, signature)) { @@ -1199,9 +1674,74 @@ fn refreshActiveUsageFromSessionsForDaemon( latest_event.snapshot = null; try registry.setAccountLastLocalRollout(allocator, ®.accounts.items[idx], latest_event.path, latest_event.event_timestamp_ms); refresh_state.clearPending(allocator); + refresh_state.clearConfirmedLimit(allocator); + return true; +} + +pub fn applyActiveLimitMessageForDaemon( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + refresh_state: *DaemonRefreshState, +) !bool { + var latest_event = (sessions.scanLatestLimitEventWithCache(allocator, codex_home, &refresh_state.limit_scan_cache) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }) orelse return false; + defer latest_event.deinit(allocator); + + const account_key = reg.active_account_key orelse return false; + const activated_at_ms = reg.active_account_activated_at_ms orelse 0; + if (latest_event.event_timestamp_ms < activated_at_ms) return false; + const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return false; + if (reg.accounts.items[idx].last_local_rollout) |last_local_rollout| { + if (latest_event.event_timestamp_ms <= last_local_rollout.event_timestamp_ms) return false; + } + + const exhausted = try exhaustedSnapshotFromExisting(allocator, reg.accounts.items[idx].last_usage); + var snapshot_consumed = false; + defer if (!snapshot_consumed) registry.freeRateLimitSnapshot(allocator, &exhausted); + + if (registry.rateLimitSnapshotsEqual(reg.accounts.items[idx].last_usage, exhausted)) return false; + + var event_time_buf: [19]u8 = undefined; + const event_time = localDateTimeLabel(&event_time_buf, latest_event.event_timestamp_ms); + var file_buf: [96]u8 = undefined; + const file_label = rolloutFileLabel(&file_buf, latest_event.path); + emitTaggedDaemonLog(.notice, "limit", "usage limit message{s}event={s}{s}file={s}", .{ + fieldSeparator(), + event_time, + fieldSeparator(), + file_label, + }); + + registry.updateUsage(allocator, reg, account_key, exhausted); + snapshot_consumed = true; + try refresh_state.setConfirmedLimit(allocator, account_key); + try refresh_state.candidate_index.rebuild(allocator, reg, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds()); + refresh_state.clearPending(allocator); return true; } +fn exhaustedSnapshotFromExisting(allocator: std.mem.Allocator, existing: ?registry.RateLimitSnapshot) !registry.RateLimitSnapshot { + var snapshot = if (existing) |value| + try registry.cloneRateLimitSnapshot(allocator, value) + else + registry.RateLimitSnapshot{ + .primary = null, + .secondary = null, + .credits = null, + .plan_type = null, + }; + errdefer registry.freeRateLimitSnapshot(allocator, &snapshot); + snapshot.primary = .{ + .used_percent = 100.0, + .window_minutes = 300, + .resets_at = null, + }; + return snapshot; +} + fn applyLatestUsableSnapshotFromRolloutFile( allocator: std.mem.Allocator, reg: *registry.Registry, @@ -1247,6 +1787,7 @@ pub fn bestAutoSwitchCandidateIndex(reg: *registry.Registry, now: i64) ?usize { var best_idx: ?usize = null; var best: ?CandidateScore = null; for (reg.accounts.items, 0..) |*rec, idx| { + if (!registry.accountInActiveGroup(reg, rec.account_key)) continue; if (std.mem.eql(u8, rec.account_key, active)) continue; const score = candidateScore(rec, now); if (best == null or candidateBetter(score, best.?)) { @@ -1257,16 +1798,22 @@ pub fn bestAutoSwitchCandidateIndex(reg: *registry.Registry, now: i64) ?usize { return best_idx; } +fn activeAccountOutsideGroup(reg: *registry.Registry) bool { + const account_key = reg.active_account_key orelse return false; + return !registry.accountInActiveGroup(reg, account_key); +} + pub fn shouldSwitchCurrent(reg: *registry.Registry, now: i64) bool { const account_key = reg.active_account_key orelse return false; + if (!registry.accountInActiveGroup(reg, account_key)) return true; const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return false; const rec = ®.accounts.items[idx]; const resolved_5h = resolve5hTriggerWindow(rec.last_usage); const threshold_5h_percent = effective5hThresholdPercent(reg, rec, resolved_5h.allow_free_guard); const rem_5h = registry.remainingPercentAt(resolved_5h.window, now); const rem_week = registry.remainingPercentAt(registry.resolveRateWindow(rec.last_usage, 10080, false), now); - return (rem_5h != null and rem_5h.? < threshold_5h_percent) or - (rem_week != null and rem_week.? < @as(i64, reg.auto_switch.threshold_weekly_percent)); + return (rem_5h != null and rem_5h.? <= threshold_5h_percent) or + (rem_week != null and rem_week.? <= @as(i64, reg.auto_switch.threshold_weekly_percent)); } fn effective5hThresholdPercent(reg: *registry.Registry, rec: *const registry.AccountRecord, allow_free_guard: bool) i64 { @@ -1312,11 +1859,24 @@ pub fn maybeAutoSwitchForDaemonWithUsageFetcher( .switched = false, }; const current = candidateScore(®.accounts.items[active_idx], now); - const should_switch_current = shouldSwitchCurrent(reg, now); + const force_switch_to_group = activeAccountOutsideGroup(reg); + const should_switch_current = force_switch_to_group or shouldSwitchCurrent(reg, now); var changed = false; var refreshed_candidates = false; + if (reg.api.usage and should_switch_current and + !force_switch_to_group and + !refresh_state.confirmedLimitMatches(active) and + refresh_state.activeUsageRefreshUnavailable(active)) + { + return .{ + .refreshed_candidates = false, + .state_changed = false, + .switched = false, + }; + } + if (reg.api.usage and !should_switch_current) { const upkeep = try refreshDaemonCandidateUpkeepWithUsageFetcher( allocator, @@ -1366,7 +1926,7 @@ pub fn maybeAutoSwitchForDaemonWithUsageFetcher( .switched = false, }; const candidate = candidateScore(®.accounts.items[candidate_idx], now); - if (candidate.value <= current.value) { + if (!force_switch_to_group and candidate.value <= current.value) { return .{ .refreshed_candidates = refreshed_candidates, .state_changed = changed, @@ -1404,7 +1964,7 @@ pub fn maybeAutoSwitchForDaemonWithUsageFetcher( .switched = false, }; const candidate = candidateScore(®.accounts.items[candidate_idx], now); - if (candidate.value <= current.value) { + if (!force_switch_to_group and candidate.value <= current.value) { return .{ .refreshed_candidates = refreshed_candidates, .state_changed = changed, @@ -1441,7 +2001,8 @@ fn maybeAutoSwitchWithUsageFetcherAndRefreshState( if (!reg.auto_switch.enabled) return .{ .refreshed_candidates = false, .switched = false }; const active = reg.active_account_key orelse return .{ .refreshed_candidates = false, .switched = false }; const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - if (!shouldSwitchCurrent(reg, now)) return .{ .refreshed_candidates = false, .switched = false }; + const force_switch_to_group = activeAccountOutsideGroup(reg); + if (!force_switch_to_group and !shouldSwitchCurrent(reg, now)) return .{ .refreshed_candidates = false, .switched = false }; _ = refresh_state; const should_refresh_candidates = reg.api.usage; @@ -1461,7 +2022,7 @@ fn maybeAutoSwitchWithUsageFetcherAndRefreshState( .switched = false, }; const candidate = candidateScore(®.accounts.items[candidate_idx], now); - if (candidate.value <= current.value) { + if (!force_switch_to_group and candidate.value <= current.value) { return .{ .refreshed_candidates = refreshed_candidates, .switched = false, @@ -1485,6 +2046,7 @@ fn refreshAutoSwitchCandidatesWithUsageFetcher( for (reg.accounts.items) |rec| { if (std.mem.eql(u8, rec.account_key, active)) continue; + if (!registry.accountInActiveGroup(reg, rec.account_key)) continue; if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; attempted += 1; @@ -1747,6 +2309,9 @@ fn daemonCycleWithAccountNameFetcher( if (try refreshActiveUsageForDaemon(allocator, codex_home, reg, refresh_state)) { changed = true; } + if (try applyActiveLimitMessageForDaemon(allocator, codex_home, reg, refresh_state)) { + changed = true; + } const active_idx_before = if (reg.active_account_key) |account_key| registry.findAccountIndexByAccountKey(reg, account_key) else @@ -1786,11 +2351,16 @@ pub fn daemonCycleWithAccountNameFetcherForTest( } fn enable(allocator: std.mem.Allocator, codex_home: []const u8) !void { + const target = defaultServiceTarget(); + try enableWithTarget(allocator, codex_home, &target); +} + +fn enableWithTarget(allocator: std.mem.Allocator, codex_home: []const u8, target: *const ServiceTarget) !void { const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); defer allocator.free(self_exe); const managed_self_exe = try managedServiceSelfExePath(allocator, self_exe); defer allocator.free(managed_self_exe); - try enableWithServiceHooks(allocator, codex_home, managed_self_exe, installService, uninstallService); + try enableWithServiceTarget(allocator, codex_home, managed_self_exe, target); } fn ensureAutoSwitchCanEnable(allocator: std.mem.Allocator) !void { @@ -1845,25 +2415,94 @@ pub fn enableWithServiceHooksAndPreflight( }; } -fn printAutoEnableUsageNote(api_enabled: bool) !void { - var stdout: io_util.Stdout = undefined; - stdout.init(); - const out = stdout.out(); - if (api_enabled) { - try out.writeAll("auto-switch enabled; usage mode: api (default, most accurate for switching decisions)\n"); - } else { - try out.writeAll("auto-switch enabled; usage mode: local-only (switching still works, but candidate validation is less accurate)\n"); - try out.writeAll("Tip: run `codex-auth config api enable` for the most accurate switching decisions.\n"); - } - try out.flush(); -} - -fn disable(allocator: std.mem.Allocator, codex_home: []const u8) !void { +fn enableWithServiceTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + self_exe: []const u8, + target: *const ServiceTarget, +) !void { + try ensureAutoSwitchCanEnable(allocator); + + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + + reg.auto_switch.enabled = true; + try registry.saveRegistry(allocator, codex_home, ®); + errdefer { + reg.auto_switch.enabled = false; + registry.saveRegistry(allocator, codex_home, ®) catch {}; + } + errdefer uninstallServiceForTarget(allocator, codex_home, target) catch {}; + try installServiceForTarget(allocator, codex_home, self_exe, target); + printAutoEnableUsageNote(reg.api.usage) catch |err| { + std.log.warn("failed to print auto-enable usage note: {}", .{err}); + }; +} + +fn enableManagedAuto(allocator: std.mem.Allocator, codex_home: []const u8) !void { + const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); + defer allocator.free(self_exe); + const managed_self_exe = try managedServiceSelfExePath(allocator, self_exe); + defer allocator.free(managed_self_exe); + + try ensureAutoSwitchCanEnable(allocator); + + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + + reg.auto_switch.enabled = true; + try registry.saveRegistry(allocator, codex_home, ®); + errdefer { + reg.auto_switch.enabled = false; + registry.saveRegistry(allocator, codex_home, ®) catch {}; + } + errdefer uninstallManagerService(allocator) catch {}; + + try installManagerService(allocator, managed_self_exe); + cleanupLegacyAutoServices(allocator); + printAutoEnableUsageNote(reg.api.usage) catch |err| { + std.log.warn("failed to print auto-enable usage note: {}", .{err}); + }; +} + +fn printAutoEnableUsageNote(api_enabled: bool) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + if (api_enabled) { + try out.writeAll("auto-switch enabled; usage mode: api (default, most accurate for switching decisions)\n"); + } else { + try out.writeAll("auto-switch enabled; usage mode: local-only (switching still works, but candidate validation is less accurate)\n"); + try out.writeAll("Tip: run `codex-auth config api enable` for the most accurate switching decisions.\n"); + } + try out.flush(); +} + +fn disable(allocator: std.mem.Allocator, codex_home: []const u8) !void { + const target = defaultServiceTarget(); + try disableWithTarget(allocator, codex_home, &target); +} + +fn disableWithTarget(allocator: std.mem.Allocator, codex_home: []const u8, target: *const ServiceTarget) !void { var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); reg.auto_switch.enabled = false; try registry.saveRegistry(allocator, codex_home, ®); - try uninstallService(allocator, codex_home); + try uninstallServiceForTarget(allocator, codex_home, target); +} + +fn disableManagedAuto(allocator: std.mem.Allocator, codex_home: []const u8) !void { + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + reg.auto_switch.enabled = false; + try registry.saveRegistry(allocator, codex_home, ®); + + cleanupLegacyAutoServices(allocator); + if (try anyAutoSwitchEnabled(allocator)) { + try ensureManagerServiceInstalled(allocator); + } else { + try uninstallManagerService(allocator); + } } pub fn applyThresholdConfig(cfg: *registry.AutoSwitchConfig, opts: cli.AutoThresholdOptions) void { @@ -1876,11 +2515,51 @@ pub fn applyThresholdConfig(cfg: *registry.AutoSwitchConfig, opts: cli.AutoThres } fn configureThresholds(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.AutoThresholdOptions) !void { + const target = defaultServiceTarget(); + try configureThresholdsForTarget(allocator, codex_home, &target, opts); +} + +fn configureThresholdsForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + target: *const ServiceTarget, + opts: cli.AutoThresholdOptions, +) !void { var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); applyThresholdConfig(®.auto_switch, opts); try registry.saveRegistry(allocator, codex_home, ®); - try printStatus(allocator, codex_home); + var status = try getStatusForTarget(allocator, codex_home, target); + defer status.deinit(allocator); + var stdout: io_util.Stdout = undefined; + stdout.init(); + try writeStatusWithColor(stdout.out(), status, colorEnabled()); +} + +fn configureManagedAutoThresholds( + allocator: std.mem.Allocator, + group_name: ?[]const u8, + codex_home: []const u8, + opts: cli.AutoThresholdOptions, +) !void { + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + applyThresholdConfig(®.auto_switch, opts); + const enabled = reg.auto_switch.enabled; + try registry.saveRegistry(allocator, codex_home, ®); + + if (enabled) { + try ensureManagerServiceInstalled(allocator); + } + + var status = if (group_name) |name| + try getStatusForGroup(allocator, name, codex_home) + else + try getStatus(allocator, codex_home); + defer status.deinit(allocator); + var stdout: io_util.Stdout = undefined; + stdout.init(); + try writeStatusWithColor(stdout.out(), status, colorEnabled()); } fn candidateScore(rec: *const registry.AccountRecord, now: i64) CandidateScore { @@ -1917,34 +2596,79 @@ fn earlierFutureTimestamp(current: ?i64, candidate: ?i64, now: i64) ?i64 { } fn queryRuntimeState(allocator: std.mem.Allocator) RuntimeState { + const target = defaultServiceTarget(); + return queryRuntimeStateForTarget(allocator, &target); +} + +fn queryRuntimeStateForTarget(allocator: std.mem.Allocator, target: *const ServiceTarget) RuntimeState { return switch (builtin.os.tag) { - .linux => queryLinuxRuntimeState(allocator), - .macos => queryMacRuntimeState(allocator), - .windows => queryWindowsRuntimeState(allocator), + .linux => queryLinuxRuntimeStateForTarget(allocator, target), + .macos => queryMacRuntimeStateForTarget(allocator, target), + .windows => queryWindowsRuntimeStateForTarget(allocator, target), else => .unknown, }; } fn installService(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !void { + const target = defaultServiceTarget(); + try installServiceForTarget(allocator, codex_home, self_exe, &target); +} + +fn installServiceForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + self_exe: []const u8, + target: *const ServiceTarget, +) !void { switch (builtin.os.tag) { - .linux => try installLinuxService(allocator, codex_home, self_exe), - .macos => try installMacService(allocator, codex_home, self_exe), - .windows => try installWindowsService(allocator, codex_home, self_exe), + .linux => try installLinuxServiceForTarget(allocator, codex_home, self_exe, target), + .macos => try installMacServiceForTarget(allocator, codex_home, self_exe, target), + .windows => try installWindowsServiceForTarget(allocator, codex_home, self_exe, target), + else => return error.UnsupportedPlatform, + } +} + +fn installManagerService(allocator: std.mem.Allocator, self_exe: []const u8) !void { + const target = managerServiceTarget(); + switch (builtin.os.tag) { + .linux => try installLinuxManagerService(allocator, self_exe, &target), + .macos => try installMacManagerService(allocator, self_exe, &target), + .windows => try installWindowsManagerService(allocator, self_exe, &target), else => return error.UnsupportedPlatform, } } fn uninstallService(allocator: std.mem.Allocator, codex_home: []const u8) !void { + const target = defaultServiceTarget(); + try uninstallServiceForTarget(allocator, codex_home, &target); +} + +fn uninstallServiceForTarget(allocator: std.mem.Allocator, codex_home: []const u8, target: *const ServiceTarget) !void { switch (builtin.os.tag) { - .linux => try uninstallLinuxService(allocator, codex_home), - .macos => try uninstallMacService(allocator, codex_home), - .windows => try uninstallWindowsService(allocator), + .linux => try uninstallLinuxServiceForTarget(allocator, codex_home, target), + .macos => try uninstallMacServiceForTarget(allocator, codex_home, target), + .windows => try uninstallWindowsServiceForTarget(allocator, target), else => return error.UnsupportedPlatform, } } -fn installLinuxService(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !void { - const unit_path = try linuxUnitPath(allocator, linux_service_name); +fn uninstallManagerService(allocator: std.mem.Allocator) !void { + const target = managerServiceTarget(); + switch (builtin.os.tag) { + .linux => try uninstallLinuxServiceForTarget(allocator, "", &target), + .macos => try uninstallMacServiceForTarget(allocator, "", &target), + .windows => try uninstallWindowsServiceForTarget(allocator, &target), + else => return error.UnsupportedPlatform, + } +} + +fn installLinuxServiceForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + self_exe: []const u8, + target: *const ServiceTarget, +) !void { + const unit_path = try linuxUnitPath(allocator, target.linux_service_name); defer allocator.free(unit_path); const unit_text = try linuxUnitText(allocator, self_exe, codex_home); defer allocator.free(unit_text); @@ -1952,19 +2676,46 @@ fn installLinuxService(allocator: std.mem.Allocator, codex_home: []const u8, sel const unit_dir = std.fs.path.dirname(unit_path).?; try std.Io.Dir.cwd().createDirPath(app_runtime.io(), unit_dir); try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = unit_path, .data = unit_text }); - try removeLinuxUnit(allocator, linux_timer_name); + if (target.linux_timer_name) |timer_name| { + try removeLinuxUnit(allocator, timer_name); + } try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "daemon-reload" }); - try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "enable", linux_service_name }); - switch (queryLinuxRuntimeState(allocator)) { - .running => try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "restart", linux_service_name }), - else => try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "start", linux_service_name }), + try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "enable", target.linux_service_name }); + switch (queryLinuxRuntimeStateForTarget(allocator, target)) { + .running => try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "restart", target.linux_service_name }), + else => try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "start", target.linux_service_name }), } } -fn uninstallLinuxService(allocator: std.mem.Allocator, codex_home: []const u8) !void { +fn installLinuxManagerService( + allocator: std.mem.Allocator, + self_exe: []const u8, + target: *const ServiceTarget, +) !void { + const unit_path = try linuxUnitPath(allocator, target.linux_service_name); + defer allocator.free(unit_path); + const node_executable = try managerNodeExecutableForServiceAlloc(allocator); + defer if (node_executable) |value| allocator.free(value); + const unit_text = try linuxManagerUnitTextWithNode(allocator, self_exe, node_executable); + defer allocator.free(unit_text); + + const unit_dir = std.fs.path.dirname(unit_path).?; + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), unit_dir); + try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = unit_path, .data = unit_text }); + try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "daemon-reload" }); + try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "enable", target.linux_service_name }); + switch (queryLinuxRuntimeStateForTarget(allocator, target)) { + .running => try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "restart", target.linux_service_name }), + else => try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "start", target.linux_service_name }), + } +} + +fn uninstallLinuxServiceForTarget(allocator: std.mem.Allocator, codex_home: []const u8, target: *const ServiceTarget) !void { _ = codex_home; - try removeLinuxUnit(allocator, linux_timer_name); - try removeLinuxUnit(allocator, linux_service_name); + if (target.linux_timer_name) |timer_name| { + try removeLinuxUnit(allocator, timer_name); + } + try removeLinuxUnit(allocator, target.linux_service_name); } fn removeLinuxUnit(allocator: std.mem.Allocator, service_name: []const u8) !void { @@ -1989,10 +2740,15 @@ fn linuxUserSystemdAvailable(allocator: std.mem.Allocator) bool { }; } -fn installMacService(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !void { - const plist_path = try macPlistPath(allocator); +fn installMacServiceForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + self_exe: []const u8, + target: *const ServiceTarget, +) !void { + const plist_path = try macPlistPathForLabel(allocator, target.mac_label); defer allocator.free(plist_path); - const plist = try macPlistText(allocator, self_exe, codex_home); + const plist = try macPlistTextForLabel(allocator, self_exe, codex_home, target.mac_label); defer allocator.free(plist); const dir = std.fs.path.dirname(plist_path).?; @@ -2002,9 +2758,28 @@ fn installMacService(allocator: std.mem.Allocator, codex_home: []const u8, self_ try runChecked(allocator, &[_][]const u8{ "launchctl", "load", plist_path }); } -fn uninstallMacService(allocator: std.mem.Allocator, codex_home: []const u8) !void { +fn installMacManagerService( + allocator: std.mem.Allocator, + self_exe: []const u8, + target: *const ServiceTarget, +) !void { + const plist_path = try macPlistPathForLabel(allocator, target.mac_label); + defer allocator.free(plist_path); + const node_executable = try managerNodeExecutableForServiceAlloc(allocator); + defer if (node_executable) |value| allocator.free(value); + const plist = try macManagerPlistTextForLabelWithNode(allocator, self_exe, target.mac_label, node_executable); + defer allocator.free(plist); + + const dir = std.fs.path.dirname(plist_path).?; + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), dir); + try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = plist_path, .data = plist }); + _ = runChecked(allocator, &[_][]const u8{ "launchctl", "unload", plist_path }) catch {}; + try runChecked(allocator, &[_][]const u8{ "launchctl", "load", plist_path }); +} + +fn uninstallMacServiceForTarget(allocator: std.mem.Allocator, codex_home: []const u8, target: *const ServiceTarget) !void { _ = codex_home; - const plist_path = try macPlistPath(allocator); + const plist_path = try macPlistPathForLabel(allocator, target.mac_label); defer allocator.free(plist_path); _ = runChecked(allocator, &[_][]const u8{ "launchctl", "unload", plist_path }) catch {}; deleteAbsoluteFileIfExists(plist_path); @@ -2017,7 +2792,12 @@ pub fn deleteAbsoluteFileIfExists(path: []const u8) void { }; } -fn installWindowsService(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !void { +fn installWindowsServiceForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + self_exe: []const u8, + target: *const ServiceTarget, +) !void { const helper_path = try windowsHelperPath(allocator, self_exe); defer allocator.free(helper_path); try std.Io.Dir.cwd().access(app_runtime.io(), helper_path, .{}); @@ -2026,7 +2806,29 @@ fn installWindowsService(allocator: std.mem.Allocator, codex_home: []const u8, s defer allocator.free(arguments); try windows_task_scheduler.installTask(allocator, .{ - .task_name = windows_task_name, + .task_name = target.windows_task_name, + .executable_path = helper_path, + .arguments = arguments, + .restart_count = windows_task_restart_count_value, + .restart_interval = windows_task_restart_interval_xml, + .execution_time_limit = windows_task_execution_time_limit_xml, + }); +} + +fn installWindowsManagerService( + allocator: std.mem.Allocator, + self_exe: []const u8, + target: *const ServiceTarget, +) !void { + const helper_path = try windowsHelperPath(allocator, self_exe); + defer allocator.free(helper_path); + try std.Io.Dir.cwd().access(app_runtime.io(), helper_path, .{}); + + const arguments = try windowsManagerTaskArguments(allocator); + defer allocator.free(arguments); + + try windows_task_scheduler.installTask(allocator, .{ + .task_name = target.windows_task_name, .executable_path = helper_path, .arguments = arguments, .restart_count = windows_task_restart_count_value, @@ -2035,12 +2837,12 @@ fn installWindowsService(allocator: std.mem.Allocator, codex_home: []const u8, s }); } -fn uninstallWindowsService(allocator: std.mem.Allocator) !void { - try windows_task_scheduler.uninstallTask(allocator, windows_task_name); +fn uninstallWindowsServiceForTarget(allocator: std.mem.Allocator, target: *const ServiceTarget) !void { + try windows_task_scheduler.uninstallTask(allocator, target.windows_task_name); } -fn queryLinuxRuntimeState(allocator: std.mem.Allocator) RuntimeState { - const result = runCapture(allocator, &[_][]const u8{ "systemctl", "--user", "is-active", linux_service_name }) catch return .unknown; +fn queryLinuxRuntimeStateForTarget(allocator: std.mem.Allocator, target: *const ServiceTarget) RuntimeState { + const result = runCapture(allocator, &[_][]const u8{ "systemctl", "--user", "is-active", target.linux_service_name }) catch return .unknown; defer { allocator.free(result.stdout); allocator.free(result.stderr); @@ -2051,10 +2853,27 @@ fn queryLinuxRuntimeState(allocator: std.mem.Allocator) RuntimeState { }; } -fn queryMacRuntimeState(allocator: std.mem.Allocator) RuntimeState { - const plist_path = macPlistPath(allocator) catch return .unknown; +fn queryMacRuntimeStateForTarget(allocator: std.mem.Allocator, target: *const ServiceTarget) RuntimeState { + const plist_path = macPlistPathForLabel(allocator, target.mac_label) catch return .unknown; defer allocator.free(plist_path); - const result = runCapture(allocator, &[_][]const u8{ "launchctl", "list", mac_label }) catch return .unknown; + + if (macGuiServiceSpecAlloc(allocator, target.mac_label)) |service_spec| { + defer allocator.free(service_spec); + const result = runCapture(allocator, &[_][]const u8{ "launchctl", "print", service_spec }) catch return .unknown; + defer { + allocator.free(result.stdout); + allocator.free(result.stderr); + } + switch (result.term) { + .exited => |code| { + if (code == 0) return parseMacLaunchctlPrintState(result.stdout); + if (code == 113) return .stopped; + }, + else => return .unknown, + } + } else |_| {} + + const result = runCapture(allocator, &[_][]const u8{ "launchctl", "list", target.mac_label }) catch return .unknown; defer { allocator.free(result.stdout); allocator.free(result.stderr); @@ -2065,8 +2884,41 @@ fn queryMacRuntimeState(allocator: std.mem.Allocator) RuntimeState { }; } -fn queryWindowsRuntimeState(allocator: std.mem.Allocator) RuntimeState { - return switch (windows_task_scheduler.queryTaskRuntimeState(allocator, windows_task_name)) { +fn macGuiServiceSpecAlloc(allocator: std.mem.Allocator, label: []const u8) ![]u8 { + const uid = try currentUidStringAlloc(allocator); + defer allocator.free(uid); + return std.fmt.allocPrint(allocator, "gui/{s}/{s}", .{ uid, label }); +} + +fn currentUidStringAlloc(allocator: std.mem.Allocator) ![]u8 { + const result = try runCapture(allocator, &[_][]const u8{ "id", "-u" }); + defer { + allocator.free(result.stdout); + allocator.free(result.stderr); + } + switch (result.term) { + .exited => |code| if (code == 0) { + const uid = std.mem.trim(u8, result.stdout, " \n\r\t"); + if (uid.len != 0) return try allocator.dupe(u8, uid); + }, + else => {}, + } + return error.UnableToResolveUid; +} + +fn parseMacLaunchctlPrintState(output: []const u8) RuntimeState { + var lines = std.mem.splitScalar(u8, output, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \r\t"); + if (!std.mem.startsWith(u8, line, "state =")) continue; + const value = std.mem.trim(u8, line["state =".len..], " \r\t"); + return if (std.mem.eql(u8, value, "running")) .running else .stopped; + } + return .unknown; +} + +fn queryWindowsRuntimeStateForTarget(allocator: std.mem.Allocator, target: *const ServiceTarget) RuntimeState { + return switch (windows_task_scheduler.queryTaskRuntimeState(allocator, target.windows_task_name)) { .running => .running, .stopped => .stopped, .unknown => .unknown, @@ -2093,7 +2945,54 @@ pub fn linuxUnitText(allocator: std.mem.Allocator, self_exe: []const u8, codex_h ); } +pub fn linuxManagerUnitText(allocator: std.mem.Allocator, self_exe: []const u8) ![]u8 { + return linuxManagerUnitTextWithNode(allocator, self_exe, null); +} + +pub fn linuxManagerUnitTextWithNode( + allocator: std.mem.Allocator, + self_exe: []const u8, + node_executable: ?[]const u8, +) ![]u8 { + const exec = try std.fmt.allocPrint(allocator, "\"{s}\" daemon --manager", .{self_exe}); + defer allocator.free(exec); + const escaped_version = try escapeSystemdValue(allocator, version.app_version); + defer allocator.free(escaped_version); + const node_env = try linuxManagerNodeEnvironmentLine(allocator, node_executable); + defer allocator.free(node_env); + return try std.fmt.allocPrint( + allocator, + "[Unit]\nDescription=codex-auth auto-switch manager\n\n[Service]\nType=simple\nRestart=always\nRestartSec=1\nEnvironment=\"{s}={s}\"\n{s}ExecStart={s}\n\n[Install]\nWantedBy=default.target\n", + .{ + service_version_env_name, + escaped_version, + node_env, + exec, + }, + ); +} + +fn linuxManagerNodeEnvironmentLine(allocator: std.mem.Allocator, node_executable: ?[]const u8) ![]u8 { + const raw = node_executable orelse return try allocator.dupe(u8, ""); + const escaped_node = try escapeSystemdValue(allocator, raw); + defer allocator.free(escaped_node); + return try std.fmt.allocPrint( + allocator, + "Environment=\"{s}={s}\"\n", + .{ chatgpt_http.node_executable_env, escaped_node }, + ); +} + pub fn macPlistText(allocator: std.mem.Allocator, self_exe: []const u8, codex_home: []const u8) ![]u8 { + return macPlistTextForLabel(allocator, self_exe, codex_home, mac_label); +} + +pub fn macPlistTextForLabel( + allocator: std.mem.Allocator, + self_exe: []const u8, + codex_home: []const u8, + label: []const u8, +) ![]u8 { const exe = try escapeXml(allocator, self_exe); defer allocator.free(exe); const current_version = try escapeXml(allocator, version.app_version); @@ -2103,7 +3002,49 @@ pub fn macPlistText(allocator: std.mem.Allocator, self_exe: []const u8, codex_ho return try std.fmt.allocPrint( allocator, "\n\n\n\n Label\n {s}\n ProgramArguments\n \n {s}\n daemon\n --watch\n \n EnvironmentVariables\n \n {s}\n {s}\n {s}\n {s}\n \n RunAtLoad\n \n KeepAlive\n \n\n\n", - .{ mac_label, exe, service_version_env_name, current_version, codex_home_env_name, escaped_codex_home }, + .{ label, exe, service_version_env_name, current_version, codex_home_env_name, escaped_codex_home }, + ); +} + +pub fn macManagerPlistText(allocator: std.mem.Allocator, self_exe: []const u8) ![]u8 { + return macManagerPlistTextForLabel(allocator, self_exe, manager_mac_label); +} + +pub fn macManagerPlistTextForLabel( + allocator: std.mem.Allocator, + self_exe: []const u8, + label: []const u8, +) ![]u8 { + return macManagerPlistTextForLabelWithNode(allocator, self_exe, label, null); +} + +pub fn macManagerPlistTextForLabelWithNode( + allocator: std.mem.Allocator, + self_exe: []const u8, + label: []const u8, + node_executable: ?[]const u8, +) ![]u8 { + const exe = try escapeXml(allocator, self_exe); + defer allocator.free(exe); + const current_version = try escapeXml(allocator, version.app_version); + defer allocator.free(current_version); + const node_env = try macManagerNodeEnvironmentXml(allocator, node_executable); + defer allocator.free(node_env); + return try std.fmt.allocPrint( + allocator, + "\n\n\n\n Label\n {s}\n ProgramArguments\n \n {s}\n daemon\n --manager\n \n EnvironmentVariables\n \n {s}\n {s}\n{s} \n RunAtLoad\n \n KeepAlive\n \n\n\n", + .{ label, exe, service_version_env_name, current_version, node_env }, + ); +} + +fn macManagerNodeEnvironmentXml(allocator: std.mem.Allocator, node_executable: ?[]const u8) ![]u8 { + const raw = node_executable orelse return try allocator.dupe(u8, ""); + const escaped_node = try escapeXml(allocator, raw); + defer allocator.free(escaped_node); + return try std.fmt.allocPrint( + allocator, + " {s}\n {s}\n", + .{ chatgpt_http.node_executable_env, escaped_node }, ); } @@ -2117,7 +3058,26 @@ pub fn windowsTaskAction(allocator: std.mem.Allocator, helper_path: []const u8, ); } +pub fn windowsManagerTaskAction(allocator: std.mem.Allocator, helper_path: []const u8) ![]u8 { + const args = try windowsManagerTaskArguments(allocator); + defer allocator.free(args); + return try std.fmt.allocPrint( + allocator, + "\"{s}\" {s}", + .{ helper_path, args }, + ); +} + pub fn windowsRegisterTaskScript(allocator: std.mem.Allocator, helper_path: []const u8, codex_home: []const u8) ![]u8 { + return windowsRegisterTaskScriptForTask(allocator, helper_path, codex_home, windows_task_name); +} + +pub fn windowsRegisterTaskScriptForTask( + allocator: std.mem.Allocator, + helper_path: []const u8, + codex_home: []const u8, + task_name: []const u8, +) ![]u8 { const escaped_helper_path = try escapePowerShellSingleQuoted(allocator, helper_path); defer allocator.free(escaped_helper_path); const args = try windowsTaskArguments(allocator, codex_home); @@ -2127,31 +3087,57 @@ pub fn windowsRegisterTaskScript(allocator: std.mem.Allocator, helper_path: []co return try std.fmt.allocPrint( allocator, "$action = New-ScheduledTaskAction -Execute '{s}' -Argument '{s}'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -RestartCount {s} -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Seconds 0); Register-ScheduledTask -TaskName '{s}' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null", - .{ escaped_helper_path, escaped_args, windows_task_restart_count, windows_task_name }, + .{ escaped_helper_path, escaped_args, windows_task_restart_count, task_name }, + ); +} + +pub fn windowsManagerRegisterTaskScript(allocator: std.mem.Allocator, helper_path: []const u8) ![]u8 { + const escaped_helper_path = try escapePowerShellSingleQuoted(allocator, helper_path); + defer allocator.free(escaped_helper_path); + const args = try windowsManagerTaskArguments(allocator); + defer allocator.free(args); + const escaped_args = try escapePowerShellSingleQuoted(allocator, args); + defer allocator.free(escaped_args); + return try std.fmt.allocPrint( + allocator, + "$action = New-ScheduledTaskAction -Execute '{s}' -Argument '{s}'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -RestartCount {s} -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Seconds 0); Register-ScheduledTask -TaskName '{s}' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null", + .{ escaped_helper_path, escaped_args, windows_task_restart_count, manager_windows_task_name }, ); } pub fn windowsTaskMatchScript(allocator: std.mem.Allocator) ![]u8 { + return windowsTaskMatchScriptForTask(allocator, windows_task_name); +} + +pub fn windowsTaskMatchScriptForTask(allocator: std.mem.Allocator, task_name: []const u8) ![]u8 { return try std.fmt.allocPrint( allocator, "$task = Get-ScheduledTask -TaskName '{s}' -ErrorAction SilentlyContinue; if ($null -eq $task) {{ exit 1 }}; $action = $task.Actions | Select-Object -First 1; if ($null -eq $action) {{ exit 2 }}; $xml = [xml](Export-ScheduledTask -TaskName '{s}'); $triggers = @($xml.Task.Triggers.ChildNodes | Where-Object {{ $_.NodeType -eq [System.Xml.XmlNodeType]::Element }}); if ($triggers.Count -ne 1) {{ exit 3 }}; $triggerKind = [string]$triggers[0].LocalName; if ([string]::IsNullOrWhiteSpace($triggerKind)) {{ exit 4 }}; $restartNode = $xml.Task.Settings.RestartOnFailure; if ($null -eq $restartNode) {{ exit 5 }}; $restartCount = [string]$restartNode.Count; $restartInterval = [string]$restartNode.Interval; if ([string]::IsNullOrWhiteSpace($restartCount) -or [string]::IsNullOrWhiteSpace($restartInterval)) {{ exit 6 }}; $executionLimit = [string]$xml.Task.Settings.ExecutionTimeLimit; if ([string]::IsNullOrWhiteSpace($executionLimit)) {{ exit 7 }}; $args = if ([string]::IsNullOrWhiteSpace($action.Arguments)) {{ '' }} else {{ ' ' + $action.Arguments }}; Write-Output ($action.Execute + $args + '|TRIGGER:' + $triggerKind + '|RESTART:' + $restartCount + ',' + $restartInterval + '|LIMIT:' + $executionLimit)", - .{ windows_task_name, windows_task_name }, + .{ task_name, task_name }, ); } pub fn windowsEndTaskScript(allocator: std.mem.Allocator) ![]u8 { + return windowsEndTaskScriptForTask(allocator, windows_task_name); +} + +pub fn windowsEndTaskScriptForTask(allocator: std.mem.Allocator, task_name: []const u8) ![]u8 { return try std.fmt.allocPrint( allocator, "$task = Get-ScheduledTask -TaskName '{s}' -ErrorAction SilentlyContinue; if ($null -eq $task) {{ exit 0 }}; if ($task.State -eq 4) {{ Stop-ScheduledTask -TaskName '{s}' -ErrorAction SilentlyContinue }}", - .{ windows_task_name, windows_task_name }, + .{ task_name, task_name }, ); } pub fn windowsDeleteTaskScript(allocator: std.mem.Allocator) ![]u8 { + return windowsDeleteTaskScriptForTask(allocator, windows_task_name); +} + +pub fn windowsDeleteTaskScriptForTask(allocator: std.mem.Allocator, task_name: []const u8) ![]u8 { return try std.fmt.allocPrint( allocator, "$task = Get-ScheduledTask -TaskName '{s}' -ErrorAction SilentlyContinue; if ($null -eq $task) {{ exit 0 }}; Unregister-ScheduledTask -TaskName '{s}' -Confirm:$false", - .{ windows_task_name, windows_task_name }, + .{ task_name, task_name }, ); } @@ -2159,6 +3145,14 @@ pub fn windowsTaskStateScript() []const u8 { return "$task = Get-ScheduledTask -TaskName '" ++ windows_task_name ++ "' -ErrorAction SilentlyContinue; if ($null -eq $task) { exit 1 }; Write-Output ([int]$task.State)"; } +pub fn windowsTaskStateScriptForTask(allocator: std.mem.Allocator, task_name: []const u8) ![]u8 { + return try std.fmt.allocPrint( + allocator, + "$task = Get-ScheduledTask -TaskName '{s}' -ErrorAction SilentlyContinue; if ($null -eq $task) {{ exit 1 }}; Write-Output ([int]$task.State)", + .{task_name}, + ); +} + pub fn parseWindowsTaskStateOutput(output: []const u8) RuntimeState { const trimmed = std.mem.trim(u8, output, " \n\r\t"); if (trimmed.len == 0) return .unknown; @@ -2191,21 +3185,64 @@ pub fn managedServiceSelfExePathFromDir(allocator: std.mem.Allocator, cwd: std.I } fn currentServiceDefinitionMatches(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !bool { + const target = defaultServiceTarget(); + return currentServiceDefinitionMatchesForTarget(allocator, codex_home, self_exe, &target); +} + +fn currentServiceDefinitionMatchesForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + self_exe: []const u8, + target: *const ServiceTarget, +) !bool { return switch (builtin.os.tag) { - .linux => try linuxUnitMatches(allocator, codex_home, self_exe), - .macos => try macPlistMatches(allocator, codex_home, self_exe), - .windows => try windowsTaskMatches(allocator, codex_home, self_exe), + .linux => try linuxUnitMatchesForTarget(allocator, codex_home, self_exe, target), + .macos => try macPlistMatchesForTarget(allocator, codex_home, self_exe, target), + .windows => try windowsTaskMatchesForTarget(allocator, codex_home, self_exe, target), + else => true, + }; +} + +fn currentManagerServiceDefinitionMatches(allocator: std.mem.Allocator, self_exe: []const u8) !bool { + const target = managerServiceTarget(); + return switch (builtin.os.tag) { + .linux => try linuxManagerUnitMatches(allocator, self_exe, &target), + .macos => try macManagerPlistMatches(allocator, self_exe, &target), + .windows => try windowsManagerTaskMatches(allocator, self_exe, &target), else => true, }; } fn linuxUnitMatches(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !bool { - const unit_path = try linuxUnitPath(allocator, linux_service_name); + const target = defaultServiceTarget(); + return linuxUnitMatchesForTarget(allocator, codex_home, self_exe, &target); +} + +fn linuxUnitMatchesForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + self_exe: []const u8, + target: *const ServiceTarget, +) !bool { + const unit_path = try linuxUnitPath(allocator, target.linux_service_name); defer allocator.free(unit_path); const expected = try linuxUnitText(allocator, self_exe, codex_home); defer allocator.free(expected); if (!(try fileEqualsBytes(allocator, unit_path, expected))) return false; - return !(try linuxUnitHasLegacyResidue(allocator, linux_timer_name)); + if (target.linux_timer_name) |timer_name| { + return !(try linuxUnitHasLegacyResidue(allocator, timer_name)); + } + return true; +} + +fn linuxManagerUnitMatches(allocator: std.mem.Allocator, self_exe: []const u8, target: *const ServiceTarget) !bool { + const unit_path = try linuxUnitPath(allocator, target.linux_service_name); + defer allocator.free(unit_path); + const node_executable = try managerNodeExecutableForServiceAlloc(allocator); + defer if (node_executable) |value| allocator.free(value); + const expected = try linuxManagerUnitTextWithNode(allocator, self_exe, node_executable); + defer allocator.free(expected); + return try fileEqualsBytes(allocator, unit_path, expected); } fn linuxUnitHasLegacyResidue(allocator: std.mem.Allocator, service_name: []const u8) !bool { @@ -2258,21 +3295,67 @@ fn linuxShowProperty(output: []const u8, key: []const u8) ?[]const u8 { } fn macPlistMatches(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !bool { - const plist_path = try macPlistPath(allocator); + const target = defaultServiceTarget(); + return macPlistMatchesForTarget(allocator, codex_home, self_exe, &target); +} + +fn macPlistMatchesForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + self_exe: []const u8, + target: *const ServiceTarget, +) !bool { + const plist_path = try macPlistPathForLabel(allocator, target.mac_label); defer allocator.free(plist_path); - const expected = try macPlistText(allocator, self_exe, codex_home); + const expected = try macPlistTextForLabel(allocator, self_exe, codex_home, target.mac_label); + defer allocator.free(expected); + return try fileEqualsBytes(allocator, plist_path, expected); +} + +fn macManagerPlistMatches(allocator: std.mem.Allocator, self_exe: []const u8, target: *const ServiceTarget) !bool { + const plist_path = try macPlistPathForLabel(allocator, target.mac_label); + defer allocator.free(plist_path); + const node_executable = try managerNodeExecutableForServiceAlloc(allocator); + defer if (node_executable) |value| allocator.free(value); + const expected = try macManagerPlistTextForLabelWithNode(allocator, self_exe, target.mac_label, node_executable); defer allocator.free(expected); return try fileEqualsBytes(allocator, plist_path, expected); } fn windowsTaskMatches(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !bool { + const target = defaultServiceTarget(); + return windowsTaskMatchesForTarget(allocator, codex_home, self_exe, &target); +} + +fn windowsTaskMatchesForTarget( + allocator: std.mem.Allocator, + codex_home: []const u8, + self_exe: []const u8, + target: *const ServiceTarget, +) !bool { const helper_path = try windowsHelperPath(allocator, self_exe); defer allocator.free(helper_path); const expected_action = try windowsExpectedTaskFingerprint(allocator, helper_path, codex_home); defer allocator.free(expected_action); const expected_fingerprint = try windowsExpectedTaskDefinitionFingerprint(allocator, expected_action); defer allocator.free(expected_fingerprint); - const task_xml = windows_task_scheduler.readTaskXmlAlloc(allocator, windows_task_name) catch return false; + const task_xml = windows_task_scheduler.readTaskXmlAlloc(allocator, target.windows_task_name) catch return false; + const xml = task_xml orelse return false; + defer allocator.free(xml); + + const actual_fingerprint = (try windowsTaskDefinitionFingerprintFromXml(allocator, xml)) orelse return false; + defer allocator.free(actual_fingerprint); + return std.mem.eql(u8, actual_fingerprint, expected_fingerprint); +} + +fn windowsManagerTaskMatches(allocator: std.mem.Allocator, self_exe: []const u8, target: *const ServiceTarget) !bool { + const helper_path = try windowsHelperPath(allocator, self_exe); + defer allocator.free(helper_path); + const expected_action = try windowsManagerExpectedTaskFingerprint(allocator, helper_path); + defer allocator.free(expected_action); + const expected_fingerprint = try windowsExpectedTaskDefinitionFingerprint(allocator, expected_action); + defer allocator.free(expected_fingerprint); + const task_xml = windows_task_scheduler.readTaskXmlAlloc(allocator, target.windows_task_name) catch return false; const xml = task_xml orelse return false; defer allocator.free(xml); @@ -2287,6 +3370,12 @@ fn windowsExpectedTaskFingerprint(allocator: std.mem.Allocator, helper_path: []c return try std.fmt.allocPrint(allocator, "{s} {s}", .{ helper_path, args }); } +fn windowsManagerExpectedTaskFingerprint(allocator: std.mem.Allocator, helper_path: []const u8) ![]u8 { + const args = try windowsManagerTaskArguments(allocator); + defer allocator.free(args); + return try std.fmt.allocPrint(allocator, "{s} {s}", .{ helper_path, args }); +} + fn windowsExpectedTaskDefinitionFingerprint(allocator: std.mem.Allocator, action: []const u8) ![]u8 { return windowsTaskDefinitionFingerprint( allocator, @@ -2474,9 +3563,15 @@ fn windowsHelperPath(allocator: std.mem.Allocator, self_exe: []const u8) ![]u8 { } fn macPlistPath(allocator: std.mem.Allocator) ![]u8 { + return macPlistPathForLabel(allocator, mac_label); +} + +fn macPlistPathForLabel(allocator: std.mem.Allocator, label: []const u8) ![]u8 { const home = try registry.resolveUserHome(allocator); defer allocator.free(home); - return try std.fs.path.join(allocator, &[_][]const u8{ home, "Library", "LaunchAgents", mac_label ++ ".plist" }); + const plist_name = try std.fmt.allocPrint(allocator, "{s}.plist", .{label}); + defer allocator.free(plist_name); + return try std.fs.path.join(allocator, &[_][]const u8{ home, "Library", "LaunchAgents", plist_name }); } fn runChecked(allocator: std.mem.Allocator, argv: []const []const u8) !void { @@ -2572,6 +3667,14 @@ fn windowsTaskArguments(allocator: std.mem.Allocator, codex_home: []const u8) ![ ); } +fn windowsManagerTaskArguments(allocator: std.mem.Allocator) ![]u8 { + return try std.fmt.allocPrint( + allocator, + "--service-version {s} --manager", + .{version.app_version}, + ); +} + fn quoteWindowsCommandArg(allocator: std.mem.Allocator, arg: []const u8) ![]u8 { const needs_quotes = blk: { if (arg.len == 0) break :blk true; diff --git a/src/chatgpt_http.zig b/src/chatgpt_http.zig index 9b40bb80..c40f8ebc 100644 --- a/src/chatgpt_http.zig +++ b/src/chatgpt_http.zig @@ -905,7 +905,7 @@ fn readWindowsRegistryStringAlloc( }; } -fn resolveNodeExecutableForLaunchAlloc(allocator: std.mem.Allocator) ![]u8 { +pub fn resolveNodeExecutableForLaunchAlloc(allocator: std.mem.Allocator) ![]u8 { const node_executable = try resolveNodeExecutable(allocator); defer allocator.free(node_executable); return ensureExecutableAvailableAlloc(allocator, node_executable); diff --git a/src/cli.zig b/src/cli.zig index 76c0b87c..e0732405 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -679,20 +679,30 @@ pub const ConfigOptions = union(enum) { auto_switch: AutoOptions, api: ApiAction, }; -pub const DaemonMode = enum { watch, once }; -pub const DaemonOptions = struct { mode: DaemonMode }; pub const GroupMutationOptions = struct { name: []u8, selectors: [][]const u8, }; +pub const GroupDeleteOptions = struct { + name: []u8, + force: bool = false, +}; +pub const LaunchOptions = struct { + argv: [][]const u8, +}; pub const GroupScopedAction = union(enum) { list: ListOptions, + status: void, login: LoginOptions, add: [][]const u8, + copy: [][]const u8, + move: [][]const u8, remove: [][]const u8, + auto_switch: AutoOptions, + config: ConfigOptions, import_auth: ImportOptions, switch_account: SwitchOptions, - launch: [][]const u8, + launch: LaunchOptions, }; pub const GroupScopedOptions = struct { name: []u8, @@ -700,10 +710,25 @@ pub const GroupScopedOptions = struct { }; pub const GroupOptions = union(enum) { list: void, + status: void, create: GroupMutationOptions, + add: GroupMutationOptions, + copy: GroupMutationOptions, + move: GroupMutationOptions, + remove: GroupMutationOptions, + delete: GroupDeleteOptions, + archive: []u8, + use: ?[]u8, path: []u8, scoped: GroupScopedOptions, }; +pub const ProjectOptions = union(enum) { + show: void, + set_group: []u8, + clear: void, +}; +pub const DaemonMode = enum { watch, once, manager, manager_once }; +pub const DaemonOptions = struct { mode: DaemonMode }; pub const HelpTopic = enum { top_level, list, @@ -712,10 +737,12 @@ pub const HelpTopic = enum { import_auth, switch_account, remove_account, + group, + project, + launch, clean, config, daemon, - group, }; pub const Command = union(enum) { @@ -724,11 +751,13 @@ pub const Command = union(enum) { import_auth: ImportOptions, switch_account: SwitchOptions, remove_account: RemoveOptions, + group: GroupOptions, + project: ProjectOptions, + launch: LaunchOptions, clean: CleanOptions, config: ConfigOptions, status: void, daemon: DaemonOptions, - group: GroupOptions, version: void, help: HelpTopic, }; @@ -820,23 +849,25 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars } var opts: LoginOptions = .{}; + errdefer freeLoginOptions(allocator, &opts); var i: usize = 2; while (i < args.len) : (i += 1) { const arg = std.mem.sliceTo(args[i], 0); if (std.mem.eql(u8, arg, "--device-auth")) { if (opts.device_auth) { + freeLoginOptions(allocator, &opts); return usageErrorResult(allocator, .login, "duplicate `--device-auth` for `login`.", .{}); } opts.device_auth = true; continue; } if (std.mem.eql(u8, arg, "--group")) { - if (i + 1 >= args.len) { - if (opts.group_name) |name| allocator.free(name); + if (i + 1 >= args.len or std.mem.startsWith(u8, std.mem.sliceTo(args[i + 1], 0), "-")) { + freeLoginOptions(allocator, &opts); return usageErrorResult(allocator, .login, "missing value for `--group`.", .{}); } if (opts.group_name != null) { - if (opts.group_name) |name| allocator.free(name); + freeLoginOptions(allocator, &opts); return usageErrorResult(allocator, .login, "duplicate `--group` for `login`.", .{}); } opts.group_name = try allocator.dupe(u8, std.mem.sliceTo(args[i + 1], 0)); @@ -844,14 +875,14 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars continue; } if (isHelpFlag(arg)) { - if (opts.group_name) |name| allocator.free(name); + freeLoginOptions(allocator, &opts); return usageErrorResult(allocator, .login, "`--help` must be used by itself for `login`.", .{}); } if (std.mem.startsWith(u8, arg, "-")) { - if (opts.group_name) |name| allocator.free(name); + freeLoginOptions(allocator, &opts); return usageErrorResult(allocator, .login, "unknown flag `{s}` for `login`.", .{arg}); } - if (opts.group_name) |name| allocator.free(name); + freeLoginOptions(allocator, &opts); return usageErrorResult(allocator, .login, "unexpected argument `{s}` for `login`.", .{arg}); } return .{ .command = .{ .login = opts } }; @@ -1082,6 +1113,20 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars return try parseGroupArgs(allocator, args[2..]); } + if (std.mem.eql(u8, cmd, "project")) { + if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { + return .{ .command = .{ .help = .project } }; + } + return try parseProjectArgs(allocator, args[2..]); + } + + if (std.mem.eql(u8, cmd, "launch")) { + if (args.len >= 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { + return .{ .command = .{ .help = .launch } }; + } + return .{ .command = .{ .launch = try parseGroupLaunchArgs(allocator, args[2..]) } }; + } + if (std.mem.eql(u8, cmd, "config")) { if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { return .{ .command = .{ .help = .config } }; @@ -1158,7 +1203,13 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars if (args.len == 3 and std.mem.eql(u8, std.mem.sliceTo(args[2], 0), "--once")) { return .{ .command = .{ .daemon = .{ .mode = .once } } }; } - return usageErrorResult(allocator, .daemon, "`daemon` requires `--watch` or `--once`.", .{}); + if (args.len == 3 and std.mem.eql(u8, std.mem.sliceTo(args[2], 0), "--manager")) { + return .{ .command = .{ .daemon = .{ .mode = .manager } } }; + } + if (args.len == 3 and std.mem.eql(u8, std.mem.sliceTo(args[2], 0), "--manager-once")) { + return .{ .command = .{ .daemon = .{ .mode = .manager_once } } }; + } + return usageErrorResult(allocator, .daemon, "`daemon` requires `--watch`, `--once`, `--manager`, or `--manager-once`.", .{}); } return usageErrorResult(allocator, .top_level, "unknown command `{s}`.", .{cmd}); @@ -1173,6 +1224,7 @@ pub fn freeParseResult(allocator: std.mem.Allocator, result: *ParseResult) void fn freeCommand(allocator: std.mem.Allocator, cmd: *Command) void { switch (cmd.*) { + .login => |*opts| freeLoginOptions(allocator, opts), .import_auth => |*opts| { if (opts.auth_path) |path| allocator.free(path); if (opts.alias) |a| allocator.free(a); @@ -1184,10 +1236,12 @@ fn freeCommand(allocator: std.mem.Allocator, cmd: *Command) void { freeOwnedStringList(allocator, opts.selectors); allocator.free(opts.selectors); }, - .login => |*opts| { - if (opts.group_name) |name| allocator.free(name); - }, .group => |*opts| freeGroupOptions(allocator, opts), + .project => |*opts| freeProjectOptions(allocator, opts), + .launch => |*opts| { + freeOwnedStringList(allocator, opts.argv); + allocator.free(opts.argv); + }, else => {}, } } @@ -1198,38 +1252,62 @@ fn freeGroupMutationOptions(allocator: std.mem.Allocator, opts: *GroupMutationOp allocator.free(opts.selectors); } +fn freeGroupOptions(allocator: std.mem.Allocator, opts: *GroupOptions) void { + switch (opts.*) { + .create => |*mutation| freeGroupMutationOptions(allocator, mutation), + .add => |*mutation| freeGroupMutationOptions(allocator, mutation), + .copy => |*mutation| freeGroupMutationOptions(allocator, mutation), + .move => |*mutation| freeGroupMutationOptions(allocator, mutation), + .remove => |*mutation| freeGroupMutationOptions(allocator, mutation), + .delete => |*delete_opts| allocator.free(delete_opts.name), + .archive => |name| allocator.free(name), + .use => |name| if (name) |value| allocator.free(value), + .path => |name| allocator.free(name), + .scoped => |*scoped| { + allocator.free(scoped.name); + freeGroupScopedAction(allocator, &scoped.action); + }, + else => {}, + } +} + fn freeGroupScopedAction(allocator: std.mem.Allocator, action: *GroupScopedAction) void { switch (action.*) { - .login => |*opts| { - if (opts.group_name) |name| allocator.free(name); - }, - .add, .remove, .launch => |items| { - freeOwnedStringList(allocator, items); - allocator.free(items); + .login => |*opts| freeLoginOptions(allocator, opts), + .add, .copy, .move, .remove => |selectors| { + freeOwnedStringList(allocator, selectors); + allocator.free(selectors); }, .import_auth => |*opts| { if (opts.auth_path) |path| allocator.free(path); - if (opts.alias) |alias| allocator.free(alias); + if (opts.alias) |a| allocator.free(a); }, + .auto_switch => {}, .switch_account => |*opts| { if (opts.query) |query| allocator.free(query); }, + .launch => |*opts| { + freeOwnedStringList(allocator, opts.argv); + allocator.free(opts.argv); + }, else => {}, } } -fn freeGroupOptions(allocator: std.mem.Allocator, opts: *GroupOptions) void { +fn freeProjectOptions(allocator: std.mem.Allocator, opts: *ProjectOptions) void { switch (opts.*) { - .create => |*mutation| freeGroupMutationOptions(allocator, mutation), - .path => |name| allocator.free(name), - .scoped => |*scoped| { - allocator.free(scoped.name); - freeGroupScopedAction(allocator, &scoped.action); - }, + .set_group => |name| allocator.free(name), else => {}, } } +fn freeLoginOptions(allocator: std.mem.Allocator, opts: *LoginOptions) void { + if (opts.group_name) |name| { + allocator.free(name); + opts.group_name = null; + } +} + fn isHelpFlag(arg: []const u8) bool { return std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"); } @@ -1264,74 +1342,55 @@ fn parseSimpleCommandArgs( 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 parseGroupMutationArgs( + allocator: std.mem.Allocator, + action_name: []const u8, + rest: []const [:0]const u8, +) !GroupMutationOptions { + if (rest.len < 2) return error.InvalidCliUsage; + const name = try allocator.dupe(u8, std.mem.sliceTo(rest[1], 0)); + errdefer allocator.free(name); -fn parseGroupNameAlloc(allocator: std.mem.Allocator, raw: [:0]const u8) ![]u8 { - const name = std.mem.sliceTo(raw, 0); - if (name.len == 0 or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return error.InvalidCliUsage; - for (name) |ch| { - switch (ch) { - 'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {}, - else => return error.InvalidCliUsage, - } + var selectors = std.ArrayList([]const u8).empty; + errdefer { + freeOwnedStringList(allocator, selectors.items); + selectors.deinit(allocator); } - return try allocator.dupe(u8, name); + for (rest[2..]) |raw| { + const selector = std.mem.sliceTo(raw, 0); + if (isHelpFlag(selector)) return error.InvalidCliUsage; + if (std.mem.startsWith(u8, selector, "-")) return error.InvalidCliUsage; + try selectors.append(allocator, try allocator.dupe(u8, selector)); + } + + _ = action_name; + return .{ + .name = name, + .selectors = try selectors.toOwnedSlice(allocator), + }; } fn parseGroupSelectorsAlloc(allocator: std.mem.Allocator, rest: []const [:0]const u8) ![][]const u8 { var selectors = std.ArrayList([]const u8).empty; - errdefer freeOwnedStringList(allocator, selectors.items); - defer selectors.deinit(allocator); - for (rest) |arg_raw| { - const arg = std.mem.sliceTo(arg_raw, 0); - if (isHelpFlag(arg) or std.mem.startsWith(u8, arg, "-")) return error.InvalidCliUsage; - try selectors.append(allocator, try allocator.dupe(u8, arg)); - } - return try selectors.toOwnedSlice(allocator); -} - -fn parseGroupMutationArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !GroupMutationOptions { - const name = try parseGroupNameAlloc(allocator, rest[1]); - errdefer allocator.free(name); - const selectors = try parseGroupSelectorsAlloc(allocator, rest[2..]); errdefer { - freeOwnedStringList(allocator, selectors); - allocator.free(selectors); + freeOwnedStringList(allocator, selectors.items); + selectors.deinit(allocator); } - return .{ .name = name, .selectors = selectors }; -} - -fn parseGroupLoginArgs(rest: []const [:0]const u8) !LoginOptions { - var opts: LoginOptions = .{}; - for (rest) |arg_raw| { - const arg = std.mem.sliceTo(arg_raw, 0); - if (std.mem.eql(u8, arg, "--device-auth")) { - if (opts.device_auth) return error.InvalidCliUsage; - opts.device_auth = true; - continue; - } - return error.InvalidCliUsage; + for (rest) |raw| { + const selector = std.mem.sliceTo(raw, 0); + if (isHelpFlag(selector)) return error.InvalidCliUsage; + if (std.mem.startsWith(u8, selector, "-")) return error.InvalidCliUsage; + try selectors.append(allocator, try allocator.dupe(u8, selector)); } - return opts; + return try selectors.toOwnedSlice(allocator); } fn parseGroupImportArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !ImportOptions { var auth_path: ?[]u8 = null; var alias: ?[]u8 = null; + var source: ImportSource = .standard; errdefer freeImportOptions(allocator, auth_path, alias); + var i: usize = 0; while (i < rest.len) : (i += 1) { const arg = std.mem.sliceTo(rest[i], 0); @@ -1341,36 +1400,60 @@ fn parseGroupImportArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8 i += 1; continue; } + if (std.mem.eql(u8, arg, "--cpa")) { + if (source == .cpa) return error.InvalidCliUsage; + source = .cpa; + continue; + } + if (std.mem.eql(u8, arg, "--purge")) return error.InvalidCliUsage; if (isHelpFlag(arg) or std.mem.startsWith(u8, arg, "-")) return error.InvalidCliUsage; if (auth_path != null) return error.InvalidCliUsage; auth_path = try allocator.dupe(u8, arg); } - if (auth_path == null) return error.InvalidCliUsage; + if (auth_path == null and source == .standard) return error.InvalidCliUsage; return .{ .auth_path = auth_path, .alias = alias, .purge = false, - .source = .standard, + .source = source, }; } -fn parseGroupLaunchArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) ![][]const u8 { - var args = std.ArrayList([]const u8).empty; - errdefer freeOwnedStringList(allocator, args.items); - defer args.deinit(allocator); +fn parseGroupLoginArgs(rest: []const [:0]const u8) !LoginOptions { + var opts: LoginOptions = .{}; + for (rest) |raw| { + const arg = std.mem.sliceTo(raw, 0); + if (std.mem.eql(u8, arg, "--device-auth")) { + if (opts.device_auth) return error.InvalidCliUsage; + opts.device_auth = true; + continue; + } + if (isHelpFlag(arg) or std.mem.startsWith(u8, arg, "-")) return error.InvalidCliUsage; + return error.InvalidCliUsage; + } + return opts; +} - var i: usize = 0; - if (i < rest.len and std.mem.eql(u8, std.mem.sliceTo(rest[i], 0), "--")) i += 1; - while (i < rest.len) : (i += 1) { - try args.append(allocator, try allocator.dupe(u8, std.mem.sliceTo(rest[i], 0))); +fn parseGroupLaunchArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !LaunchOptions { + var argv = std.ArrayList([]const u8).empty; + errdefer { + freeOwnedStringList(allocator, argv.items); + argv.deinit(allocator); + } + var start: usize = 0; + if (rest.len != 0 and std.mem.eql(u8, std.mem.sliceTo(rest[0], 0), "--")) start = 1; + for (rest[start..]) |raw| { + try argv.append(allocator, try allocator.dupe(u8, std.mem.sliceTo(raw, 0))); } - return try args.toOwnedSlice(allocator); + return .{ .argv = try argv.toOwnedSlice(allocator) }; } -fn parseGroupListOptions(rest: []const [:0]const u8) !ListOptions { +fn parseGroupListOptions(allocator: std.mem.Allocator, rest: []const [:0]const u8) !ListOptions { + _ = allocator; var opts: ListOptions = .{}; - for (rest) |arg_raw| { - const arg = std.mem.sliceTo(arg_raw, 0); + var i: usize = 0; + while (i < rest.len) : (i += 1) { + const arg = std.mem.sliceTo(rest[i], 0); if (std.mem.eql(u8, arg, "--live")) { if (opts.live) return error.InvalidCliUsage; opts.live = true; @@ -1397,117 +1480,206 @@ fn parseGroupListOptions(rest: []const [:0]const u8) !ListOptions { fn parseGroupSwitchOptions(allocator: std.mem.Allocator, rest: []const [:0]const u8) !SwitchOptions { var opts: SwitchOptions = .{ .query = null }; - errdefer if (opts.query) |query| allocator.free(query); - for (rest) |arg_raw| { - const arg = std.mem.sliceTo(arg_raw, 0); + var i: usize = 0; + while (i < rest.len) : (i += 1) { + const arg = std.mem.sliceTo(rest[i], 0); if (std.mem.eql(u8, arg, "--live")) { - if (opts.live) return error.InvalidCliUsage; + if (opts.live) { + if (opts.query) |query| allocator.free(query); + return error.InvalidCliUsage; + } opts.live = true; continue; } if (std.mem.eql(u8, arg, "--auto")) { - if (opts.auto) return error.InvalidCliUsage; + if (opts.auto) { + if (opts.query) |query| allocator.free(query); + return error.InvalidCliUsage; + } opts.auto = true; continue; } if (std.mem.eql(u8, arg, "--api")) { switch (opts.api_mode) { .default => opts.api_mode = .force_api, - else => return error.InvalidCliUsage, + else => { + if (opts.query) |query| allocator.free(query); + return error.InvalidCliUsage; + }, } continue; } if (std.mem.eql(u8, arg, "--skip-api")) { switch (opts.api_mode) { .default => opts.api_mode = .skip_api, - else => return error.InvalidCliUsage, + else => { + if (opts.query) |query| allocator.free(query); + return error.InvalidCliUsage; + }, } continue; } - if (isHelpFlag(arg) or std.mem.startsWith(u8, arg, "-")) return error.InvalidCliUsage; - if (opts.query != null) return error.InvalidCliUsage; + if (std.mem.startsWith(u8, arg, "-")) { + if (opts.query) |query| allocator.free(query); + return error.InvalidCliUsage; + } + if (opts.query != null) { + if (opts.query) |query| allocator.free(query); + return error.InvalidCliUsage; + } opts.query = try allocator.dupe(u8, arg); } - if (opts.auto and !opts.live) return error.InvalidCliUsage; - if (opts.query != null and (opts.api_mode != .default or opts.live or opts.auto)) return error.InvalidCliUsage; + if (opts.auto and !opts.live) { + if (opts.query) |query| allocator.free(query); + return error.InvalidCliUsage; + } + if (opts.query != null and (opts.api_mode != .default or opts.live or opts.auto)) { + if (opts.query) |query| allocator.free(query); + return error.InvalidCliUsage; + } return opts; } -fn parseNamedGroupAction(allocator: std.mem.Allocator, name: []u8, rest: []const [:0]const u8) !ParseResult { - errdefer allocator.free(name); - if (rest.len == 0) { - allocator.free(name); - return usageErrorResult(allocator, .group, "`group ` requires an action.", .{}); +fn parseAutoArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !AutoOptions { + _ = allocator; + if (rest.len == 1) { + const action = std.mem.sliceTo(rest[0], 0); + if (std.mem.eql(u8, action, "enable")) { + return .{ .action = .enable }; + } + if (std.mem.eql(u8, action, "disable")) { + return .{ .action = .disable }; + } + } + + var opts = AutoThresholdOptions{ .threshold_5h_percent = null, .threshold_weekly_percent = null }; + var i: usize = 0; + while (i < rest.len) : (i += 1) { + const flag = std.mem.sliceTo(rest[i], 0); + if (!std.mem.eql(u8, flag, "--5h") and !std.mem.eql(u8, flag, "--weekly")) { + return error.InvalidCliUsage; + } + if (i + 1 >= rest.len) return error.InvalidCliUsage; + const raw_value = std.mem.sliceTo(rest[i + 1], 0); + const value = std.fmt.parseUnsigned(u8, raw_value, 10) catch return error.InvalidCliUsage; + if (value > 100) return error.InvalidCliUsage; + if (std.mem.eql(u8, flag, "--5h")) { + if (opts.threshold_5h_percent != null) return error.InvalidCliUsage; + opts.threshold_5h_percent = value; + } else { + if (opts.threshold_weekly_percent != null) return error.InvalidCliUsage; + opts.threshold_weekly_percent = value; + } + i += 1; } + if (opts.threshold_5h_percent == null and opts.threshold_weekly_percent == null) return error.InvalidCliUsage; + return .{ .configure = opts }; +} + +fn parseScopedGroupArgs(allocator: std.mem.Allocator, name_raw: []const u8, rest: []const [:0]const u8) !ParseResult { + if (rest.len == 0) return usageErrorResult(allocator, .group, "`group {s}` requires an action.", .{name_raw}); const action = std.mem.sliceTo(rest[0], 0); + if (std.mem.eql(u8, action, "list")) { - const opts = parseGroupListOptions(rest[1..]) catch - { - allocator.free(name); - return usageErrorResult(allocator, .group, "invalid `group list` arguments.", .{}); - }; + const opts = parseGroupListOptions(allocator, rest[1..]) catch + return usageErrorResult(allocator, .group, "invalid `group list` arguments.", .{}); + const name = try allocator.dupe(u8, name_raw); return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .list = opts } } } } }; } + if (std.mem.eql(u8, action, "status")) { + if (rest.len != 1) return usageErrorResult(allocator, .group, "`group status` does not take arguments.", .{}); + const name = try allocator.dupe(u8, name_raw); + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .status = {} } } } } }; + } if (std.mem.eql(u8, action, "login")) { const opts = parseGroupLoginArgs(rest[1..]) catch - { - allocator.free(name); - return usageErrorResult(allocator, .group, "invalid `group login` arguments.", .{}); - }; + return usageErrorResult(allocator, .group, "invalid `group login` arguments.", .{}); + const name = try allocator.dupe(u8, name_raw); return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .login = opts } } } } }; } if (std.mem.eql(u8, action, "add")) { - if (rest.len < 2) { - allocator.free(name); - return usageErrorResult(allocator, .group, "`group add` requires at least one account selector.", .{}); - } - const selectors = parseGroupSelectorsAlloc(allocator, rest[1..]) catch - { - allocator.free(name); - return usageErrorResult(allocator, .group, "invalid `group add` arguments.", .{}); - }; + if (rest.len < 2) return usageErrorResult(allocator, .group, "`group add` requires at least one account selector.", .{}); + const selectors = parseGroupSelectorsAlloc(allocator, rest[1..]) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group add` arguments.", .{}), + else => return err, + }; + const name = try allocator.dupe(u8, name_raw); return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .add = selectors } } } } }; } + if (std.mem.eql(u8, action, "copy")) { + const selectors = parseGroupSelectorsAlloc(allocator, rest[1..]) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group copy` arguments.", .{}), + else => return err, + }; + const name = try allocator.dupe(u8, name_raw); + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .copy = selectors } } } } }; + } + if (std.mem.eql(u8, action, "move")) { + const selectors = parseGroupSelectorsAlloc(allocator, rest[1..]) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group move` arguments.", .{}), + else => return err, + }; + const name = try allocator.dupe(u8, name_raw); + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .move = selectors } } } } }; + } if (std.mem.eql(u8, action, "remove")) { - if (rest.len < 2) { - allocator.free(name); - return usageErrorResult(allocator, .group, "`group remove` requires at least one account selector.", .{}); - } - const selectors = parseGroupSelectorsAlloc(allocator, rest[1..]) catch - { - allocator.free(name); - return usageErrorResult(allocator, .group, "invalid `group remove` arguments.", .{}); - }; + if (rest.len < 2) return usageErrorResult(allocator, .group, "`group remove` requires at least one account selector.", .{}); + const selectors = parseGroupSelectorsAlloc(allocator, rest[1..]) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group remove` arguments.", .{}), + else => return err, + }; + const name = try allocator.dupe(u8, name_raw); return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .remove = selectors } } } } }; } + if (std.mem.eql(u8, action, "auto")) { + const opts = parseAutoArgs(allocator, rest[1..]) catch + return usageErrorResult(allocator, .group, "invalid `group auto` arguments.", .{}); + const name = try allocator.dupe(u8, name_raw); + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .auto_switch = opts } } } } }; + } + if (std.mem.eql(u8, action, "config")) { + if (rest.len != 3) return usageErrorResult(allocator, .group, "`group config` requires `api enable` or `api disable`.", .{}); + const scope = std.mem.sliceTo(rest[1], 0); + if (!std.mem.eql(u8, scope, "api")) { + return usageErrorResult(allocator, .group, "unknown `group config` section `{s}`.", .{scope}); + } + const api_action = std.mem.sliceTo(rest[2], 0); + const config: ConfigOptions = if (std.mem.eql(u8, api_action, "enable")) + .{ .api = .enable } + else if (std.mem.eql(u8, api_action, "disable")) + .{ .api = .disable } + else + return usageErrorResult(allocator, .group, "unknown action `{s}` for `group config api`.", .{api_action}); + const name = try allocator.dupe(u8, name_raw); + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .config = config } } } } }; + } if (std.mem.eql(u8, action, "import")) { - const opts = parseGroupImportArgs(allocator, rest[1..]) catch - { - allocator.free(name); - return usageErrorResult(allocator, .group, "invalid `group import` arguments.", .{}); - }; + const opts = parseGroupImportArgs(allocator, rest[1..]) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group import` arguments.", .{}), + else => return err, + }; + const name = try allocator.dupe(u8, name_raw); return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .import_auth = opts } } } } }; } if (std.mem.eql(u8, action, "switch")) { - const opts = parseGroupSwitchOptions(allocator, rest[1..]) catch - { - allocator.free(name); - return usageErrorResult(allocator, .group, "invalid `group switch` arguments.", .{}); - }; + const opts = parseGroupSwitchOptions(allocator, rest[1..]) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group switch` arguments.", .{}), + else => return err, + }; + const name = try allocator.dupe(u8, name_raw); return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .switch_account = opts } } } } }; } if (std.mem.eql(u8, action, "launch")) { - const launch_args = try parseGroupLaunchArgs(allocator, rest[1..]); - return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .launch = launch_args } } } } }; + const opts = try parseGroupLaunchArgs(allocator, rest[1..]); + const name = try allocator.dupe(u8, name_raw); + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .launch = opts } } } } }; } if (std.mem.eql(u8, action, "path")) { - if (rest.len != 1) { - allocator.free(name); - return usageErrorResult(allocator, .group, "`group path` does not take arguments.", .{}); - } + if (rest.len != 1) return usageErrorResult(allocator, .group, "`group path` does not take arguments.", .{}); + const name = try allocator.dupe(u8, name_raw); return .{ .command = .{ .group = .{ .path = name } } }; } - allocator.free(name); + return usageErrorResult(allocator, .group, "unknown group action `{s}`.", .{action}); } @@ -1515,25 +1687,125 @@ fn parseGroupArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !Par if (rest.len == 0) return usageErrorResult(allocator, .group, "`group` requires an action.", .{}); const action = std.mem.sliceTo(rest[0], 0); if (isHelpFlag(action)) return .{ .command = .{ .help = .group } }; + if (std.mem.eql(u8, action, "list")) { if (rest.len == 1) return .{ .command = .{ .group = .{ .list = {} } } }; - if (rest.len < 2) return usageErrorResult(allocator, .group, "`group list` takes an optional group name.", .{}); - if (rest.len > 2) return usageErrorResult(allocator, .group, "`group list ` does not take extra arguments.", .{}); - const name = try parseGroupNameAlloc(allocator, rest[1]); - return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .list = .{} } } } } }; + const name = try allocator.dupe(u8, std.mem.sliceTo(rest[1], 0)); + errdefer allocator.free(name); + const opts = parseGroupListOptions(allocator, rest[2..]) catch + return usageErrorResult(allocator, .group, "invalid `group list ` arguments.", .{}); + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .list = opts } } } } }; + } + if (std.mem.eql(u8, action, "status")) { + if (rest.len == 1) return .{ .command = .{ .group = .{ .status = {} } } }; + if (rest.len != 2) return usageErrorResult(allocator, .group, "`group status` takes an optional group name.", .{}); + const name = try allocator.dupe(u8, std.mem.sliceTo(rest[1], 0)); + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .status = {} } } } } }; } if (std.mem.eql(u8, action, "create")) { if (rest.len < 2) return usageErrorResult(allocator, .group, "`group create` requires a name.", .{}); - const opts = parseGroupMutationArgs(allocator, rest) catch - return usageErrorResult(allocator, .group, "invalid `group create` arguments.", .{}); + const opts = parseGroupMutationArgs(allocator, action, rest) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group create` arguments.", .{}), + else => return err, + }; return .{ .command = .{ .group = .{ .create = opts } } }; } + if (std.mem.eql(u8, action, "add")) { + if (rest.len < 3) return usageErrorResult(allocator, .group, "`group add` requires a name and at least one account selector.", .{}); + const name = try allocator.dupe(u8, std.mem.sliceTo(rest[1], 0)); + errdefer allocator.free(name); + const selectors = parseGroupSelectorsAlloc(allocator, rest[2..]) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group add` arguments.", .{}), + else => return err, + }; + return .{ .command = .{ .group = .{ .scoped = .{ .name = name, .action = .{ .add = selectors } } } } }; + } + if (std.mem.eql(u8, action, "copy")) { + if (rest.len < 2) return usageErrorResult(allocator, .group, "`group copy` requires a target group name.", .{}); + const opts = parseGroupMutationArgs(allocator, action, rest) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group copy` arguments.", .{}), + else => return err, + }; + return .{ .command = .{ .group = .{ .copy = opts } } }; + } + if (std.mem.eql(u8, action, "move")) { + if (rest.len < 2) return usageErrorResult(allocator, .group, "`group move` requires a target group name.", .{}); + const opts = parseGroupMutationArgs(allocator, action, rest) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group move` arguments.", .{}), + else => return err, + }; + return .{ .command = .{ .group = .{ .move = opts } } }; + } + if (std.mem.eql(u8, action, "remove")) { + if (rest.len < 3) return usageErrorResult(allocator, .group, "`group remove` requires a name and at least one account selector.", .{}); + const opts = parseGroupMutationArgs(allocator, action, rest) catch |err| switch (err) { + error.InvalidCliUsage => return usageErrorResult(allocator, .group, "invalid `group remove` arguments.", .{}), + else => return err, + }; + return .{ .command = .{ .group = .{ .remove = opts } } }; + } + if (std.mem.eql(u8, action, "delete")) { + if (rest.len != 2 and rest.len != 3) return usageErrorResult(allocator, .group, "`group delete` requires a name and optional `--force`.", .{}); + var force = false; + if (rest.len == 3) { + const flag = std.mem.sliceTo(rest[2], 0); + if (!std.mem.eql(u8, flag, "--force")) return usageErrorResult(allocator, .group, "`group delete` only accepts `--force`.", .{}); + force = true; + } + return .{ .command = .{ .group = .{ .delete = .{ .name = try allocator.dupe(u8, std.mem.sliceTo(rest[1], 0)), .force = force } } } }; + } + if (std.mem.eql(u8, action, "archive")) { + if (rest.len != 2) return usageErrorResult(allocator, .group, "`group archive` requires exactly one name.", .{}); + return .{ .command = .{ .group = .{ .archive = try allocator.dupe(u8, std.mem.sliceTo(rest[1], 0)) } } }; + } if (std.mem.eql(u8, action, "path")) { if (rest.len != 2) return usageErrorResult(allocator, .group, "`group path` requires exactly one name.", .{}); - return .{ .command = .{ .group = .{ .path = try parseGroupNameAlloc(allocator, rest[1]) } } }; + return .{ .command = .{ .group = .{ .path = try allocator.dupe(u8, std.mem.sliceTo(rest[1], 0)) } } }; + } + if (std.mem.eql(u8, action, "use")) { + if (rest.len != 2) return usageErrorResult(allocator, .group, "`group use` requires a group name or `none`.", .{}); + const name = std.mem.sliceTo(rest[1], 0); + if (std.mem.eql(u8, name, "none")) { + return .{ .command = .{ .group = .{ .use = null } } }; + } + return .{ .command = .{ .group = .{ .use = try allocator.dupe(u8, name) } } }; + } + + return try parseScopedGroupArgs(allocator, action, rest[1..]); +} + +fn parseProjectArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !ParseResult { + if (rest.len == 0) return .{ .command = .{ .project = .{ .show = {} } } }; + const action = std.mem.sliceTo(rest[0], 0); + if (isHelpFlag(action)) return .{ .command = .{ .help = .project } }; + if (std.mem.eql(u8, action, "show")) { + if (rest.len != 1) return usageErrorResult(allocator, .project, "`project show` does not take arguments.", .{}); + return .{ .command = .{ .project = .{ .show = {} } } }; + } + if (std.mem.eql(u8, action, "set-group")) { + if (rest.len != 2) return usageErrorResult(allocator, .project, "`project set-group` requires exactly one group name.", .{}); + return .{ .command = .{ .project = .{ .set_group = try allocator.dupe(u8, std.mem.sliceTo(rest[1], 0)) } } }; } - const name = try parseGroupNameAlloc(allocator, rest[0]); - return try parseNamedGroupAction(allocator, name, rest[1..]); + if (std.mem.eql(u8, action, "clear")) { + if (rest.len != 1) return usageErrorResult(allocator, .project, "`project clear` does not take arguments.", .{}); + return .{ .command = .{ .project = .{ .clear = {} } } }; + } + return usageErrorResult(allocator, .project, "unknown project action `{s}`.", .{action}); +} + +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 { @@ -1543,10 +1815,12 @@ fn helpTopicForName(name: []const u8) ?HelpTopic { 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, "group")) return .group; + if (std.mem.eql(u8, name, "project")) return .project; + if (std.mem.eql(u8, name, "launch")) return .launch; 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; - if (std.mem.eql(u8, name, "group")) return .group; return null; } @@ -1587,7 +1861,7 @@ pub fn writeHelp( try out.writeAll("Auto Switch:"); if (use_color) try out.writeAll(ansi.reset); try out.print( - " {s} (5h<{d}%, weekly<{d}%)\n\n", + " {s} (5h<={d}%, weekly<={d}%)\n\n", .{ if (auto_cfg.enabled) "ON" else "OFF", auto_cfg.threshold_5h_percent, auto_cfg.threshold_weekly_percent }, ); @@ -1616,12 +1890,14 @@ pub fn writeHelp( .{ .name = "--version, -V", .description = "Show version" }, .{ .name = "list", .description = "List available accounts" }, .{ .name = "status", .description = "Show auto-switch and usage API status" }, - .{ .name = "login", .description = "Login and add the current account" }, + .{ .name = "login [--device-auth] [--group ]", .description = "Login and add the current account" }, .{ .name = "import", .description = "Import auth files or rebuild registry" }, .{ .name = "switch [--live] [--auto] [--api|--skip-api] | switch ", .description = "Switch the active account" }, .{ .name = "remove [--live] [--api|--skip-api] | remove [...] | remove --all", .description = "Remove one or more accounts" }, + .{ .name = "group", .description = "Manage account groups" }, + .{ .name = "project", .description = "Remember the preferred group for this project" }, + .{ .name = "launch", .description = "Launch codext with the remembered project group" }, .{ .name = "clean", .description = "Delete backup and stale files under accounts/" }, - .{ .name = "group", .description = "Manage separate account groups" }, .{ .name = "config", .description = "Manage configuration" }, }; const import_details = [_]HelpEntry{ @@ -1637,12 +1913,32 @@ pub fn writeHelp( .{ .name = "api enable", .description = "Enable usage and account APIs" }, .{ .name = "api disable", .description = "Disable usage and account APIs" }, }; + const group_details = [_]HelpEntry{ + .{ .name = "list", .description = "List managed groups and their CODEX_HOME folders" }, + .{ .name = "list | list", .description = "List accounts in one group" }, + .{ .name = " status | status []", .description = "Show all groups or one group status" }, + .{ .name = "create [...]", .description = "Create a managed group and optionally copy accounts into it" }, + .{ .name = " login [--device-auth]", .description = "Log in directly into a group" }, + .{ .name = " add ...", .description = "Copy existing accounts into a group" }, + .{ .name = " copy [...]", .description = "Copy accounts into a group; interactive when omitted" }, + .{ .name = " move [...]", .description = "Move accounts into a group; interactive when omitted" }, + .{ .name = " remove ...", .description = "Remove accounts from a group" }, + .{ .name = " import [--alias ]", .description = "Import an auth file into a group" }, + .{ .name = " switch [--live] [--auto] [--api|--skip-api] | switch ", .description = "Switch the active account inside a group" }, + .{ .name = " launch [resume [session]] [-- ...]", .description = "Launch codext with a group's CODEX_HOME" }, + .{ .name = " auto enable|disable|--5h ", .description = "Configure that group's auto-switch settings" }, + .{ .name = " config api enable|disable", .description = "Configure that group's usage and account APIs" }, + .{ .name = " path | path ", .description = "Print a group's CODEX_HOME path" }, + .{ .name = "archive | delete --force", .description = "Archive or permanently delete a managed group" }, + .{ .name = "use |none", .description = "Scope the current registry to a legacy in-registry group" }, + }; const parent_indent: usize = 2; const child_indent: usize = parent_indent + 4; const child_description_extra: usize = 4; const command_col = helpTargetColumn(&commands, parent_indent); const import_detail_col = @max(command_col + child_description_extra, helpTargetColumn(&import_details, child_indent)); const config_detail_col = @max(command_col + child_description_extra, helpTargetColumn(&config_details, child_indent)); + const group_detail_col = @max(command_col + child_description_extra, helpTargetColumn(&group_details, child_indent)); try writeHelpEntry(out, use_color, parent_indent, command_col, commands[0].name, commands[0].description); try writeHelpEntry(out, use_color, parent_indent, command_col, commands[1].name, commands[1].description); @@ -1656,8 +1952,13 @@ pub fn writeHelp( try writeHelpEntry(out, use_color, parent_indent, command_col, commands[5].name, commands[5].description); try writeHelpEntry(out, use_color, parent_indent, command_col, commands[6].name, commands[6].description); try writeHelpEntry(out, use_color, parent_indent, command_col, commands[7].name, commands[7].description); + for (group_details) |detail| { + try writeHelpEntry(out, use_color, child_indent, group_detail_col, detail.name, detail.description); + } try writeHelpEntry(out, use_color, parent_indent, command_col, commands[8].name, commands[8].description); try writeHelpEntry(out, use_color, parent_indent, command_col, commands[9].name, commands[9].description); + try writeHelpEntry(out, use_color, parent_indent, command_col, commands[10].name, commands[10].description); + try writeHelpEntry(out, use_color, parent_indent, command_col, commands[11].name, commands[11].description); try writeHelpEntry(out, use_color, child_indent, config_detail_col, config_details[0].name, config_details[0].description); try writeHelpEntry(out, use_color, child_indent, config_detail_col, config_details[1].name, config_details[1].description); try writeHelpEntry(out, use_color, child_indent, config_detail_col, config_details[2].name, config_details[2].description); @@ -1754,10 +2055,12 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { .import_auth => "import", .switch_account => "switch", .remove_account => "remove", + .group => "group", + .project => "project", + .launch => "launch", .clean => "clean", .config => "config", .daemon => "daemon", - .group => "group", }; } @@ -1766,20 +2069,22 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .top_level => "Command-line account management for Codex.", .list => "List available accounts.", .status => "Show auto-switch, service, and usage API status.", - .login => "Run `codex login` or `codex login --device-auth`, then add the current account.", + .login => "Run `codex login`, then add the current account to the default home or a group.", .import_auth => "Import auth files or rebuild the registry.", .switch_account => "Switch the active account interactively, or by query using stored local data.", .remove_account => "Remove one or more accounts interactively or by query.", + .group => "Create groups and choose the active account pool.", + .project => "Remember or show the account group for the current project.", + .launch => "Launch codext with the remembered project group.", .clean => "Delete backup and stale files under accounts/.", .config => "Manage auto-switch and usage API configuration.", .daemon => "Run the background auto-switch daemon.", - .group => "Create groups and run account commands against a group's CODEX_HOME.", }; } fn commandHelpHasExamples(topic: HelpTopic) bool { return switch (topic) { - .import_auth, .switch_account, .remove_account, .config, .daemon, .group => true, + .import_auth, .switch_account, .remove_account, .group, .project, .launch, .config, .daemon => true, else => false, }; } @@ -1813,6 +2118,37 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth remove [...]\n"); try out.writeAll(" codex-auth remove --all\n"); }, + .group => { + try out.writeAll(" codex-auth group list\n"); + try out.writeAll(" codex-auth group list [--live] [--api|--skip-api]\n"); + try out.writeAll(" codex-auth group list [--live] [--api|--skip-api]\n"); + try out.writeAll(" codex-auth group create [...]\n"); + try out.writeAll(" codex-auth group login [--device-auth]\n"); + try out.writeAll(" codex-auth group add [...]\n"); + try out.writeAll(" codex-auth group copy [...]\n"); + try out.writeAll(" codex-auth group move [...]\n"); + try out.writeAll(" codex-auth group remove [...]\n"); + try out.writeAll(" codex-auth group import [--alias ]\n"); + try out.writeAll(" codex-auth group switch [--live] [--auto] [--api|--skip-api]\n"); + try out.writeAll(" codex-auth group switch \n"); + try out.writeAll(" codex-auth group launch [resume [session]] [-- ...]\n"); + try out.writeAll(" codex-auth group auto enable|disable\n"); + try out.writeAll(" codex-auth group auto --5h [--weekly ]\n"); + try out.writeAll(" codex-auth group config api enable|disable\n"); + try out.writeAll(" codex-auth group status\n"); + try out.writeAll(" codex-auth group status []\n"); + try out.writeAll(" codex-auth group archive \n"); + try out.writeAll(" codex-auth group delete --force\n"); + try out.writeAll(" codex-auth group path\n"); + try out.writeAll(" codex-auth group path \n"); + try out.writeAll(" codex-auth group use |none\n"); + }, + .project => { + try out.writeAll(" codex-auth project show\n"); + try out.writeAll(" codex-auth project set-group \n"); + try out.writeAll(" codex-auth project clear\n"); + }, + .launch => try out.writeAll(" codex-auth launch [resume [session]] [-- ...]\n"), .clean => try out.writeAll(" codex-auth clean\n"), .config => { try out.writeAll(" codex-auth config auto enable\n"); @@ -1825,20 +2161,8 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { .daemon => { try out.writeAll(" codex-auth daemon --watch\n"); try out.writeAll(" codex-auth daemon --once\n"); - }, - .group => { - try out.writeAll(" codex-auth group list\n"); - try out.writeAll(" codex-auth group create [...]\n"); - try out.writeAll(" codex-auth group list [--live] [--api|--skip-api]\n"); - try out.writeAll(" codex-auth group login [--device-auth]\n"); - try out.writeAll(" codex-auth group add [...]\n"); - try out.writeAll(" codex-auth group remove [...]\n"); - try out.writeAll(" codex-auth group import [--alias ]\n"); - try out.writeAll(" codex-auth group switch [--live] [--auto] [--api|--skip-api]\n"); - try out.writeAll(" codex-auth group switch \n"); - try out.writeAll(" codex-auth group launch [-- ...]\n"); - try out.writeAll(" codex-auth group path\n"); - try out.writeAll(" codex-auth group path \n"); + try out.writeAll(" codex-auth daemon --manager\n"); + try out.writeAll(" codex-auth daemon --manager-once\n"); }, } } @@ -1887,6 +2211,33 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth remove john@example.com jane@example.com\n"); try out.writeAll(" codex-auth remove --all\n"); }, + .group => { + try out.writeAll(" codex-auth group create work work@example.com\n"); + try out.writeAll(" codex-auth list --skip-api\n"); + try out.writeAll(" codex-auth group work status\n"); + try out.writeAll(" codex-auth group work login --device-auth\n"); + try out.writeAll(" codex-auth group work add 01 03\n"); + try out.writeAll(" codex-auth group work copy\n"); + try out.writeAll(" codex-auth group work move personal@example.com\n"); + try out.writeAll(" codex-auth group work list --skip-api\n"); + try out.writeAll(" codex-auth group work switch\n"); + try out.writeAll(" codex-auth group work switch 02\n"); + try out.writeAll(" codex-auth group work launch -- --model gpt-5.4\n"); + try out.writeAll(" codex-auth group work launch resume\n"); + try out.writeAll(" codex-auth group work launch resume 019db67d-2190-7563-a899-ce3082e491cf\n"); + try out.writeAll(" codex-auth group work auto enable\n"); + try out.writeAll(" codex-auth group work config api enable\n"); + }, + .project => { + try out.writeAll(" codex-auth project set-group work\n"); + try out.writeAll(" codex-auth launch\n"); + }, + .launch => { + try out.writeAll(" codex-auth launch\n"); + try out.writeAll(" codex-auth launch -- --model gpt-5.4\n"); + try out.writeAll(" codex-auth launch resume\n"); + try out.writeAll(" codex-auth launch resume 019db67d-2190-7563-a899-ce3082e491cf\n"); + }, .clean => try out.writeAll(" codex-auth clean\n"), .config => { try out.writeAll(" codex-auth config auto --5h 12 --weekly 8\n"); @@ -1895,14 +2246,8 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { .daemon => { try out.writeAll(" codex-auth daemon --watch\n"); try out.writeAll(" codex-auth daemon --once\n"); - }, - .group => { - try out.writeAll(" codex-auth group create work\n"); - try out.writeAll(" codex-auth group work login --device-auth\n"); - try out.writeAll(" codex-auth group work add 01 jane@example.com\n"); - try out.writeAll(" codex-auth group work list --skip-api\n"); - try out.writeAll(" codex-auth group work switch 02\n"); - try out.writeAll(" codex-auth group work launch -- --model gpt-5.4\n"); + try out.writeAll(" codex-auth daemon --manager\n"); + try out.writeAll(" codex-auth daemon --manager-once\n"); }, } } @@ -1930,10 +2275,12 @@ fn helpCommandForTopic(topic: HelpTopic) []const u8 { .import_auth => "codex-auth import --help", .switch_account => "codex-auth switch --help", .remove_account => "codex-auth remove --help", + .group => "codex-auth group --help", + .project => "codex-auth project --help", + .launch => "codex-auth launch --help", .clean => "codex-auth clean --help", .config => "codex-auth config --help", .daemon => "codex-auth daemon --help", - .group => "codex-auth group --help", }; } diff --git a/src/format.zig b/src/format.zig index c7822ec7..1936950a 100644 --- a/src/format.zig +++ b/src/format.zig @@ -2,6 +2,7 @@ const std = @import("std"); const app_runtime = @import("runtime.zig"); const builtin = @import("builtin"); const display_rows = @import("display_rows.zig"); +const group_manager = @import("group_manager.zig"); const registry = @import("registry.zig"); const io_util = @import("io_util.zig"); const terminal_color = @import("terminal_color.zig"); @@ -14,8 +15,34 @@ const ansi = struct { const reset = "\x1b[0m"; const dim = "\x1b[2m"; const red = "\x1b[31m"; + const bright_red = "\x1b[91m"; const bold_red = "\x1b[1;31m"; const green = "\x1b[32m"; + const bright_green = "\x1b[92m"; + const blue = "\x1b[34m"; + const bright_blue = "\x1b[94m"; + const cyan = "\x1b[36m"; + const bright_cyan = "\x1b[96m"; + const magenta = "\x1b[35m"; + const bright_magenta = "\x1b[95m"; + const yellow = "\x1b[33m"; + const bright_yellow = "\x1b[93m"; + const dark_gray = "\x1b[90m"; + const light_gray = "\x1b[37m"; + const active_group_gray = "\x1b[1;90m"; + const active_group_blue = "\x1b[1;38;5;25m"; + const active_group_cyan = "\x1b[1;38;5;31m"; + const active_group_green = "\x1b[1;38;5;28m"; + const active_group_magenta = "\x1b[1;38;5;91m"; + const active_group_yellow = "\x1b[1;38;5;136m"; + const active_group_red = "\x1b[1;38;5;124m"; + const inactive_group_gray = "\x1b[38;5;250m"; + const inactive_group_blue = "\x1b[38;5;153m"; + const inactive_group_cyan = "\x1b[38;5;159m"; + const inactive_group_green = "\x1b[38;5;157m"; + const inactive_group_magenta = "\x1b[38;5;219m"; + const inactive_group_yellow = "\x1b[38;5;229m"; + const inactive_group_red = "\x1b[38;5;217m"; }; fn colorEnabled() bool { @@ -35,19 +62,59 @@ pub fn printAccountsWithUsageOverrides( reg: *registry.Registry, usage_overrides: ?[]const ?[]const u8, ) !void { - try printAccountsTable(reg, usage_overrides); + try printAccountsTable(reg, usage_overrides, null, null); } -fn printAccountsTable(reg: *registry.Registry, usage_overrides: ?[]const ?[]const u8) !void { +pub fn printAccountsWithUsageOverridesForIndices( + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, + account_indices: []const usize, +) !void { + try printAccountsTable(reg, usage_overrides, account_indices, null); +} + +pub fn printAccountsWithUsageOverridesForGroup( + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, + account_indices: ?[]const usize, + display_color: []const u8, +) !void { + try printAccountsTable(reg, usage_overrides, account_indices, display_color); +} + +pub const GroupedAccountsView = struct { + group_name: []const u8, + display_color: ?[]const u8 = null, + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8 = null, + account_indices: ?[]const usize = null, +}; + +pub fn printGroupedAccountsWithUsageOverrides( + views: []const GroupedAccountsView, +) !void { var stdout: io_util.Stdout = undefined; stdout.init(); const out = stdout.out(); - try writeAccountsTableWithUsageOverrides(out, reg, colorEnabled(), usage_overrides); + try writeGroupedAccountsTableWithUsageOverrides(out, views, colorEnabled()); + try out.flush(); +} + +fn printAccountsTable( + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, + account_indices: ?[]const usize, + group_display_color: ?[]const u8, +) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try writeAccountsTableWithUsageOverrides(out, reg, colorEnabled(), usage_overrides, account_indices, group_display_color); try out.flush(); } fn writeAccountsTable(out: *std.Io.Writer, reg: *registry.Registry, use_color: bool) !void { - try writeAccountsTableWithUsageOverrides(out, reg, use_color, null); + try writeAccountsTableWithUsageOverrides(out, reg, use_color, null, null, null); } fn usageOverrideForAccount( @@ -78,13 +145,74 @@ fn usageCellFullTextAlloc( return formatRateLimitFullAlloc(window); } +fn groupActiveAnsi(display_color: ?[]const u8) []const u8 { + const color = display_color orelse group_manager.default_group_display_color; + if (std.mem.eql(u8, color, group_manager.default_group_display_color)) return ansi.active_group_gray; + if (std.mem.eql(u8, color, "blue")) return ansi.active_group_blue; + if (std.mem.eql(u8, color, "cyan")) return ansi.active_group_cyan; + if (std.mem.eql(u8, color, "green")) return ansi.active_group_green; + if (std.mem.eql(u8, color, "magenta")) return ansi.active_group_magenta; + if (std.mem.eql(u8, color, "yellow")) return ansi.active_group_yellow; + if (std.mem.eql(u8, color, "red")) return ansi.active_group_red; + return ansi.active_group_gray; +} + +fn groupInactiveAnsi(display_color: ?[]const u8) []const u8 { + const color = display_color orelse group_manager.default_group_display_color; + if (std.mem.eql(u8, color, group_manager.default_group_display_color)) return ansi.inactive_group_gray; + if (std.mem.eql(u8, color, "blue")) return ansi.inactive_group_blue; + if (std.mem.eql(u8, color, "cyan")) return ansi.inactive_group_cyan; + if (std.mem.eql(u8, color, "green")) return ansi.inactive_group_green; + if (std.mem.eql(u8, color, "magenta")) return ansi.inactive_group_magenta; + if (std.mem.eql(u8, color, "yellow")) return ansi.inactive_group_yellow; + if (std.mem.eql(u8, color, "red")) return ansi.inactive_group_red; + return ansi.inactive_group_gray; +} + +fn writeGroupRowColor( + out: *std.Io.Writer, + display_color: ?[]const u8, + is_active: bool, + usage_override: ?[]const u8, +) !void { + if (usage_override != null) { + try out.writeAll(if (is_active) ansi.bold_red else ansi.red); + return; + } + try out.writeAll(if (is_active) groupActiveAnsi(display_color) else groupInactiveAnsi(display_color)); +} + +fn writeGroupSeparator( + out: *std.Io.Writer, + group_name: []const u8, + display_color: ?[]const u8, + total_width: usize, + use_color: bool, +) !void { + if (use_color) try out.writeAll(groupActiveAnsi(display_color)); + try out.writeAll("-- "); + const max_name_len = if (total_width > 4) total_width - 4 else 0; + const name_cell = try truncateAlloc(group_name, max_name_len); + defer std.heap.page_allocator.free(name_cell); + try out.writeAll(name_cell); + const used = @min(total_width, 3 + name_cell.len); + if (total_width > used + 1) { + try out.writeAll(" "); + try writeRepeat(out, '-', total_width - used - 1); + } + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.reset); +} + fn writeAccountsTableWithUsageOverrides( out: *std.Io.Writer, reg: *registry.Registry, use_color: bool, usage_overrides: ?[]const ?[]const u8, + account_indices: ?[]const usize, + group_display_color: ?[]const u8, ) !void { - const headers = [_][]const u8{ "ACCOUNT", "PLAN", "5H USAGE", "WEEKLY USAGE", "LAST ACTIVITY" }; + const headers = [_][]const u8{ "ACCOUNT", "PLAN", "5H LEFT", "WEEKLY LEFT", "LAST ACTIVITY" }; var widths = [_]usize{ headers[0].len, headers[1].len, @@ -93,7 +221,7 @@ fn writeAccountsTableWithUsageOverrides( headers[4].len, }; const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - var display = try display_rows.buildDisplayRows(std.heap.page_allocator, reg, null); + var display = try display_rows.buildDisplayRows(std.heap.page_allocator, reg, account_indices); defer display.deinit(std.heap.page_allocator); const idx_width = @max(@as(usize, 2), indexWidth(display.selectable_row_indices.len)); const prefix_len: usize = 2 + idx_width + 1; @@ -122,16 +250,16 @@ fn writeAccountsTableWithUsageOverrides( } } - adjustListWidths(&widths, prefix_len, sep_len); + adjustListWidths(widths[0..], prefix_len, sep_len); const h0 = try truncateAlloc(headers[0], widths[0]); defer std.heap.page_allocator.free(h0); const h1 = try truncateAlloc(headers[1], widths[1]); defer std.heap.page_allocator.free(h1); - const header_5h = if (widths[2] >= "5H USAGE".len) "5H USAGE" else "5H"; + const header_5h = if (widths[2] >= "5H LEFT".len) "5H LEFT" else "5H"; const h2 = try truncateAlloc(header_5h, widths[2]); defer std.heap.page_allocator.free(h2); - const header_week = if (widths[3] >= "WEEKLY USAGE".len) "WEEKLY USAGE" else if (widths[3] >= "WEEKLY".len) "WEEKLY" else if (widths[3] >= "WEEK".len) "WEEK" else "W"; + const header_week = if (widths[3] >= "WEEKLY LEFT".len) "WEEKLY LEFT" else if (widths[3] >= "WEEKLY".len) "WEEKLY" else if (widths[3] >= "WEEK".len) "WEEK" else "W"; const h3 = try truncateAlloc(header_week, widths[3]); defer std.heap.page_allocator.free(h3); const header_last = if (widths[4] >= "LAST ACTIVITY".len) "LAST ACTIVITY" else "LAST"; @@ -151,7 +279,7 @@ fn writeAccountsTableWithUsageOverrides( try writePadded(out, h4, widths[4]); try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.dim); - try writeRepeat(out, '-', listTotalWidth(&widths, prefix_len, sep_len)); + try writeRepeat(out, '-', listTotalWidth(widths[0..], prefix_len, sep_len)); try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); @@ -189,9 +317,15 @@ fn writeAccountsTableWithUsageOverrides( try out.writeAll(ansi.red); } } else if (row.is_active) { - try out.writeAll(ansi.green); + try out.writeAll(if (group_display_color) |display_color| + groupActiveAnsi(display_color) + else + ansi.green); } else { - try out.writeAll(ansi.dim); + try out.writeAll(if (group_display_color) |display_color| + groupInactiveAnsi(display_color) + else + ansi.dim); } } try out.writeAll(if (row.is_active) "* " else " "); @@ -222,6 +356,162 @@ fn writeAccountsTableWithUsageOverrides( } } +fn writeGroupedAccountsTableWithUsageOverrides( + out: *std.Io.Writer, + views: []const GroupedAccountsView, + use_color: bool, +) !void { + const headers = [_][]const u8{ "GROUP", "ACCOUNT", "PLAN", "5H LEFT", "WEEKLY LEFT", "LAST ACTIVITY" }; + var widths = [_]usize{ + headers[0].len, + headers[1].len, + headers[2].len, + headers[3].len, + headers[4].len, + headers[5].len, + }; + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + var selectable_count: usize = 0; + const sep_len: usize = 2; + + for (views) |view| { + var display = try display_rows.buildDisplayRows(std.heap.page_allocator, view.reg, view.account_indices); + defer display.deinit(std.heap.page_allocator); + selectable_count += display.selectable_row_indices.len; + widths[0] = @max(widths[0], view.group_name.len); + + for (display.rows) |row| { + const indent: usize = @as(usize, row.depth) * 2; + widths[1] = @max(widths[1], row.account_cell.len + indent); + if (row.account_index) |account_idx| { + const rec = view.reg.accounts.items[account_idx]; + const plan = planDisplay(&rec, "-"); + const rate_5h = resolveRateWindow(rec.last_usage, 300, true); + const rate_week = resolveRateWindow(rec.last_usage, 10080, false); + const usage_override = usageOverrideForAccount(view.usage_overrides, account_idx); + const rate_5h_str = try usageCellFullTextAlloc(std.heap.page_allocator, rate_5h, usage_override); + defer std.heap.page_allocator.free(rate_5h_str); + const rate_week_str = try usageCellFullTextAlloc(std.heap.page_allocator, rate_week, usage_override); + defer std.heap.page_allocator.free(rate_week_str); + const last_str = try timefmt.formatRelativeTimeOrDashAlloc(std.heap.page_allocator, rec.last_usage_at, now); + defer std.heap.page_allocator.free(last_str); + + widths[2] = @max(widths[2], plan.len); + widths[3] = @max(widths[3], rate_5h_str.len); + widths[4] = @max(widths[4], rate_week_str.len); + widths[5] = @max(widths[5], last_str.len); + } + } + } + + const idx_width = @max(@as(usize, 2), indexWidth(selectable_count)); + const prefix_len: usize = 2 + idx_width + 1; + adjustListWidths(widths[0..], prefix_len, sep_len); + + const h0 = try truncateAlloc(headers[0], widths[0]); + defer std.heap.page_allocator.free(h0); + const h1 = try truncateAlloc(headers[1], widths[1]); + defer std.heap.page_allocator.free(h1); + const h2 = try truncateAlloc(headers[2], widths[2]); + defer std.heap.page_allocator.free(h2); + const header_5h = if (widths[3] >= "5H LEFT".len) "5H LEFT" else "5H"; + const h3 = try truncateAlloc(header_5h, widths[3]); + defer std.heap.page_allocator.free(h3); + const header_week = if (widths[4] >= "WEEKLY LEFT".len) "WEEKLY LEFT" else if (widths[4] >= "WEEKLY".len) "WEEKLY" else if (widths[4] >= "WEEK".len) "WEEK" else "W"; + const h4 = try truncateAlloc(header_week, widths[4]); + defer std.heap.page_allocator.free(h4); + const header_last = if (widths[5] >= "LAST ACTIVITY".len) "LAST ACTIVITY" else "LAST"; + const h5 = try truncateAlloc(header_last, widths[5]); + defer std.heap.page_allocator.free(h5); + + if (use_color) try out.writeAll(ansi.dim); + try writeRepeat(out, ' ', prefix_len); + try writePadded(out, h0, widths[0]); + try out.writeAll(" "); + try writePadded(out, h1, widths[1]); + try out.writeAll(" "); + try writePadded(out, h2, widths[2]); + try out.writeAll(" "); + try writePadded(out, h3, widths[3]); + try out.writeAll(" "); + try writePadded(out, h4, widths[4]); + try out.writeAll(" "); + try writePadded(out, h5, widths[5]); + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.dim); + const total_width = listTotalWidth(widths[0..], prefix_len, sep_len); + try writeRepeat(out, '-', total_width); + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.reset); + + var selectable_counter: usize = 0; + for (views) |view| { + var display = try display_rows.buildDisplayRows(std.heap.page_allocator, view.reg, view.account_indices); + defer display.deinit(std.heap.page_allocator); + + try writeGroupSeparator(out, view.group_name, view.display_color, total_width, use_color); + + for (display.rows) |row| { + const group_cell = try truncateAlloc(view.group_name, widths[0]); + defer std.heap.page_allocator.free(group_cell); + if (row.account_index) |account_idx| { + const rec = view.reg.accounts.items[account_idx]; + const plan = planDisplay(&rec, "-"); + const rate_5h = resolveRateWindow(rec.last_usage, 300, true); + const rate_week = resolveRateWindow(rec.last_usage, 10080, false); + const usage_override = usageOverrideForAccount(view.usage_overrides, account_idx); + const rate_5h_str = try usageCellTextAlloc(std.heap.page_allocator, rate_5h, widths[3], usage_override); + defer std.heap.page_allocator.free(rate_5h_str); + const rate_week_str = try usageCellTextAlloc(std.heap.page_allocator, rate_week, widths[4], usage_override); + defer std.heap.page_allocator.free(rate_week_str); + const last = try timefmt.formatRelativeTimeOrDashAlloc(std.heap.page_allocator, rec.last_usage_at, now); + defer std.heap.page_allocator.free(last); + const indent: usize = @as(usize, row.depth) * 2; + const indent_to_print: usize = @min(indent, widths[1]); + const account_cell = try truncateAlloc(row.account_cell, widths[1] - indent_to_print); + defer std.heap.page_allocator.free(account_cell); + const plan_cell = try truncateAlloc(plan, widths[2]); + defer std.heap.page_allocator.free(plan_cell); + const rate_5h_cell = try truncateAlloc(rate_5h_str, widths[3]); + defer std.heap.page_allocator.free(rate_5h_cell); + const rate_week_cell = try truncateAlloc(rate_week_str, widths[4]); + defer std.heap.page_allocator.free(rate_week_cell); + const last_cell = try truncateAlloc(last, widths[5]); + defer std.heap.page_allocator.free(last_cell); + if (use_color) try writeGroupRowColor(out, view.display_color, row.is_active, usage_override); + try out.writeAll(if (row.is_active) "* " else " "); + try writeIndexPadded(out, selectable_counter + 1, idx_width); + try out.writeAll(" "); + try writePadded(out, group_cell, widths[0]); + try out.writeAll(" "); + try writeRepeat(out, ' ', indent_to_print); + try writePadded(out, account_cell, widths[1] - indent_to_print); + try out.writeAll(" "); + try writePadded(out, plan_cell, widths[2]); + try out.writeAll(" "); + try writePadded(out, rate_5h_cell, widths[3]); + try out.writeAll(" "); + try writePadded(out, rate_week_cell, widths[4]); + try out.writeAll(" "); + try writePadded(out, last_cell, widths[5]); + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.reset); + selectable_counter += 1; + } else { + const account_cell = try truncateAlloc(row.account_cell, widths[1]); + defer std.heap.page_allocator.free(account_cell); + if (use_color) try out.writeAll(groupInactiveAnsi(view.display_color)); + try writeRepeat(out, ' ', prefix_len); + try writePadded(out, group_cell, widths[0]); + try out.writeAll(" "); + try writePadded(out, account_cell, widths[1]); + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.reset); + } + } + } +} + fn resolveRateWindow(usage: ?registry.RateLimitSnapshot, minutes: i64, fallback_primary: bool) ?registry.RateLimitWindow { if (usage == null) return null; if (usage.?.primary) |p| { @@ -313,7 +603,7 @@ fn resetPartsAlloc(reset_at: i64, now: i64) !ResetParts { }; } -fn formatRateLimitFullAlloc(window: ?registry.RateLimitWindow) ![]u8 { +pub fn formatRateLimitFullAlloc(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.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); @@ -479,64 +769,46 @@ fn writeRepeat(out: *std.Io.Writer, ch: u8, count: usize) !void { } } -fn listTotalWidth(widths: *const [5]usize, prefix_len: usize, sep_len: usize) usize { +fn listTotalWidth(widths: []const usize, prefix_len: usize, sep_len: usize) usize { var sum: usize = prefix_len; for (widths) |w| sum += w; sum += sep_len * (widths.len - 1); return sum; } -fn adjustListWidths(widths: *[5]usize, prefix_len: usize, sep_len: usize) void { +fn listMinColumnWidth(column_count: usize, idx: usize) usize { + if (column_count == 6) { + return switch (idx) { + 0 => 5, + 1 => 10, + 2 => 4, + 3, 4 => 1, + else => 4, + }; + } + return switch (idx) { + 0 => 10, + 1 => 4, + 2, 3 => 1, + else => 4, + }; +} + +fn adjustListWidths(widths: []usize, prefix_len: usize, sep_len: usize) void { const term_cols = terminalWidth(); if (term_cols == 0) return; const total = listTotalWidth(widths, prefix_len, sep_len); if (total <= term_cols) return; - const min_email: usize = 10; - const min_plan: usize = 4; - const min_rate: usize = 1; - const min_last: usize = 4; - var over = total - term_cols; - if (over == 0) return; - - if (widths[0] > min_email) { - const reducible = widths[0] - min_email; + for (widths, 0..) |*width, idx| { + const min_width = listMinColumnWidth(widths.len, idx); + if (width.* <= min_width) continue; + const reducible = width.* - min_width; const reduce = @min(reducible, over); - widths[0] -= reduce; - over -= reduce; - } - if (over == 0) return; - - if (widths[1] > min_plan) { - const reducible = widths[1] - min_plan; - const reduce = @min(reducible, over); - widths[1] -= reduce; - over -= reduce; - } - if (over == 0) return; - - if (widths[2] > min_rate) { - const reducible = widths[2] - min_rate; - const reduce = @min(reducible, over); - widths[2] -= reduce; - over -= reduce; - } - if (over == 0) return; - - if (widths[3] > min_rate) { - const reducible = widths[3] - min_rate; - const reduce = @min(reducible, over); - widths[3] -= reduce; - over -= reduce; - } - if (over == 0) return; - - if (widths[4] > min_last) { - const reducible = widths[4] - min_last; - const reduce = @min(reducible, over); - widths[4] -= reduce; + width.* -= reduce; over -= reduce; + if (over == 0) return; } } @@ -755,7 +1027,7 @@ test "writeAccountsTable shows usage override statuses for failed refreshes" { var buffer: [2048]u8 = undefined; var writer: std.Io.Writer = .fixed(&buffer); - try writeAccountsTableWithUsageOverrides(&writer, ®, false, &usage_overrides); + try writeAccountsTableWithUsageOverrides(&writer, ®, false, &usage_overrides, null, null); const output = writer.buffered(); try std.testing.expect(std.mem.count(u8, output, "403") >= 2); @@ -773,12 +1045,72 @@ test "writeAccountsTable highlights usage override rows in red when color is ena var buffer: [4096]u8 = undefined; var writer: std.Io.Writer = .fixed(&buffer); - try writeAccountsTableWithUsageOverrides(&writer, ®, true, &usage_overrides); + try writeAccountsTableWithUsageOverrides(&writer, ®, true, &usage_overrides, null, null); const output = writer.buffered(); try std.testing.expect(std.mem.indexOf(u8, output, ansi.red) != null); } +test "writeAccountsTable can use the scoped group palette" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-2::acc-1", "inactive@example.com", "", .free); + reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); + + var buffer: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try writeAccountsTableWithUsageOverrides(&writer, ®, true, null, null, group_manager.default_group_display_color); + + const output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, ansi.active_group_gray) != null); + try std.testing.expect(std.mem.indexOf(u8, output, ansi.inactive_group_gray) != null); + try std.testing.expect(std.mem.indexOf(u8, output, ansi.green) == null); +} + +test "writeGroupedAccountsTable separates groups and uses bold/pale group colors" { + const gpa = std.testing.allocator; + var default_reg = makeTestRegistry(); + defer default_reg.deinit(gpa); + var work_reg = makeTestRegistry(); + defer work_reg.deinit(gpa); + + try appendTestAccount(gpa, &default_reg, "user-1::acc-1", "active-default@example.com", "", .plus); + try appendTestAccount(gpa, &default_reg, "user-2::acc-1", "inactive-default@example.com", "", .plus); + default_reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); + + try appendTestAccount(gpa, &work_reg, "user-3::acc-1", "inactive-work@example.com", "", .team); + try appendTestAccount(gpa, &work_reg, "user-4::acc-1", "active-work@example.com", "", .team); + work_reg.active_account_key = try gpa.dupe(u8, "user-4::acc-1"); + + const views = [_]GroupedAccountsView{ + .{ + .group_name = "default", + .display_color = group_manager.default_group_display_color, + .reg = &default_reg, + }, + .{ + .group_name = "work", + .display_color = "blue", + .reg = &work_reg, + }, + }; + + var buffer: [8192]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try writeGroupedAccountsTableWithUsageOverrides(&writer, &views, true); + + const output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "-- default") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "-- work") != null); + try std.testing.expect(std.mem.indexOf(u8, output, ansi.active_group_gray) != null); + try std.testing.expect(std.mem.indexOf(u8, output, ansi.inactive_group_gray) != null); + try std.testing.expect(std.mem.indexOf(u8, output, ansi.active_group_blue) != null); + try std.testing.expect(std.mem.indexOf(u8, output, ansi.inactive_group_blue) != null); +} + test "writeAccountsTable prefers usage snapshot plan labels over stored auth plan" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); diff --git a/src/group_manager.zig b/src/group_manager.zig index f17e27d3..fd100f88 100644 --- a/src/group_manager.zig +++ b/src/group_manager.zig @@ -5,17 +5,30 @@ const registry = @import("registry.zig"); pub const default_group_name = "default"; pub const manager_dir_name = "codex-auth"; pub const groups_dir_name = "groups"; +pub const archive_dir_name = "archive"; pub const config_file_name = "groups.json"; +pub const projects_file_name = "projects.json"; pub const config_schema_version: u32 = 1; +pub const default_group_display_color = "gray"; +pub const display_color_palette = [_][]const u8{ + "blue", + "cyan", + "green", + "magenta", + "yellow", + "red", +}; pub const GroupRef = struct { name: []u8, codex_home: []u8, managed: bool, + display_color: []u8, pub fn deinit(self: *GroupRef, allocator: std.mem.Allocator) void { allocator.free(self.name); allocator.free(self.codex_home); + allocator.free(self.display_color); } }; @@ -28,10 +41,39 @@ pub const GroupList = struct { } }; +pub const FolderList = struct { + items: std.ArrayList([]u8) = .empty, + + pub fn deinit(self: *FolderList, allocator: std.mem.Allocator) void { + for (self.items.items) |item| allocator.free(item); + self.items.deinit(allocator); + } +}; + +pub const ProjectGroup = struct { + root: []u8, + group: []u8, + + fn deinit(self: *ProjectGroup, allocator: std.mem.Allocator) void { + allocator.free(self.root); + allocator.free(self.group); + } +}; + +pub const ProjectGroupList = struct { + items: std.ArrayList(ProjectGroup) = .empty, + + pub fn deinit(self: *ProjectGroupList, allocator: std.mem.Allocator) void { + for (self.items.items) |*item| item.deinit(allocator); + self.items.deinit(allocator); + } +}; + const ConfigGroupOut = struct { name: []const u8, codex_home: []const u8, managed: bool, + display_color: []const u8, }; const ConfigOut = struct { @@ -39,6 +81,16 @@ const ConfigOut = struct { groups: []const ConfigGroupOut, }; +const ProjectGroupOut = struct { + root: []const u8, + group: []const u8, +}; + +const ProjectConfigOut = struct { + schema_version: u32, + projects: []const ProjectGroupOut, +}; + fn readFileAlloc(file: std.Io.File, allocator: std.mem.Allocator, max_bytes: usize) ![]u8 { var read_buffer: [4096]u8 = undefined; var file_reader = file.reader(app_runtime.io(), &read_buffer); @@ -68,12 +120,24 @@ pub fn groupsRootAlloc(allocator: std.mem.Allocator) ![]u8 { return try std.fs.path.join(allocator, &[_][]const u8{ root, groups_dir_name }); } +pub fn archiveRootAlloc(allocator: std.mem.Allocator) ![]u8 { + const root = try managerRootAlloc(allocator); + defer allocator.free(root); + return try std.fs.path.join(allocator, &[_][]const u8{ root, archive_dir_name }); +} + pub fn configPathAlloc(allocator: std.mem.Allocator) ![]u8 { const root = try managerRootAlloc(allocator); defer allocator.free(root); return try std.fs.path.join(allocator, &[_][]const u8{ root, config_file_name }); } +pub fn projectsPathAlloc(allocator: std.mem.Allocator) ![]u8 { + const root = try managerRootAlloc(allocator); + defer allocator.free(root); + return try std.fs.path.join(allocator, &[_][]const u8{ root, projects_file_name }); +} + pub fn defaultCodexHomeAlloc(allocator: std.mem.Allocator) ![]u8 { const home = try registry.resolveUserHome(allocator); defer allocator.free(home); @@ -93,15 +157,54 @@ fn appendGroupRef( name: []const u8, codex_home: []const u8, managed: bool, + requested_display_color: ?[]const u8, ) !void { if (findGroupIndex(list, name) != null) return; + const display_color = resolveDisplayColorForAppend(list, name, requested_display_color); try list.items.append(allocator, .{ .name = try allocator.dupe(u8, name), .codex_home = try allocator.dupe(u8, codex_home), .managed = managed, + .display_color = try allocator.dupe(u8, display_color), }); } +fn displayColorIsPaletteColor(color: []const u8) bool { + for (display_color_palette) |candidate| { + if (std.mem.eql(u8, color, candidate)) return true; + } + return false; +} + +fn displayColorIsUsed(list: *const GroupList, color: []const u8) bool { + for (list.items.items) |item| { + if (std.mem.eql(u8, item.display_color, color)) return true; + } + return false; +} + +fn chooseDisplayColor(list: *const GroupList, name: []const u8) []const u8 { + const start = std.hash.Wyhash.hash(0, name) % display_color_palette.len; + var offset: usize = 0; + while (offset < display_color_palette.len) : (offset += 1) { + const candidate = display_color_palette[(start + offset) % display_color_palette.len]; + if (!displayColorIsUsed(list, candidate)) return candidate; + } + return display_color_palette[start]; +} + +fn resolveDisplayColorForAppend( + list: *const GroupList, + name: []const u8, + requested_display_color: ?[]const u8, +) []const u8 { + if (std.mem.eql(u8, name, default_group_name)) return default_group_display_color; + if (requested_display_color) |color| { + if (displayColorIsPaletteColor(color) and !displayColorIsUsed(list, color)) return color; + } + return chooseDisplayColor(list, name); +} + pub fn findGroupIndex(list: *const GroupList, name: []const u8) ?usize { for (list.items.items, 0..) |item, idx| { if (std.mem.eql(u8, item.name, name)) return idx; @@ -141,7 +244,11 @@ fn parseConfigGroups(allocator: std.mem.Allocator, list: *GroupList, root_obj: s .bool => |value| value, else => true, }; - try appendGroupRef(allocator, list, name, codex_home, managed); + const display_color: ?[]const u8 = if (obj.get("display_color")) |value| switch (value) { + .string => |s| s, + else => null, + } else null; + try appendGroupRef(allocator, list, name, codex_home, managed, display_color); } } @@ -164,8 +271,37 @@ fn discoverManagedGroupFolders(allocator: std.mem.Allocator, list: *GroupList) ! defer allocator.free(codex_home); if (findGroupIndex(list, entry.name) != null) continue; if (findGroupIndexByCodexHome(list, codex_home) != null) continue; - try appendGroupRef(allocator, list, entry.name, codex_home, true); + try appendGroupRef(allocator, list, entry.name, codex_home, true, null); + } +} + +pub fn listAvailableManagedFolderNamesAlloc(allocator: std.mem.Allocator) !FolderList { + var folders: FolderList = .{}; + errdefer folders.deinit(allocator); + + var groups = try loadConfiguredGroups(allocator); + defer groups.deinit(allocator); + + const groups_root = try groupsRootAlloc(allocator); + defer allocator.free(groups_root); + + var dir = std.Io.Dir.cwd().openDir(app_runtime.io(), groups_root, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return folders, + else => return err, + }; + defer dir.close(app_runtime.io()); + + var it = dir.iterate(); + while (try it.next(app_runtime.io())) |entry| { + if (entry.kind != .directory) continue; + validateGroupName(entry.name) catch continue; + if (std.mem.eql(u8, entry.name, default_group_name)) continue; + const codex_home = try std.fs.path.join(allocator, &[_][]const u8{ groups_root, entry.name }); + defer allocator.free(codex_home); + if (findGroupIndexByCodexHome(&groups, codex_home) != null) continue; + try folders.items.append(allocator, try allocator.dupe(u8, entry.name)); } + return folders; } fn loadConfiguredGroups(allocator: std.mem.Allocator) !GroupList { @@ -174,7 +310,7 @@ fn loadConfiguredGroups(allocator: std.mem.Allocator) !GroupList { const default_codex_home = try defaultCodexHomeAlloc(allocator); defer allocator.free(default_codex_home); - try appendGroupRef(allocator, &list, default_group_name, default_codex_home, false); + try appendGroupRef(allocator, &list, default_group_name, default_codex_home, false, default_group_display_color); const config_path = try configPathAlloc(allocator); defer allocator.free(config_path); @@ -219,6 +355,7 @@ pub fn saveGroups(allocator: std.mem.Allocator, list: *const GroupList) !void { .name = item.name, .codex_home = item.codex_home, .managed = item.managed, + .display_color = item.display_color, }); } @@ -236,6 +373,186 @@ pub fn saveGroups(allocator: std.mem.Allocator, list: *const GroupList) !void { try file.writeStreamingAll(app_runtime.io(), aw.written()); } +fn appendProjectGroup( + allocator: std.mem.Allocator, + list: *ProjectGroupList, + root: []const u8, + group: []const u8, +) !void { + for (list.items.items, 0..) |*item, idx| { + if (!std.mem.eql(u8, item.root, root)) continue; + item.deinit(allocator); + list.items.items[idx] = .{ + .root = try allocator.dupe(u8, root), + .group = try allocator.dupe(u8, group), + }; + return; + } + try list.items.append(allocator, .{ + .root = try allocator.dupe(u8, root), + .group = try allocator.dupe(u8, group), + }); +} + +fn parseProjectGroups(allocator: std.mem.Allocator, list: *ProjectGroupList, root_obj: std.json.ObjectMap) !void { + const projects_value = root_obj.get("projects") orelse return; + const projects = switch (projects_value) { + .array => |arr| arr, + else => return, + }; + for (projects.items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + const root = switch (obj.get("root") orelse continue) { + .string => |s| s, + else => continue, + }; + const group = switch (obj.get("group") orelse continue) { + .string => |s| s, + else => continue, + }; + validateGroupName(group) catch continue; + if (root.len == 0) continue; + try appendProjectGroup(allocator, list, root, group); + } +} + +pub fn loadProjectGroups(allocator: std.mem.Allocator) !ProjectGroupList { + var list: ProjectGroupList = .{}; + errdefer list.deinit(allocator); + + const projects_path = try projectsPathAlloc(allocator); + defer allocator.free(projects_path); + const data = blk: { + var file = std.Io.Dir.cwd().openFile(app_runtime.io(), projects_path, .{}) catch |err| switch (err) { + error.FileNotFound => break :blk null, + else => return err, + }; + defer file.close(app_runtime.io()); + break :blk try readFileAlloc(file, allocator, 1024 * 1024); + }; + if (data) |bytes| { + defer allocator.free(bytes); + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, bytes, .{}); + defer parsed.deinit(); + switch (parsed.value) { + .object => |obj| try parseProjectGroups(allocator, &list, obj), + else => {}, + } + } + return list; +} + +pub fn saveProjectGroups(allocator: std.mem.Allocator, list: *const ProjectGroupList) !void { + const root = try managerRootAlloc(allocator); + defer allocator.free(root); + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), root); + + var out_projects = std.ArrayList(ProjectGroupOut).empty; + defer out_projects.deinit(allocator); + for (list.items.items) |item| { + try out_projects.append(allocator, .{ + .root = item.root, + .group = item.group, + }); + } + + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + try std.json.Stringify.value(ProjectConfigOut{ + .schema_version = config_schema_version, + .projects = out_projects.items, + }, .{ .whitespace = .indent_2 }, &aw.writer); + + const projects_path = try projectsPathAlloc(allocator); + defer allocator.free(projects_path); + var file = try std.Io.Dir.cwd().createFile(app_runtime.io(), projects_path, .{ .truncate = true }); + defer file.close(app_runtime.io()); + try file.writeStreamingAll(app_runtime.io(), aw.written()); +} + +fn cwdAlloc(allocator: std.mem.Allocator) ![]u8 { + return try app_runtime.realPathFileAlloc(allocator, std.Io.Dir.cwd(), "."); +} + +pub fn rememberCurrentProjectGroup(allocator: std.mem.Allocator, group_name: []const u8) !void { + try validateGroupName(group_name); + const cwd = try cwdAlloc(allocator); + defer allocator.free(cwd); + + var projects = try loadProjectGroups(allocator); + defer projects.deinit(allocator); + try appendProjectGroup(allocator, &projects, cwd, group_name); + try saveProjectGroups(allocator, &projects); +} + +fn pathIsSameOrChild(path: []const u8, root: []const u8) bool { + if (std.mem.eql(u8, path, root)) return true; + if (!std.mem.startsWith(u8, path, root)) return false; + if (path.len <= root.len) return false; + return path[root.len] == '/' or path[root.len] == '\\'; +} + +pub fn currentProjectGroupAlloc(allocator: std.mem.Allocator) !?[]u8 { + const cwd = try cwdAlloc(allocator); + defer allocator.free(cwd); + + var projects = try loadProjectGroups(allocator); + defer projects.deinit(allocator); + + var best_idx: ?usize = null; + var best_len: usize = 0; + for (projects.items.items, 0..) |item, idx| { + if (!pathIsSameOrChild(cwd, item.root)) continue; + if (item.root.len <= best_len) continue; + best_idx = idx; + best_len = item.root.len; + } + const idx = best_idx orelse return null; + return try allocator.dupe(u8, projects.items.items[idx].group); +} + +pub fn clearCurrentProjectGroup(allocator: std.mem.Allocator) !bool { + const cwd = try cwdAlloc(allocator); + defer allocator.free(cwd); + + var projects = try loadProjectGroups(allocator); + defer projects.deinit(allocator); + + var idx: usize = 0; + while (idx < projects.items.items.len) { + if (!std.mem.eql(u8, projects.items.items[idx].root, cwd)) { + idx += 1; + continue; + } + projects.items.items[idx].deinit(allocator); + _ = projects.items.orderedRemove(idx); + try saveProjectGroups(allocator, &projects); + return true; + } + return false; +} + +pub fn removeProjectMappingsForGroup(allocator: std.mem.Allocator, group_name: []const u8) !void { + var projects = try loadProjectGroups(allocator); + defer projects.deinit(allocator); + + var idx: usize = 0; + var changed = false; + while (idx < projects.items.items.len) { + if (!std.mem.eql(u8, projects.items.items[idx].group, group_name)) { + idx += 1; + continue; + } + projects.items.items[idx].deinit(allocator); + _ = projects.items.orderedRemove(idx); + changed = true; + } + if (changed) try saveProjectGroups(allocator, &projects); +} + pub fn resolveGroupAlloc(allocator: std.mem.Allocator, name: []const u8) !GroupRef { var groups = try loadGroups(allocator); defer groups.deinit(allocator); @@ -244,6 +561,7 @@ pub fn resolveGroupAlloc(allocator: std.mem.Allocator, name: []const u8) !GroupR .name = try allocator.dupe(u8, groups.items.items[idx].name), .codex_home = try allocator.dupe(u8, groups.items.items[idx].codex_home), .managed = groups.items.items[idx].managed, + .display_color = try allocator.dupe(u8, groups.items.items[idx].display_color), }; } @@ -261,32 +579,109 @@ pub fn ensureManagedGroupAlloc(allocator: std.mem.Allocator, name: []const u8) ! .name = try allocator.dupe(u8, groups.items.items[idx].name), .codex_home = try allocator.dupe(u8, groups.items.items[idx].codex_home), .managed = groups.items.items[idx].managed, + .display_color = try allocator.dupe(u8, groups.items.items[idx].display_color), }; } const codex_home = try managedGroupCodexHomeAlloc(allocator, name); defer allocator.free(codex_home); try std.Io.Dir.cwd().createDirPath(app_runtime.io(), codex_home); - try appendGroupRef(allocator, &groups, name, codex_home, true); + try appendGroupRef(allocator, &groups, name, codex_home, true, null); try saveGroups(allocator, &groups); return .{ .name = try allocator.dupe(u8, name), .codex_home = try allocator.dupe(u8, codex_home), .managed = true, + .display_color = try allocator.dupe(u8, groups.items.items[findGroupIndex(&groups, name).?].display_color), }; } -test "group names allow simple shell-safe identifiers" { - try validateGroupName("work"); - try validateGroupName("team_1"); - try validateGroupName("personal-alpha"); +pub fn ensureManagedGroupWithFolderAlloc( + allocator: std.mem.Allocator, + name: []const u8, + folder_name: []const u8, +) !GroupRef { + try validateGroupName(name); + try validateGroupName(folder_name); + if (std.mem.eql(u8, name, default_group_name)) { + return try resolveGroupAlloc(allocator, default_group_name); + } + + var groups = try loadConfiguredGroups(allocator); + defer groups.deinit(allocator); + if (findGroupIndex(&groups, name)) |idx| { + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), groups.items.items[idx].codex_home); + return .{ + .name = try allocator.dupe(u8, groups.items.items[idx].name), + .codex_home = try allocator.dupe(u8, groups.items.items[idx].codex_home), + .managed = groups.items.items[idx].managed, + .display_color = try allocator.dupe(u8, groups.items.items[idx].display_color), + }; + } + + const groups_root = try groupsRootAlloc(allocator); + defer allocator.free(groups_root); + const codex_home = try std.fs.path.join(allocator, &[_][]const u8{ groups_root, folder_name }); + defer allocator.free(codex_home); + + if (findGroupIndexByCodexHome(&groups, codex_home) != null) { + return error.GroupAlreadyExists; + } + + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), codex_home); + try appendGroupRef(allocator, &groups, name, codex_home, true, null); + try saveGroups(allocator, &groups); + + return .{ + .name = try allocator.dupe(u8, name), + .codex_home = try allocator.dupe(u8, codex_home), + .managed = true, + .display_color = try allocator.dupe(u8, groups.items.items[findGroupIndex(&groups, name).?].display_color), + }; } -test "group names reject path-like or empty identifiers" { - try std.testing.expectError(error.InvalidGroupName, validateGroupName("")); - try std.testing.expectError(error.InvalidGroupName, validateGroupName(".")); - try std.testing.expectError(error.InvalidGroupName, validateGroupName("..")); - try std.testing.expectError(error.InvalidGroupName, validateGroupName("../work")); - try std.testing.expectError(error.InvalidGroupName, validateGroupName("work/group")); +pub fn removeManagedGroupConfig(allocator: std.mem.Allocator, name: []const u8) !void { + if (std.mem.eql(u8, name, default_group_name)) return error.InvalidGroupName; + + var groups = try loadConfiguredGroups(allocator); + defer groups.deinit(allocator); + const idx = findGroupIndex(&groups, name) orelse { + try removeProjectMappingsForGroup(allocator, name); + return; + }; + groups.items.items[idx].deinit(allocator); + _ = groups.items.orderedRemove(idx); + try saveGroups(allocator, &groups); + try removeProjectMappingsForGroup(allocator, name); +} + +pub fn deleteManagedGroupFolder(allocator: std.mem.Allocator, name: []const u8) ![]u8 { + var group = try resolveGroupAlloc(allocator, name); + defer group.deinit(allocator); + if (std.mem.eql(u8, group.name, default_group_name)) return error.InvalidGroupName; + const deleted_path = try allocator.dupe(u8, group.codex_home); + errdefer allocator.free(deleted_path); + try std.Io.Dir.cwd().deleteTree(app_runtime.io(), group.codex_home); + try removeManagedGroupConfig(allocator, group.name); + return deleted_path; +} + +pub fn archiveManagedGroupFolder(allocator: std.mem.Allocator, name: []const u8, timestamp_ms: i64) ![]u8 { + var group = try resolveGroupAlloc(allocator, name); + defer group.deinit(allocator); + if (std.mem.eql(u8, group.name, default_group_name)) return error.InvalidGroupName; + + const archive_root = try archiveRootAlloc(allocator); + defer allocator.free(archive_root); + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), archive_root); + + const archive_name = try std.fmt.allocPrint(allocator, "{s}-{d}", .{ group.name, timestamp_ms }); + defer allocator.free(archive_name); + const archive_path = try std.fs.path.join(allocator, &[_][]const u8{ archive_root, archive_name }); + errdefer allocator.free(archive_path); + + try std.Io.Dir.renameAbsolute(group.codex_home, archive_path, app_runtime.io()); + try removeManagedGroupConfig(allocator, group.name); + return archive_path; } diff --git a/src/main.zig b/src/main.zig index 9eb534db..62cd169c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,11 +5,12 @@ const account_name_refresh = @import("account_name_refresh.zig"); const cli = @import("cli.zig"); const chatgpt_http = @import("chatgpt_http.zig"); const display_rows = @import("display_rows.zig"); -const group_manager = @import("group_manager.zig"); const registry = @import("registry.zig"); +const group_manager = @import("group_manager.zig"); const auth = @import("auth.zig"); const auto = @import("auto.zig"); const format = @import("format.zig"); +const timefmt = @import("timefmt.zig"); const usage_api = @import("usage_api.zig"); const bdd = @import("tests/bdd_helpers.zig"); @@ -157,6 +158,10 @@ fn runMain(init: std.process.Init.Minimal) !void { const needs_codex_home = switch (cmd) { .version => false, .help => |topic| topic == .top_level, + .daemon => |opts| switch (opts.mode) { + .manager, .manager_once => false, + .watch, .once => true, + }, else => true, }; const codex_home = if (needs_codex_home) try registry.resolveCodexHome(allocator) else null; @@ -172,14 +177,18 @@ fn runMain(init: std.process.Init.Minimal) !void { .daemon => |opts| switch (opts.mode) { .watch => try auto.runDaemon(allocator, codex_home.?), .once => try auto.runDaemonOnce(allocator, codex_home.?), + .manager => try auto.runManagerDaemon(allocator), + .manager_once => try auto.runManagerDaemonOnce(allocator), }, .config => |opts| try handleConfig(allocator, codex_home.?, opts), - .list => |opts| try handleList(allocator, codex_home.?, opts), + .list => |opts| try handleList(allocator, codex_home.?, opts, .managed_groups), .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), - .group => |opts| try handleGroup(allocator, opts), + .group => |opts| try handleGroup(allocator, codex_home.?, opts), + .project => |opts| try handleProject(allocator, opts), + .launch => |opts| try handleProjectLaunch(allocator, opts), .clean => try handleClean(allocator, codex_home.?), } @@ -194,20 +203,22 @@ fn isHandledCliError(err: anyerror) bool { err == error.ListLiveRequiresTty or err == error.TuiOutputUnavailable or err == error.NodeJsRequired or - err == error.GroupNotFound or - err == error.GroupAlreadyExists or - err == error.InvalidGroupName or - err == error.CodextLaunchFailed or err == error.SwitchSelectionRequiresTty or err == error.RemoveConfirmationUnavailable or err == error.RemoveSelectionRequiresTty or - err == error.InvalidRemoveSelectionInput; + err == error.InvalidRemoveSelectionInput or + err == error.InvalidGroupName or + err == error.GroupNotFound or + err == error.GroupAlreadyExists or + err == error.GroupEmpty or + err == error.CodextLaunchFailed; } pub fn shouldReconcileManagedService(cmd: cli.Command) bool { if (hasNonEmptyEnvVar(skip_service_reconcile_env)) return false; return switch (cmd) { - .help, .version, .status, .daemon, .group => false, + .help, .version, .status, .daemon, .group, .project, .launch => false, + .login => |opts| opts.group_name == null, else => true, }; } @@ -1225,7 +1236,121 @@ fn loadSingleFileImportAuthInfo( }; } -fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ListOptions) !void { +const ListScope = union(enum) { + current_group: ?[]const u8, + managed_groups, +}; + +const ListDisplayState = struct { + reg: registry.Registry, + usage_state: ForegroundUsageRefreshState, + + fn deinit(self: *@This(), allocator: std.mem.Allocator) void { + self.usage_state.deinit(allocator); + self.reg.deinit(allocator); + } +}; + +const ManagedListDisplay = struct { + group_name: []const u8, + display_color: []const u8, + reg: registry.Registry, + usage_state: ForegroundUsageRefreshState, + + fn deinit(self: *@This(), allocator: std.mem.Allocator) void { + self.usage_state.deinit(allocator); + self.reg.deinit(allocator); + } +}; + +fn loadListDisplayState( + allocator: std.mem.Allocator, + codex_home: []const u8, + opts: cli.ListOptions, +) !ListDisplayState { + var reg = try registry.loadRegistry(allocator, codex_home); + errdefer reg.deinit(allocator); + if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { + try registry.saveRegistry(allocator, codex_home, ®); + } + + const usage_api_enabled = apiModeUsesApi(reg.api.usage, opts.api_mode); + const account_api_enabled = apiModeUsesApi(reg.api.account, opts.api_mode); + + try ensureForegroundNodeAvailableWithApiEnabled( + allocator, + codex_home, + ®, + .list, + usage_api_enabled, + account_api_enabled, + ); + + var usage_state = try refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabled( + allocator, + codex_home, + ®, + usage_api_enabled, + ); + errdefer usage_state.deinit(allocator); + try maybeRefreshForegroundAccountNamesWithAccountApiEnabled( + allocator, + codex_home, + ®, + .list, + defaultAccountFetcher, + account_api_enabled, + ); + + return .{ + .reg = reg, + .usage_state = usage_state, + }; +} + +fn shouldListManagedGroups(allocator: std.mem.Allocator, codex_home: []const u8) !bool { + const default_codex_home = try group_manager.defaultCodexHomeAlloc(allocator); + defer allocator.free(default_codex_home); + return std.mem.eql(u8, codex_home, default_codex_home); +} + +fn handleManagedGroupList(allocator: std.mem.Allocator, opts: cli.ListOptions) !void { + var groups = try group_manager.loadGroups(allocator); + defer groups.deinit(allocator); + + var loaded = std.ArrayList(ManagedListDisplay).empty; + defer { + for (loaded.items) |*item| item.deinit(allocator); + loaded.deinit(allocator); + } + + for (groups.items.items) |group| { + var state = try loadListDisplayState(allocator, group.codex_home, opts); + loaded.append(allocator, .{ + .group_name = group.name, + .display_color = group.display_color, + .reg = state.reg, + .usage_state = state.usage_state, + }) catch |err| { + state.deinit(allocator); + return err; + }; + } + + var views = try allocator.alloc(format.GroupedAccountsView, loaded.items.len); + defer allocator.free(views); + for (loaded.items, 0..) |*item, idx| { + views[idx] = .{ + .group_name = item.group_name, + .display_color = item.display_color, + .reg = &item.reg, + .usage_overrides = item.usage_state.usage_overrides, + }; + } + try format.printGroupedAccountsWithUsageOverrides(views); +} + +fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ListOptions, scope: ListScope) !void { if (isAccountNameRefreshOnlyMode()) return try runBackgroundAccountNameRefresh(allocator, codex_home, defaultAccountFetcher); if (opts.live) { @@ -1270,49 +1395,35 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li return; } - var reg = try registry.loadRegistry(allocator, codex_home); - defer reg.deinit(allocator); - if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { - try registry.saveRegistry(allocator, codex_home, ®); + if (std.meta.activeTag(scope) == .managed_groups and try shouldListManagedGroups(allocator, codex_home)) { + return try handleManagedGroupList(allocator, opts); } - const usage_api_enabled = apiModeUsesApi(reg.api.usage, opts.api_mode); - const account_api_enabled = apiModeUsesApi(reg.api.account, opts.api_mode); - - try ensureForegroundNodeAvailableWithApiEnabled( - allocator, - codex_home, - ®, - .list, - usage_api_enabled, - account_api_enabled, - ); - - var usage_state = try refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabled( - allocator, - codex_home, - ®, - usage_api_enabled, - ); - defer usage_state.deinit(allocator); - try maybeRefreshForegroundAccountNamesWithAccountApiEnabled( - allocator, - codex_home, - ®, - .list, - defaultAccountFetcher, - account_api_enabled, - ); - try format.printAccountsWithUsageOverrides(®, usage_state.usage_overrides); -} - -fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.LoginOptions) !void { - if (opts.group_name) |group_name| { - try handleManagedGroupLogin(allocator, group_name, opts); - return; + var state = try loadListDisplayState(allocator, codex_home, opts); + defer state.deinit(allocator); + const group_indices = try registry.activeGroupAccountIndicesAlloc(allocator, &state.reg); + defer if (group_indices) |indices| allocator.free(indices); + switch (scope) { + .current_group => |display_color| { + try format.printAccountsWithUsageOverridesForGroup( + &state.reg, + state.usage_state.usage_overrides, + group_indices, + display_color orelse group_manager.default_group_display_color, + ); + }, + .managed_groups => { + if (group_indices) |indices| { + try format.printAccountsWithUsageOverridesForIndices(&state.reg, state.usage_state.usage_overrides, indices); + } else { + try format.printAccountsWithUsageOverrides(&state.reg, state.usage_state.usage_overrides); + } + }, } +} - try cli.runCodexLogin(opts); +fn handleLoginInCodexHome(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.LoginOptions) !void { + try cli.runCodexLoginWithCodexHome(allocator, opts, codex_home); const auth_path = try registry.activeAuthPath(allocator, codex_home); defer allocator.free(auth_path); @@ -1338,6 +1449,21 @@ fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.L try registry.saveRegistry(allocator, codex_home, ®); } +fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.LoginOptions) !void { + if (opts.group_name) |group_name| { + try handleManagedGroupLogin(allocator, group_name, .{ .device_auth = opts.device_auth }); + return; + } + try handleLoginInCodexHome(allocator, codex_home, opts); +} + +fn handleManagedGroupLogin(allocator: std.mem.Allocator, group_name: []const u8, opts: cli.LoginOptions) !void { + var group = try group_manager.resolveGroupAlloc(allocator, group_name); + defer group.deinit(allocator); + try handleLoginInCodexHome(allocator, group.codex_home, opts); + try printGroupMessage("Logged in account to group `{s}` ({s}).", .{ group.name, group.codex_home }); +} + fn handleImport(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ImportOptions) !void { if (opts.purge) { var report = try registry.purgeRegistryFromImportSource(allocator, codex_home, opts.auth_path, opts.alias); @@ -1384,7 +1510,10 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. std.debug.assert(!opts.live); std.debug.assert(!opts.auto); - var resolution = try resolveSwitchQueryLocally(allocator, ®, query); + const group_indices = try registry.activeGroupAccountIndicesAlloc(allocator, ®); + defer if (group_indices) |indices| allocator.free(indices); + + var resolution = try resolveSwitchQueryLocallyInScope(allocator, ®, query, group_indices); defer resolution.deinit(allocator); const selected_account_key = switch (resolution) { @@ -1443,8 +1572,13 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. return err; }; if (selected_account_key == null) return; - try registry.activateAccountByKey(allocator, codex_home, &loaded.display.reg, selected_account_key.?); - try registry.saveRegistry(allocator, codex_home, &loaded.display.reg); + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { + try registry.saveRegistry(allocator, codex_home, ®); + } + try registry.activateAccountByKey(allocator, codex_home, ®, selected_account_key.?); + try registry.saveRegistry(allocator, codex_home, ®); return; } @@ -1937,29 +2071,137 @@ fn freeOwnedAccountRecord(allocator: std.mem.Allocator, rec: *const registry.Acc if (rec.last_local_rollout) |*signature| registry.freeRolloutSignature(allocator, signature); } +fn cloneAccountGroup(allocator: std.mem.Allocator, group: *const registry.AccountGroup) !registry.AccountGroup { + const name = try allocator.dupe(u8, group.name); + errdefer allocator.free(name); + const account_keys = try allocator.alloc([]u8, group.account_keys.len); + errdefer allocator.free(account_keys); + var cloned_count: usize = 0; + errdefer { + for (account_keys[0..cloned_count]) |key| allocator.free(key); + } + for (group.account_keys, 0..) |account_key, idx| { + account_keys[idx] = try allocator.dupe(u8, account_key); + cloned_count += 1; + } + return .{ + .name = name, + .account_keys = account_keys, + }; +} + fn cloneRegistryAlloc(allocator: std.mem.Allocator, reg: *const registry.Registry) !registry.Registry { const active_account_key = if (reg.active_account_key) |value| try allocator.dupe(u8, value) else null; errdefer if (active_account_key) |value| allocator.free(value); + const active_group_name = if (reg.active_group_name) |value| + try allocator.dupe(u8, value) + else + null; + errdefer if (active_group_name) |value| allocator.free(value); var cloned: registry.Registry = .{ .schema_version = reg.schema_version, .active_account_key = active_account_key, .active_account_activated_at_ms = reg.active_account_activated_at_ms, + .active_group_name = active_group_name, .auto_switch = reg.auto_switch, .api = reg.api, .accounts = std.ArrayList(registry.AccountRecord).empty, + .groups = std.ArrayList(registry.AccountGroup).empty, }; errdefer cloned.deinit(allocator); for (reg.accounts.items) |*rec| { try cloned.accounts.append(allocator, try cloneAccountRecord(allocator, rec)); } + for (reg.groups.items) |*group| { + try cloned.groups.append(allocator, try cloneAccountGroup(allocator, group)); + } return cloned; } +fn scopeSwitchSelectionDisplayToActiveGroupAlloc( + allocator: std.mem.Allocator, + display: cli.OwnedSwitchSelectionDisplay, +) !cli.OwnedSwitchSelectionDisplay { + const group_name = display.reg.active_group_name orelse return display; + const indices = try registry.groupAccountIndicesAlloc(allocator, &display.reg, group_name); + defer allocator.free(indices); + + var scoped_reg: registry.Registry = .{ + .schema_version = display.reg.schema_version, + .active_account_key = null, + .active_account_activated_at_ms = null, + .active_group_name = try allocator.dupe(u8, group_name), + .auto_switch = display.reg.auto_switch, + .api = display.reg.api, + .accounts = std.ArrayList(registry.AccountRecord).empty, + .groups = std.ArrayList(registry.AccountGroup).empty, + }; + errdefer scoped_reg.deinit(allocator); + for (display.reg.groups.items) |*group| { + try scoped_reg.groups.append(allocator, try cloneAccountGroup(allocator, group)); + } + + const usage_overrides = try allocEmptySwitchUsageOverrides(allocator, indices.len); + errdefer { + for (usage_overrides) |value| { + if (value) |text| allocator.free(text); + } + allocator.free(usage_overrides); + } + + for (indices, 0..) |account_idx, scoped_idx| { + try scoped_reg.accounts.append(allocator, try cloneAccountRecord(allocator, &display.reg.accounts.items[account_idx])); + if (account_idx < display.usage_overrides.len) { + if (display.usage_overrides[account_idx]) |text| { + usage_overrides[scoped_idx] = try allocator.dupe(u8, text); + } + } + } + + if (display.reg.active_account_key) |active| { + if (registry.findAccountIndexByAccountKey(&scoped_reg, active) != null) { + scoped_reg.active_account_key = try allocator.dupe(u8, active); + scoped_reg.active_account_activated_at_ms = display.reg.active_account_activated_at_ms; + } + } + + var old_display = display; + old_display.deinit(allocator); + return .{ + .reg = scoped_reg, + .usage_overrides = usage_overrides, + }; +} + +fn scopeSwitchLoadedDisplayToActiveGroupAlloc( + allocator: std.mem.Allocator, + loaded: SwitchLoadedDisplay, +) !SwitchLoadedDisplay { + var scoped = loaded; + errdefer { + scoped.display.deinit(allocator); + if (scoped.refresh_error_name) |name| allocator.free(name); + } + scoped.display = try scopeSwitchSelectionDisplayToActiveGroupAlloc(allocator, loaded.display); + return scoped; +} + +fn maybeScopeSwitchLoadedDisplayToActiveGroupAlloc( + allocator: std.mem.Allocator, + loaded: SwitchLoadedDisplay, + target: ForegroundUsageRefreshTarget, +) !SwitchLoadedDisplay { + return switch (target) { + .list, .switch_account => try scopeSwitchLoadedDisplayToActiveGroupAlloc(allocator, loaded), + .remove_account => loaded, + }; +} + fn cloneSwitchUsageOverridesAlloc( allocator: std.mem.Allocator, usage_overrides: ?[]const ?[]const u8, @@ -2091,17 +2333,27 @@ fn loadStoredSwitchSelectionDisplay( api_mode: cli.ApiMode, ) !SwitchLoadedDisplay { var latest = try registry.loadRegistry(allocator, codex_home); - errdefer latest.deinit(allocator); + var latest_owned = true; + errdefer if (latest_owned) latest.deinit(allocator); if (try registry.syncActiveAccountFromAuth(allocator, codex_home, &latest)) { try registry.saveRegistry(allocator, codex_home, &latest); } - return .{ + const usage_overrides = try allocEmptySwitchUsageOverrides(allocator, latest.accounts.items.len); + errdefer if (latest_owned) { + for (usage_overrides) |value| { + if (value) |text| allocator.free(text); + } + allocator.free(usage_overrides); + }; + const policy = switchLiveRefreshPolicy(&latest, target, api_mode); + latest_owned = false; + return try maybeScopeSwitchLoadedDisplayToActiveGroupAlloc(allocator, .{ .display = .{ .reg = latest, - .usage_overrides = try allocEmptySwitchUsageOverrides(allocator, latest.accounts.items.len), + .usage_overrides = usage_overrides, }, - .policy = switchLiveRefreshPolicy(&latest, target, api_mode), - }; + .policy = policy, + }, target); } fn loadStoredSwitchSelectionDisplayWithRefreshError( @@ -2202,7 +2454,8 @@ fn loadSwitchSelectionDisplay( }; var latest = try registry.loadRegistry(allocator, codex_home); - errdefer latest.deinit(allocator); + var latest_owned = true; + errdefer if (latest_owned) latest.deinit(allocator); var latest_changed = try registry.syncActiveAccountFromAuth(allocator, codex_home, &latest); if (try mergeSwitchLiveRefreshIntoLatest(allocator, &latest, &base, &refreshed)) { @@ -2219,13 +2472,15 @@ fn loadSwitchSelectionDisplay( usage_state.deinit(allocator); refreshed.deinit(allocator); - return .{ + const policy = switchLiveRefreshPolicy(&latest, target, api_mode); + latest_owned = false; + return try maybeScopeSwitchLoadedDisplayToActiveGroupAlloc(allocator, .{ .display = .{ .reg = latest, .usage_overrides = mapped_usage_overrides, }, - .policy = switchLiveRefreshPolicy(&latest, target, api_mode), - }; + .policy = policy, + }, target); } fn runSwitchLiveRefreshRound(task_ctx: SwitchLiveRefreshTaskContext) void { @@ -2481,11 +2736,20 @@ pub fn resolveSwitchQueryLocally( reg: *registry.Registry, query: []const u8, ) !SwitchQueryResolution { - if (try findAccountIndexByDisplayNumber(allocator, reg, query)) |account_idx| { + return resolveSwitchQueryLocallyInScope(allocator, reg, query, null); +} + +pub fn resolveSwitchQueryLocallyInScope( + allocator: std.mem.Allocator, + reg: *registry.Registry, + query: []const u8, + account_indices: ?[]const usize, +) !SwitchQueryResolution { + if (try findAccountIndexByDisplayNumberInScope(allocator, reg, query, account_indices)) |account_idx| { return .{ .direct = reg.accounts.items[account_idx].account_key }; } - var matches = try findMatchingAccounts(allocator, reg, query); + var matches = try findMatchingAccountsInScope(allocator, reg, query, account_indices); if (matches.items.len == 0) { matches.deinit(allocator); return .not_found; @@ -2497,227 +2761,330 @@ pub fn resolveSwitchQueryLocally( return .{ .multiple = matches }; } -fn handleConfig(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ConfigOptions) !void { - switch (opts) { - .auto_switch => |auto_opts| try auto.handleAutoCommand(allocator, codex_home, auto_opts), - .api => |action| try auto.handleApiCommand(allocator, codex_home, action), - } +fn printGroupError(comptime fmt: []const u8, args: anytype) !void { + var buffer: [512]u8 = undefined; + var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + try cli.writeErrorPrefixTo(&writer.interface, false); + try writer.interface.print(" " ++ fmt ++ "\n", args); + try writer.interface.flush(); } -fn freeOwnedStrings(allocator: std.mem.Allocator, items: []const []const u8) void { - for (items) |item| allocator.free(@constCast(item)); +fn printGroupMessage(comptime fmt: []const u8, args: anytype) !void { + var buffer: [1024]u8 = undefined; + var writer = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + try writer.interface.print(fmt ++ "\n", args); + try writer.interface.flush(); } -pub fn findMatchingAccounts( - allocator: std.mem.Allocator, - reg: *registry.Registry, - query: []const u8, -) !std.ArrayList(usize) { - var matches = std.ArrayList(usize).empty; - for (reg.accounts.items, 0..) |*rec, idx| { - const matches_email = std.ascii.indexOfIgnoreCase(rec.email, query) != null; - const matches_alias = rec.alias.len != 0 and std.ascii.indexOfIgnoreCase(rec.alias, query) != null; - const matches_name = if (rec.account_name) |name| - name.len != 0 and std.ascii.indexOfIgnoreCase(name, query) != null - else - false; - if (matches_email or matches_alias or matches_name) { - try matches.append(allocator, idx); - } +fn printGroupListAll(allocator: std.mem.Allocator) !void { + var groups = try group_manager.loadGroups(allocator); + defer groups.deinit(allocator); + + var buffer: [4096]u8 = undefined; + var writer = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + try out.writeAll("GROUP ACCOUNTS CODEX_HOME\n"); + try out.writeAll("------------------------------\n"); + for (groups.items.items) |item| { + const account_count: usize = blk: { + var reg = registry.loadRegistry(allocator, item.codex_home) catch |err| switch (err) { + error.FileNotFound, error.UnsupportedRegistryVersion => break :blk 0, + else => return err, + }; + defer reg.deinit(allocator); + break :blk reg.accounts.items.len; + }; + try out.print("{s:<8} {d:>8} {s}\n", .{ item.name, account_count, item.codex_home }); } - return matches; + try out.flush(); } -fn findMatchingAccountsForRemove( - allocator: std.mem.Allocator, - reg: *registry.Registry, - query: []const u8, -) !std.ArrayList(usize) { - var matches = std.ArrayList(usize).empty; - for (reg.accounts.items, 0..) |*rec, idx| { - const matches_email = std.ascii.indexOfIgnoreCase(rec.email, query) != null; - const matches_alias = rec.alias.len != 0 and std.ascii.indexOfIgnoreCase(rec.alias, query) != null; - const matches_name = if (rec.account_name) |name| - name.len != 0 and std.ascii.indexOfIgnoreCase(name, query) != null - else - false; - const matches_key = std.ascii.indexOfIgnoreCase(rec.account_key, query) != null; - if (matches_email or matches_alias or matches_name or matches_key) { - try matches.append(allocator, idx); - } - } - return matches; +fn activeAccountLabelOrDashAlloc(allocator: std.mem.Allocator, reg: *registry.Registry) ![]u8 { + const account_key = reg.active_account_key orelse return try allocator.dupe(u8, "-"); + return accountLabelForKeyAlloc(allocator, reg, account_key) catch |err| switch (err) { + error.AccountNotFound => try allocator.dupe(u8, "-"), + else => return err, + }; } -fn parseDisplayNumber(selector: []const u8) ?usize { - if (selector.len == 0) return null; - for (selector) |ch| { - if (ch < '0' or ch > '9') return null; - } +fn printManagedGroupStatusAll(allocator: std.mem.Allocator) !void { + var groups = try group_manager.loadGroups(allocator); + defer groups.deinit(allocator); - const parsed = std.fmt.parseInt(usize, selector, 10) catch return null; - if (parsed == 0) return null; - return parsed; + var buffer: [4096]u8 = undefined; + var writer = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + try out.writeAll("GROUP ACCOUNTS ACTIVE AUTO MANAGER CODEX_HOME\n"); + try out.writeAll("-----------------------------------------------------\n"); + for (groups.items.items) |item| { + var reg = try registry.loadRegistry(allocator, item.codex_home); + defer reg.deinit(allocator); + const active_label = try activeAccountLabelOrDashAlloc(allocator, ®); + defer allocator.free(active_label); + var status = try auto.getStatusForGroup(allocator, item.name, item.codex_home); + defer status.deinit(allocator); + try out.print("{s:<8} {d:>8} {s:<6} {s:<4} {s:<7} {s}\n", .{ + item.name, + reg.accounts.items.len, + active_label, + auto.helpStateLabel(status.enabled), + @tagName(status.runtime), + item.codex_home, + }); + } + try out.flush(); } -fn findAccountIndexByDisplayNumber( - allocator: std.mem.Allocator, - reg: *registry.Registry, - selector: []const u8, -) !?usize { - const display_number = parseDisplayNumber(selector) orelse return null; - - var display = try display_rows.buildDisplayRows(allocator, reg, null); - defer display.deinit(allocator); - - if (display_number > display.selectable_row_indices.len) return null; - const row_idx = display.selectable_row_indices[display_number - 1]; - return display.rows[row_idx].account_index; +fn printManagedGroupPath(allocator: std.mem.Allocator, name: []const u8) !void { + var group = try group_manager.resolveGroupAlloc(allocator, name); + defer group.deinit(allocator); + try printGroupMessage("{s}", .{group.codex_home}); } -fn printGroupMessage(comptime fmt: []const u8, args: anytype) !void { - var buffer: [1024]u8 = undefined; - var stdout = std.Io.File.stdout().writer(app_runtime.io(), &buffer); - try stdout.interface.print(fmt ++ "\n", args); - try stdout.interface.flush(); +fn managedGroupExists(allocator: std.mem.Allocator, name: []const u8) !bool { + var groups = try group_manager.loadGroups(allocator); + defer groups.deinit(allocator); + return group_manager.findGroupIndex(&groups, name) != null; } -fn printGroupError(comptime fmt: []const u8, args: anytype) !void { +fn promptForNewManagedFolderNameAlloc(allocator: std.mem.Allocator, group_name: []const u8) ![]u8 { var buffer: [1024]u8 = undefined; - var stderr = std.Io.File.stderr().writer(app_runtime.io(), &buffer); - try stderr.interface.print("error: " ++ fmt ++ "\n", args); - try stderr.interface.flush(); -} + var writer = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + try out.print("New CODEX_HOME folder name for group `{s}` [{s}]: ", .{ group_name, group_name }); + try out.flush(); -fn handleManagedGroupLogin(allocator: std.mem.Allocator, group_name: []const u8, opts: cli.LoginOptions) !void { - var group = try group_manager.ensureManagedGroupAlloc(allocator, group_name); - defer group.deinit(allocator); - try handleLoginInCodexHome(allocator, group.codex_home, opts); - try printGroupMessage("Logged in account to group `{s}` ({s}).", .{ group.name, group.codex_home }); + var input: [128]u8 = undefined; + const n = try readFileOnce(std.Io.File.stdin(), &input); + const line = std.mem.trim(u8, input[0..n], " \n\r\t"); + const folder_name = if (line.len == 0) group_name else line; + group_manager.validateGroupName(folder_name) catch { + try printGroupError("invalid folder name `{s}`; use letters, numbers, `-`, or `_`.", .{folder_name}); + return error.InvalidGroupName; + }; + return try allocator.dupe(u8, folder_name); } -fn handleLoginInCodexHome(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.LoginOptions) !void { - try cli.runCodexLoginWithCodexHome(allocator, opts, codex_home); - const auth_path = try registry.activeAuthPath(allocator, codex_home); - defer allocator.free(auth_path); - - const info = try auth.parseAuthInfo(allocator, auth_path); - defer info.deinit(allocator); +fn promptForManagedGroupFolderAlloc(allocator: std.mem.Allocator, group_name: []const u8) !?[]u8 { + if (!(std.Io.File.stdin().isTty(app_runtime.io()) catch false)) return null; + if (!(std.Io.File.stdout().isTty(app_runtime.io()) catch false)) return null; - var reg = try registry.loadRegistry(allocator, codex_home); - defer reg.deinit(allocator); + var folders = try group_manager.listAvailableManagedFolderNamesAlloc(allocator); + defer folders.deinit(allocator); - const email = info.email orelse return error.MissingEmail; - _ = email; - const record_key = info.record_key orelse return error.MissingChatgptUserId; - const dest = try registry.accountAuthPath(allocator, codex_home, record_key); - defer allocator.free(dest); + const groups_root = try group_manager.groupsRootAlloc(allocator); + defer allocator.free(groups_root); - try registry.ensureAccountsDir(allocator, codex_home); - try registry.copyManagedFile(auth_path, dest); + var buffer: [4096]u8 = undefined; + var writer = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &writer.interface; - const record = try registry.accountFromAuth(allocator, "", &info); - try registry.upsertAccount(allocator, ®, record); - try registry.setActiveAccountKey(allocator, ®, record_key); - _ = try refreshAccountNamesAfterLogin(allocator, ®, &info, defaultAccountFetcher); - try registry.saveRegistry(allocator, codex_home, ®); -} + if (folders.items.items.len == 0) { + try out.print("No unused managed CODEX_HOME folders found under {s}.\n", .{groups_root}); + try out.flush(); + return try promptForNewManagedFolderNameAlloc(allocator, group_name); + } -fn groupAccountCount(allocator: std.mem.Allocator, codex_home: []const u8) !usize { - var reg = registry.loadRegistry(allocator, codex_home) catch |err| switch (err) { - error.FileNotFound, error.UnsupportedRegistryVersion => return 0, - else => return err, - }; - defer reg.deinit(allocator); - return reg.accounts.items.len; -} + try out.print("Available managed CODEX_HOME folders under {s}:\n", .{groups_root}); + for (folders.items.items, 0..) |folder_name, idx| { + try out.print(" {d}. {s}\n", .{ idx + 1, folder_name }); + } + const create_choice = folders.items.items.len + 1; + try out.print(" {d}. Create a new folder\n", .{create_choice}); + try out.print("Select CODEX_HOME folder for group `{s}` [1-{d}, Enter=create]: ", .{ group_name, create_choice }); + try out.flush(); -fn printGroupListAll(allocator: std.mem.Allocator) !void { - var groups = try group_manager.loadGroups(allocator); - defer groups.deinit(allocator); + var input: [128]u8 = undefined; + const n = try readFileOnce(std.Io.File.stdin(), &input); + const line = std.mem.trim(u8, input[0..n], " \n\r\t"); + if (line.len == 0) return try promptForNewManagedFolderNameAlloc(allocator, group_name); - var buffer: [4096]u8 = undefined; - var stdout = std.Io.File.stdout().writer(app_runtime.io(), &buffer); - const out = &stdout.interface; - try out.writeAll("GROUP ACCOUNTS CODEX_HOME\n"); - try out.writeAll("------------------------------\n"); - for (groups.items.items) |group| { - const count = try groupAccountCount(allocator, group.codex_home); - try out.print("{s:<8} {d:>8} {s}\n", .{ group.name, count, group.codex_home }); + const choice = std.fmt.parseUnsigned(usize, line, 10) catch { + try printGroupError("invalid folder selection `{s}`.", .{line}); + return error.InvalidCliUsage; + }; + if (choice >= 1 and choice <= folders.items.items.len) { + return try allocator.dupe(u8, folders.items.items[choice - 1]); } - try out.flush(); + if (choice == create_choice) { + return try promptForNewManagedFolderNameAlloc(allocator, group_name); + } + + try printGroupError("folder selection must be between 1 and {d}.", .{create_choice}); + return error.InvalidCliUsage; } -fn printManagedGroupPath(allocator: std.mem.Allocator, name: []const u8) !void { - var group = try group_manager.resolveGroupAlloc(allocator, name); +fn handleManagedGroupCreate(allocator: std.mem.Allocator, opts: cli.GroupMutationOptions) !void { + const exists = try managedGroupExists(allocator, opts.name); + var group = blk: { + if (!exists and !std.mem.eql(u8, opts.name, group_manager.default_group_name)) { + if (try promptForManagedGroupFolderAlloc(allocator, opts.name)) |folder_name| { + defer allocator.free(folder_name); + break :blk try group_manager.ensureManagedGroupWithFolderAlloc(allocator, opts.name, folder_name); + } + } + break :blk try group_manager.ensureManagedGroupAlloc(allocator, opts.name); + }; defer group.deinit(allocator); - try printGroupMessage("{s}", .{group.codex_home}); + try printGroupMessage("Group `{s}` uses {s}.", .{ group.name, group.codex_home }); + if (opts.selectors.len != 0) { + try handleManagedGroupAdd(allocator, group.name, opts.selectors); + } } const ManagedGroupAccountMatch = struct { source_group_name: []u8, source_codex_home: []u8, + account_key: []u8, snapshot_path: []u8, label: []u8, alias: []u8, + plan: []u8, + rate_5h: []u8, + rate_week: []u8, + last_activity: []u8, + is_active: bool, fn deinit(self: *ManagedGroupAccountMatch, allocator: std.mem.Allocator) void { allocator.free(self.source_group_name); allocator.free(self.source_codex_home); + allocator.free(self.account_key); allocator.free(self.snapshot_path); allocator.free(self.label); allocator.free(self.alias); + allocator.free(self.plan); + allocator.free(self.rate_5h); + allocator.free(self.rate_week); + allocator.free(self.last_activity); } }; +const ManagedGroupTransferMode = enum { + add, + copy, + move, +}; + +fn managedTransferVerb(mode: ManagedGroupTransferMode) []const u8 { + return switch (mode) { + .add => "add", + .copy => "copy", + .move => "move", + }; +} + +fn managedTransferPastVerb(mode: ManagedGroupTransferMode) []const u8 { + return switch (mode) { + .add => "Added", + .copy => "Copied", + .move => "Moved", + }; +} + +fn managedMatchIsDefault(match: *const ManagedGroupAccountMatch) bool { + return std.mem.eql(u8, match.source_group_name, group_manager.default_group_name); +} + +fn replaceManagedMatch( + allocator: std.mem.Allocator, + matches: *std.ArrayList(ManagedGroupAccountMatch), + idx: usize, + value: ManagedGroupAccountMatch, +) void { + matches.items[idx].deinit(allocator); + matches.items[idx] = value; +} + +fn appendManagedMatchPreferDefault( + allocator: std.mem.Allocator, + matches: *std.ArrayList(ManagedGroupAccountMatch), + value: ManagedGroupAccountMatch, +) !void { + for (matches.items, 0..) |*existing, idx| { + if (!std.mem.eql(u8, existing.account_key, value.account_key)) continue; + if (!managedMatchIsDefault(existing) and managedMatchIsDefault(&value)) { + replaceManagedMatch(allocator, matches, idx, value); + return; + } + var discard = value; + discard.deinit(allocator); + return; + } + try matches.append(allocator, value); +} + fn managedGroupAccountMatchForRecord( allocator: std.mem.Allocator, source: *const group_manager.GroupRef, + source_reg: *const registry.Registry, rec: *const registry.AccountRecord, ) !ManagedGroupAccountMatch { const snapshot_path = try registry.accountAuthPath(allocator, source.codex_home, rec.account_key); errdefer allocator.free(snapshot_path); const label = try display_rows.buildPreferredAccountLabelAlloc(allocator, rec, rec.email); errdefer allocator.free(label); + const plan = try allocator.dupe(u8, if (registry.resolveDisplayPlan(rec)) |p| registry.planLabel(p) else "-"); + errdefer allocator.free(plan); + const rate_5h_page = try format.formatRateLimitFullAlloc(registry.resolveRateWindow(rec.last_usage, 300, true)); + defer std.heap.page_allocator.free(rate_5h_page); + const rate_5h = try allocator.dupe(u8, rate_5h_page); + errdefer allocator.free(rate_5h); + const rate_week_page = try format.formatRateLimitFullAlloc(registry.resolveRateWindow(rec.last_usage, 10080, false)); + defer std.heap.page_allocator.free(rate_week_page); + const rate_week = try allocator.dupe(u8, rate_week_page); + errdefer allocator.free(rate_week); + const last_activity = try timefmt.formatRelativeTimeOrDashAlloc(allocator, rec.last_usage_at, nowSeconds()); + errdefer allocator.free(last_activity); return .{ .source_group_name = try allocator.dupe(u8, source.name), .source_codex_home = try allocator.dupe(u8, source.codex_home), + .account_key = try allocator.dupe(u8, rec.account_key), .snapshot_path = snapshot_path, .label = label, .alias = try allocator.dupe(u8, rec.alias), + .plan = plan, + .rate_5h = rate_5h, + .rate_week = rate_week, + .last_activity = last_activity, + .is_active = if (source_reg.active_account_key) |active| std.mem.eql(u8, active, rec.account_key) else false, }; } -fn appendManagedGroupMatchesFromGroup( +fn collectManagedGroupMatches( allocator: std.mem.Allocator, - result: *std.ArrayList(ManagedGroupAccountMatch), selector: []const u8, - source: *const group_manager.GroupRef, -) !void { - var reg = registry.loadRegistry(allocator, source.codex_home) catch |err| switch (err) { - error.FileNotFound, error.UnsupportedRegistryVersion => return, - else => return err, - }; - defer reg.deinit(allocator); - - var matches = std.ArrayList(usize).empty; - defer matches.deinit(allocator); - if (try findAccountIndexByDisplayNumber(allocator, ®, selector)) |account_idx| { - try matches.append(allocator, account_idx); - } else { - var fuzzy = try findMatchingAccountsForRemove(allocator, ®, selector); - defer fuzzy.deinit(allocator); - try matches.appendSlice(allocator, fuzzy.items); + target_group_name: []const u8, +) !std.ArrayList(ManagedGroupAccountMatch) { + var result = std.ArrayList(ManagedGroupAccountMatch).empty; + errdefer { + for (result.items) |*match| match.deinit(allocator); + result.deinit(allocator); } - for (matches.items) |account_idx| { - try result.append(allocator, try managedGroupAccountMatchForRecord(allocator, source, ®.accounts.items[account_idx])); + var groups = try group_manager.loadGroups(allocator); + defer groups.deinit(allocator); + for (groups.items.items) |source| { + var reg = registry.loadRegistry(allocator, source.codex_home) catch |err| switch (err) { + error.FileNotFound, error.UnsupportedRegistryVersion => continue, + else => return err, + }; + defer reg.deinit(allocator); + + var matches = try findMatchingAccountsForRemove(allocator, ®, selector); + defer matches.deinit(allocator); + for (matches.items) |account_idx| { + const rec = ®.accounts.items[account_idx]; + if (std.mem.eql(u8, source.name, target_group_name)) continue; + + const match = try managedGroupAccountMatchForRecord(allocator, &source, ®, rec); + try appendManagedMatchPreferDefault(allocator, &result, match); + } } + return result; } -fn collectManagedGroupMatches( +fn collectAllManagedGroupTransferMatches( allocator: std.mem.Allocator, - selector: []const u8, target_group_name: []const u8, ) !std.ArrayList(ManagedGroupAccountMatch) { var result = std.ArrayList(ManagedGroupAccountMatch).empty; @@ -2728,75 +3095,430 @@ fn collectManagedGroupMatches( var groups = try group_manager.loadGroups(allocator); defer groups.deinit(allocator); + for (groups.items.items) |source| { + if (std.mem.eql(u8, source.name, target_group_name)) continue; + var reg = registry.loadRegistry(allocator, source.codex_home) catch |err| switch (err) { + error.FileNotFound, error.UnsupportedRegistryVersion => continue, + else => return err, + }; + defer reg.deinit(allocator); - if (!std.mem.eql(u8, target_group_name, group_manager.default_group_name)) { - if (group_manager.findGroupIndex(&groups, group_manager.default_group_name)) |idx| { - try appendManagedGroupMatchesFromGroup(allocator, &result, selector, &groups.items.items[idx]); - if (result.items.len != 0) return result; + for (reg.accounts.items) |*rec| { + try result.append(allocator, try managedGroupAccountMatchForRecord(allocator, &source, ®, rec)); } } + return result; +} - for (groups.items.items) |source| { - if (std.mem.eql(u8, source.name, target_group_name)) continue; - if (std.mem.eql(u8, source.name, group_manager.default_group_name)) continue; - try appendManagedGroupMatchesFromGroup(allocator, &result, selector, &source); +fn readFileOnce(file: std.Io.File, buffer: []u8) !usize { + var buffers = [_][]u8{buffer}; + return file.readStreaming(app_runtime.io(), &buffers) catch |err| switch (err) { + error.EndOfStream => 0, + else => |e| return e, + }; +} + +fn confirmAddFromNonDefaultGroup(match: *const ManagedGroupAccountMatch, target_group_name: []const u8) !bool { + var buffer: [1024]u8 = undefined; + var stdout = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &stdout.interface; + try out.print("Found {s} in group `{s}`.\n", .{ match.label, match.source_group_name }); + if (!(std.Io.File.stdin().isTty(app_runtime.io()) catch false)) { + try out.print("Skipped {s}; confirmation is required to copy from `{s}` to `{s}`.\n", .{ + match.label, + match.source_group_name, + target_group_name, + }); + try out.flush(); + return false; + } + try out.print("Add it to group `{s}`? [y/N]: ", .{target_group_name}); + try out.flush(); + + var input: [64]u8 = undefined; + const n = try readFileOnce(std.Io.File.stdin(), &input); + const line = std.mem.trim(u8, input[0..n], " \n\r\t"); + return line.len == 1 and (line[0] == 'y' or line[0] == 'Y'); +} + +fn appendTransferSelectionUnique( + allocator: std.mem.Allocator, + selected: *std.ArrayList(usize), + idx: usize, +) !void { + for (selected.items) |existing| { + if (existing == idx) return; + } + try selected.append(allocator, idx); +} + +fn managedTransferIndexWidth(count: usize) usize { + var value = count; + var width: usize = 1; + while (value >= 10) : (value /= 10) { + width += 1; + } + return @max(@as(usize, 2), width); +} + +fn writeManagedTransferRepeat(out: *std.Io.Writer, ch: u8, count: usize) !void { + var i: usize = 0; + while (i < count) : (i += 1) { + try out.writeByte(ch); + } +} + +fn writeManagedTransferPadded(out: *std.Io.Writer, value: []const u8, width: usize) !void { + try out.writeAll(value); + if (value.len < width) { + try writeManagedTransferRepeat(out, ' ', width - value.len); } - return result; +} + +fn writeManagedTransferIndex(out: *std.Io.Writer, idx: usize, width: usize) !void { + var value = idx; + var digits: usize = 1; + while (value >= 10) : (value /= 10) { + digits += 1; + } + if (digits < width) try writeManagedTransferRepeat(out, '0', width - digits); + try out.print("{d}", .{idx}); +} + +fn writeManagedTransferSeparator(out: *std.Io.Writer, group_name: []const u8, total_width: usize) !void { + try out.print("-- {s} ", .{group_name}); + const used = group_name.len + 4; + if (used < total_width) { + try writeManagedTransferRepeat(out, '-', total_width - used); + } + try out.writeByte('\n'); +} + +fn writeManagedTransferPickerTable(out: *std.Io.Writer, matches: []const ManagedGroupAccountMatch) !void { + const headers = [_][]const u8{ "GROUP", "ACCOUNT", "PLAN", "5H LEFT", "WEEKLY LEFT", "LAST ACTIVITY" }; + var widths = [_]usize{ + headers[0].len, + headers[1].len, + headers[2].len, + headers[3].len, + headers[4].len, + headers[5].len, + }; + for (matches) |match| { + widths[0] = @max(widths[0], match.source_group_name.len); + widths[1] = @max(widths[1], match.label.len); + widths[2] = @max(widths[2], match.plan.len); + widths[3] = @max(widths[3], match.rate_5h.len); + widths[4] = @max(widths[4], match.rate_week.len); + widths[5] = @max(widths[5], match.last_activity.len); + } + + const idx_width = managedTransferIndexWidth(matches.len); + const prefix_len: usize = 2 + idx_width + 1; + const sep_len: usize = 2; + const total_width = prefix_len + + widths[0] + widths[1] + widths[2] + widths[3] + widths[4] + widths[5] + + sep_len * (widths.len - 1); + + try writeManagedTransferRepeat(out, ' ', prefix_len); + inline for (headers, 0..) |header, idx| { + if (idx != 0) try out.writeAll(" "); + try writeManagedTransferPadded(out, header, widths[idx]); + } + try out.writeByte('\n'); + try writeManagedTransferRepeat(out, '-', total_width); + try out.writeByte('\n'); + + var last_group: ?[]const u8 = null; + for (matches, 0..) |match, idx| { + if (last_group == null or !std.mem.eql(u8, last_group.?, match.source_group_name)) { + try writeManagedTransferSeparator(out, match.source_group_name, total_width); + last_group = match.source_group_name; + } + try out.writeAll(if (match.is_active) "* " else " "); + try writeManagedTransferIndex(out, idx + 1, idx_width); + try out.writeByte(' '); + try writeManagedTransferPadded(out, match.source_group_name, widths[0]); + try out.writeAll(" "); + try writeManagedTransferPadded(out, match.label, widths[1]); + try out.writeAll(" "); + try writeManagedTransferPadded(out, match.plan, widths[2]); + try out.writeAll(" "); + try writeManagedTransferPadded(out, match.rate_5h, widths[3]); + try out.writeAll(" "); + try writeManagedTransferPadded(out, match.rate_week, widths[4]); + try out.writeAll(" "); + try writeManagedTransferPadded(out, match.last_activity, widths[5]); + try out.writeByte('\n'); + } +} + +test "managed transfer picker table includes list account columns" { + const gpa = std.testing.allocator; + var matches = [_]ManagedGroupAccountMatch{ + .{ + .source_group_name = try gpa.dupe(u8, "default"), + .source_codex_home = try gpa.dupe(u8, "default-home"), + .account_key = try gpa.dupe(u8, "alpha-key"), + .snapshot_path = try gpa.dupe(u8, "default-home/accounts/alpha.json"), + .label = try gpa.dupe(u8, "alpha@example.com"), + .alias = try gpa.dupe(u8, ""), + .plan = try gpa.dupe(u8, "Plus"), + .rate_5h = try gpa.dupe(u8, "88% (01:00)"), + .rate_week = try gpa.dupe(u8, "42% (2 May)"), + .last_activity = try gpa.dupe(u8, "4m ago"), + .is_active = true, + }, + .{ + .source_group_name = try gpa.dupe(u8, "work"), + .source_codex_home = try gpa.dupe(u8, "work-home"), + .account_key = try gpa.dupe(u8, "beta-key"), + .snapshot_path = try gpa.dupe(u8, "work-home/accounts/beta.json"), + .label = try gpa.dupe(u8, "beta@example.com"), + .alias = try gpa.dupe(u8, ""), + .plan = try gpa.dupe(u8, "Business"), + .rate_5h = try gpa.dupe(u8, "-"), + .rate_week = try gpa.dupe(u8, "-"), + .last_activity = try gpa.dupe(u8, "-"), + .is_active = false, + }, + }; + defer { + for (&matches) |*match| match.deinit(gpa); + } + + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try writeManagedTransferPickerTable(&aw.writer, &matches); + + const rendered = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, rendered, "GROUP") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "ACCOUNT") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "PLAN") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "5H LEFT") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "WEEKLY LEFT") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "LAST ACTIVITY") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "-- default") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "* 01") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "default") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "alpha@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "88% (01:00)") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "-- work") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, " 02") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "work") != null); +} + +fn selectManagedGroupTransferMatches( + allocator: std.mem.Allocator, + target_group_name: []const u8, + mode: ManagedGroupTransferMode, + matches: []const ManagedGroupAccountMatch, +) !?[]usize { + if (matches.len == 0) { + try printGroupMessage("No accounts available to {s} into group `{s}`.", .{ managedTransferVerb(mode), target_group_name }); + return null; + } + if (!(std.Io.File.stdin().isTty(app_runtime.io()) catch false) or + !(std.Io.File.stdout().isTty(app_runtime.io()) catch false)) + { + try printGroupError("`group {s}` requires account selectors when not running interactively.", .{managedTransferVerb(mode)}); + return error.InvalidCliUsage; + } + + var buffer: [4096]u8 = undefined; + var stdout = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &stdout.interface; + try out.print("Accounts available to {s} into group `{s}`:\n", .{ managedTransferVerb(mode), target_group_name }); + try writeManagedTransferPickerTable(out, matches); + try out.print("Select accounts to {s} [number(s), all, q]: ", .{managedTransferVerb(mode)}); + try out.flush(); + + var input: [1024]u8 = undefined; + const n = try readFileOnce(std.Io.File.stdin(), &input); + const line = std.mem.trim(u8, input[0..n], " \n\r\t"); + if (line.len == 0 or (line.len == 1 and (line[0] == 'q' or line[0] == 'Q'))) return null; + + var selected = std.ArrayList(usize).empty; + errdefer selected.deinit(allocator); + + if (std.ascii.eqlIgnoreCase(line, "all")) { + for (matches, 0..) |_, idx| { + try selected.append(allocator, idx); + } + return try selected.toOwnedSlice(allocator); + } + + var tokens = std.mem.tokenizeAny(u8, line, " ,\t"); + while (tokens.next()) |token| { + const choice = std.fmt.parseUnsigned(usize, token, 10) catch { + try printGroupError("invalid account selection `{s}`.", .{token}); + return error.InvalidCliUsage; + }; + if (choice == 0 or choice > matches.len) { + try printGroupError("account selection must be between 1 and {d}.", .{matches.len}); + return error.InvalidCliUsage; + } + try appendTransferSelectionUnique(allocator, &selected, choice - 1); + } + if (selected.items.len == 0) return null; + return try selected.toOwnedSlice(allocator); } fn importManagedMatchIntoGroup( allocator: std.mem.Allocator, target: *const group_manager.GroupRef, - target_reg: *registry.Registry, match: *const ManagedGroupAccountMatch, + target_reg: *registry.Registry, ) !registry.ImportOutcome { - const alias: ?[]const u8 = if (match.alias.len == 0) null else match.alias; + const alias = if (match.alias.len == 0) null else match.alias; var report = try registry.importAuthPath(allocator, target.codex_home, target_reg, match.snapshot_path, alias); defer report.deinit(allocator); - if (report.failure) |err| return err; - if (report.imported > 0) return .imported; - if (report.updated > 0) return .updated; - return .skipped; + return if (report.events.items.len == 0) .updated else report.events.items[0].outcome; } -fn handleManagedGroupAdd(allocator: std.mem.Allocator, target_group_name: []const u8, selectors: []const []const u8) !void { +fn removeManagedMatchFromSource(allocator: std.mem.Allocator, match: *const ManagedGroupAccountMatch) !bool { + var source_reg = try registry.loadRegistry(allocator, match.source_codex_home); + defer source_reg.deinit(allocator); + + const source_idx = registry.findAccountIndexByAccountKey(&source_reg, match.account_key) orelse return false; + const selected = [_]usize{source_idx}; + try removeSelectedAccountsAndPersist(allocator, match.source_codex_home, &source_reg, &selected, false); + return true; +} + +fn transferManagedGroupMatch( + allocator: std.mem.Allocator, + target: *const group_manager.GroupRef, + target_reg: *registry.Registry, + match: *const ManagedGroupAccountMatch, + mode: ManagedGroupTransferMode, + added: *usize, + updated: *usize, + removed_from_source: *usize, +) !void { + if (mode == .add and !managedMatchIsDefault(match)) { + if (!(try confirmAddFromNonDefaultGroup(match, target.name))) return; + } + + const outcome = try importManagedMatchIntoGroup(allocator, target, match, target_reg); + switch (outcome) { + .imported => added.* += 1, + .updated => updated.* += 1, + .skipped => { + try printGroupMessage("Skipped {s} from group `{s}`.", .{ match.label, match.source_group_name }); + return; + }, + } + if (mode == .move) { + if (try removeManagedMatchFromSource(allocator, match)) { + removed_from_source.* += 1; + } + } + try printGroupMessage("{s} {s} from group `{s}` to group `{s}`.", .{ + managedTransferPastVerb(mode), + match.label, + match.source_group_name, + target.name, + }); +} + +fn handleManagedGroupTransfer( + allocator: std.mem.Allocator, + target_group_name: []const u8, + selectors: []const []const u8, + mode: ManagedGroupTransferMode, +) !void { var target = try group_manager.resolveGroupAlloc(allocator, target_group_name); defer target.deinit(allocator); + var target_reg = try registry.loadRegistry(allocator, target.codex_home); defer target_reg.deinit(allocator); - var imported: usize = 0; + var added: usize = 0; var updated: usize = 0; - for (selectors) |selector| { - var matches = try collectManagedGroupMatches(allocator, selector, target.name); + var removed_from_source: usize = 0; + var missing = std.ArrayList([]const u8).empty; + defer missing.deinit(allocator); + + if (selectors.len == 0) { + var matches = try collectAllManagedGroupTransferMatches(allocator, target.name); defer { for (matches.items) |*match| match.deinit(allocator); matches.deinit(allocator); } - if (matches.items.len == 0) { - try cli.printAccountNotFoundError(selector); - return error.AccountNotFound; + const selected = try selectManagedGroupTransferMatches(allocator, target.name, mode, matches.items); + const selected_indices = selected orelse return; + defer allocator.free(selected_indices); + for (selected_indices) |match_idx| { + try transferManagedGroupMatch( + allocator, + &target, + &target_reg, + &matches.items[match_idx], + mode, + &added, + &updated, + &removed_from_source, + ); } - for (matches.items) |*match| { - switch (try importManagedMatchIntoGroup(allocator, &target, &target_reg, match)) { - .imported => imported += 1, - .updated => updated += 1, - .skipped => {}, + } else { + for (selectors) |selector| { + var matches = try collectManagedGroupMatches(allocator, selector, target.name); + defer { + for (matches.items) |*match| match.deinit(allocator); + matches.deinit(allocator); + } + + if (matches.items.len == 0) { + try missing.append(allocator, selector); + continue; + } + + for (matches.items) |*match| { + try transferManagedGroupMatch( + allocator, + &target, + &target_reg, + match, + mode, + &added, + &updated, + &removed_from_source, + ); } } } - try registry.saveRegistry(allocator, target.codex_home, &target_reg); - try printGroupMessage("Group `{s}` updated: {d} imported, {d} refreshed.", .{ target.name, imported, updated }); + if (added + updated > 0) { + try registry.saveRegistry(allocator, target.codex_home, &target_reg); + } + if (missing.items.len != 0) { + try cli.printAccountNotFoundErrors(missing.items); + if (added + updated == 0) return error.AccountNotFound; + } + switch (mode) { + .add => try printGroupMessage("Group `{s}` updated: {d} imported, {d} refreshed.", .{ target.name, added, updated }), + .copy => try printGroupMessage("Group `{s}` copy complete: {d} imported, {d} refreshed.", .{ target.name, added, updated }), + .move => try printGroupMessage("Group `{s}` move complete: {d} imported, {d} refreshed, {d} removed from source groups.", .{ + target.name, + added, + updated, + removed_from_source, + }), + } } -fn handleManagedGroupCreate(allocator: std.mem.Allocator, opts: cli.GroupMutationOptions) !void { - var group = try group_manager.ensureManagedGroupAlloc(allocator, opts.name); - defer group.deinit(allocator); - try printGroupMessage("Group `{s}` uses {s}.", .{ group.name, group.codex_home }); - if (opts.selectors.len != 0) { - try handleManagedGroupAdd(allocator, group.name, opts.selectors); - } +fn handleManagedGroupAdd(allocator: std.mem.Allocator, target_group_name: []const u8, selectors: []const []const u8) !void { + try handleManagedGroupTransfer(allocator, target_group_name, selectors, .add); +} + +fn handleManagedGroupCopy(allocator: std.mem.Allocator, target_group_name: []const u8, selectors: []const []const u8) !void { + try handleManagedGroupTransfer(allocator, target_group_name, selectors, .copy); +} + +fn handleManagedGroupMove(allocator: std.mem.Allocator, target_group_name: []const u8, selectors: []const []const u8) !void { + try handleManagedGroupTransfer(allocator, target_group_name, selectors, .move); } fn handleManagedGroupRemove(allocator: std.mem.Allocator, target_group_name: []const u8, selectors: []const []const u8) !void { @@ -2806,18 +3528,19 @@ fn handleManagedGroupRemove(allocator: std.mem.Allocator, target_group_name: []c .selectors = @constCast(selectors), .all = false, .live = false, - .api_mode = .default, + .api_mode = .skip_api, }); } -fn handleManagedGroupLaunch(allocator: std.mem.Allocator, name: []const u8, argv_tail: []const []const u8) !void { +fn handleManagedGroupLaunch(allocator: std.mem.Allocator, name: []const u8, opts: cli.LaunchOptions) !void { var group = try group_manager.resolveGroupAlloc(allocator, name); defer group.deinit(allocator); + try group_manager.rememberCurrentProjectGroup(allocator, group.name); var argv = std.ArrayList([]const u8).empty; defer argv.deinit(allocator); try argv.append(allocator, "codext"); - try argv.appendSlice(allocator, argv_tail); + try argv.appendSlice(allocator, opts.argv); var env_map = try getEnvMap(allocator); defer env_map.deinit(); @@ -2841,34 +3564,370 @@ fn handleManagedGroupLaunch(allocator: std.mem.Allocator, name: []const u8, argv return error.CodextLaunchFailed; } -fn handleGroup(allocator: std.mem.Allocator, opts: cli.GroupOptions) !void { +fn handleManagedGroupArchive(allocator: std.mem.Allocator, name: []const u8) !void { + var group = try group_manager.resolveGroupAlloc(allocator, name); + defer group.deinit(allocator); + if (std.mem.eql(u8, group.name, group_manager.default_group_name)) { + try printGroupError("default group cannot be archived.", .{}); + return error.InvalidGroupName; + } + auto.handleGroupAutoCommand(allocator, group.name, group.codex_home, .{ .action = .disable }) catch |err| switch (err) { + error.FileNotFound, error.UnsupportedRegistryVersion => {}, + else => return err, + }; + const archive_path = try group_manager.archiveManagedGroupFolder(allocator, group.name, nowMilliseconds()); + defer allocator.free(archive_path); + try printGroupMessage("Archived group `{s}` to {s}.", .{ group.name, archive_path }); +} + +fn handleManagedGroupDelete(allocator: std.mem.Allocator, opts: cli.GroupDeleteOptions) !void { + if (!opts.force) { + try printGroupError("group delete is permanent; run `codex-auth group archive {s}` to move it aside first, or rerun with `--force`.", .{opts.name}); + return error.InvalidCliUsage; + } + + var group = try group_manager.resolveGroupAlloc(allocator, opts.name); + defer group.deinit(allocator); + if (std.mem.eql(u8, group.name, group_manager.default_group_name)) { + try printGroupError("default group cannot be deleted.", .{}); + return error.InvalidGroupName; + } + auto.handleGroupAutoCommand(allocator, group.name, group.codex_home, .{ .action = .disable }) catch |err| switch (err) { + error.FileNotFound, error.UnsupportedRegistryVersion => {}, + else => return err, + }; + const deleted_path = try group_manager.deleteManagedGroupFolder(allocator, group.name); + defer allocator.free(deleted_path); + try printGroupMessage("Deleted group `{s}` from {s}.", .{ group.name, deleted_path }); +} + +fn handleProjectShow(allocator: std.mem.Allocator) !void { + const remembered = try group_manager.currentProjectGroupAlloc(allocator); + defer if (remembered) |name| allocator.free(name); + const group_name = remembered orelse group_manager.default_group_name; + var group = try group_manager.resolveGroupAlloc(allocator, group_name); + defer group.deinit(allocator); + try printGroupMessage("Project group: {s}{s}", .{ + group.name, + if (remembered == null) " (default)" else "", + }); + try printGroupMessage("CODEX_HOME: {s}", .{group.codex_home}); +} + +fn handleProjectSetGroup(allocator: std.mem.Allocator, name: []const u8) !void { + var group = try group_manager.resolveGroupAlloc(allocator, name); + defer group.deinit(allocator); + try group_manager.rememberCurrentProjectGroup(allocator, group.name); + try printGroupMessage("Project group set to `{s}` ({s}).", .{ group.name, group.codex_home }); +} + +fn handleProjectClear(allocator: std.mem.Allocator) !void { + const cleared = try group_manager.clearCurrentProjectGroup(allocator); + try printGroupMessage("{s}", .{if (cleared) "Project group cleared." else "No project group was set for this directory."}); +} + +fn handleProject(allocator: std.mem.Allocator, opts: cli.ProjectOptions) !void { + switch (opts) { + .show => try handleProjectShow(allocator), + .set_group => |name| try handleProjectSetGroup(allocator, name), + .clear => try handleProjectClear(allocator), + } +} + +fn handleProjectLaunch(allocator: std.mem.Allocator, opts: cli.LaunchOptions) !void { + const remembered = try group_manager.currentProjectGroupAlloc(allocator); + defer if (remembered) |name| allocator.free(name); + const group_name = remembered orelse group_manager.default_group_name; + try handleManagedGroupLaunch(allocator, group_name, opts); +} + +fn appendAccountKeyUnique( + allocator: std.mem.Allocator, + keys: *std.ArrayList([]const u8), + account_key: []const u8, +) !void { + for (keys.items) |existing| { + if (std.mem.eql(u8, existing, account_key)) return; + } + try keys.append(allocator, account_key); +} + +fn resolveGroupSelectorsAlloc( + allocator: std.mem.Allocator, + reg: *registry.Registry, + selectors: []const []const u8, +) ![]const []const u8 { + var keys = std.ArrayList([]const u8).empty; + defer keys.deinit(allocator); + var missing = std.ArrayList([]const u8).empty; + defer missing.deinit(allocator); + + for (selectors) |selector| { + if (try findAccountIndexByDisplayNumber(allocator, reg, selector)) |account_idx| { + try appendAccountKeyUnique(allocator, &keys, reg.accounts.items[account_idx].account_key); + continue; + } + + var matches = try findMatchingAccountsForRemove(allocator, reg, selector); + defer matches.deinit(allocator); + if (matches.items.len == 0) { + try missing.append(allocator, selector); + continue; + } + for (matches.items) |account_idx| { + try appendAccountKeyUnique(allocator, &keys, reg.accounts.items[account_idx].account_key); + } + } + + if (missing.items.len != 0) { + try cli.printAccountNotFoundErrors(missing.items); + return error.AccountNotFound; + } + return try keys.toOwnedSlice(allocator); +} + +fn printGroupList(reg: *registry.Registry) !void { + var buffer: [2048]u8 = undefined; + var writer = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + if (reg.groups.items.len == 0) { + try out.writeAll("No groups configured.\n"); + try out.flush(); + return; + } + for (reg.groups.items) |group| { + const active = reg.active_group_name != null and std.mem.eql(u8, reg.active_group_name.?, group.name); + try out.print("{s} {s} ({d} account(s))\n", .{ if (active) "*" else " ", group.name, group.account_keys.len }); + } + try out.flush(); +} + +fn handleGroupMutation( + allocator: std.mem.Allocator, + reg: *registry.Registry, + opts: cli.GroupMutationOptions, + action: enum { create, add, remove }, +) !usize { + const account_keys = try resolveGroupSelectorsAlloc(allocator, reg, opts.selectors); + defer allocator.free(account_keys); + + return switch (action) { + .create => blk: { + if (registry.findGroupIndexByName(reg, opts.name) != null) { + try printGroupError("group `{s}` already exists.", .{opts.name}); + return error.GroupAlreadyExists; + } + try registry.createGroup(allocator, reg, opts.name, account_keys); + break :blk account_keys.len; + }, + .add => blk: { + if (registry.findGroupIndexByName(reg, opts.name) == null) { + try printGroupError("group `{s}` does not exist.", .{opts.name}); + return error.GroupNotFound; + } + break :blk try registry.addAccountsToGroup(allocator, reg, opts.name, account_keys); + }, + .remove => blk: { + if (registry.findGroupIndexByName(reg, opts.name) == null) { + try printGroupError("group `{s}` does not exist.", .{opts.name}); + return error.GroupNotFound; + } + break :blk try registry.removeAccountsFromGroup(allocator, reg, opts.name, account_keys); + }, + }; +} + +fn handleGroupUse(allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry, name: ?[]const u8) !void { + if (name == null) { + registry.clearActiveGroup(allocator, reg); + try registry.saveRegistry(allocator, codex_home, reg); + try printGroupMessage("Active group cleared.", .{}); + return; + } + + const group_name = name.?; + if (registry.findGroupIndexByName(reg, group_name) == null) { + try printGroupError("group `{s}` does not exist.", .{group_name}); + return error.GroupNotFound; + } + const indices = try registry.groupAccountIndicesAlloc(allocator, reg, group_name); + defer allocator.free(indices); + if (indices.len == 0) { + try printGroupError("group `{s}` has no accounts.", .{group_name}); + return error.GroupEmpty; + } + + try registry.setActiveGroupName(allocator, reg, group_name); + var switched = false; + if (reg.active_account_key == null or !registry.accountInActiveGroup(reg, reg.active_account_key.?)) { + const best_idx = registry.selectBestAccountIndexByUsageFromIndices(reg, indices) orelse indices[0]; + try registry.activateAccountByKey(allocator, codex_home, reg, reg.accounts.items[best_idx].account_key); + switched = true; + } + try registry.saveRegistry(allocator, codex_home, reg); + + if (switched) { + const label = try accountLabelForKeyAlloc(allocator, reg, reg.active_account_key.?); + defer allocator.free(label); + try printGroupMessage("Active group: {s}; switched to {s}.", .{ group_name, label }); + } else { + try printGroupMessage("Active group: {s}.", .{group_name}); + } +} + +fn handleGroup(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.GroupOptions) !void { switch (opts) { .list => try printGroupListAll(allocator), + .status => try printManagedGroupStatusAll(allocator), .create => |mutation| try handleManagedGroupCreate(allocator, mutation), + .add => |mutation| try handleManagedGroupAdd(allocator, mutation.name, mutation.selectors), + .copy => |mutation| try handleManagedGroupCopy(allocator, mutation.name, mutation.selectors), + .move => |mutation| try handleManagedGroupMove(allocator, mutation.name, mutation.selectors), + .remove => |mutation| try handleManagedGroupRemove(allocator, mutation.name, mutation.selectors), + .delete => |delete_opts| try handleManagedGroupDelete(allocator, delete_opts), + .archive => |name| try handleManagedGroupArchive(allocator, name), + .use => |name| { + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { + try registry.saveRegistry(allocator, codex_home, ®); + } + try handleGroupUse(allocator, codex_home, ®, name); + }, .path => |name| try printManagedGroupPath(allocator, name), .scoped => |scoped| { - switch (scoped.action) { - .login => |login_opts| { - try handleManagedGroupLogin(allocator, scoped.name, login_opts); - return; - }, - else => {}, - } var group = try group_manager.resolveGroupAlloc(allocator, scoped.name); defer group.deinit(allocator); switch (scoped.action) { - .list => |list_opts| try handleList(allocator, group.codex_home, list_opts), - .login => unreachable, + .list => |list_opts| try handleList(allocator, group.codex_home, list_opts, .{ .current_group = group.display_color }), + .status => try auto.printStatusForGroup(allocator, group.name, group.codex_home), + .login => |login_opts| try handleManagedGroupLogin(allocator, group.name, login_opts), .add => |selectors| try handleManagedGroupAdd(allocator, group.name, selectors), + .copy => |selectors| try handleManagedGroupCopy(allocator, group.name, selectors), + .move => |selectors| try handleManagedGroupMove(allocator, group.name, selectors), .remove => |selectors| try handleManagedGroupRemove(allocator, group.name, selectors), + .auto_switch => |auto_opts| try auto.handleGroupAutoCommand(allocator, group.name, group.codex_home, auto_opts), + .config => |config_opts| try handleGroupConfig(allocator, group.codex_home, config_opts), .import_auth => |import_opts| try handleImport(allocator, group.codex_home, import_opts), .switch_account => |switch_opts| try handleSwitch(allocator, group.codex_home, switch_opts), - .launch => |argv| try handleManagedGroupLaunch(allocator, group.name, argv), + .launch => |launch_opts| try handleManagedGroupLaunch(allocator, group.name, launch_opts), } }, } } +fn handleGroupConfig(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ConfigOptions) !void { + switch (opts) { + .api => |action| try auto.handleApiCommand(allocator, codex_home, action), + .auto_switch => |auto_opts| try auto.handleAutoCommand(allocator, codex_home, auto_opts), + } +} + +fn handleConfig(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ConfigOptions) !void { + switch (opts) { + .auto_switch => |auto_opts| try auto.handleAutoCommand(allocator, codex_home, auto_opts), + .api => |action| try auto.handleApiCommand(allocator, codex_home, action), + } +} + +fn freeOwnedStrings(allocator: std.mem.Allocator, items: []const []const u8) void { + for (items) |item| allocator.free(@constCast(item)); +} + +pub fn findMatchingAccounts( + allocator: std.mem.Allocator, + reg: *registry.Registry, + query: []const u8, +) !std.ArrayList(usize) { + return findMatchingAccountsInScope(allocator, reg, query, null); +} + +fn accountIndexInScope(idx: usize, account_indices: ?[]const usize) bool { + const indices = account_indices orelse return true; + for (indices) |account_idx| { + if (account_idx == idx) return true; + } + return false; +} + +pub fn findMatchingAccountsInScope( + allocator: std.mem.Allocator, + reg: *registry.Registry, + query: []const u8, + account_indices: ?[]const usize, +) !std.ArrayList(usize) { + var matches = std.ArrayList(usize).empty; + for (reg.accounts.items, 0..) |*rec, idx| { + if (!accountIndexInScope(idx, account_indices)) continue; + const matches_email = std.ascii.indexOfIgnoreCase(rec.email, query) != null; + const matches_alias = rec.alias.len != 0 and std.ascii.indexOfIgnoreCase(rec.alias, query) != null; + const matches_name = if (rec.account_name) |name| + name.len != 0 and std.ascii.indexOfIgnoreCase(name, query) != null + else + false; + if (matches_email or matches_alias or matches_name) { + try matches.append(allocator, idx); + } + } + return matches; +} + +fn findMatchingAccountsForRemove( + allocator: std.mem.Allocator, + reg: *registry.Registry, + query: []const u8, +) !std.ArrayList(usize) { + var matches = std.ArrayList(usize).empty; + for (reg.accounts.items, 0..) |*rec, idx| { + const matches_email = std.ascii.indexOfIgnoreCase(rec.email, query) != null; + const matches_alias = rec.alias.len != 0 and std.ascii.indexOfIgnoreCase(rec.alias, query) != null; + const matches_name = if (rec.account_name) |name| + name.len != 0 and std.ascii.indexOfIgnoreCase(name, query) != null + else + false; + const matches_key = std.ascii.indexOfIgnoreCase(rec.account_key, query) != null; + if (matches_email or matches_alias or matches_name or matches_key) { + try matches.append(allocator, idx); + } + } + return matches; +} + +fn parseDisplayNumber(selector: []const u8) ?usize { + if (selector.len == 0) return null; + for (selector) |ch| { + if (ch < '0' or ch > '9') return null; + } + + const parsed = std.fmt.parseInt(usize, selector, 10) catch return null; + if (parsed == 0) return null; + return parsed; +} + +fn findAccountIndexByDisplayNumber( + allocator: std.mem.Allocator, + reg: *registry.Registry, + selector: []const u8, +) !?usize { + return findAccountIndexByDisplayNumberInScope(allocator, reg, selector, null); +} + +fn findAccountIndexByDisplayNumberInScope( + allocator: std.mem.Allocator, + reg: *registry.Registry, + selector: []const u8, + account_indices: ?[]const usize, +) !?usize { + const display_number = parseDisplayNumber(selector) orelse return null; + + var display = try display_rows.buildDisplayRows(allocator, reg, account_indices); + defer display.deinit(allocator); + + if (display_number > display.selectable_row_indices.len) return null; + const row_idx = display.selectable_row_indices[display_number - 1]; + return display.rows[row_idx].account_index; +} + const CurrentAuthState = struct { record_key: ?[]u8, syncable: bool, @@ -3952,7 +5011,6 @@ test { _ = @import("cli.zig"); _ = @import("compat_fs.zig"); _ = @import("format.zig"); - _ = @import("group_manager.zig"); _ = @import("timefmt.zig"); _ = @import("tests/auth_test.zig"); _ = @import("tests/sessions_test.zig"); diff --git a/src/registry.zig b/src/registry.zig index 52264151..3ea2413f 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -8,7 +8,7 @@ const c_time = @cImport({ pub const PlanType = enum { free, plus, prolite, pro, team, business, enterprise, edu, unknown }; pub const AuthMode = enum { chatgpt, apikey }; -pub const current_schema_version: u32 = 3; +pub const current_schema_version: u32 = 4; pub const min_supported_schema_version: u32 = 2; pub const default_auto_switch_threshold_5h_percent: u8 = 10; pub const default_auto_switch_threshold_weekly_percent: u8 = 5; @@ -112,6 +112,11 @@ pub const AccountRecord = struct { last_local_rollout: ?RolloutSignature, }; +pub const AccountGroup = struct { + name: []u8, + account_keys: [][]u8, +}; + pub fn resolvePlan(rec: *const AccountRecord) ?PlanType { if (rec.plan) |p| return p; if (rec.last_usage) |u| return u.plan_type; @@ -143,16 +148,23 @@ pub const Registry = struct { schema_version: u32, active_account_key: ?[]u8, active_account_activated_at_ms: ?i64, + active_group_name: ?[]u8 = null, auto_switch: AutoSwitchConfig, api: ApiConfig, accounts: std.ArrayList(AccountRecord), + groups: std.ArrayList(AccountGroup) = .empty, pub fn deinit(self: *Registry, allocator: std.mem.Allocator) void { for (self.accounts.items) |*rec| { freeAccountRecord(allocator, rec); } + for (self.groups.items) |*group| { + freeAccountGroup(allocator, group); + } if (self.active_account_key) |k| allocator.free(k); + if (self.active_group_name) |name| allocator.free(name); self.accounts.deinit(allocator); + self.groups.deinit(allocator); } }; @@ -177,6 +189,12 @@ fn freeAccountRecord(allocator: std.mem.Allocator, rec: *const AccountRecord) vo } } +fn freeAccountGroup(allocator: std.mem.Allocator, group: *const AccountGroup) void { + allocator.free(group.name); + for (group.account_keys) |key| allocator.free(key); + allocator.free(group.account_keys); +} + pub fn freeRateLimitSnapshot(allocator: std.mem.Allocator, snapshot: *const RateLimitSnapshot) void { if (snapshot.credits) |*c| { if (c.balance) |b| allocator.free(b); @@ -1661,7 +1679,7 @@ fn syncCurrentAuthBestEffort( return if (existing_idx != null) .updated else .imported; } -pub fn findAccountIndexByAccountKey(reg: *Registry, account_key: []const u8) ?usize { +pub fn findAccountIndexByAccountKey(reg: *const Registry, account_key: []const u8) ?usize { for (reg.accounts.items, 0..) |rec, i| { if (std.mem.eql(u8, rec.account_key, account_key)) return i; } @@ -1687,6 +1705,207 @@ pub fn setActiveAccountKey(allocator: std.mem.Allocator, reg: *Registry, account } } +pub fn findGroupIndexByName(reg: *const Registry, name: []const u8) ?usize { + for (reg.groups.items, 0..) |group, idx| { + if (std.mem.eql(u8, group.name, name)) return idx; + } + return null; +} + +pub fn groupContainsAccountKey(group: *const AccountGroup, account_key: []const u8) bool { + for (group.account_keys) |key| { + if (std.mem.eql(u8, key, account_key)) return true; + } + return false; +} + +pub fn accountInActiveGroup(reg: *const Registry, account_key: []const u8) bool { + const group_name = reg.active_group_name orelse return true; + const group_idx = findGroupIndexByName(reg, group_name) orelse return false; + return groupContainsAccountKey(®.groups.items[group_idx], account_key); +} + +fn stringListContains(items: []const []u8, value: []const u8) bool { + for (items) |item| { + if (std.mem.eql(u8, item, value)) return true; + } + return false; +} + +fn appendOwnedGroupKeyUnique( + allocator: std.mem.Allocator, + keys: *std.ArrayList([]u8), + account_key: []const u8, +) !bool { + if (stringListContains(keys.items, account_key)) return false; + try keys.append(allocator, try allocator.dupe(u8, account_key)); + return true; +} + +pub fn createGroup( + allocator: std.mem.Allocator, + reg: *Registry, + name: []const u8, + account_keys: []const []const u8, +) !void { + if (name.len == 0) return error.InvalidGroupName; + if (findGroupIndexByName(reg, name) != null) return error.GroupAlreadyExists; + + const owned_name = try allocator.dupe(u8, name); + errdefer allocator.free(owned_name); + + var keys = std.ArrayList([]u8).empty; + errdefer { + for (keys.items) |key| allocator.free(key); + keys.deinit(allocator); + } + + for (account_keys) |account_key| { + if (findAccountIndexByAccountKey(reg, account_key) == null) continue; + _ = try appendOwnedGroupKeyUnique(allocator, &keys, account_key); + } + + const owned_keys = try keys.toOwnedSlice(allocator); + errdefer { + for (owned_keys) |key| allocator.free(key); + allocator.free(owned_keys); + } + + try reg.groups.append(allocator, .{ + .name = owned_name, + .account_keys = owned_keys, + }); +} + +fn appendGroupAccountKey( + allocator: std.mem.Allocator, + group: *AccountGroup, + account_key: []const u8, +) !bool { + if (groupContainsAccountKey(group, account_key)) return false; + const new_keys = try allocator.alloc([]u8, group.account_keys.len + 1); + errdefer allocator.free(new_keys); + @memcpy(new_keys[0..group.account_keys.len], group.account_keys); + new_keys[group.account_keys.len] = try allocator.dupe(u8, account_key); + allocator.free(group.account_keys); + group.account_keys = new_keys; + return true; +} + +pub fn addAccountsToGroup( + allocator: std.mem.Allocator, + reg: *Registry, + group_name: []const u8, + account_keys: []const []const u8, +) !usize { + const group_idx = findGroupIndexByName(reg, group_name) orelse return error.GroupNotFound; + var added: usize = 0; + for (account_keys) |account_key| { + if (findAccountIndexByAccountKey(reg, account_key) == null) continue; + if (try appendGroupAccountKey(allocator, ®.groups.items[group_idx], account_key)) { + added += 1; + } + } + return added; +} + +fn keyMatchesAny(key: []const u8, account_keys: []const []const u8) bool { + for (account_keys) |account_key| { + if (std.mem.eql(u8, key, account_key)) return true; + } + return false; +} + +pub fn removeAccountsFromGroup( + allocator: std.mem.Allocator, + reg: *Registry, + group_name: []const u8, + account_keys: []const []const u8, +) !usize { + const group_idx = findGroupIndexByName(reg, group_name) orelse return error.GroupNotFound; + const group = ®.groups.items[group_idx]; + + var kept_count: usize = 0; + var removed_count: usize = 0; + for (group.account_keys) |key| { + if (keyMatchesAny(key, account_keys)) { + removed_count += 1; + } else { + kept_count += 1; + } + } + if (removed_count == 0) return 0; + + const new_keys = try allocator.alloc([]u8, kept_count); + var write_idx: usize = 0; + for (group.account_keys) |key| { + if (keyMatchesAny(key, account_keys)) { + allocator.free(key); + continue; + } + new_keys[write_idx] = key; + write_idx += 1; + } + allocator.free(group.account_keys); + group.account_keys = new_keys; + return removed_count; +} + +pub fn deleteGroup(allocator: std.mem.Allocator, reg: *Registry, name: []const u8) !void { + const group_idx = findGroupIndexByName(reg, name) orelse return error.GroupNotFound; + freeAccountGroup(allocator, ®.groups.items[group_idx]); + var idx = group_idx; + while (idx + 1 < reg.groups.items.len) : (idx += 1) { + reg.groups.items[idx] = reg.groups.items[idx + 1]; + } + reg.groups.items.len -= 1; + if (reg.active_group_name) |active| { + if (std.mem.eql(u8, active, name)) { + allocator.free(active); + reg.active_group_name = null; + } + } +} + +pub fn setActiveGroupName(allocator: std.mem.Allocator, reg: *Registry, name: []const u8) !void { + if (findGroupIndexByName(reg, name) == null) return error.GroupNotFound; + if (reg.active_group_name) |active| { + if (std.mem.eql(u8, active, name)) return; + } + const owned_name = try allocator.dupe(u8, name); + if (reg.active_group_name) |active| allocator.free(active); + reg.active_group_name = owned_name; +} + +pub fn clearActiveGroup(allocator: std.mem.Allocator, reg: *Registry) void { + if (reg.active_group_name) |active| allocator.free(active); + reg.active_group_name = null; +} + +pub fn groupAccountIndicesAlloc( + allocator: std.mem.Allocator, + reg: *const Registry, + group_name: []const u8, +) ![]usize { + const group_idx = findGroupIndexByName(reg, group_name) orelse return error.GroupNotFound; + var indices = std.ArrayList(usize).empty; + defer indices.deinit(allocator); + for (reg.groups.items[group_idx].account_keys) |account_key| { + if (findAccountIndexByAccountKey(reg, account_key)) |account_idx| { + try indices.append(allocator, account_idx); + } + } + return try indices.toOwnedSlice(allocator); +} + +pub fn activeGroupAccountIndicesAlloc( + allocator: std.mem.Allocator, + reg: *const Registry, +) !?[]usize { + const group_name = reg.active_group_name orelse return null; + return try groupAccountIndicesAlloc(allocator, reg, group_name); +} + pub fn updateUsage(allocator: std.mem.Allocator, reg: *Registry, account_key: []const u8, snapshot: RateLimitSnapshot) void { const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); for (reg.accounts.items) |*rec| { @@ -1799,6 +2018,7 @@ pub fn removeAccounts(allocator: std.mem.Allocator, codex_home: []const u8, reg: } try deleteRemovedAccountBackups(allocator, codex_home, reg, removed); + try removeDeletedAccountsFromGroups(allocator, reg, removed); if (reg.active_account_key) |key| { var active_removed = false; @@ -1832,6 +2052,74 @@ pub fn removeAccounts(allocator: std.mem.Allocator, codex_home: []const u8, reg: reg.accounts.items.len = write_idx; } +fn removedAccountKeyMatches(reg: *const Registry, removed: []const bool, account_key: []const u8) bool { + for (reg.accounts.items, 0..) |rec, i| { + if (!removed[i]) continue; + if (std.mem.eql(u8, rec.account_key, account_key)) return true; + } + return false; +} + +fn removeDeletedAccountsFromGroups( + allocator: std.mem.Allocator, + reg: *Registry, + removed: []const bool, +) !void { + for (reg.groups.items) |*group| { + var kept_count: usize = 0; + var removed_count: usize = 0; + for (group.account_keys) |account_key| { + if (removedAccountKeyMatches(reg, removed, account_key)) { + removed_count += 1; + } else { + kept_count += 1; + } + } + if (removed_count == 0) continue; + + const new_keys = try allocator.alloc([]u8, kept_count); + var write_idx: usize = 0; + for (group.account_keys) |account_key| { + if (removedAccountKeyMatches(reg, removed, account_key)) { + allocator.free(account_key); + continue; + } + new_keys[write_idx] = account_key; + write_idx += 1; + } + allocator.free(group.account_keys); + group.account_keys = new_keys; + } +} + +fn pruneUnknownGroupAccountKeys(allocator: std.mem.Allocator, reg: *Registry) !void { + for (reg.groups.items) |*group| { + var kept_count: usize = 0; + var removed_count: usize = 0; + for (group.account_keys) |account_key| { + if (findAccountIndexByAccountKey(reg, account_key) == null) { + removed_count += 1; + } else { + kept_count += 1; + } + } + if (removed_count == 0) continue; + + const new_keys = try allocator.alloc([]u8, kept_count); + var write_idx: usize = 0; + for (group.account_keys) |account_key| { + if (findAccountIndexByAccountKey(reg, account_key) == null) { + allocator.free(account_key); + continue; + } + new_keys[write_idx] = account_key; + write_idx += 1; + } + allocator.free(group.account_keys); + group.account_keys = new_keys; + } +} + fn deleteRemovedAccountBackups( allocator: std.mem.Allocator, codex_home: []const u8, @@ -1894,6 +2182,29 @@ pub fn selectBestAccountIndexByUsage(reg: *Registry) ?usize { return best_idx; } +pub fn selectBestAccountIndexByUsageFromIndices(reg: *Registry, indices: []const usize) ?usize { + if (indices.len == 0) return null; + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + var best_idx: ?usize = null; + var best_score: i64 = -2; + var best_seen: i64 = -1; + for (indices) |i| { + if (i >= reg.accounts.items.len) continue; + const rec = reg.accounts.items[i]; + const score = usageScoreAt(rec.last_usage, now) orelse -1; + const seen = rec.last_usage_at orelse -1; + if (score > best_score) { + best_score = score; + best_seen = seen; + best_idx = i; + } else if (score == best_score and seen > best_seen) { + best_seen = seen; + best_idx = i; + } + } + return best_idx; +} + pub fn usageScoreAt(usage: ?RateLimitSnapshot, now: i64) ?i64 { const rate_5h = resolveRateWindow(usage, 300, true); const rate_week = resolveRateWindow(usage, 10080, false); @@ -2142,9 +2453,11 @@ fn defaultRegistry() Registry { .schema_version = current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, + .active_group_name = null, .auto_switch = defaultAutoSwitchConfig(), .api = defaultApiConfig(), .accounts = std.ArrayList(AccountRecord).empty, + .groups = std.ArrayList(AccountGroup).empty, }; } @@ -2249,6 +2562,42 @@ fn parseAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !Ac return rec; } +fn parseAccountGroup(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !AccountGroup { + const name_val = obj.get("name") orelse return error.MissingGroupName; + const name = switch (name_val) { + .string => |s| s, + else => return error.MissingGroupName, + }; + + var keys = std.ArrayList([]u8).empty; + errdefer { + for (keys.items) |key| allocator.free(key); + keys.deinit(allocator); + } + if (obj.get("account_keys")) |v| { + switch (v) { + .array => |arr| { + for (arr.items) |item| { + const account_key = switch (item) { + .string => |s| s, + else => continue, + }; + if (stringListContains(keys.items, account_key)) continue; + try keys.append(allocator, try allocator.dupe(u8, account_key)); + } + }, + else => {}, + } + } + + const owned_name = try allocator.dupe(u8, name); + errdefer allocator.free(owned_name); + return .{ + .name = owned_name, + .account_keys = try keys.toOwnedSlice(allocator), + }; +} + fn parseOptionalStoredStringAlloc(allocator: std.mem.Allocator, value: ?std.json.Value) !?[]u8 { const text = switch (value orelse return null) { .string => |s| s, @@ -2463,6 +2812,12 @@ fn loadCurrentRegistry(allocator: std.mem.Allocator, root_obj: std.json.ObjectMa } else if (reg.active_account_key != null) { reg.active_account_activated_at_ms = 0; } + if (root_obj.get("active_group")) |v| { + switch (v) { + .string => |s| reg.active_group_name = try allocator.dupe(u8, s), + else => {}, + } + } if (root_obj.get("accounts")) |v| { switch (v) { .array => |arr| { @@ -2478,6 +2833,26 @@ fn loadCurrentRegistry(allocator: std.mem.Allocator, root_obj: std.json.ObjectMa else => {}, } } + if (root_obj.get("groups")) |v| { + switch (v) { + .array => |arr| { + for (arr.items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + var group = try parseAccountGroup(allocator, obj); + errdefer freeAccountGroup(allocator, &group); + if (findGroupIndexByName(®, group.name) != null) { + freeAccountGroup(allocator, &group); + continue; + } + try reg.groups.append(allocator, group); + } + }, + else => {}, + } + } if (root_obj.get("auto_switch")) |v| { parseAutoSwitch(allocator, ®.auto_switch, v); @@ -2485,6 +2860,13 @@ fn loadCurrentRegistry(allocator: std.mem.Allocator, root_obj: std.json.ObjectMa if (root_obj.get("api")) |v| { parseApiConfig(®.api, v); } + try pruneUnknownGroupAccountKeys(allocator, ®); + if (reg.active_group_name) |name| { + if (findGroupIndexByName(®, name) == null) { + allocator.free(name); + reg.active_group_name = null; + } + } return reg; } @@ -2565,7 +2947,7 @@ pub fn loadRegistry(allocator: std.mem.Allocator, codex_home: []const u8) !Regis (schema_version == current_schema_version and currentLayoutNeedsRewrite(root_obj)); var reg = switch (schema_version) { 2 => try loadLegacyRegistryV2(allocator, codex_home, root_obj), - 3 => try loadCurrentRegistry(allocator, root_obj), + 3, 4 => try loadCurrentRegistry(allocator, root_obj), else => { std.log.err( "registry schema_version {d} is older than the minimum supported {d}; use an intermediate codex-auth release or import --purge", @@ -2650,9 +3032,11 @@ pub fn saveRegistry(allocator: std.mem.Allocator, codex_home: []const u8, reg: * .schema_version = current_schema_version, .active_account_key = reg.active_account_key, .active_account_activated_at_ms = reg.active_account_activated_at_ms, + .active_group = reg.active_group_name, .auto_switch = reg.auto_switch, .api = reg.api, .accounts = reg.accounts.items, + .groups = reg.groups.items, }; var aw: std.Io.Writer.Allocating = .init(allocator); defer aw.deinit(); @@ -2673,9 +3057,11 @@ const RegistryOut = struct { schema_version: u32, active_account_key: ?[]const u8, active_account_activated_at_ms: ?i64, + active_group: ?[]const u8, auto_switch: AutoSwitchConfig, api: ApiConfig, accounts: []const AccountRecord, + groups: []const AccountGroup, }; fn parsePlanType(s: []const u8) ?PlanType { diff --git a/src/sessions.zig b/src/sessions.zig index 8c9e2c4c..5a826731 100644 --- a/src/sessions.zig +++ b/src/sessions.zig @@ -36,6 +36,16 @@ pub const LatestRolloutEvent = struct { } }; +pub const LatestLimitEvent = struct { + path: []u8, + mtime: i64, + event_timestamp_ms: i64, + + pub fn deinit(self: *LatestLimitEvent, allocator: std.mem.Allocator) void { + allocator.free(self.path); + } +}; + const RolloutCandidate = struct { path: []u8, mtime: i64, @@ -46,6 +56,10 @@ const ParsedUsageEvent = struct { snapshot: ?registry.RateLimitSnapshot, }; +const ParsedLimitEvent = struct { + event_timestamp_ms: i64, +}; + const UsageEventLineJson = struct { timestamp: []const u8 = "", type: []const u8 = "", @@ -57,6 +71,18 @@ const UsagePayloadJson = struct { rate_limits: ?UsageRateLimitsJson = null, }; +const LimitEventLineJson = struct { + timestamp: []const u8 = "", + type: []const u8 = "", + payload: LimitPayloadJson = .{}, +}; + +const LimitPayloadJson = struct { + type: []const u8 = "", + message: []const u8 = "", + codex_error_info: []const u8 = "", +}; + const UsageRateLimitsJson = struct { primary: ?UsageWindowJson = null, secondary: ?UsageWindowJson = null, @@ -78,6 +104,8 @@ const UsageCreditsJson = struct { const max_rollout_line_bytes: usize = 10 * 1024 * 1024; const rollout_full_rescan_interval_ns = 15 * std.time.ns_per_s; +const limit_full_rescan_interval_ns = 1 * std.time.ns_per_s; +const limit_message_needle = "hit your usage limit"; pub const RolloutScanCache = struct { last_full_scan_at_ns: i128 = 0, @@ -111,6 +139,38 @@ pub const RolloutScanCache = struct { } }; +pub const LimitScanCache = struct { + last_full_scan_at_ns: i128 = 0, + latest: ?LatestLimitEvent = null, + + pub fn deinit(self: *LimitScanCache, allocator: std.mem.Allocator) void { + self.clear(allocator); + } + + fn clear(self: *LimitScanCache, allocator: std.mem.Allocator) void { + if (self.latest) |*latest| { + latest.deinit(allocator); + } + self.latest = null; + self.last_full_scan_at_ns = 0; + } + + fn replace(self: *LimitScanCache, allocator: std.mem.Allocator, latest: ?LatestLimitEvent, scanned_at_ns: i128) void { + if (self.latest) |*cached| { + cached.deinit(allocator); + } + self.latest = latest; + self.last_full_scan_at_ns = scanned_at_ns; + } + + fn cloneLatest(self: *const LimitScanCache, allocator: std.mem.Allocator) !?LatestLimitEvent { + if (self.latest) |latest| { + return try cloneLatestLimitEvent(allocator, latest); + } + return null; + } +}; + pub fn scanLatestUsage(allocator: std.mem.Allocator, codex_home: []const u8) !?registry.RateLimitSnapshot { const latest = try scanLatestUsageWithSource(allocator, codex_home); if (latest == null) return null; @@ -206,6 +266,58 @@ pub fn scanLatestRolloutEventWithSource(allocator: std.mem.Allocator, codex_home }; } +pub fn scanLatestLimitEventWithSource(allocator: std.mem.Allocator, codex_home: []const u8) !?LatestLimitEvent { + const sessions_root = try std.fs.path.join(allocator, &[_][]const u8{ codex_home, "sessions" }); + defer allocator.free(sessions_root); + + var latest: ?LatestLimitEvent = null; + errdefer if (latest) |*event| event.deinit(allocator); + + var dir = try std.Io.Dir.cwd().openDir(app_runtime.io(), sessions_root, .{ .iterate = true }); + defer dir.close(app_runtime.io()); + var walker = try dir.walk(allocator); + defer walker.deinit(); + + while (try walker.next(app_runtime.io())) |entry| { + if (entry.kind != .file) continue; + if (!isRolloutFile(entry.path)) continue; + const stat = dir.statFile(app_runtime.io(), entry.path, .{}) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; + const mtime: i64 = @intCast(stat.mtime.nanoseconds); + const path = try std.fs.path.join(allocator, &[_][]const u8{ sessions_root, entry.path }); + errdefer allocator.free(path); + const parsed = scanFileForLimitMessage(allocator, path, mtime) catch |err| switch (err) { + error.FileNotFound => { + allocator.free(path); + continue; + }, + else => return err, + }; + if (parsed == null) { + allocator.free(path); + continue; + } + + const event: LatestLimitEvent = .{ + .path = path, + .mtime = mtime, + .event_timestamp_ms = parsed.?.event_timestamp_ms, + }; + if (latest) |*current| { + if (!limitEventNewer(event, current.*)) { + allocator.free(event.path); + continue; + } + current.deinit(allocator); + } + latest = event; + } + + return latest; +} + pub fn scanLatestRolloutEventWithCache( allocator: std.mem.Allocator, codex_home: []const u8, @@ -237,6 +349,37 @@ pub fn scanLatestRolloutEventWithCache( return try refreshRolloutScanCache(allocator, codex_home, cache, now_ns); } +pub fn scanLatestLimitEventWithCache( + allocator: std.mem.Allocator, + codex_home: []const u8, + cache: *LimitScanCache, +) !?LatestLimitEvent { + const now_ns = @as(i128, std.Io.Timestamp.now(app_runtime.io(), .real).toNanoseconds()); + + if (cache.latest) |cached| { + const stat = std.Io.Dir.cwd().statFile(app_runtime.io(), cached.path, .{}) catch |err| switch (err) { + error.FileNotFound => return try refreshLimitScanCache(allocator, codex_home, cache, now_ns), + else => return err, + }; + const current_mtime: i64 = @intCast(stat.mtime.nanoseconds); + if (current_mtime != cached.mtime) { + const reparsed = try scanFileForLimitMessage(allocator, cached.path, current_mtime); + if (reparsed) |parsed| { + const updated = try latestLimitEventFromParsedLimit(allocator, cached.path, current_mtime, parsed); + cache.replace(allocator, updated, cache.last_full_scan_at_ns); + return try cache.cloneLatest(allocator); + } + return try refreshLimitScanCache(allocator, codex_home, cache, now_ns); + } + + if (cache.last_full_scan_at_ns != 0 and (now_ns - cache.last_full_scan_at_ns) < limit_full_rescan_interval_ns) { + return try cache.cloneLatest(allocator); + } + } + + return try refreshLimitScanCache(allocator, codex_home, cache, now_ns); +} + fn scanFileForUsage(allocator: std.mem.Allocator, path: []const u8) !?ParsedUsageEvent { return scanFileForUsageWithMode(allocator, path, true); } @@ -265,6 +408,27 @@ fn scanFileForUsageWithMode(allocator: std.mem.Allocator, path: []const u8, keep return scanUsageEventSliceBackwards(allocator, map.memory, keep_latest_unusable); } +fn scanFileForLimitMessage(allocator: std.mem.Allocator, path: []const u8, mtime: i64) !?ParsedLimitEvent { + var file = try std.Io.Dir.cwd().openFile(app_runtime.io(), path, .{}); + defer file.close(app_runtime.io()); + + const stat = try file.stat(app_runtime.io()); + if (stat.size == 0) return null; + const fallback_timestamp_ms = mtimeToMillis(mtime); + if (comptime builtin.os.tag == .windows) { + return scanFileForLimitMessageStreaming(allocator, file, fallback_timestamp_ms); + } + + var map = try file.createMemoryMap(app_runtime.io(), .{ + .len = @intCast(stat.size), + .protection = .{ .read = true, .write = false }, + .populate = false, + }); + defer map.destroy(app_runtime.io()); + + return scanLimitEventSliceBackwards(allocator, map.memory, fallback_timestamp_ms); +} + fn scanFileForUsageStreaming( allocator: std.mem.Allocator, file: std.Io.File, @@ -325,6 +489,58 @@ fn scanFileForUsageStreaming( return last; } +fn scanFileForLimitMessageStreaming( + allocator: std.mem.Allocator, + file: std.Io.File, + fallback_timestamp_ms: i64, +) !?ParsedLimitEvent { + var read_buffer: [8192]u8 = undefined; + var file_reader = file.reader(app_runtime.io(), &read_buffer); + const reader = &file_reader.interface; + var line_buffer: std.Io.Writer.Allocating = .init(allocator); + defer line_buffer.deinit(); + var last: ?ParsedLimitEvent = null; + + while (true) { + line_buffer.clearRetainingCapacity(); + const line_len = reader.streamDelimiterLimit( + &line_buffer.writer, + '\n', + .limited(max_rollout_line_bytes), + ) catch |err| switch (err) { + error.StreamTooLong => { + _ = reader.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) { + error.EndOfStream => break, + error.ReadFailed => return file_reader.err orelse error.ReadFailed, + }; + continue; + }, + error.ReadFailed => return file_reader.err orelse error.ReadFailed, + error.WriteFailed => return error.OutOfMemory, + }; + const line = line_buffer.written(); + const next_byte: ?u8 = reader.peekByte() catch |err| switch (err) { + error.EndOfStream => null, + error.ReadFailed => return file_reader.err orelse error.ReadFailed, + }; + if (next_byte) |byte| { + std.debug.assert(byte == '\n'); + _ = reader.discardDelimiterInclusive('\n') catch |err| switch (err) { + error.EndOfStream => unreachable, + error.ReadFailed => return file_reader.err orelse error.ReadFailed, + }; + } else if (line_len == 0) { + break; + } + const trimmed = std.mem.trim(u8, line, " \r\t"); + if (trimmed.len == 0) continue; + if (parseLimitEventLine(allocator, trimmed, fallback_timestamp_ms)) |event| { + last = event; + } + } + return last; +} + fn scanUsageEventSliceBackwards( allocator: std.mem.Allocator, contents: []const u8, @@ -354,6 +570,29 @@ fn scanUsageEventSliceBackwards( return null; } +fn scanLimitEventSliceBackwards(allocator: std.mem.Allocator, contents: []const u8, fallback_timestamp_ms: i64) ?ParsedLimitEvent { + var line_end = contents.len; + + while (line_end > 0) { + const maybe_newline = std.mem.lastIndexOfScalar(u8, contents[0..line_end], '\n'); + const line_start = if (maybe_newline) |idx| idx + 1 else 0; + const line = std.mem.trim(u8, contents[line_start..line_end], " \r\t"); + if (line.len != 0 and line.len <= max_rollout_line_bytes) { + if (parseLimitEventLine(allocator, line, fallback_timestamp_ms)) |event| { + return event; + } + } + + if (maybe_newline) |idx| { + line_end = idx; + continue; + } + break; + } + + return null; +} + fn refreshRolloutScanCache( allocator: std.mem.Allocator, codex_home: []const u8, @@ -365,6 +604,17 @@ fn refreshRolloutScanCache( return try cache.cloneLatest(allocator); } +fn refreshLimitScanCache( + allocator: std.mem.Allocator, + codex_home: []const u8, + cache: *LimitScanCache, + scanned_at_ns: i128, +) !?LatestLimitEvent { + const latest = try scanLatestLimitEventWithSource(allocator, codex_home); + cache.replace(allocator, latest, scanned_at_ns); + return try cache.cloneLatest(allocator); +} + fn latestRolloutEventFromParsedUsage( allocator: std.mem.Allocator, path: []const u8, @@ -382,6 +632,19 @@ fn latestRolloutEventFromParsedUsage( }; } +fn latestLimitEventFromParsedLimit( + allocator: std.mem.Allocator, + path: []const u8, + mtime: i64, + parsed: ParsedLimitEvent, +) !LatestLimitEvent { + return .{ + .path = try allocator.dupe(u8, path), + .mtime = mtime, + .event_timestamp_ms = parsed.event_timestamp_ms, + }; +} + fn cloneLatestRolloutEvent(allocator: std.mem.Allocator, latest: LatestRolloutEvent) !LatestRolloutEvent { return .{ .path = try allocator.dupe(u8, latest.path), @@ -394,6 +657,14 @@ fn cloneLatestRolloutEvent(allocator: std.mem.Allocator, latest: LatestRolloutEv }; } +fn cloneLatestLimitEvent(allocator: std.mem.Allocator, latest: LatestLimitEvent) !LatestLimitEvent { + return .{ + .path = try allocator.dupe(u8, latest.path), + .mtime = latest.mtime, + .event_timestamp_ms = latest.event_timestamp_ms, + }; +} + pub fn parseUsageLine(allocator: std.mem.Allocator, line: []const u8) ?registry.RateLimitSnapshot { const event = parseUsageEventLine(allocator, line) orelse return null; return event.snapshot; @@ -429,6 +700,46 @@ fn looksLikeUsageEventLine(line: []const u8) bool { std.mem.indexOf(u8, line, "\"timestamp\"") != null; } +fn parseLimitEventLine(allocator: std.mem.Allocator, line: []const u8, fallback_timestamp_ms: i64) ?ParsedLimitEvent { + if (!looksLikeLimitEventLine(line)) return null; + + var parsed = std.json.parseFromSlice(LimitEventLineJson, allocator, line, .{ + .ignore_unknown_fields = true, + }) catch return null; + defer parsed.deinit(); + + const root = parsed.value; + if (!std.mem.eql(u8, root.type, "event_msg")) return null; + + const explicit_limit_error = std.mem.eql(u8, root.payload.codex_error_info, "usage_limit_exceeded"); + const error_message_limit = + std.mem.eql(u8, root.payload.type, "error") and + std.ascii.indexOfIgnoreCase(root.payload.message, limit_message_needle) != null; + if (!explicit_limit_error and !error_message_limit) return null; + + return .{ + .event_timestamp_ms = parseTimestampMs(root.timestamp) orelse fallback_timestamp_ms, + }; +} + +fn looksLikeLimitEventLine(line: []const u8) bool { + if (std.mem.indexOf(u8, line, "\"event_msg\"") == null) return false; + if (std.mem.indexOf(u8, line, "\"payload\"") == null) return false; + return std.mem.indexOf(u8, line, "\"usage_limit_exceeded\"") != null or + std.ascii.indexOfIgnoreCase(line, limit_message_needle) != null; +} + +fn mtimeToMillis(mtime: i64) i64 { + return @divFloor(mtime, @as(i64, std.time.ns_per_ms)); +} + +fn limitEventNewer(candidate: LatestLimitEvent, current: LatestLimitEvent) bool { + if (candidate.event_timestamp_ms != current.event_timestamp_ms) { + return candidate.event_timestamp_ms > current.event_timestamp_ms; + } + return candidate.mtime > current.mtime; +} + fn parseRateLimits(allocator: std.mem.Allocator, parsed: UsageRateLimitsJson) ?registry.RateLimitSnapshot { var snap = registry.RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .plan_type = null }; if (parsed.primary) |p| snap.primary = parseWindow(p); diff --git a/src/tests/auto_test.zig b/src/tests/auto_test.zig index c5986b2b..342d3f5e 100644 --- a/src/tests/auto_test.zig +++ b/src/tests/auto_test.zig @@ -659,6 +659,44 @@ test "Scenario: Given custom 5h threshold when checking current then it uses con try std.testing.expect(auto.shouldSwitchCurrent(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds())); } +test "Scenario: Given 5h remaining equal to threshold when checking current then auto switch is required" { + const gpa = std.testing.allocator; + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.threshold_5h_percent = 15; + + try appendAccountWithUsage(gpa, ®, "active@example.com", .{ + .primary = .{ .used_percent = 85.0, .window_minutes = 300, .resets_at = null }, + .secondary = .{ .used_percent = 40.0, .window_minutes = 10080, .resets_at = null }, + .credits = null, + .plan_type = null, + }, 100); + const active_account_key = try bdd.accountKeyForEmailAlloc(gpa, "active@example.com"); + defer gpa.free(active_account_key); + try registry.setActiveAccountKey(gpa, ®, active_account_key); + + try std.testing.expect(auto.shouldSwitchCurrent(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds())); +} + +test "Scenario: Given weekly remaining equal to threshold when checking current then auto switch is required" { + const gpa = std.testing.allocator; + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.threshold_weekly_percent = 5; + + try appendAccountWithUsage(gpa, ®, "active@example.com", .{ + .primary = .{ .used_percent = 20.0, .window_minutes = 300, .resets_at = null }, + .secondary = .{ .used_percent = 95.0, .window_minutes = 10080, .resets_at = null }, + .credits = null, + .plan_type = null, + }, 100); + const active_account_key = try bdd.accountKeyForEmailAlloc(gpa, "active@example.com"); + defer gpa.free(active_account_key); + try registry.setActiveAccountKey(gpa, ®, active_account_key); + + try std.testing.expect(auto.shouldSwitchCurrent(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds())); +} + test "Scenario: Given missing window_minutes in the primary slot when checking current then 5h fallback still triggers auto switch" { const gpa = std.testing.allocator; var reg = bdd.makeEmptyRegistry(); @@ -932,6 +970,102 @@ test "Scenario: Given repeated daemon candidate refresh attempts within cooldown try std.testing.expectEqual(@as(usize, 1), candidate_api_fetch_count); } +test "Scenario: Given a fresh limit message when daemon runs then active account is exhausted for switching" { + 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.makePath("sessions/2025/01/01"); + + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.enabled = true; + reg.api.usage = false; + + try appendAccountWithUsage(gpa, ®, "active@example.com", .{ + .primary = .{ .used_percent = 15.0, .window_minutes = 300, .resets_at = null }, + .secondary = .{ .used_percent = 20.0, .window_minutes = 10080, .resets_at = null }, + .credits = null, + .plan_type = .pro, + }, 100); + try appendAccountWithUsage(gpa, ®, "candidate@example.com", null, null); + + const active_account_id = try bdd.accountKeyForEmailAlloc(gpa, "active@example.com"); + defer gpa.free(active_account_id); + const candidate_account_id = try bdd.accountKeyForEmailAlloc(gpa, "candidate@example.com"); + defer gpa.free(candidate_account_id); + try registry.setActiveAccountKey(gpa, ®, active_account_id); + reg.active_account_activated_at_ms = 0; + + const candidate_auth = try bdd.authJsonWithEmailPlan(gpa, "candidate@example.com", "pro"); + defer gpa.free(candidate_auth); + const candidate_path = try registry.accountAuthPath(gpa, codex_home, candidate_account_id); + defer gpa.free(candidate_path); + try fs.cwd().writeFile(.{ .sub_path = candidate_path, .data = candidate_auth }); + + const limit_contents = + "{\"timestamp\":\"2025-01-01T00:00:20.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"error\",\"message\":\"You've hit your usage limit. Try again later.\",\"codex_error_info\":\"usage_limit_exceeded\"}}\n"; + try tmp.dir.writeFile(.{ .sub_path = "sessions/2025/01/01/rollout-limit.jsonl", .data = limit_contents }); + + var refresh_state = auto.DaemonRefreshState{}; + defer refresh_state.deinit(gpa); + + try std.testing.expect(try auto.applyActiveLimitMessageForDaemon(gpa, codex_home, ®, &refresh_state)); + try std.testing.expect(reg.accounts.items[0].last_usage != null); + try std.testing.expectEqual(@as(f64, 100.0), reg.accounts.items[0].last_usage.?.primary.?.used_percent); + + const attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingCandidateUsageByAuthPathDetailed); + try std.testing.expect(attempt.switched); + try std.testing.expect(reg.active_account_key != null); + try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, candidate_account_id)); +} + +test "Scenario: Given a stale limit message older than latest usage then daemon keeps the newer usage snapshot" { + 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("sessions/2025/01/01"); + + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.enabled = true; + reg.api.usage = false; + + try appendAccountWithUsage(gpa, ®, "active@example.com", .{ + .primary = .{ .used_percent = 15.0, .window_minutes = 300, .resets_at = null }, + .secondary = .{ .used_percent = 20.0, .window_minutes = 10080, .resets_at = null }, + .credits = null, + .plan_type = .pro, + }, 100); + const active_account_id = try bdd.accountKeyForEmailAlloc(gpa, "active@example.com"); + defer gpa.free(active_account_id); + try registry.setActiveAccountKey(gpa, ®, active_account_id); + reg.active_account_activated_at_ms = 0; + + const rollout_contents = + "{\"timestamp\":\"2025-01-01T00:00:20.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"error\",\"message\":\"You've hit your usage limit. Try again later.\",\"codex_error_info\":\"usage_limit_exceeded\"}}\n" ++ + "{\"timestamp\":\"2025-01-01T00:00:30.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"rate_limits\":{\"primary\":{\"used_percent\":20.0,\"window_minutes\":300,\"resets_at\":123},\"secondary\":{\"used_percent\":30.0,\"window_minutes\":10080,\"resets_at\":456},\"plan_type\":\"pro\"}}}\n"; + try tmp.dir.writeFile(.{ .sub_path = "sessions/2025/01/01/rollout-limit-then-usage.jsonl", .data = rollout_contents }); + + var refresh_state = auto.DaemonRefreshState{}; + defer refresh_state.deinit(gpa); + + try std.testing.expect(try auto.refreshActiveUsageForDaemonWithApiFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingApiError)); + try std.testing.expect(!(try auto.applyActiveLimitMessageForDaemon(gpa, codex_home, ®, &refresh_state))); + + const idx = bdd.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; + try std.testing.expect(reg.accounts.items[idx].last_usage != null); + try std.testing.expectEqual(@as(f64, 20.0), reg.accounts.items[idx].last_usage.?.primary.?.used_percent); + try std.testing.expect(reg.accounts.items[idx].last_local_rollout != null); + try std.testing.expectEqual(@as(i64, 1735689630000), reg.accounts.items[idx].last_local_rollout.?.event_timestamp_ms); +} + test "Scenario: Given switch-time candidate validation returns non-200 then that candidate is disqualified" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); @@ -1166,6 +1300,54 @@ test "Scenario: Given switch-time candidate validation gets no response then the try std.testing.expectEqual(@as(usize, 1), candidate_api_fetch_count); } +test "Scenario: Given active usage API is unavailable then daemon does not switch from a stale exhausted snapshot" { + 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 = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.enabled = true; + reg.api.usage = true; + + try appendAccountWithUsage(gpa, ®, "active@example.com", .{ + .primary = .{ .used_percent = 99.0, .window_minutes = 300, .resets_at = null }, + .secondary = .{ .used_percent = 90.0, .window_minutes = 10080, .resets_at = null }, + .credits = null, + .plan_type = .pro, + }, 100); + try appendAccountWithUsage(gpa, ®, "candidate@example.com", .{ + .primary = .{ .used_percent = 20.0, .window_minutes = 300, .resets_at = null }, + .secondary = .{ .used_percent = 20.0, .window_minutes = 10080, .resets_at = null }, + .credits = null, + .plan_type = .pro, + }, 100); + + const active_account_id = try bdd.accountKeyForEmailAlloc(gpa, "active@example.com"); + defer gpa.free(active_account_id); + try registry.setActiveAccountKey(gpa, ®, active_account_id); + + daemon_api_fetch_count = 0; + candidate_api_fetch_count = 0; + var refresh_state = auto.DaemonRefreshState{}; + defer refresh_state.deinit(gpa); + + try std.testing.expect(!(try auto.refreshActiveUsageForDaemonWithApiFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingApiError))); + try std.testing.expectEqual(@as(usize, 1), daemon_api_fetch_count); + + const attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingCandidateUsageByAuthPathDetailed); + try std.testing.expect(!attempt.refreshed_candidates); + try std.testing.expect(!attempt.state_changed); + try std.testing.expect(!attempt.switched); + try std.testing.expect(reg.active_account_key != null); + try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, active_account_id)); + try std.testing.expectEqual(@as(usize, 0), candidate_api_fetch_count); +} + test "Scenario: Given daemon api mode and an api-key candidate when auto switching then the candidate stays eligible without usage refresh" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); @@ -1373,6 +1555,72 @@ test "Scenario: Given linux service unit when rendering then it keeps a persiste try std.testing.expect(std.mem.indexOf(u8, unit, "WantedBy=default.target") != null); } +test "Scenario: Given legacy managed group service target when rendering then it can clean up old isolated service identities" { + const gpa = std.testing.allocator; + var target = try auto.groupServiceTargetAlloc(gpa, "work"); + defer target.deinit(gpa); + + try std.testing.expectEqualStrings("codex-auth-autoswitch-work.service", target.linux_service_name); + try std.testing.expect(target.linux_timer_name == null); + try std.testing.expectEqualStrings("com.loongphy.codex-auth.auto.work", target.mac_label); + try std.testing.expectEqualStrings("CodexAuthAutoSwitch-work", target.windows_task_name); + + const plist = try auto.macPlistTextForLabel(gpa, "/tmp/codex-auth", "/tmp/custom-codex-home", target.mac_label); + defer gpa.free(plist); + try std.testing.expect(std.mem.indexOf(u8, plist, "com.loongphy.codex-auth.auto.work") != null); + + const script = try auto.windowsRegisterTaskScriptForTask( + gpa, + "C:\\Program Files\\codex-auth\\codex-auth-auto.exe", + "C:\\Users\\demo\\Codex Work\\", + target.windows_task_name, + ); + defer gpa.free(script); + try std.testing.expect(std.mem.indexOf(u8, script, "Register-ScheduledTask -TaskName 'CodexAuthAutoSwitch-work'") != null); +} + +test "Scenario: Given manager service target when rendering then one service can watch all enabled groups" { + const gpa = std.testing.allocator; + const target = auto.managerServiceTarget(); + + try std.testing.expectEqualStrings("codex-auth-manager.service", target.linux_service_name); + try std.testing.expect(target.linux_timer_name == null); + try std.testing.expectEqualStrings("com.loongphy.codex-auth.manager", target.mac_label); + try std.testing.expectEqualStrings("CodexAuthManager", target.windows_task_name); + + const unit = try auto.linuxManagerUnitText(gpa, "/tmp/codex-auth"); + defer gpa.free(unit); + try std.testing.expect(std.mem.indexOf(u8, unit, "Description=codex-auth auto-switch manager") != null); + try std.testing.expect(std.mem.indexOf(u8, unit, "ExecStart=\"/tmp/codex-auth\" daemon --manager") != null); + try std.testing.expect(std.mem.indexOf(u8, unit, "CODEX_HOME") == null); + try std.testing.expect(std.mem.indexOf(u8, unit, "CODEX_AUTH_NODE_EXECUTABLE") == null); + + const unit_with_node = try auto.linuxManagerUnitTextWithNode(gpa, "/tmp/codex-auth", "/opt/node/bin/node"); + defer gpa.free(unit_with_node); + try std.testing.expect(std.mem.indexOf(u8, unit_with_node, "Environment=\"CODEX_AUTH_NODE_EXECUTABLE=/opt/node/bin/node\"") != null); + + const plist = try auto.macManagerPlistText(gpa, "/tmp/codex-auth"); + defer gpa.free(plist); + try std.testing.expect(std.mem.indexOf(u8, plist, "com.loongphy.codex-auth.manager") != null); + try std.testing.expect(std.mem.indexOf(u8, plist, "--manager") != null); + try std.testing.expect(std.mem.indexOf(u8, plist, "CODEX_HOME") == null); + try std.testing.expect(std.mem.indexOf(u8, plist, "CODEX_AUTH_NODE_EXECUTABLE") == null); + + const plist_with_node = try auto.macManagerPlistTextForLabelWithNode(gpa, "/tmp/codex-auth", target.mac_label, "/opt/node/bin/node"); + defer gpa.free(plist_with_node); + try std.testing.expect(std.mem.indexOf(u8, plist_with_node, "CODEX_AUTH_NODE_EXECUTABLE") != null); + try std.testing.expect(std.mem.indexOf(u8, plist_with_node, "/opt/node/bin/node") != null); + + const action = try auto.windowsManagerTaskAction(gpa, "C:\\Program Files\\codex-auth\\codex-auth-auto.exe"); + defer gpa.free(action); + try std.testing.expect(std.mem.indexOf(u8, action, "--manager") != null); + try std.testing.expect(std.mem.indexOf(u8, action, "--codex-home") == null); + + const script = try auto.windowsManagerRegisterTaskScript(gpa, "C:\\Program Files\\codex-auth\\codex-auth-auto.exe"); + defer gpa.free(script); + try std.testing.expect(std.mem.indexOf(u8, script, "Register-ScheduledTask -TaskName 'CodexAuthManager'") != null); +} + test "Scenario: Given a zig build run executable path when resolving the managed service binary then it prefers zig-out" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); @@ -1599,14 +1847,17 @@ test "Scenario: Given status when rendering then auto and usage api settings are .threshold_weekly_percent = 8, .api_usage_enabled = false, .api_account_enabled = false, + .codex_home = "/tmp/codex-auth-status/.codex", }); const output = aw.written(); try std.testing.expect(std.mem.indexOf(u8, output, "auto-switch: ON") != null); try std.testing.expect(std.mem.indexOf(u8, output, "service: running") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "thresholds: 5h<12%, weekly<8%") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "thresholds: 5h left<=12%, weekly left<=8%") != null); try std.testing.expect(std.mem.indexOf(u8, output, "usage: local") != null); try std.testing.expect(std.mem.indexOf(u8, output, "account: disabled") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "account scope: all accounts in CODEX_HOME: /tmp/codex-auth-status/.codex") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "group: all") == null); try std.testing.expect(std.mem.indexOf(u8, output, "Warning: Usage refresh is currently using the ChatGPT usage API") == null); } @@ -1614,6 +1865,10 @@ test "Scenario: Given api usage mode when rendering status body then risk warnin const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); defer aw.deinit(); + const managed_group = try gpa.dupe(u8, "trading"); + defer gpa.free(managed_group); + const active_group = try gpa.dupe(u8, "legacy-work"); + defer gpa.free(active_group); try auto.writeStatus(&aw.writer, .{ .enabled = true, @@ -1622,11 +1877,16 @@ test "Scenario: Given api usage mode when rendering status body then risk warnin .threshold_weekly_percent = 8, .api_usage_enabled = true, .api_account_enabled = true, + .codex_home = "/tmp/codex-auth-status/groups/trading", + .managed_group = managed_group, + .active_group = active_group, }); const output = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, output, "managed group: trading") != null); try std.testing.expect(std.mem.indexOf(u8, output, "usage: api") != null); try std.testing.expect(std.mem.indexOf(u8, output, "account: api") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "account scope: legacy group legacy-work in CODEX_HOME: /tmp/codex-auth-status/groups/trading") != null); try std.testing.expect(std.mem.indexOf(u8, output, "Warning: Usage refresh is currently using the ChatGPT usage API") == null); try std.testing.expect(std.mem.indexOf(u8, output, "`codex-auth config api disable`") == null); } diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 387305e2..2fc38143 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -253,16 +253,7 @@ test "Scenario: Given login with device auth flag when parsing then device auth } } -test "Scenario: Given login with duplicate device auth flag when parsing then usage error is returned" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "login", "--device-auth", "--device-auth" }; - var result = try cli.parseArgs(gpa, &args); - defer cli.freeParseResult(gpa, &result); - - try expectUsageError(result, .login, "duplicate `--device-auth`"); -} - -test "Scenario: Given login with group when parsing then group name and device auth are preserved" { +test "Scenario: Given login with group flag when parsing then group target is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "login", "--group", "work", "--device-auth" }; var result = try cli.parseArgs(gpa, &args); @@ -281,20 +272,30 @@ test "Scenario: Given login with group when parsing then group name and device a } } -test "Scenario: Given group create with account selectors when parsing then group mutation is preserved" { +test "Scenario: Given login with duplicate device auth flag when parsing then usage error is returned" { const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "group", "create", "work", "01", "jane@example.com" }; + const args = [_][:0]const u8{ "codex-auth", "login", "--device-auth", "--device-auth" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + try expectUsageError(result, .login, "duplicate `--device-auth`"); +} + +test "Scenario: Given scoped group login when parsing then group and device auth are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "work", "login", "--device-auth" }; var result = try cli.parseArgs(gpa, &args); defer cli.freeParseResult(gpa, &result); switch (result) { .command => |cmd| switch (cmd) { - .group => |opts| switch (opts) { - .create => |mutation| { - try std.testing.expectEqualStrings("work", mutation.name); - try std.testing.expectEqual(@as(usize, 2), mutation.selectors.len); - try std.testing.expectEqualStrings("01", mutation.selectors[0]); - try std.testing.expectEqualStrings("jane@example.com", mutation.selectors[1]); + .group => |group_opts| switch (group_opts) { + .scoped => |scoped| { + try std.testing.expectEqualStrings("work", scoped.name); + switch (scoped.action) { + .login => |login_opts| try std.testing.expect(login_opts.device_auth), + else => return error.TestExpectedEqual, + } }, else => return error.TestExpectedEqual, }, @@ -304,23 +305,19 @@ test "Scenario: Given group create with account selectors when parsing then grou } } -test "Scenario: Given scoped group commands when parsing then delegated options are preserved" { +test "Scenario: Given accounts group name when parsing group accounts list then scoped list is preserved" { const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "group", "work", "switch", "--live", "--auto", "--skip-api" }; + const args = [_][:0]const u8{ "codex-auth", "group", "accounts", "list" }; var result = try cli.parseArgs(gpa, &args); defer cli.freeParseResult(gpa, &result); switch (result) { .command => |cmd| switch (cmd) { - .group => |opts| switch (opts) { + .group => |group_opts| switch (group_opts) { .scoped => |scoped| { - try std.testing.expectEqualStrings("work", scoped.name); + try std.testing.expectEqualStrings("accounts", scoped.name); switch (scoped.action) { - .switch_account => |switch_opts| { - try std.testing.expect(switch_opts.live); - try std.testing.expect(switch_opts.auto); - try std.testing.expectEqual(cli.ApiMode.skip_api, switch_opts.api_mode); - }, + .list => {}, else => return error.TestExpectedEqual, } }, @@ -332,17 +329,13 @@ test "Scenario: Given scoped group commands when parsing then delegated options } } -test "Scenario: Given group help when rendering then core group usage is shown" { +test "Scenario: Given group name before removed show action when parsing then usage error is returned" { const gpa = std.testing.allocator; - var aw: std.Io.Writer.Allocating = .init(gpa); - defer aw.deinit(); - - try cli.writeCommandHelp(&aw.writer, false, .group); + const args = [_][:0]const u8{ "codex-auth", "group", "work", "show" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); - const help = aw.written(); - try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group create [...]") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group login [--device-auth]") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group launch [-- ...]") != null); + try expectUsageError(result, .group, "unknown group action `show`"); } test "Scenario: Given command help selector when parsing then command-specific help is preserved" { @@ -369,7 +362,7 @@ test "Scenario: Given help when rendering then login and command help notes are try cli.writeHelp(&aw.writer, false, &auto_cfg, &api_cfg); const help = aw.written(); - 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, "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, "Account API: ON") != null); try std.testing.expect(std.mem.indexOf(u8, help, "--cpa []") != null); @@ -377,9 +370,12 @@ test "Scenario: Given help when rendering then login and command help notes are 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); - try std.testing.expect(std.mem.indexOf(u8, help, "group") != null); try std.testing.expect(std.mem.indexOf(u8, help, "switch [--live] [--auto] [--api|--skip-api] | switch ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "remove [--live] [--api|--skip-api] | remove [...] | remove --all") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "list | list") != null); + try std.testing.expect(std.mem.indexOf(u8, help, " switch [--live] [--auto] [--api|--skip-api] | switch ") != null); + try std.testing.expect(std.mem.indexOf(u8, help, " config api enable|disable") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "archive | delete --force") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Delete backup and stale files under accounts/") != null); try std.testing.expect(std.mem.indexOf(u8, help, "status") != null); try std.testing.expect(std.mem.indexOf(u8, help, "config") != null); @@ -419,6 +415,270 @@ test "Scenario: Given complex command help when rendering then examples are show 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 group command help when rendering then managed group forms are shown" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.writeCommandHelp(&aw.writer, false, .group); + + const help = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group list [--live] [--api|--skip-api]") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group show") == null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group show ") == null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group status") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group config api enable|disable") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group copy [...]") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group move [...]") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group path") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group launch [resume [session]] [-- ...]") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group work status") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group work show") == null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group work list --skip-api") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group work copy") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group work move personal@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group work switch\n") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group work switch 02") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group work launch resume") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth group work config api enable") != null); +} + +test "Scenario: Given scoped group copy without selectors when parsing then interactive copy is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "work", "copy" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |group_opts| switch (group_opts) { + .scoped => |scoped| { + try std.testing.expectEqualStrings("work", scoped.name); + switch (scoped.action) { + .copy => |selectors| try std.testing.expectEqual(@as(usize, 0), selectors.len), + else => return error.TestExpectedEqual, + } + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given scoped group move with selector when parsing then direct move is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "work", "move", "02" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |group_opts| switch (group_opts) { + .scoped => |scoped| { + try std.testing.expectEqualStrings("work", scoped.name); + switch (scoped.action) { + .move => |selectors| { + try std.testing.expectEqual(@as(usize, 1), selectors.len); + try std.testing.expectEqualStrings("02", selectors[0]); + }, + else => return error.TestExpectedEqual, + } + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given top-level group copy alias when parsing then target group is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "copy", "work" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |group_opts| switch (group_opts) { + .copy => |opts| { + try std.testing.expectEqualStrings("work", opts.name); + try std.testing.expectEqual(@as(usize, 0), opts.selectors.len); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given top-level group move alias when parsing then selector is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "move", "work", "alpha" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |group_opts| switch (group_opts) { + .move => |opts| { + try std.testing.expectEqualStrings("work", opts.name); + try std.testing.expectEqual(@as(usize, 1), opts.selectors.len); + try std.testing.expectEqualStrings("alpha", opts.selectors[0]); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given scoped group launch resume when parsing then codext resume arguments are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "work", "launch", "resume", "019db67d-2190-7563-a899-ce3082e491cf" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |group_opts| switch (group_opts) { + .scoped => |scoped| { + try std.testing.expectEqualStrings("work", scoped.name); + switch (scoped.action) { + .launch => |opts| try expectArgv(opts.argv, &[_][]const u8{ + "resume", + "019db67d-2190-7563-a899-ce3082e491cf", + }), + else => return error.TestExpectedEqual, + } + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given remembered project launch resume when parsing then codext resume arguments are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "launch", "resume", "019db67d-2190-7563-a899-ce3082e491cf" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .launch => |opts| try expectArgv(opts.argv, &[_][]const u8{ + "resume", + "019db67d-2190-7563-a899-ce3082e491cf", + }), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given scoped group switch without query when parsing then interactive switch is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "work", "switch" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |group_opts| switch (group_opts) { + .scoped => |scoped| { + try std.testing.expectEqualStrings("work", scoped.name); + switch (scoped.action) { + .switch_account => |opts| { + try std.testing.expect(opts.query == null); + try std.testing.expect(!opts.live); + try std.testing.expect(!opts.auto); + try std.testing.expectEqual(cli.ApiMode.default, opts.api_mode); + }, + else => return error.TestExpectedEqual, + } + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given scoped group switch with skip api when parsing then interactive switch options are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "work", "switch", "--skip-api" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |group_opts| switch (group_opts) { + .scoped => |scoped| { + try std.testing.expectEqualStrings("work", scoped.name); + switch (scoped.action) { + .switch_account => |opts| { + try std.testing.expect(opts.query == null); + try std.testing.expect(!opts.live); + try std.testing.expect(!opts.auto); + try std.testing.expectEqual(cli.ApiMode.skip_api, opts.api_mode); + }, + else => return error.TestExpectedEqual, + } + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given scoped group switch with query when parsing then direct switch is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "work", "switch", "02" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |group_opts| switch (group_opts) { + .scoped => |scoped| { + try std.testing.expectEqualStrings("work", scoped.name); + switch (scoped.action) { + .switch_account => |opts| { + try std.testing.expect(opts.query != null); + try std.testing.expectEqualStrings("02", opts.query.?); + try std.testing.expect(!opts.live); + try std.testing.expect(!opts.auto); + try std.testing.expectEqual(cli.ApiMode.default, opts.api_mode); + }, + else => return error.TestExpectedEqual, + } + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given scoped group switch query with api flag when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "work", "switch", "--api", "02" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + try expectUsageError(result, .group, "invalid `group switch` arguments"); +} + test "Scenario: Given scanned import report when rendering then stdout and stderr match the import format" { const gpa = std.testing.allocator; var stdout_aw: std.Io.Writer.Allocating = .init(gpa); @@ -755,6 +1015,63 @@ test "Scenario: Given daemon once when parsing then one-shot daemon command is p } } +test "Scenario: Given daemon manager when parsing then manager daemon command is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "daemon", "--manager" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .daemon => |opts| try std.testing.expectEqual(cli.DaemonMode.manager, opts.mode), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given daemon manager once when parsing then one-shot manager daemon command is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "daemon", "--manager-once" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .daemon => |opts| try std.testing.expectEqual(cli.DaemonMode.manager_once, opts.mode), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given scoped group api config when parsing then group config is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "group", "work", "config", "api", "enable" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .group => |opts| switch (opts) { + .scoped => |scoped| { + try std.testing.expectEqualStrings("work", scoped.name); + switch (scoped.action) { + .config => |config| switch (config) { + .api => |action| try std.testing.expectEqual(cli.ApiAction.enable, action), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + 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); @@ -780,7 +1097,7 @@ test "Scenario: Given codex login client missing when rendering then detection h try std.testing.expect(std.mem.indexOf(u8, hint, "Ensure the Codex CLI is installed and available in your environment.") != null); } -test "Scenario: Given login help when rendering then device auth usage is included" { +test "Scenario: Given login help when rendering then device auth and group usage are included" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); defer aw.deinit(); @@ -789,6 +1106,7 @@ test "Scenario: Given login help when rendering then device auth usage is includ const help = aw.written(); try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth login --device-auth") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth login --group ") != null); } test "Scenario: Given login options when building codex argv then device auth is forwarded" { diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index b30aa923..9ad86a63 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -187,6 +187,32 @@ fn writeSuccessfulFakeCodex(dir: fs.Dir) !void { } } +fn fakeCodextCommandPath() []const u8 { + return if (builtin.os.tag == .windows) "fake-bin/codext.cmd" else "fake-bin/codext"; +} + +fn writeSuccessfulFakeCodext(dir: fs.Dir) !void { + const script = + if (builtin.os.tag == .windows) + "@echo off\r\n" ++ + ">\"%HOME%\\fake-codext-codex-home.txt\" echo %CODEX_HOME%\r\n" ++ + ">\"%HOME%\\fake-codext-argv.txt\" echo %*\r\n" ++ + "exit /b 0\r\n" + else + "#!/bin/sh\n" ++ + "printf '%s\\n' \"$CODEX_HOME\" > \"$HOME/fake-codext-codex-home.txt\"\n" ++ + "printf '%s\\n' \"$*\" > \"$HOME/fake-codext-argv.txt\"\n" ++ + "exit 0\n"; + const sub_path = fakeCodextCommandPath(); + try dir.writeFile(.{ .sub_path = sub_path, .data = script }); + + if (builtin.os.tag != .windows) { + var file = try dir.openFile(sub_path, .{ .mode = .read_write }); + defer file.close(); + try file.chmod(0o755); + } +} + fn fakeNodeCommandPath() []const u8 { return if (builtin.os.tag == .windows) "fake-node-bin/node.cmd" else "fake-node-bin/node"; } @@ -495,6 +521,16 @@ fn countAuthBackups(dir: fs.Dir, rel_path: []const u8) !usize { return count; } +fn countOccurrences(haystack: []const u8, needle: []const u8) usize { + var count: usize = 0; + var pos: usize = 0; + while (std.mem.indexOfPos(u8, haystack, pos, needle)) |idx| { + count += 1; + pos = idx + needle.len; + } + return count; +} + fn legacySnapshotNameForEmail(allocator: std.mem.Allocator, email: []const u8) ![]u8 { const encoded = try bdd.b64url(allocator, email); defer allocator.free(encoded); @@ -510,6 +546,15 @@ fn seedRegistryWithAccounts( const codex_home = try codexHomeAlloc(allocator, home_root); defer allocator.free(codex_home); + try seedRegistryAtCodexHome(allocator, codex_home, active_email, entries); +} + +fn seedRegistryAtCodexHome( + allocator: std.mem.Allocator, + codex_home: []const u8, + active_email: []const u8, + entries: []const SeedAccount, +) !void { var reg = bdd.makeEmptyRegistry(); defer reg.deinit(allocator); @@ -523,6 +568,22 @@ fn seedRegistryWithAccounts( try registry.saveRegistry(allocator, codex_home, ®); } +fn writeAuthSnapshotForEmail( + allocator: std.mem.Allocator, + codex_home: []const u8, + email: []const u8, + plan: []const u8, +) ![]u8 { + const account_key = try bdd.accountKeyForEmailAlloc(allocator, email); + defer allocator.free(account_key); + const snapshot_path = try registry.accountAuthPath(allocator, codex_home, account_key); + defer allocator.free(snapshot_path); + const auth_json = try bdd.authJsonWithEmailPlan(allocator, email, plan); + errdefer allocator.free(auth_json); + try fs.cwd().writeFile(.{ .sub_path = snapshot_path, .data = auth_json }); + return auth_json; +} + fn setRegistryApiConfig( allocator: std.mem.Allocator, home_root: []const u8, @@ -752,6 +813,106 @@ test "Scenario: Given CODEX_HOME override when running login then it stores auth try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, expected_email)); } +test "Scenario: Given managed group login when running login then it stores auth state under that group" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath("fake-bin"); + try writeSuccessfulFakeCodex(tmp.dir); + + const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const create_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "group", "create", "work" }, + ); + defer gpa.free(create_result.stdout); + defer gpa.free(create_result.stderr); + try expectSuccess(create_result); + + const first_email = "group-login@example.com"; + const first_auth = try bdd.authJsonWithEmailPlan(gpa, first_email, "plus"); + defer gpa.free(first_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = first_auth }); + + const login_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--group", "work", "--device-auth" }, + ); + defer gpa.free(login_result.stdout); + defer gpa.free(login_result.stderr); + + try expectSuccess(login_result); + try std.testing.expect(std.mem.indexOf(u8, login_result.stdout, "Logged in account to group `work`") != null); + try std.testing.expectEqualStrings("", login_result.stderr); + + const argv_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-argv.txt" }); + defer gpa.free(argv_path); + const argv_data = try bdd.readFileAlloc(gpa, argv_path); + defer gpa.free(argv_data); + try std.testing.expect(std.mem.indexOf(u8, argv_data, "login --device-auth") != null); + + const work_codex_home = try fs.path.join(gpa, &[_][]const u8{ home_root, "codex-auth", "groups", "work" }); + defer gpa.free(work_codex_home); + var work_registry = try registry.loadRegistry(gpa, work_codex_home); + defer work_registry.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), work_registry.accounts.items.len); + try std.testing.expect(std.mem.eql(u8, work_registry.accounts.items[0].email, first_email)); + + const default_auth_path = try authJsonPathAlloc(gpa, home_root); + defer gpa.free(default_auth_path); + try std.testing.expectError(error.FileNotFound, fs.cwd().access(default_auth_path, .{})); + + const second_email = "group-scoped-login@example.com"; + const second_auth = try bdd.authJsonWithEmailPlan(gpa, second_email, "plus"); + defer gpa.free(second_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = second_auth }); + + const scoped_login_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "group", "work", "login" }, + ); + defer gpa.free(scoped_login_result.stdout); + defer gpa.free(scoped_login_result.stderr); + + try expectSuccess(scoped_login_result); + try std.testing.expect(std.mem.indexOf(u8, scoped_login_result.stdout, "Logged in account to group `work`") != null); + try std.testing.expectEqualStrings("", scoped_login_result.stderr); + + var loaded_after_scoped_login = try registry.loadRegistry(gpa, work_codex_home); + defer loaded_after_scoped_login.deinit(gpa); + try std.testing.expectEqual(@as(usize, 2), loaded_after_scoped_login.accounts.items.len); + try std.testing.expect(loaded_after_scoped_login.active_account_key != null); + const second_account_key = try bdd.accountKeyForEmailAlloc(gpa, second_email); + defer gpa.free(second_account_key); + try std.testing.expect(std.mem.eql(u8, loaded_after_scoped_login.active_account_key.?, second_account_key)); + + const work_auth_path = try registry.activeAuthPath(gpa, work_codex_home); + defer gpa.free(work_auth_path); + const active_group_auth = try bdd.readFileAlloc(gpa, work_auth_path); + defer gpa.free(active_group_auth); + try std.testing.expectEqualStrings(second_auth, active_group_auth); +} + test "Scenario: Given failed device auth login with existing auth json when running login then it forwards the flag and does not mutate the registry" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); @@ -1490,6 +1651,450 @@ test "Scenario: Given switch query with a direct local match when running switch try std.testing.expect(std.mem.eql(u8, loaded.active_account_key.?, backup_key)); } +test "Scenario: Given a managed account group when adding listing switching and launching then cli uses that group folder" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + try seedRegistryWithAccounts(gpa, home_root, "gamma@example.com", &[_]SeedAccount{ + .{ .email = "alpha@example.com", .alias = "alpha" }, + .{ .email = "beta@example.com", .alias = "beta" }, + .{ .email = "gamma@example.com", .alias = "gamma" }, + }); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + const active_auth_path = try authJsonPathAlloc(gpa, home_root); + defer gpa.free(active_auth_path); + + const alpha_auth = try writeAuthSnapshotForEmail(gpa, codex_home, "alpha@example.com", "plus"); + defer gpa.free(alpha_auth); + const beta_auth = try writeAuthSnapshotForEmail(gpa, codex_home, "beta@example.com", "plus"); + defer gpa.free(beta_auth); + const gamma_auth = try writeAuthSnapshotForEmail(gpa, codex_home, "gamma@example.com", "plus"); + defer gpa.free(gamma_auth); + try fs.cwd().writeFile(.{ .sub_path = active_auth_path, .data = gamma_auth }); + + try tmp.dir.makePath("fake-bin"); + try writeSuccessfulFakeCodext(tmp.dir); + const fake_bin_path = try tmp.dir.realpathAlloc(gpa, "fake-bin"); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const work_codex_home = try fs.path.join(gpa, &[_][]const u8{ home_root, "codex-auth", "groups", "work" }); + defer gpa.free(work_codex_home); + const work_auth_path = try registry.activeAuthPath(gpa, work_codex_home); + defer gpa.free(work_auth_path); + + const create_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "group", "create", "work", "alpha@example.com", "beta@example.com" }, + ); + defer gpa.free(create_result.stdout); + defer gpa.free(create_result.stderr); + + try expectSuccess(create_result); + try std.testing.expect(std.mem.indexOf(u8, create_result.stdout, "Group `work` uses") != null); + try std.testing.expect(std.mem.indexOf(u8, create_result.stdout, "Added alpha from group `default` to group `work`.") != null); + try std.testing.expect(std.mem.indexOf(u8, create_result.stdout, "Added beta from group `default` to group `work`.") != null); + try std.testing.expectEqualStrings("", create_result.stderr); + + const manager_config_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "codex-auth", "groups.json" }); + defer gpa.free(manager_config_path); + const manager_config = try bdd.readFileAlloc(gpa, manager_config_path); + defer gpa.free(manager_config); + try std.testing.expect(std.mem.indexOf(u8, manager_config, "\"name\": \"work\"") != null); + try std.testing.expect(std.mem.indexOf(u8, manager_config, "\"display_color\"") != null); + + const list_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "group", "work", "list", "--skip-api" }, + ); + defer gpa.free(list_result.stdout); + defer gpa.free(list_result.stderr); + + try expectSuccess(list_result); + try std.testing.expect(std.mem.indexOf(u8, list_result.stdout, "alpha@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, list_result.stdout, "beta@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, list_result.stdout, "gamma@example.com") == null); + + const blocked_switch_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "group", "work", "switch", "gamma" }, + ); + defer gpa.free(blocked_switch_result.stdout); + defer gpa.free(blocked_switch_result.stderr); + + try expectFailure(blocked_switch_result); + try std.testing.expectEqualStrings("", blocked_switch_result.stdout); + try std.testing.expect(std.mem.indexOf(u8, blocked_switch_result.stderr, "no account matches 'gamma'") != null); + + const beta_switch_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "group", "work", "switch", "beta" }, + ); + defer gpa.free(beta_switch_result.stdout); + defer gpa.free(beta_switch_result.stderr); + + try expectSuccess(beta_switch_result); + try std.testing.expectEqualStrings("", beta_switch_result.stdout); + try std.testing.expectEqualStrings("", beta_switch_result.stderr); + + const auth_after_beta_switch = try bdd.readFileAlloc(gpa, work_auth_path); + defer gpa.free(auth_after_beta_switch); + try std.testing.expectEqualStrings(beta_auth, auth_after_beta_switch); + + const gamma_switch_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "switch", "gamma" }, + ); + defer gpa.free(gamma_switch_result.stdout); + defer gpa.free(gamma_switch_result.stderr); + + try expectSuccess(gamma_switch_result); + try std.testing.expectEqualStrings("", gamma_switch_result.stdout); + try std.testing.expectEqualStrings("", gamma_switch_result.stderr); + + const auth_after_gamma_switch = try bdd.readFileAlloc(gpa, active_auth_path); + defer gpa.free(auth_after_gamma_switch); + try std.testing.expectEqualStrings(gamma_auth, auth_after_gamma_switch); + + const launch_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "group", "work", "launch" }, + ); + defer gpa.free(launch_result.stdout); + defer gpa.free(launch_result.stderr); + + try expectSuccess(launch_result); + const launched_codex_home_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codext-codex-home.txt" }); + defer gpa.free(launched_codex_home_path); + const launched_codex_home = try bdd.readFileAlloc(gpa, launched_codex_home_path); + defer gpa.free(launched_codex_home); + try std.testing.expect(std.mem.indexOf(u8, launched_codex_home, work_codex_home) != null); + + const project_show_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "project", "show" }, + ); + defer gpa.free(project_show_result.stdout); + defer gpa.free(project_show_result.stderr); + + try expectSuccess(project_show_result); + try std.testing.expect(std.mem.indexOf(u8, project_show_result.stdout, "Project group: work") != null); + try std.testing.expect(std.mem.indexOf(u8, project_show_result.stdout, work_codex_home) != null); + + const remembered_launch_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{"launch"}, + ); + defer gpa.free(remembered_launch_result.stdout); + defer gpa.free(remembered_launch_result.stderr); + + try expectSuccess(remembered_launch_result); + const remembered_launched_codex_home = try bdd.readFileAlloc(gpa, launched_codex_home_path); + defer gpa.free(remembered_launched_codex_home); + try std.testing.expect(std.mem.indexOf(u8, remembered_launched_codex_home, work_codex_home) != null); + + const resume_launch_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "launch", "resume", "019db67d-2190-7563-a899-ce3082e491cf" }, + ); + defer gpa.free(resume_launch_result.stdout); + defer gpa.free(resume_launch_result.stderr); + + try expectSuccess(resume_launch_result); + const resume_launched_codex_home = try bdd.readFileAlloc(gpa, launched_codex_home_path); + defer gpa.free(resume_launched_codex_home); + try std.testing.expect(std.mem.indexOf(u8, resume_launched_codex_home, work_codex_home) != null); + const launched_argv_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codext-argv.txt" }); + defer gpa.free(launched_argv_path); + const launched_argv = try bdd.readFileAlloc(gpa, launched_argv_path); + defer gpa.free(launched_argv); + try std.testing.expect(std.mem.indexOf(u8, launched_argv, "resume 019db67d-2190-7563-a899-ce3082e491cf") != null); + + const group_status_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "group", "status" }, + ); + defer gpa.free(group_status_result.stdout); + defer gpa.free(group_status_result.stderr); + + try expectSuccess(group_status_result); + try std.testing.expect(std.mem.indexOf(u8, group_status_result.stdout, "GROUP") != null); + try std.testing.expect(std.mem.indexOf(u8, group_status_result.stdout, "work") != null); + + const managed_list_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "list", "--skip-api" }, + ); + defer gpa.free(managed_list_result.stdout); + defer gpa.free(managed_list_result.stderr); + + try expectSuccess(managed_list_result); + try std.testing.expect(std.mem.indexOf(u8, managed_list_result.stdout, "GROUP") != null); + try std.testing.expect(std.mem.indexOf(u8, managed_list_result.stdout, "ACCOUNT") != null); + try std.testing.expect(std.mem.indexOf(u8, managed_list_result.stdout, "-- default") != null); + try std.testing.expect(std.mem.indexOf(u8, managed_list_result.stdout, "-- work") != null); + try std.testing.expect(std.mem.indexOf(u8, managed_list_result.stdout, "default") != null); + try std.testing.expect(std.mem.indexOf(u8, managed_list_result.stdout, "work") != null); + try std.testing.expect(std.mem.indexOf(u8, managed_list_result.stdout, "alpha@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, managed_list_result.stdout, "gamma@example.com") != null); + + const list_alias_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "group", "list", "work", "--skip-api" }, + ); + defer gpa.free(list_alias_result.stdout); + defer gpa.free(list_alias_result.stderr); + + try expectSuccess(list_alias_result); + try std.testing.expect(std.mem.indexOf(u8, list_alias_result.stdout, "alpha@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, list_alias_result.stdout, "beta@example.com") != null); + + const remove_result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "group", "work", "remove", "alpha" }, + ); + defer gpa.free(remove_result.stdout); + defer gpa.free(remove_result.stderr); + + try expectSuccess(remove_result); + try std.testing.expect(std.mem.indexOf(u8, remove_result.stdout, "Removed") != null); + try std.testing.expectEqualStrings("", remove_result.stderr); + + var loaded_work = try registry.loadRegistry(gpa, work_codex_home); + defer loaded_work.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), loaded_work.accounts.items.len); + try std.testing.expect(std.mem.eql(u8, loaded_work.accounts.items[0].email, "beta@example.com")); +} + +test "Scenario: Given managed account groups when copying and moving then memberships are duplicated or transferred" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + try tmp.dir.makePath(".codex/accounts"); + const beta_auth = try writeAuthSnapshotForEmail(gpa, codex_home, "beta@example.com", "plus"); + defer gpa.free(beta_auth); + try seedRegistryAtCodexHome(gpa, codex_home, "beta@example.com", &[_]SeedAccount{ + .{ .email = "beta@example.com", .alias = "" }, + }); + + const work_codex_home = try fs.path.join(gpa, &[_][]const u8{ home_root, "codex-auth", "groups", "work" }); + defer gpa.free(work_codex_home); + const trading_codex_home = try fs.path.join(gpa, &[_][]const u8{ home_root, "codex-auth", "groups", "trading" }); + defer gpa.free(trading_codex_home); + + const create_work_result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ "group", "create", "work" }); + defer gpa.free(create_work_result.stdout); + defer gpa.free(create_work_result.stderr); + try expectSuccess(create_work_result); + + const create_trading_result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ "group", "create", "trading" }); + defer gpa.free(create_trading_result.stdout); + defer gpa.free(create_trading_result.stderr); + try expectSuccess(create_trading_result); + + const work_only_auth = try bdd.authJsonWithEmailPlan(gpa, "work-only@example.com", "team"); + defer gpa.free(work_only_auth); + try tmp.dir.writeFile(.{ .sub_path = "work-only-auth.json", .data = work_only_auth }); + const work_only_import_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "work-only-auth.json" }); + defer gpa.free(work_only_import_path); + + const import_work_only_result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ + "group", + "work", + "import", + work_only_import_path, + }); + defer gpa.free(import_work_only_result.stdout); + defer gpa.free(import_work_only_result.stderr); + try expectSuccess(import_work_only_result); + + const copy_result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ + "group", + "trading", + "copy", + "beta@example.com", + }); + defer gpa.free(copy_result.stdout); + defer gpa.free(copy_result.stderr); + try expectSuccess(copy_result); + try std.testing.expect(std.mem.indexOf(u8, copy_result.stdout, "Copied ") != null); + try std.testing.expect(std.mem.indexOf(u8, copy_result.stdout, "from group `default` to group `trading`.") != null); + + const move_result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ + "group", + "trading", + "move", + "work-only@example.com", + }); + defer gpa.free(move_result.stdout); + defer gpa.free(move_result.stderr); + try expectSuccess(move_result); + try std.testing.expect(std.mem.indexOf(u8, move_result.stdout, "Moved ") != null); + try std.testing.expect(std.mem.indexOf(u8, move_result.stdout, "from group `work` to group `trading`.") != null); + + var loaded_default = try registry.loadRegistry(gpa, codex_home); + defer loaded_default.deinit(gpa); + var loaded_work = try registry.loadRegistry(gpa, work_codex_home); + defer loaded_work.deinit(gpa); + var loaded_trading = try registry.loadRegistry(gpa, trading_codex_home); + defer loaded_trading.deinit(gpa); + + try std.testing.expect(bdd.findAccountIndexByEmail(&loaded_default, "beta@example.com") != null); + try std.testing.expect(bdd.findAccountIndexByEmail(&loaded_trading, "beta@example.com") != null); + try std.testing.expect(bdd.findAccountIndexByEmail(&loaded_work, "work-only@example.com") == null); + try std.testing.expect(bdd.findAccountIndexByEmail(&loaded_trading, "work-only@example.com") != null); + + const list_result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ "list", "--skip-api" }); + defer gpa.free(list_result.stdout); + defer gpa.free(list_result.stderr); + try expectSuccess(list_result); + try std.testing.expect(std.mem.indexOf(u8, list_result.stdout, "-- default") != null); + try std.testing.expect(std.mem.indexOf(u8, list_result.stdout, "-- trading") != null); + try std.testing.expectEqual(@as(usize, 2), countOccurrences(list_result.stdout, "beta@example.com")); +} + +test "Scenario: Given managed account groups when archiving and deleting then folders are moved or removed explicitly" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + const archive_group_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "codex-auth", "groups", "archive-me" }); + defer gpa.free(archive_group_path); + const delete_group_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "codex-auth", "groups", "delete-me" }); + defer gpa.free(delete_group_path); + + const create_archive_result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "group", "create", "archive-me" }, + ); + defer gpa.free(create_archive_result.stdout); + defer gpa.free(create_archive_result.stderr); + + try expectSuccess(create_archive_result); + try fs.cwd().access(archive_group_path, .{}); + + const archive_result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "group", "archive", "archive-me" }, + ); + defer gpa.free(archive_result.stdout); + defer gpa.free(archive_result.stderr); + + try expectSuccess(archive_result); + try std.testing.expect(std.mem.indexOf(u8, archive_result.stdout, "Archived group `archive-me`") != null); + try std.testing.expectError(error.FileNotFound, fs.cwd().access(archive_group_path, .{})); + try std.testing.expect(std.mem.indexOf(u8, archive_result.stdout, "codex-auth/archive/archive-me-") != null); + + const create_delete_result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "group", "create", "delete-me" }, + ); + defer gpa.free(create_delete_result.stdout); + defer gpa.free(create_delete_result.stderr); + + try expectSuccess(create_delete_result); + try fs.cwd().access(delete_group_path, .{}); + + const blocked_delete_result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "group", "delete", "delete-me" }, + ); + defer gpa.free(blocked_delete_result.stdout); + defer gpa.free(blocked_delete_result.stderr); + + try expectFailure(blocked_delete_result); + try std.testing.expect(std.mem.indexOf(u8, blocked_delete_result.stderr, "group delete is permanent") != null); + try fs.cwd().access(delete_group_path, .{}); + + const delete_result = try runCliWithIsolatedHome( + gpa, + project_root, + home_root, + &[_][]const u8{ "group", "delete", "delete-me", "--force" }, + ); + defer gpa.free(delete_result.stdout); + defer gpa.free(delete_result.stderr); + + try expectSuccess(delete_result); + try std.testing.expect(std.mem.indexOf(u8, delete_result.stdout, "Deleted group `delete-me`") != null); + try std.testing.expectError(error.FileNotFound, fs.cwd().access(delete_group_path, .{})); +} + test "Scenario: Given list with api override when api config is disabled then it still requires api refresh executables" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); diff --git a/src/tests/purge_test.zig b/src/tests/purge_test.zig index f02b5c8e..41875b36 100644 --- a/src/tests/purge_test.zig +++ b/src/tests/purge_test.zig @@ -142,7 +142,7 @@ test "Scenario: Given legacy version key current-layout registry when loading th defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 3") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"version\": 3") == null); } @@ -242,7 +242,7 @@ test "Scenario: Given v2 registry when loading then it migrates to record-key la defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 3") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); const active_expect = try std.fmt.allocPrint(gpa, "\"active_account_key\": \"{s}\"", .{account_id}); defer gpa.free(active_expect); try std.testing.expect(std.mem.indexOf(u8, contents, active_expect) != null); diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index d67b160a..8db7b290 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -652,7 +652,7 @@ test "registry load backfills missing api.usage from api.account and rewrites fi try std.testing.expect(std.mem.indexOf(u8, saved, "\"account\": false") != null); } -test "schema 3 registry with legacy rollout attribution rewrites to normalized schema 3" { +test "schema 3 registry with legacy rollout attribution rewrites to normalized schema 4" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); defer tmp.cleanup(); @@ -697,7 +697,7 @@ test "schema 3 registry with legacy rollout attribution rewrites to normalized s defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 3") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"active_account_activated_at_ms\": 0") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"last_attributed_rollout\"") == null); } @@ -732,7 +732,7 @@ test "legacy current-layout registry version field rewrites to schema_version" { defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 3") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"version\"") == null); } @@ -819,7 +819,7 @@ test "v2 registry migrates active email records to current schema" { defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 3") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); const active_expect = try std.fmt.allocPrint(gpa, "\"active_account_key\": \"{s}\"", .{expected_account_id}); defer gpa.free(active_expect); try std.testing.expect(std.mem.indexOf(u8, contents, active_expect) != null); diff --git a/src/tests/sessions_test.zig b/src/tests/sessions_test.zig index 4fef63b0..39ca1e91 100644 --- a/src/tests/sessions_test.zig +++ b/src/tests/sessions_test.zig @@ -18,6 +18,19 @@ const missing_primary_used_percent_line = "{" ++ "\"timestamp\":\"2025-01-01T00:00:02Z\"," ++ "\"type\":\"event_msg\"," ++ "\"payload\":{\"type\":\"token_count\",\"rate_limits\":{\"primary\":{\"window_minutes\":300,\"resets_at\":123},\"secondary\":{\"used_percent\":10.0,\"window_minutes\":10080,\"resets_at\":456},\"plan_type\":\"pro\"}}}"; +const limit_line = "{" ++ + "\"timestamp\":\"2025-01-01T00:00:20.000Z\"," ++ + "\"type\":\"event_msg\"," ++ + "\"payload\":{\"type\":\"error\",\"message\":\"You've hit your usage limit. Try again later.\",\"codex_error_info\":\"usage_limit_exceeded\"}}"; +const response_item_limit_text_line = "{" ++ + "\"timestamp\":\"2025-01-01T00:00:30.000Z\"," ++ + "\"type\":\"response_item\"," ++ + "\"kind\":\"event_msg\"," ++ + "\"payload\":{\"type\":\"function_call_output\",\"codex_error_info\":\"usage_limit_exceeded\",\"output\":\"You've hit your usage limit.\"}}"; +const agent_message_limit_text_line = "{" ++ + "\"timestamp\":\"2025-01-01T00:00:31.000Z\"," ++ + "\"type\":\"event_msg\"," ++ + "\"payload\":{\"type\":\"agent_message\",\"message\":\"You've hit your usage limit. Try again later.\"}}"; fn usageLineAlloc(allocator: std.mem.Allocator, timestamp: []const u8, used_percent: f64) ![]u8 { return std.fmt.allocPrint( @@ -334,6 +347,91 @@ test "scan latest usage keeps final line without trailing newline" { try std.testing.expectEqual(@as(f64, 33.0), latest.snapshot.primary.?.used_percent); } +test "scan latest limit event finds the newest usage limit message" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try app_runtime.realPathFileAlloc(gpa, tmp.dir, "."); + defer gpa.free(codex_home); + try tmp.dir.createDirPath(app_runtime.io(), "sessions/2025/01/01"); + try tmp.dir.writeFile(app_runtime.io(), .{ + .sub_path = "sessions/2025/01/01/rollout-a.jsonl", + .data = line ++ "\n" ++ limit_line ++ "\n", + }); + + var latest = (try sessions.scanLatestLimitEventWithSource(gpa, codex_home)) orelse return error.TestExpectedEqual; + defer latest.deinit(gpa); + + try std.testing.expectEqualStrings("rollout-a.jsonl", std.fs.path.basename(latest.path)); + try std.testing.expectEqual(@as(i64, 1735689620000), latest.event_timestamp_ms); +} + +test "scan latest limit event ignores non-error mentions of the limit text" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try app_runtime.realPathFileAlloc(gpa, tmp.dir, "."); + defer gpa.free(codex_home); + try tmp.dir.createDirPath(app_runtime.io(), "sessions/2025/01/01"); + try tmp.dir.writeFile(app_runtime.io(), .{ + .sub_path = "sessions/2025/01/01/rollout-false-positive.jsonl", + .data = response_item_limit_text_line ++ "\n" ++ agent_message_limit_text_line ++ "\n", + }); + + try std.testing.expect((try sessions.scanLatestLimitEventWithSource(gpa, codex_home)) == null); +} + +test "scan latest limit event skips newer non-error mentions and keeps the real error" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try app_runtime.realPathFileAlloc(gpa, tmp.dir, "."); + defer gpa.free(codex_home); + try tmp.dir.createDirPath(app_runtime.io(), "sessions/2025/01/01"); + try tmp.dir.writeFile(app_runtime.io(), .{ + .sub_path = "sessions/2025/01/01/rollout-mixed-limit.jsonl", + .data = limit_line ++ "\n" ++ response_item_limit_text_line ++ "\n" ++ agent_message_limit_text_line ++ "\n", + }); + + var latest = (try sessions.scanLatestLimitEventWithSource(gpa, codex_home)) orelse return error.TestExpectedEqual; + defer latest.deinit(gpa); + + try std.testing.expectEqualStrings("rollout-mixed-limit.jsonl", std.fs.path.basename(latest.path)); + try std.testing.expectEqual(@as(i64, 1735689620000), latest.event_timestamp_ms); +} + +test "scan latest limit event cache tracks appends to the current rollout file" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try app_runtime.realPathFileAlloc(gpa, tmp.dir, "."); + defer gpa.free(codex_home); + try tmp.dir.createDirPath(app_runtime.io(), "sessions/2025/01/01"); + + const rollout_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "sessions", "2025", "01", "01", "rollout-limit-cache.jsonl" }); + defer gpa.free(rollout_path); + try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = rollout_path, .data = line }); + + var cache = sessions.LimitScanCache{}; + defer cache.deinit(gpa); + + try std.testing.expect((try sessions.scanLatestLimitEventWithCache(gpa, codex_home, &cache)) == null); + + const base_time = @as(i128, std.Io.Timestamp.now(app_runtime.io(), .real).toNanoseconds()); + const file_contents = line ++ "\n" ++ limit_line ++ "\n"; + try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = rollout_path, .data = file_contents }); + try updateFileTimes(rollout_path, base_time + std.time.ns_per_s, base_time + std.time.ns_per_s); + + var latest = (try sessions.scanLatestLimitEventWithCache(gpa, codex_home, &cache)) orelse return error.TestExpectedEqual; + defer latest.deinit(gpa); + try std.testing.expectEqualStrings("rollout-limit-cache.jsonl", std.fs.path.basename(latest.path)); + try std.testing.expectEqual(@as(i64, 1735689620000), latest.event_timestamp_ms); +} + test "scan latest rollout event cache tracks changes to the current rollout file without a full rescan" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/src/windows_auto_main.zig b/src/windows_auto_main.zig index e77e772d..c221b918 100644 --- a/src/windows_auto_main.zig +++ b/src/windows_auto_main.zig @@ -2,13 +2,26 @@ const std = @import("std"); const auto = @import("auto.zig"); const registry = @import("registry.zig"); -fn resolveDaemonCodexHome(allocator: std.mem.Allocator, init: std.process.Init.Minimal) ![]u8 { +const DaemonTarget = union(enum) { + codex_home: []u8, + manager: void, + + fn deinit(self: *DaemonTarget, allocator: std.mem.Allocator) void { + switch (self.*) { + .codex_home => |path| allocator.free(path), + .manager => {}, + } + } +}; + +fn resolveDaemonTarget(allocator: std.mem.Allocator, init: std.process.Init.Minimal) !DaemonTarget { var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const args = try init.args.toSlice(arena_state.allocator()); var codex_home_override: ?[]u8 = null; defer if (codex_home_override) |path| allocator.free(path); + var manager = false; var i: usize = 1; while (i < args.len) : (i += 1) { @@ -25,13 +38,19 @@ fn resolveDaemonCodexHome(allocator: std.mem.Allocator, init: std.process.Init.M i += 1; continue; } + if (std.mem.eql(u8, arg, "--manager")) { + if (manager or codex_home_override != null) return error.InvalidCliUsage; + manager = true; + continue; + } return error.InvalidCliUsage; } + if (manager) return .{ .manager = {} }; if (codex_home_override) |path| { - return try registry.resolveCodexHomeFromEnv(allocator, path, null, null); + return .{ .codex_home = try registry.resolveCodexHomeFromEnv(allocator, path, null, null) }; } - return try registry.resolveCodexHome(allocator); + return .{ .codex_home = try registry.resolveCodexHome(allocator) }; } pub fn main(init: std.process.Init.Minimal) !void { @@ -39,8 +58,11 @@ pub fn main(init: std.process.Init.Minimal) !void { defer std.debug.assert(gpa.deinit() == .ok); const allocator = gpa.allocator(); - const codex_home = try resolveDaemonCodexHome(allocator, init); - defer allocator.free(codex_home); + var target = try resolveDaemonTarget(allocator, init); + defer target.deinit(allocator); - try auto.runDaemon(allocator, codex_home); + switch (target) { + .codex_home => |codex_home| try auto.runDaemon(allocator, codex_home), + .manager => try auto.runManagerDaemon(allocator), + } }