From ac52fa70637046605a2d8aa16036008a1373bd4b Mon Sep 17 00:00:00 2001 From: Mouaad SK Date: Mon, 27 Apr 2026 08:46:54 +0100 Subject: [PATCH] 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);