diff --git a/README.md b/README.md index 7a1e7888..f7d43e74 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,12 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | [`codex-auth export --cpa []`](./docs/commands/export.md) | Export CLIProxyAPI token JSON | | [`codex-auth clean`](./docs/commands/clean.md) | Delete managed backup and stale account files | +### Codex App Launching + +| Command | Description | +|---------|-------------| +| [`codex-auth app [--app-id ] [--codex-cli-path ]`](./docs/commands/app.md) | Launch Codex App with detected defaults, CODEX_HOME, CODEX_CLI_PATH, and platform overrides | + ### Configuration | Command | Description | diff --git a/docs/commands/README.md b/docs/commands/README.md index 8d3cae45..8dd102ee 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -15,6 +15,7 @@ This directory documents command behavior by command. Use `codex-auth | `alias` | [docs/commands/alias.md](./alias.md) | | `clean` | [docs/commands/clean.md](./clean.md) | | `config` | [docs/commands/config.md](./config.md) | +| `app` | [docs/commands/app.md](./app.md) | ## Shared Behavior diff --git a/docs/commands/app.md b/docs/commands/app.md new file mode 100644 index 00000000..a3a938f0 --- /dev/null +++ b/docs/commands/app.md @@ -0,0 +1,82 @@ +# `codex-auth app` + +## Usage + +```shell +codex-auth app [--app-id ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] +``` + +## Behavior + +Launches the official Codex App with per-process environment overrides. + +- `codex-auth app` launches the app. There is no `launch` subcommand. +- If the Codex App is already running, `app` prints that status and exits before + resolving or downloading the managed CLI. +- `--app-id ` selects the packaged app to launch. On Windows it accepts an + AppX/MSIX package name such as `OpenAI.Codex` or `Loongphy.Codext`, or a full + AUMID. On macOS it accepts a bundle identifier such as `com.openai.codex`. +- If `--app-id` is omitted, `CODEX_AUTH_APP_ID` is used when set; otherwise the + default is `OpenAI.Codex` on Windows and `com.openai.codex` on macOS. +- `--codex-cli-path ` is injected as `CODEX_CLI_PATH` for this launch. Explicit CLI paths must exist. If it is omitted, `app` fetches the latest [`Loongphy/codext`](https://github.com/Loongphy/codext) release metadata, compares it with the managed cached CLI version for the selected platform, downloads only when the cached version differs or is missing, and uses that file; it does not reuse an existing `CODEX_CLI_PATH` from the current shell. +- `--codex-home ` is injected as `CODEX_HOME` for `app` launches and selects the accounts cache used for managed CLI resolution. +- `--platform win|wsl|mac` selects the app runtime platform: + - `win` writes the Windows global setting so the app runs the agent natively. + - `wsl` writes the Windows global setting so the app runs the agent inside WSL. + - `mac` launches the macOS app directly and does not use the Windows WSL setting. +- `--std` resolves the packaged app executable, then starts it with stdout/stderr attached to the current terminal. Use it for debugging app logs; normal launches stay quiet and use the platform GUI launcher. + +`app` prints its launch plan and managed CLI resolution to stderr before +starting the GUI launcher. Example output: + +```text +Codex App is already running, launch skipped. +``` + +When the app is not already running, the output continues with launch planning: + +```text +- Checking latest https://github.com/Loongphy/codext release... + Downloading Codext CLI for WSL (v0.3.0) + https://github.com/Loongphy/codext/releases/download/.../codext-linux-x64.tar.gz +OK Downloaded Codext CLI for WSL (v0.3.0) + +- Environment Configuration ------------------------------------------------ + Platform: WSL (auto-detected) + Codex Home: C:\Users\Alice\.codext (explicit) + App ID: Loongphy.Codext (explicit) + CLI Path: C:\Users\Alice\.codext\accounts\codext-cli\codex-linux-x64 (downloaded) +---------------------------------------------------------------------------- +Launching Codex App... +``` + +See [Windows](../windows.md) for Windows console color and character rules. + +If `--platform` is omitted, Windows reads `$CODEX_HOME/.codex-global-state.json` +and uses `wsl` when `runCodexInWindowsSubsystemForLinux` is `true`; otherwise it +uses `win`. macOS defaults to `mac`. + +Default downloaded CLIs are cached directly under: + +```text +$CODEX_HOME/accounts/codext-cli/codex- +$CODEX_HOME/accounts/codext-cli/codex-.version +``` + +The default download prepares only the selected platform's +[`Loongphy/codext`](https://github.com/Loongphy/codext) asset for the current +CPU architecture, such as `win32-x64`, `linux-x64`, `darwin-x64`, or +`darwin-arm64`. + +Windows App launching is handled by the Windows `codex-auth.exe` build. Normal +launch resolves the package name or AUMID and opens `shell:AppsFolder\`. +The WSL build does not launch Windows App packages. + +For Windows-native App launches, `--codex-cli-path` must point to something the Windows +App process can spawn. A WSL command name such as `codex-custom` is not a +Windows executable path. + +For macOS App launches, the app is opened with its bundle identifier. The +packaged macOS app normally uses `Contents/Resources/codex` directly as its +bundled CLI; setting `--codex-cli-path` injects `CODEX_CLI_PATH` and takes +precedence over that bundled resource. diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 00000000..a7ab100b --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,59 @@ +# Windows + +## CLI output + +Windows console hosts vary in how they handle UTF-8 text and ANSI escape +sequences. `codex-auth` keeps Windows CLI output conservative so PowerShell, +Windows Terminal, `cmd.exe`, and CI logs stay readable. + +### Color + +- Color is enabled only for TTY output. +- `NO_COLOR` disables color. +- On Windows, ANSI color is emitted only after + `ENABLE_VIRTUAL_TERMINAL_PROCESSING` is already enabled or can be enabled for + the target console handle. +- If virtual-terminal processing cannot be verified, output falls back to plain + text with no ANSI escape sequences. + +### Characters + +- Windows-facing status markers must be ASCII by default. +- Do not use Unicode status glyphs such as check marks, warning signs, bullets, + arrows, or box-drawing characters in Windows default output. +- Unicode may be used for non-Windows output when it is already part of an + established command style. + +Recommended Windows status markers: + +```text +Codex App is already running, launch skipped. +- Checking latest https://github.com/Loongphy/codext release... + Downloading Codext CLI for WSL (v0.3.0) +OK Downloaded Codext CLI for WSL (v0.3.0) +``` + +### App command examples + +Already running: + +```text +Codex App is already running, launch skipped. +``` + +Launch with a managed CLI download: + +```text +- Checking latest https://github.com/Loongphy/codext release... + Downloading Codext CLI for WSL (v0.3.0) + https://github.com/Loongphy/codext/releases/download/.../codext-linux-x64.tar.gz +OK Downloaded Codext CLI for WSL (v0.3.0) + +- Environment Configuration ------------------------------------------------ + Platform: WSL (auto-detected) + Codex Home: C:\Users\Loong\.codext (explicit) + App ID: Loongphy.Codext (explicit) + CLI Path: C:\Users\Loong\.codext\accounts\codext-cli\codex-linux-x64 (downloaded) +---------------------------------------------------------------------------- +Launching Codex App... +``` diff --git a/src/cli/commands/app.zig b/src/cli/commands/app.zig new file mode 100644 index 00000000..4b772609 --- /dev/null +++ b/src/cli/commands/app.zig @@ -0,0 +1,73 @@ +const std = @import("std"); +const types = @import("../types.zig"); +const common = @import("common.zig"); + +pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { + if (args.len == 0) return parseOptions(allocator, .launch, args); + const first = std.mem.sliceTo(args[0], 0); + if (common.isHelpFlag(first)) return .{ .command = .{ .help = .app } }; + + return parseOptions(allocator, .launch, args); +} + +fn parseOptions( + allocator: std.mem.Allocator, + action: types.AppAction, + args: []const [:0]const u8, +) !types.ParseResult { + var opts = types.AppOptions{ .action = action }; + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = std.mem.sliceTo(args[i], 0); + if (std.mem.eql(u8, arg, "--")) return common.usageErrorResult(allocator, .app, "`app` does not accept passthrough arguments.", .{}); + if (common.isHelpFlag(arg)) return .{ .command = .{ .help = .app } }; + if (std.mem.eql(u8, arg, "--app-id")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--app-id`.", .{}); + if (opts.app_id != null) return common.usageErrorResult(allocator, .app, "duplicate `--app-id` for `app`.", .{}); + i += 1; + opts.app_id = std.mem.sliceTo(args[i], 0); + continue; + } + if (std.mem.eql(u8, arg, "--codex-cli-path")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--codex-cli-path`.", .{}); + if (opts.codex_cli_path != null) return common.usageErrorResult(allocator, .app, "duplicate `--codex-cli-path` for `app`.", .{}); + i += 1; + opts.codex_cli_path = std.mem.sliceTo(args[i], 0); + continue; + } + if (std.mem.eql(u8, arg, "--codex-home")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--codex-home`.", .{}); + if (opts.codex_home != null) return common.usageErrorResult(allocator, .app, "duplicate `--codex-home` for `app`.", .{}); + i += 1; + opts.codex_home = std.mem.sliceTo(args[i], 0); + continue; + } + if (std.mem.eql(u8, arg, "--platform")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--platform`.", .{}); + if (opts.platform != null) return common.usageErrorResult(allocator, .app, "duplicate `--platform` for `app`.", .{}); + i += 1; + const value = std.mem.sliceTo(args[i], 0); + if (std.mem.eql(u8, value, "win")) { + opts.platform = .win; + } else if (std.mem.eql(u8, value, "wsl")) { + opts.platform = .wsl; + } else if (std.mem.eql(u8, value, "mac")) { + opts.platform = .mac; + } else { + return common.usageErrorResult(allocator, .app, "`--platform` must be `win`, `wsl`, or `mac`.", .{}); + } + continue; + } + if (std.mem.eql(u8, arg, "--std")) { + if (opts.inherit_stdio) return common.usageErrorResult(allocator, .app, "duplicate `--std` for `app`.", .{}); + opts.inherit_stdio = true; + continue; + } + if (std.mem.startsWith(u8, arg, "-")) { + return common.usageErrorResult(allocator, .app, "unknown flag `{s}` for `app`.", .{arg}); + } + return common.usageErrorResult(allocator, .app, "unexpected argument `{s}` for `app`.", .{arg}); + } + + return .{ .command = .{ .app = opts } }; +} diff --git a/src/cli/commands/root.zig b/src/cli/commands/root.zig index 70e2b1ca..87498fda 100644 --- a/src/cli/commands/root.zig +++ b/src/cli/commands/root.zig @@ -2,6 +2,7 @@ const std = @import("std"); const types = @import("../types.zig"); const common = @import("common.zig"); +const app = @import("app.zig"); const alias = @import("alias.zig"); const clean = @import("clean.zig"); const config = @import("config.zig"); @@ -47,6 +48,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type if (std.mem.eql(u8, cmd, "alias")) return alias.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "clean")) return clean.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "config")) return config.parse(allocator, args[2..]); + if (std.mem.eql(u8, cmd, "app")) return app.parse(allocator, args[2..]); return common.usageErrorResult(allocator, .top_level, "unknown command `{s}`.", .{cmd}); } @@ -107,5 +109,6 @@ fn helpTopicForName(name: []const u8) ?types.HelpTopic { if (std.mem.eql(u8, name, "alias")) return .alias; if (std.mem.eql(u8, name, "clean")) return .clean; if (std.mem.eql(u8, name, "config")) return .config; + if (std.mem.eql(u8, name, "app")) return .app; return null; } diff --git a/src/cli/help.zig b/src/cli/help.zig index c9164ced..459b03c8 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -57,6 +57,7 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "clean background"); try writeCommandSummary(out, use_color, "config", "Manage configuration"); try writeCommandDetail(out, use_color, "config live --interval "); + try writeCommandSummary(out, use_color, "app", "Launch Codex App with CLI overrides"); try out.writeAll("\n"); if (use_color) try out.writeAll(style.ansi.cyan); @@ -131,6 +132,7 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { .alias => "alias", .clean => "clean", .config => "config", + .app => "app", }; } @@ -146,19 +148,20 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .alias => "Set or clear an account alias by alias, email, display number, or partial query.", .clean => "Delete backup and stale files under accounts/.", .config => "Manage live refresh configuration.", + .app => "Launch Codex App with CLI overrides.", }; } fn commandHelpHasExamples(topic: HelpTopic) bool { return switch (topic) { - .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config => true, + .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config, .app => true, else => false, }; } fn commandHelpHasOptions(topic: HelpTopic) bool { return switch (topic) { - .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config => true, + .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config, .app => true, else => false, }; } @@ -221,6 +224,9 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { .config => { try out.writeAll(" codex-auth config live --interval \n"); }, + .app => { + try out.writeAll(" codex-auth app [--app-id ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); + }, } } @@ -236,6 +242,7 @@ pub fn helpCommandForTopic(topic: HelpTopic) []const u8 { .alias => "codex-auth alias --help", .clean => "codex-auth clean --help", .config => "codex-auth config --help", + .app => "codex-auth app --help", }; } @@ -291,6 +298,16 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" live --interval \n"); try out.writeAll(" Set the live TUI refresh interval from 5 to 3600 seconds.\n"); }, + .app => { + try out.writeAll(" --app-id Windows package/AUMID or macOS bundle identifier.\n"); + try out.writeAll(" --codex-cli-path \n"); + try out.writeAll(" Value injected or persisted as CODEX_CLI_PATH. Defaults to latest managed Loongphy/codext.\n"); + try out.writeAll(" --codex-home \n"); + try out.writeAll(" Value injected as CODEX_HOME for this launch.\n"); + try out.writeAll(" --platform win|wsl|mac\n"); + try out.writeAll(" Preselect the app platform. Defaults to the current app setting on Windows and mac on macOS.\n"); + try out.writeAll(" --std Resolve the app package executable, then attach stdout/stderr to this terminal.\n"); + }, else => {}, } } @@ -362,6 +379,10 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { .config => { try out.writeAll(" codex-auth config live --interval 60\n"); }, + .app => { + try out.writeAll(" codex-auth app\n"); + try out.writeAll(" codex-auth app --platform win\n"); + }, } } diff --git a/src/cli/style.zig b/src/cli/style.zig index cac3b5f1..49396455 100644 --- a/src/cli/style.zig +++ b/src/cli/style.zig @@ -2,12 +2,22 @@ const std = @import("std"); pub const ansi = struct { pub const reset = "\x1b[0m"; + pub const bold = "\x1b[1m"; pub const dim = "\x1b[2m"; pub const red = "\x1b[31m"; pub const green = "\x1b[32m"; pub const cyan = "\x1b[36m"; }; +pub const role = struct { + pub const key = ansi.bold; + pub const secondary = ansi.dim; + pub const status = ansi.cyan; + pub const success = ansi.green; + pub const warning = ansi.cyan; + pub const error_text = ansi.red; +}; + pub const StyledWriter = struct { out: *std.Io.Writer, color_enabled: bool, diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 37702a45..24db81f5 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -31,7 +31,7 @@ pub const LiveTable = struct { prefix_width: usize, pub fn writeHeader(self: *const LiveTable, writer: *style.StyledWriter) !void { - try writer.writeStyle(style.ansi.cyan); + try writer.writeStyle(style.role.status); try writeRepeat(writer.out, ' ', self.prefix_width); try self.writeCells(writer.out, &.{ .{ .text = self.columns[0].header }, @@ -45,7 +45,7 @@ pub const LiveTable = struct { } pub fn writeGroupRow(self: *const LiveTable, writer: *style.StyledWriter, account: []const u8) !void { - try writer.writeStyle(style.ansi.dim); + try writer.writeStyle(style.role.secondary); try writeRepeat(writer.out, ' ', self.prefix_width); try writeAccountTruncatedPadded(writer.out, account, self.columns[0].width); try writer.reset(); diff --git a/src/cli/types.zig b/src/cli/types.zig index c6103f39..24dc21c6 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -54,6 +54,16 @@ pub const LiveOptions = struct { interval_seconds: u16, }; pub const ConfigOptions = union(enum) { live: LiveOptions }; +pub const AppAction = enum { launch }; +pub const AppPlatform = enum { win, wsl, mac }; +pub const AppOptions = struct { + action: AppAction, + app_id: ?[]const u8 = null, + codex_cli_path: ?[]const u8 = null, + codex_home: ?[]const u8 = null, + platform: ?AppPlatform = null, + inherit_stdio: bool = false, +}; pub const HelpTopic = enum { top_level, list, @@ -65,6 +75,7 @@ pub const HelpTopic = enum { alias, clean, config, + app, }; pub const Command = union(enum) { @@ -77,6 +88,7 @@ pub const Command = union(enum) { alias: AliasOptions, clean: CleanOptions, config: ConfigOptions, + app: AppOptions, version: void, help: HelpTopic, }; diff --git a/src/main.zig b/src/main.zig index 42008362..fb6d5ded 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,5 +2,7 @@ const std = @import("std"); const codex_auth = @import("root.zig"); pub fn main(init: std.process.Init.Minimal) !void { + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer std.debug.assert(gpa.deinit() == .ok); return codex_auth.workflows.main(init); } diff --git a/src/root.zig b/src/root.zig index 4f7c321a..cf9adf4c 100644 --- a/src/root.zig +++ b/src/root.zig @@ -11,6 +11,7 @@ pub const auth = struct { pub const cli = @import("cli/root.zig"); pub const workflows = @import("workflows/root.zig"); +pub const app_workflow = @import("workflows/app.zig"); pub const core = struct { pub const compat_fs = @import("core/compat_fs.zig"); diff --git a/src/terminal/color.zig b/src/terminal/color.zig index a300f115..8a316c1c 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -1,6 +1,48 @@ const std = @import("std"); +const builtin = @import("builtin"); const app_runtime = @import("../core/runtime.zig"); +const windows = std.os.windows; + +const win = if (builtin.os.tag == .windows) struct { + const ENABLE_PROCESSED_OUTPUT: windows.DWORD = 0x0001; + + extern "kernel32" fn GetConsoleMode( + console_handle: windows.HANDLE, + mode: *windows.DWORD, + ) callconv(.winapi) windows.BOOL; + + extern "kernel32" fn SetConsoleMode( + console_handle: windows.HANDLE, + mode: windows.DWORD, + ) callconv(.winapi) windows.BOOL; +} else struct {}; + pub fn fileColorEnabled(file: std.Io.File) bool { - return file.isTty(app_runtime.io()) catch false; + if (envExists("NO_COLOR")) return false; + if (!(file.isTty(app_runtime.io()) catch false)) return false; + if (builtin.os.tag == .windows) return windowsAnsiColorEnabled(file); + return true; +} + +fn envExists(comptime name: [:0]const u8) bool { + const value = std.c.getenv(name) orelse return false; + return std.mem.span(value).len != 0; +} + +fn windowsAnsiColorEnabled(file: std.Io.File) bool { + if (builtin.os.tag != .windows) return false; + + var mode: windows.DWORD = 0; + if (win.GetConsoleMode(file.handle, &mode) == .FALSE) return false; + if ((mode & windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0) return true; + + const requested = mode | + win.ENABLE_PROCESSED_OUTPUT | + windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING; + if (win.SetConsoleMode(file.handle, requested) == .FALSE) return false; + + var verified: windows.DWORD = 0; + if (win.GetConsoleMode(file.handle, &verified) == .FALSE) return false; + return (verified & windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0; } diff --git a/src/workflows/app.zig b/src/workflows/app.zig new file mode 100644 index 00000000..ac405075 --- /dev/null +++ b/src/workflows/app.zig @@ -0,0 +1,1200 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const app_runtime = @import("../core/runtime.zig"); +const http_child = @import("../api/http_child.zig"); +const registry = @import("../registry/root.zig"); +const types = @import("../cli/types.zig"); +const io_util = @import("../core/io_util.zig"); +const cli_style = @import("../cli/style.zig"); + +const codex_cli_path_env = "CODEX_CLI_PATH"; +const codex_home_env = "CODEX_HOME"; +const app_id_env = "CODEX_AUTH_APP_ID"; +const codex_app_package_name = "OpenAI.Codex"; +const codex_app_bundle_id = "com.openai.codex"; +const wsl_agent_mode_key = "runCodexInWindowsSubsystemForLinux"; +const codext_repo_latest_url = "https://api.github.com/repos/Loongphy/codext/releases/latest"; +const codext_repo_url = "https://github.com/Loongphy/codext"; +const codext_cache_dir_name = "codext-cli"; +const windows_app_id_resolver_script = + "function Resolve-CodexAppPackage { param([string]$AppId) " ++ + "if ([string]::IsNullOrWhiteSpace($AppId)) { throw 'Codex App ID is empty' }; " ++ + "$id=$AppId; " ++ + "if ($id.Contains('!')) { " ++ + "$family=$id.Split('!')[0]; " ++ + "$pkg=Get-AppxPackage | Where-Object { $_.PackageFamilyName -ieq $family -or $_.PackageFullName -ieq $family -or $_.Name -ieq $family } | Sort-Object Version -Descending | Select-Object -First 1; " ++ + "if (-not $pkg) { throw \"App package not found: $id\" }; return $pkg } " ++ + "$pkg=Get-AppxPackage -Name $id | Sort-Object Version -Descending | Select-Object -First 1; " ++ + "if (-not $pkg) { $pkg=Get-AppxPackage | Where-Object { $_.PackageFamilyName -ieq $id -or $_.PackageFullName -ieq $id } | Sort-Object Version -Descending | Select-Object -First 1 }; " ++ + "if (-not $pkg) { throw \"App package not found: $id\" }; return $pkg } " ++ + "function Resolve-CodexAppAumid { param([string]$AppId) " ++ + "if ($AppId.Contains('!')) { return $AppId }; " ++ + "$pkg=Resolve-CodexAppPackage $AppId; " ++ + "$appId=(Get-AppxPackageManifest $pkg).Package.Applications.Application | Select-Object -First 1 -ExpandProperty Id; " ++ + "if (-not $appId) { throw \"App manifest has no application id: $AppId\" }; " ++ + "return \"$($pkg.PackageFamilyName)!$appId\" } " ++ + "function Resolve-CodexAppExecutable { param([string]$AppId) " ++ + "$pkg=Resolve-CodexAppPackage $AppId; " ++ + "$app=(Get-AppxPackageManifest $pkg).Package.Applications.Application | Select-Object -First 1; " ++ + "if (-not $app -or -not $app.Executable) { throw \"App executable not found: $AppId\" }; " ++ + "$exe=Join-Path $pkg.InstallLocation ([string]$app.Executable); " ++ + "if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw \"App executable not found: $exe\" }; " ++ + "return [System.IO.Path]::GetFullPath($exe) }"; + +const ValueSource = enum { explicit, env, detected, built_in, cached, downloaded, not_set }; +const WindowsLaunchMode = enum { gui, stdio }; + +const ResolvedValue = struct { + value: ?[]const u8, + source: ValueSource, + owned: bool = false, + + fn deinit(self: ResolvedValue, allocator: std.mem.Allocator) void { + if (self.owned) if (self.value) |value| allocator.free(@constCast(value)); + } +}; + +const ResolvedPlatform = struct { + value: ?types.AppPlatform, + source: ValueSource, +}; + +const CodextInstallResult = struct { + path: []u8, + source: ValueSource, +}; + +const PathValidationIssue = struct { + option: []const u8, + message: []const u8, + path: []const u8, +}; + +pub fn handleApp(allocator: std.mem.Allocator, resolved_codex_home: []const u8, opts: types.AppOptions) !void { + const effective_home = opts.codex_home orelse resolved_codex_home; + const effective_platform = try resolvePlatform(allocator, effective_home, opts.platform); + try validateAppPlatform(effective_platform.value); + const effective_app_id = try resolveAppId(allocator, effective_platform.value, opts); + defer effective_app_id.deinit(allocator); + try requireAppId(effective_app_id); + try validateConfiguredPaths(allocator, opts); + if (try isCodexAppRunning(allocator, effective_platform.value, effective_app_id)) { + try writeAppAlreadyRunning(); + return; + } + try requireResolvableAppId(allocator, effective_platform.value, effective_app_id); + const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, true, false); + defer effective_cli_path.deinit(allocator); + try writeAppLaunchPlan(allocator, opts.codex_home != null, effective_home, effective_platform, effective_app_id, effective_cli_path); + + switch (opts.action) { + .launch => try launchApp(allocator, effective_app_id, effective_cli_path, effective_home, effective_platform, opts.inherit_stdio), + } +} + +fn getOptionalEnv(allocator: std.mem.Allocator, name: []const u8) ?[]const u8 { + const value = registry.getEnvVarOwned(allocator, name) catch return null; + if (value.len == 0) { + allocator.free(value); + return null; + } + return value; +} + +fn resolveAppId(allocator: std.mem.Allocator, platform: ?types.AppPlatform, opts: types.AppOptions) !ResolvedValue { + if (opts.app_id) |app_id| return .{ .value = app_id, .source = .explicit }; + if (getOptionalEnv(allocator, app_id_env)) |app_id| return .{ .value = app_id, .source = .env, .owned = true }; + if (defaultAppId(platform)) |app_id| return .{ .value = app_id, .source = .built_in }; + return .{ .value = null, .source = .not_set }; +} + +fn defaultAppId(platform: ?types.AppPlatform) ?[]const u8 { + const value = platform orelse return null; + return switch (value) { + .win, .wsl => codex_app_package_name, + .mac => codex_app_bundle_id, + }; +} + +fn resolveCliPath( + allocator: std.mem.Allocator, + home: []const u8, + platform: ?types.AppPlatform, + opts: types.AppOptions, + allow_download: bool, + quiet_download: bool, +) !ResolvedValue { + if (opts.codex_cli_path) |path| return .{ .value = path, .source = .explicit }; + + const target_platform = platform orelse nativeDefaultPlatform(); + if (allow_download) { + const result = try downloadDefaultCodextCli(allocator, home, target_platform, quiet_download); + return .{ .value = result.path, .source = result.source, .owned = true }; + } + if (try cachedCodextCliPath(allocator, home, target_platform)) |path| return .{ .value = path, .source = .cached, .owned = true }; + return .{ .value = null, .source = .not_set }; +} + +fn resolvePlatform(allocator: std.mem.Allocator, home: []const u8, explicit: ?types.AppPlatform) !ResolvedPlatform { + if (explicit) |platform| return .{ .value = platform, .source = .explicit }; + if (builtin.os.tag == .windows) { + const use_wsl = try readWindowsWslBackendSetting(allocator, home); + return .{ .value = if (use_wsl) .wsl else .win, .source = .detected }; + } + if (builtin.os.tag == .macos) return .{ .value = .mac, .source = .detected }; + return .{ .value = null, .source = .not_set }; +} + +fn validateConfiguredPaths(allocator: std.mem.Allocator, opts: types.AppOptions) !void { + var issues = std.ArrayList(PathValidationIssue).empty; + defer issues.deinit(allocator); + + if (opts.codex_cli_path) |path| try appendConfiguredCliPathIssue(allocator, &issues, path); + + if (issues.items.len == 0) return; + try writePathValidationIssues(issues.items); + return error.AppLaunchConfigValidationFailed; +} + +fn requireAppId(app_id: ResolvedValue) !void { + if (app_id.value != null) return; + try writeAppError("app launch needs an app ID. Pass `--app-id ` or set CODEX_AUTH_APP_ID.\n"); + return error.AppIdRequired; +} + +fn requireResolvableAppId(allocator: std.mem.Allocator, platform: ?types.AppPlatform, app_id: ResolvedValue) !void { + const value = app_id.value orelse return error.AppIdRequired; + if (try appIdCanResolve(allocator, platform, value)) return; + try writeAppError("app launch could not find the installed app for the configured app ID.\n"); + return error.AppIdNotFound; +} + +fn appIdCanResolve(allocator: std.mem.Allocator, platform: ?types.AppPlatform, app_id: []const u8) !bool { + const value = platform orelse return true; + return switch (value) { + .win, .wsl => try windowsCanResolveAppId(allocator, app_id), + .mac => try macCanResolveAppId(allocator, app_id), + }; +} + +fn appendConfiguredCliPathIssue( + allocator: std.mem.Allocator, + issues: *std.ArrayList(PathValidationIssue), + path: []const u8, +) !void { + const kind = pathKind(path) catch |err| switch (err) { + error.AccessDenied, error.PermissionDenied => { + try issues.append(allocator, .{ .option = "--codex-cli-path", .message = "Path is not accessible", .path = path }); + return; + }, + else => return err, + } orelse { + try issues.append(allocator, .{ .option = "--codex-cli-path", .message = "Path does not exist", .path = path }); + return; + }; + if (kind == .file or kind == .sym_link) return; + + try issues.append(allocator, .{ .option = "--codex-cli-path", .message = "Path is not a file", .path = path }); +} + +fn writePathValidationIssues(issues: []const PathValidationIssue) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + + for (issues) |issue| { + try writer.writeStyle(cli_style.role.error_text); + try writer.print("ERROR: {s}: {s}\n", .{ issue.option, issue.message }); + try writer.reset(); + try writer.writeStyle(cli_style.role.secondary); + try writer.print(" \"{s}\"\n", .{issue.path}); + try writer.reset(); + } + try writer.flush(); +} + +fn writeAppLaunchPlan( + allocator: std.mem.Allocator, + show_home: bool, + home: []const u8, + platform: ResolvedPlatform, + app_id: ResolvedValue, + cli_path: ResolvedValue, +) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + const out = &writer; + const columns = terminalColumns(); + + try out.writeStyle(cli_style.role.secondary); + try out.writeAll("\n- Environment Configuration ------------------------------------------------\n"); + + if (platform.value) |value| { + try writePanelField(allocator, out, columns, "Platform:", platformLabel(value), platformSourceLabel(platform.source)); + } else { + try writePanelField(allocator, out, columns, "Platform:", "", null); + } + if (show_home) try writePanelField(allocator, out, columns, "Codex Home:", home, valueSourceLabel(.explicit)); + if (app_id.value) |value| { + try writePanelField(allocator, out, columns, "App ID:", value, valueSourceLabel(app_id.source)); + } else { + try writePanelField(allocator, out, columns, "App ID:", "", null); + } + if (cli_path.value) |value| { + try writePanelField(allocator, out, columns, "CLI Path:", value, valueSourceLabel(cli_path.source)); + } else { + try writePanelField(allocator, out, columns, "CLI Path:", "", null); + } + + try out.writeAll("----------------------------------------------------------------------------\n"); + try out.reset(); + try out.flush(); +} + +fn valueSourceLabel(source: ValueSource) []const u8 { + return switch (source) { + .explicit => "explicit", + .env => "environment", + .detected => "auto-detected", + .built_in => "default", + .cached => "", + .downloaded => "downloaded", + .not_set => "not set", + }; +} + +fn platformSourceLabel(source: ValueSource) []const u8 { + return switch (source) { + .detected => "auto-detected", + else => valueSourceLabel(source), + }; +} + +fn writePanelField( + allocator: std.mem.Allocator, + out: *cli_style.StyledWriter, + columns: usize, + label: []const u8, + value: []const u8, + source: ?[]const u8, +) !void { + try writePanelFieldStyled(allocator, out, columns, label, value, source, ""); +} + +fn writePanelFieldStyled( + allocator: std.mem.Allocator, + out: *cli_style.StyledWriter, + columns: usize, + label: []const u8, + value: []const u8, + source: ?[]const u8, + value_style: []const u8, +) !void { + const display_value = try panelDisplayValue(allocator, value, source); + defer allocator.free(display_value); + + try out.writeAll(" "); + try out.print("{s}", .{label}); + try out.writeAll(" "); + try out.writeStyle(value_style); + try writeWrappedPanelValue(out, columns, 2 + label.len + 1, display_value); + try out.writeAll("\n"); +} + +fn panelDisplayValue(allocator: std.mem.Allocator, value: []const u8, source: ?[]const u8) ![]u8 { + if (source) |source_label| { + if (source_label.len != 0) return try std.fmt.allocPrint(allocator, "{s} ({s})", .{ value, source_label }); + } + return try allocator.dupe(u8, value); +} + +fn writeWrappedPanelValue(out: *cli_style.StyledWriter, columns: usize, first_line_prefix: usize, value: []const u8) !void { + var remaining = value; + var used = first_line_prefix; + while (remaining.len > 0) { + const available = if (columns > used) columns - used else 1; + if (remaining.len <= available) { + try out.writeAll(remaining); + return; + } + try out.writeAll(remaining[0..available]); + remaining = remaining[available..]; + try out.writeAll("\n "); + used = 2; + } +} + +fn terminalColumns() usize { + const file = std.Io.File.stderr(); + if (!(file.isTty(app_runtime.io()) catch false)) return 80; + if (comptime builtin.os.tag == .windows) { + var get_console_info = std.os.windows.CONSOLE.USER_IO.GET_SCREEN_BUFFER_INFO; + switch (get_console_info.operate(app_runtime.io(), file) catch return 80) { + .SUCCESS => {}, + else => return 80, + } + const cols = @as(i32, get_console_info.Data.dwWindowSize.X); + if (cols <= 0) return 80; + return @intCast(cols); + } else { + var wsz: std.posix.winsize = .{ + .row = 0, + .col = 0, + .xpixel = 0, + .ypixel = 0, + }; + const rc = std.posix.system.ioctl(file.handle, std.posix.T.IOCGWINSZ, @intFromPtr(&wsz)); + if (std.posix.errno(rc) != .SUCCESS or wsz.col == 0) return 80; + return @intCast(wsz.col); + } +} + +fn platformLabel(platform: types.AppPlatform) []const u8 { + return switch (platform) { + .win => "Windows", + .wsl => "WSL", + .mac => "macOS", + }; +} + +fn isCodexAppRunning(allocator: std.mem.Allocator, platform: ?types.AppPlatform, app_id: ResolvedValue) !bool { + const value = app_id.value orelse return false; + return switch (builtin.os.tag) { + .windows => try isCodexAppRunningOnWindows(allocator, value), + .macos => try isCodexAppRunningOnMac(allocator, value), + else => switch (platform orelse return false) { + .win, .wsl, .mac => false, + }, + }; +} + +fn isCodexAppRunningOnWindows(allocator: std.mem.Allocator, app_id: []const u8) !bool { + const script = try windowsAppIdRunningScriptAlloc(allocator, app_id); + defer allocator.free(script); + + var result = http_child.runChildCapture( + allocator, + &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, + 3000, + null, + ) catch return false; + defer result.deinit(allocator); + if (result.timed_out) return false; + return std.mem.trim(u8, result.stdout, " \t\r\n").len != 0; +} + +fn isCodexAppRunningOnMac(allocator: std.mem.Allocator, app_id: []const u8) !bool { + var result = http_child.runChildCapture( + allocator, + &[_][]const u8{ + "/usr/bin/osascript", + "-e", + "on run argv", + "-e", + "application id (item 1 of argv) is running", + "-e", + "end run", + app_id, + }, + 3000, + null, + ) catch return false; + defer result.deinit(allocator); + if (result.timed_out or !childExitedSuccessfully(result.term)) return false; + return std.mem.eql(u8, std.mem.trim(u8, result.stdout, " \t\r\n"), "true"); +} + +fn windowsAppIdRunningScriptAlloc(allocator: std.mem.Allocator, app_id: []const u8) ![]u8 { + const app_quoted = try psSingleQuoteAlloc(allocator, app_id); + defer allocator.free(app_quoted); + return try std.fmt.allocPrint( + allocator, + "$ErrorActionPreference='SilentlyContinue'; {s}; " ++ + "$target=Resolve-CodexAppExecutable {s}; " ++ + "$p=Get-CimInstance Win32_Process | Where-Object {{ $_.ExecutablePath -and ([System.IO.Path]::GetFullPath($_.ExecutablePath) -ieq $target) }} | Select-Object -First 1; " ++ + "if ($p) {{ [Console]::Out.Write('running') }}", + .{ windows_app_id_resolver_script, app_quoted }, + ); +} + +fn childExitedSuccessfully(term: std.process.Child.Term) bool { + return switch (term) { + .exited => |code| code == 0, + else => false, + }; +} + +fn nativeDefaultPlatform() types.AppPlatform { + return switch (builtin.os.tag) { + .windows => .win, + .macos => .mac, + else => .wsl, + }; +} + +fn readWindowsWslBackendSetting(allocator: std.mem.Allocator, home: []const u8) !bool { + const state_path = try std.fs.path.join(allocator, &.{ home, ".codex-global-state.json" }); + defer allocator.free(state_path); + + var file = std.Io.Dir.cwd().openFile(app_runtime.io(), state_path, .{}) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(app_runtime.io()); + const data = try registry.readFileAlloc(file, allocator, 1024 * 1024); + defer allocator.free(data); + + const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return false; + defer parsed.deinit(); + const object = switch (parsed.value) { + .object => |object| object, + else => return false, + }; + const value = object.get(wsl_agent_mode_key) orelse return false; + return switch (value) { + .bool => |enabled| enabled, + else => false, + }; +} + +fn launchApp( + allocator: std.mem.Allocator, + app_id: ResolvedValue, + cli_path: ResolvedValue, + home: []const u8, + platform: ResolvedPlatform, + inherit_stdio: bool, +) !void { + const target = app_id.value orelse { + try writeAppError("app launch needs an app ID. Pass `--app-id ` or set CODEX_AUTH_APP_ID.\n"); + return error.AppIdRequired; + }; + try validateAppPlatform(platform.value); + try applyAppPlatform(allocator, home, platform.value); + + if (builtin.os.tag == .windows) { + try writeAppLaunching(); + return launchWindowsViaPowerShell(allocator, target, cli_path.value, home, if (inherit_stdio) .stdio else .gui); + } + + if (builtin.os.tag == .macos) { + if (inherit_stdio) { + try writeAppLaunching(); + return launchMacExecutableWithStdio(allocator, target, cli_path.value, home); + } + return launchMac(allocator, target, cli_path.value, home); + } + try writeAppError("app launch is supported only from the Windows or macOS codex-auth executable.\n"); + return error.UnsupportedPlatform; +} + +fn validateAppPlatform(value: ?types.AppPlatform) !void { + const platform = value orelse return; + switch (platform) { + .win, .wsl => if (builtin.os.tag != .windows) { + try writeAppError("app with `--platform win` or `--platform wsl` must run from the Windows codex-auth executable.\n"); + return error.WindowsAppPlatformRequiresWindows; + }, + .mac => if (builtin.os.tag != .macos) { + try writeAppError("app with `--platform mac` must run from the macOS codex-auth executable.\n"); + return error.MacAppPlatformRequiresMacOS; + }, + } +} + +fn applyAppPlatform(allocator: std.mem.Allocator, home: []const u8, value: ?types.AppPlatform) !void { + const platform = value orelse return; + const use_wsl = switch (platform) { + .win => false, + .wsl => true, + .mac => return, + }; + const state_path = try std.fs.path.join(allocator, &.{ home, ".codex-global-state.json" }); + defer allocator.free(state_path); + + if (std.fs.path.dirname(state_path)) |dir| { + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), dir); + } + + var parsed: ?std.json.Parsed(std.json.Value) = null; + defer if (parsed) |*p| p.deinit(); + + var root: std.json.Value = blk: { + var file = std.Io.Dir.cwd().openFile(app_runtime.io(), state_path, .{}) catch |err| switch (err) { + error.FileNotFound => break :blk .{ .object = .{} }, + else => return err, + }; + defer file.close(app_runtime.io()); + const data = try registry.readFileAlloc(file, allocator, 1024 * 1024); + defer allocator.free(data); + parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch { + break :blk .{ .object = .{} }; + }; + break :blk switch (parsed.?.value) { + .object => try cloneJsonValue(allocator, parsed.?.value), + else => .{ .object = .{} }, + }; + }; + defer deinitClonedJsonValue(allocator, &root); + + switch (root) { + .object => |*obj| try obj.put(allocator, wsl_agent_mode_key, .{ .bool = use_wsl }), + else => unreachable, + } + + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + try std.json.Stringify.value(root, .{ .whitespace = .indent_2 }, &aw.writer); + + var file = try std.Io.Dir.cwd().createFile(app_runtime.io(), state_path, .{ .truncate = true }); + defer file.close(app_runtime.io()); + try file.writeStreamingAll(app_runtime.io(), aw.written()); +} + +fn cloneJsonValue(allocator: std.mem.Allocator, value: std.json.Value) !std.json.Value { + return switch (value) { + .null, .bool, .integer, .float, .number_string, .string => value, + .array => |array| blk: { + var cloned = std.json.Array.init(allocator); + for (array.items) |item| { + try cloned.append(try cloneJsonValue(allocator, item)); + } + break :blk .{ .array = cloned }; + }, + .object => |object| blk: { + var cloned: std.json.ObjectMap = .{}; + for (object.keys(), object.values()) |key, item| { + try cloned.put(allocator, key, try cloneJsonValue(allocator, item)); + } + break :blk .{ .object = cloned }; + }, + }; +} + +fn deinitClonedJsonValue(allocator: std.mem.Allocator, value: *std.json.Value) void { + switch (value.*) { + .array => |*array| { + for (array.items) |*item| deinitClonedJsonValue(allocator, item); + array.deinit(); + }, + .object => |*object| { + for (object.values()) |*item| deinitClonedJsonValue(allocator, item); + object.deinit(allocator); + }, + else => {}, + } +} + +fn launchMacExecutableWithStdio( + allocator: std.mem.Allocator, + app_id: []const u8, + cli_path: ?[]const u8, + home: []const u8, +) !void { + const bundle_path = try resolveMacBundlePath(allocator, app_id); + defer allocator.free(bundle_path); + const launch_path = try resolveMacBundleExecutablePath(allocator, bundle_path); + defer allocator.free(launch_path); + + var env_map = try registry.getEnvMap(allocator); + defer env_map.deinit(); + try env_map.put(codex_home_env, home); + if (cli_path) |path| try env_map.put(codex_cli_path_env, path); + + var child = try std.process.spawn(app_runtime.io(), .{ + .argv = &[_][]const u8{launch_path}, + .environ_map = &env_map, + .stdin = .ignore, + .stdout = .inherit, + .stderr = .inherit, + }); + _ = try child.wait(app_runtime.io()); +} + +fn launchMac( + allocator: std.mem.Allocator, + app_id: []const u8, + cli_path: ?[]const u8, + home: []const u8, +) !void { + try writeAppLaunching(); + + const home_env = try std.fmt.allocPrint(allocator, "{s}={s}", .{ codex_home_env, home }); + defer allocator.free(home_env); + const cli_env = if (cli_path) |path| try std.fmt.allocPrint(allocator, "{s}={s}", .{ codex_cli_path_env, path }) else null; + defer if (cli_env) |value| allocator.free(value); + + var argv = std.ArrayList([]const u8).empty; + defer argv.deinit(allocator); + try argv.append(allocator, "/usr/bin/open"); + try argv.appendSlice(allocator, &[_][]const u8{ "--env", home_env }); + if (cli_env) |value| try argv.appendSlice(allocator, &[_][]const u8{ "--env", value }); + try argv.appendSlice(allocator, &[_][]const u8{ + "--stdout", + "/dev/null", + "--stderr", + "/dev/null", + "-b", + app_id, + }); + var child = try std.process.spawn(app_runtime.io(), .{ + .argv = argv.items, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + }); + switch (try child.wait(app_runtime.io())) { + .exited => |code| if (code == 0) return, + else => {}, + } + try writeAppError("app launcher failed.\n"); + return error.AppLaunchFailed; +} + +fn resolveMacBundlePath(allocator: std.mem.Allocator, app_id: []const u8) ![]u8 { + var result = http_child.runChildCapture( + allocator, + &[_][]const u8{ + "/usr/bin/osascript", + "-e", + "on run argv", + "-e", + "POSIX path of (path to application id (item 1 of argv))", + "-e", + "end run", + app_id, + }, + 7000, + null, + ) catch return error.AppIdNotFound; + defer result.deinit(allocator); + if (result.timed_out or !childExitedSuccessfully(result.term)) return error.AppIdNotFound; + const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); + if (trimmed.len == 0) return error.AppIdNotFound; + return try allocator.dupe(u8, trimmed); +} + +fn macCanResolveAppId(allocator: std.mem.Allocator, app_id: []const u8) !bool { + const bundle_path = resolveMacBundlePath(allocator, app_id) catch return false; + allocator.free(bundle_path); + return true; +} + +fn resolveMacBundleExecutablePath(allocator: std.mem.Allocator, bundle_path: []const u8) ![]u8 { + const candidates = [_][]const u8{ + "Contents/MacOS/Codex", + "Contents/MacOS/codex", + "Contents/MacOS/Codext", + "Contents/MacOS/codext", + }; + for (candidates) |candidate| { + const joined = try std.fs.path.join(allocator, &.{ bundle_path, candidate }); + if (fileExists(joined)) return joined; + allocator.free(joined); + } + return error.AppExecutableNotFound; +} + +fn windowsCanResolveAppId(allocator: std.mem.Allocator, app_id: []const u8) !bool { + const app_quoted = try psSingleQuoteAlloc(allocator, app_id); + defer allocator.free(app_quoted); + const script = try std.fmt.allocPrint( + allocator, + "$ErrorActionPreference='Stop'; {s}; try {{ $null=Resolve-CodexAppAumid {s}; [Console]::Out.Write('ok') }} catch {{ }}", + .{ windows_app_id_resolver_script, app_quoted }, + ); + defer allocator.free(script); + var result = http_child.runChildCapture(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 5000, null) catch return false; + defer result.deinit(allocator); + if (result.timed_out) return false; + return std.mem.eql(u8, std.mem.trim(u8, result.stdout, " \t\r\n"), "ok"); +} + +fn isDirectory(path: []const u8) bool { + const stat = std.Io.Dir.cwd().statFile(app_runtime.io(), path, .{}) catch return false; + return stat.kind == .directory; +} + +fn pathKind(path: []const u8) !?std.Io.File.Kind { + const stat = std.Io.Dir.cwd().statFile(app_runtime.io(), path, .{}) catch |err| switch (err) { + error.FileNotFound, error.NotDir => return null, + else => return err, + }; + return stat.kind; +} + +fn fileExists(path: []const u8) bool { + std.Io.Dir.cwd().access(app_runtime.io(), path, .{}) catch return false; + return true; +} + +fn cachedCodextCliPath(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform) !?[]u8 { + const candidate = try managedCodextExecutablePath(allocator, home, platform); + if (fileExists(candidate)) return candidate; + allocator.free(candidate); + return null; +} + +fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform, quiet: bool) !CodextInstallResult { + if (!quiet) try writeAppStep("Checking latest " ++ codext_repo_url ++ " release..."); + const release = try fetchLatestCodextRelease(allocator); + defer release.deinit(allocator); + + const cache_root = try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name }); + defer allocator.free(cache_root); + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), cache_root); + + const asset = release.assetFor(platform) orelse return error.CodextReleaseAssetNotFound; + const target_downloaded = try ensureCodextAssetInstalled(allocator, cache_root, release.tag, platform, asset, quiet); + + const installed = try managedCodextExecutablePath(allocator, home, platform); + if (!fileExists(installed)) { + allocator.free(installed); + return error.CodextReleaseInstallFailed; + } + return .{ + .path = installed, + .source = if (target_downloaded) .downloaded else .cached, + }; +} + +fn managedCodextExecutablePath(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform) ![]u8 { + const name = try managedCodextExecutableName(allocator, platform); + defer allocator.free(name); + return try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name, name }); +} + +fn ensureCodextAssetInstalled( + allocator: std.mem.Allocator, + cache_root: []const u8, + tag: []const u8, + platform: types.AppPlatform, + asset: CodextAsset, + quiet: bool, +) !bool { + if (try managedCodextAssetIsCurrent(allocator, cache_root, tag, platform, asset)) { + if (!quiet) try writeAppUpToDate(platform, tag); + return false; + } + if (!quiet) try writeAppDownload(platform, tag, asset.url); + try downloadAndInstallCodextAsset(allocator, cache_root, tag, platform, asset); + if (!quiet) try writeAppInstalled(platform, tag); + return true; +} + +fn managedCodextAssetIsCurrent( + allocator: std.mem.Allocator, + cache_root: []const u8, + tag: []const u8, + platform: types.AppPlatform, + asset: CodextAsset, +) !bool { + const executable_name = try managedCodextExecutableName(allocator, platform); + defer allocator.free(executable_name); + const executable_path = try std.fs.path.join(allocator, &.{ cache_root, executable_name }); + defer allocator.free(executable_path); + if (!fileExists(executable_path)) return false; + + const version_path = try managedCodextVersionPath(allocator, cache_root, platform); + defer allocator.free(version_path); + var file = std.Io.Dir.cwd().openFile(app_runtime.io(), version_path, .{}) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(app_runtime.io()); + const data = try registry.readFileAlloc(file, allocator, 16 * 1024); + defer allocator.free(data); + + const expected = try managedCodextVersionText(allocator, tag, asset); + defer allocator.free(expected); + return std.mem.eql(u8, data, expected); +} + +fn managedCodextVersionPath(allocator: std.mem.Allocator, cache_root: []const u8, platform: types.AppPlatform) ![]u8 { + const executable_name = try managedCodextExecutableName(allocator, platform); + defer allocator.free(executable_name); + const version_name = try std.fmt.allocPrint(allocator, "{s}.version", .{executable_name}); + defer allocator.free(version_name); + return try std.fs.path.join(allocator, &.{ cache_root, version_name }); +} + +fn managedCodextVersionText(allocator: std.mem.Allocator, tag: []const u8, asset: CodextAsset) ![]u8 { + return try std.fmt.allocPrint(allocator, "tag={s}\nasset={s}\n", .{ tag, asset.name }); +} + +const CodextAsset = struct { + name: []u8, + url: []u8, + + fn deinit(self: CodextAsset, allocator: std.mem.Allocator) void { + allocator.free(self.name); + allocator.free(self.url); + } +}; + +const CodextRelease = struct { + tag: []u8, + win_asset: ?CodextAsset = null, + linux_asset: ?CodextAsset = null, + mac_asset: ?CodextAsset = null, + + fn deinit(self: CodextRelease, allocator: std.mem.Allocator) void { + allocator.free(self.tag); + if (self.win_asset) |value| value.deinit(allocator); + if (self.linux_asset) |value| value.deinit(allocator); + if (self.mac_asset) |value| value.deinit(allocator); + } + + fn assetFor(self: CodextRelease, platform: types.AppPlatform) ?CodextAsset { + return switch (platform) { + .win => self.win_asset, + .wsl => self.linux_asset, + .mac => self.mac_asset, + }; + } +}; + +fn fetchLatestCodextRelease(allocator: std.mem.Allocator) !CodextRelease { + var result = try http_child.runChildCapture(allocator, &[_][]const u8{ curlExecutable(), "-L", "--fail", "--silent", codext_repo_latest_url }, 15000, null); + defer result.deinit(allocator); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.stdout, .{}); + defer parsed.deinit(); + const object = switch (parsed.value) { + .object => |object| object, + else => return error.InvalidCodextReleaseResponse, + }; + const tag_value = object.get("tag_name") orelse return error.InvalidCodextReleaseResponse; + const tag = switch (tag_value) { + .string => |value| try allocator.dupe(u8, value), + else => return error.InvalidCodextReleaseResponse, + }; + var release = CodextRelease{ .tag = tag }; + errdefer release.deinit(allocator); + + const assets_value = object.get("assets") orelse return error.InvalidCodextReleaseResponse; + const assets = switch (assets_value) { + .array => |array| array.items, + else => return error.InvalidCodextReleaseResponse, + }; + const want_win = releaseAssetNeedle(.win); + const want_linux = releaseAssetNeedle(.wsl); + const want_mac = releaseAssetNeedle(.mac); + for (assets) |asset| { + const asset_object = switch (asset) { + .object => |asset_object| asset_object, + else => continue, + }; + const name = switch (asset_object.get("name") orelse continue) { + .string => |value| value, + else => continue, + }; + const url = switch (asset_object.get("browser_download_url") orelse continue) { + .string => |value| value, + else => continue, + }; + if (std.mem.indexOf(u8, name, want_win) != null) { + if (release.win_asset == null) release.win_asset = try dupeCodextAsset(allocator, name, url); + } else if (std.mem.indexOf(u8, name, want_linux) != null) { + if (release.linux_asset == null) release.linux_asset = try dupeCodextAsset(allocator, name, url); + } else if (std.mem.indexOf(u8, name, want_mac) != null) { + if (release.mac_asset == null) release.mac_asset = try dupeCodextAsset(allocator, name, url); + } + } + return release; +} + +fn dupeCodextAsset(allocator: std.mem.Allocator, name: []const u8, url: []const u8) !CodextAsset { + return .{ + .name = try allocator.dupe(u8, name), + .url = try allocator.dupe(u8, url), + }; +} + +fn downloadAndInstallCodextAsset( + allocator: std.mem.Allocator, + cache_root: []const u8, + tag: []const u8, + platform: types.AppPlatform, + asset: CodextAsset, +) !void { + const extract_dir_name = try std.fmt.allocPrint(allocator, ".extract-{s}", .{codextPlatformCacheName(platform)}); + defer allocator.free(extract_dir_name); + const extract_dir = try std.fs.path.join(allocator, &.{ cache_root, extract_dir_name }); + defer allocator.free(extract_dir); + if (isDirectory(extract_dir)) try std.Io.Dir.cwd().deleteTree(app_runtime.io(), extract_dir); + defer std.Io.Dir.cwd().deleteTree(app_runtime.io(), extract_dir) catch {}; + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), extract_dir); + + const archive_name = if (platform == .win) "codext.zip" else "codext.tar.gz"; + const archive_path = try std.fs.path.join(allocator, &.{ extract_dir, archive_name }); + defer allocator.free(archive_path); + try runChecked(allocator, &[_][]const u8{ curlExecutable(), "-L", "--fail", "--silent", "--show-error", "-o", archive_path, asset.url }, 120000); + if (platform == .win) { + const archive_quoted = try psSingleQuoteAlloc(allocator, archive_path); + defer allocator.free(archive_quoted); + const dest_quoted = try psSingleQuoteAlloc(allocator, extract_dir); + defer allocator.free(dest_quoted); + const script = try std.fmt.allocPrint(allocator, "Expand-Archive -LiteralPath {s} -DestinationPath {s} -Force", .{ archive_quoted, dest_quoted }); + defer allocator.free(script); + try runChecked(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 120000); + } else { + try runChecked(allocator, &[_][]const u8{ tarExecutable(), "-xzf", archive_path, "-C", extract_dir }, 120000); + } + try installManagedCodextExecutable(allocator, cache_root, extract_dir, platform); + try writeManagedCodextVersion(allocator, cache_root, tag, platform, asset); +} + +fn writeManagedCodextVersion( + allocator: std.mem.Allocator, + cache_root: []const u8, + tag: []const u8, + platform: types.AppPlatform, + asset: CodextAsset, +) !void { + const version_path = try managedCodextVersionPath(allocator, cache_root, platform); + defer allocator.free(version_path); + const data = try managedCodextVersionText(allocator, tag, asset); + defer allocator.free(data); + try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = version_path, .data = data }); +} + +fn runChecked(allocator: std.mem.Allocator, argv: []const []const u8, timeout_ms: u64) !void { + var result = try http_child.runChildCapture(allocator, argv, timeout_ms, null); + defer result.deinit(allocator); + if (result.timed_out) return error.ChildProcessTimedOut; + switch (result.term) { + .exited => |code| if (code == 0) return, + else => {}, + } + return error.ChildProcessFailed; +} + +fn codextPlatformCacheName(platform: types.AppPlatform) []const u8 { + return switch (platform) { + .win => if (builtin.cpu.arch == .aarch64) "win32-arm64" else "win32-x64", + .wsl => if (builtin.cpu.arch == .aarch64) "linux-arm64" else "linux-x64", + .mac => if (builtin.cpu.arch == .aarch64) "darwin-arm64" else "darwin-x64", + }; +} + +fn releaseAssetNeedle(platform: types.AppPlatform) []const u8 { + return codextPlatformCacheName(platform); +} + +fn curlExecutable() []const u8 { + return if (builtin.os.tag == .windows) "C:\\Windows\\System32\\curl.exe" else "curl"; +} + +fn tarExecutable() []const u8 { + return if (builtin.os.tag == .windows) "C:\\Windows\\System32\\tar.exe" else "tar"; +} + +fn codextExecutableName(platform: types.AppPlatform) []const u8 { + return switch (platform) { + .win => "codex.exe", + .wsl, .mac => "codex", + }; +} + +fn codextReleaseExecutableName(platform: types.AppPlatform) []const u8 { + return switch (platform) { + .win => "codext.exe", + .wsl, .mac => "codext", + }; +} + +fn managedCodextExecutableName(allocator: std.mem.Allocator, platform: types.AppPlatform) ![]u8 { + return if (platform == .win) + try std.fmt.allocPrint(allocator, "codex-{s}.exe", .{codextPlatformCacheName(platform)}) + else + try std.fmt.allocPrint(allocator, "codex-{s}", .{codextPlatformCacheName(platform)}); +} + +fn installManagedCodextExecutable(allocator: std.mem.Allocator, cache_root: []const u8, extract_dir: []const u8, platform: types.AppPlatform) !void { + const source = try extractedCodextExecutablePath(allocator, extract_dir, platform); + defer allocator.free(source); + const target_name = try managedCodextExecutableName(allocator, platform); + defer allocator.free(target_name); + const target = try std.fs.path.join(allocator, &.{ cache_root, target_name }); + defer allocator.free(target); + if (fileExists(target)) try std.Io.Dir.deleteFileAbsolute(app_runtime.io(), target); + try std.Io.Dir.renameAbsolute(source, target, app_runtime.io()); +} + +fn extractedCodextExecutablePath(allocator: std.mem.Allocator, extract_dir: []const u8, platform: types.AppPlatform) ![]u8 { + const primary = try std.fs.path.join(allocator, &.{ extract_dir, codextExecutableName(platform) }); + if (fileExists(primary)) return primary; + allocator.free(primary); + + const release = try std.fs.path.join(allocator, &.{ extract_dir, codextReleaseExecutableName(platform) }); + if (fileExists(release)) return release; + allocator.free(release); + + return error.CodextReleaseInstallFailed; +} + +fn writeAppError(message: []const u8) !void { + var buffer: [512]u8 = undefined; + var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + try out.writeAll(message); + try out.flush(); +} + +fn writeAppAlreadyRunning() !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeAll("Codex App is already running, launch skipped.\n"); + try writer.flush(); +} + +fn writeAppStep(message: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeStyle(cli_style.role.status); + try writer.writeAll(stepMarker()); + try writer.writeAll(message); + try writer.reset(); + try writer.writeAll("\n"); + try writer.flush(); +} + +fn stepMarker() []const u8 { + return "- "; +} + +fn writeAppDownload(platform: types.AppPlatform, tag: []const u8, url: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeStyle(cli_style.role.secondary); + try writer.print(" {s}Downloading Codext CLI for {s} ({s})\n", .{ downloadMarker(), platformLabel(platform), tag }); + try writer.print(" {s}\n", .{url}); + try writer.reset(); + try writer.flush(); +} + +fn downloadMarker() []const u8 { + return ""; +} + +fn writeAppInstalled(platform: types.AppPlatform, tag: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeStyle(cli_style.role.success); + try writer.writeAll(successMarker()); + try writer.reset(); + try writer.print(" Downloaded Codext CLI for {s} ({s})\n", .{ platformLabel(platform), tag }); + try writer.flush(); +} + +fn writeAppUpToDate(platform: types.AppPlatform, tag: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeStyle(cli_style.role.success); + try writer.writeAll(successMarker()); + try writer.reset(); + _ = platform; + try writer.print(" Codext CLI is up-to-date ({s})\n", .{tag}); + try writer.flush(); +} + +fn successMarker() []const u8 { + return "OK"; +} + +fn writeAppLaunching() !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeStyle(cli_style.role.status); + try writer.writeAll(launchMarker()); + try writer.writeAll("Launching"); + try writer.reset(); + try writer.writeAll(" Codex App...\n"); + try writer.flush(); +} + +fn launchMarker() []const u8 { + return ""; +} + +fn writeAppInfo(comptime format: []const u8, args: anytype) !void { + var buffer: [1024]u8 = undefined; + var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + try out.print(format, args); + try out.flush(); +} + +fn writeAppOutput(comptime format: []const u8, args: anytype) !void { + var buffer: [1024]u8 = undefined; + var writer = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + try out.print(format, args); + try out.flush(); +} + +fn launchWindowsViaPowerShell( + allocator: std.mem.Allocator, + app_id: []const u8, + cli_path: ?[]const u8, + home: []const u8, + mode: WindowsLaunchMode, +) !void { + const app_quoted = try psSingleQuoteAlloc(allocator, app_id); + defer allocator.free(app_quoted); + const home_quoted = try psSingleQuoteAlloc(allocator, home); + defer allocator.free(home_quoted); + const cli_quoted = if (cli_path) |path| try psSingleQuoteAlloc(allocator, path) else null; + defer if (cli_quoted) |path| allocator.free(path); + + const cli_part = if (cli_quoted) |path| + try std.fmt.allocPrint(allocator, "; $env:CODEX_CLI_PATH={s}", .{path}) + else + try allocator.dupe(u8, ""); + defer allocator.free(cli_part); + + const launch_part = switch (mode) { + .gui => "$aumid=Resolve-CodexAppAumid $id; Start-Process -FilePath \"shell:AppsFolder\\$aumid\"", + .stdio => "$app=Resolve-CodexAppExecutable $id; $wd=Split-Path -Parent $app; Push-Location $wd; try { & $app; $code=$LASTEXITCODE } finally { Pop-Location }; if ($null -ne $code) { exit $code }", + }; + + const script = try std.fmt.allocPrint( + allocator, + "$ErrorActionPreference='Stop'; {s}; $id={s}; $env:CODEX_HOME={s}{s}; {s}", + .{ windows_app_id_resolver_script, app_quoted, home_quoted, cli_part, launch_part }, + ); + defer allocator.free(script); + + var child = try std.process.spawn(app_runtime.io(), .{ + .argv = &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, + .stdin = .ignore, + .stdout = if (mode == .stdio) .inherit else .ignore, + .stderr = if (mode == .stdio) .inherit else .ignore, + .create_no_window = mode == .gui, + }); + switch (try child.wait(app_runtime.io())) { + .exited => |code| if (code == 0) return, + else => {}, + } + try writeAppError("app launcher failed.\n"); + return error.AppLaunchFailed; +} + +fn psSingleQuoteAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + try out.append(allocator, '\''); + for (value) |ch| { + try out.append(allocator, ch); + if (ch == '\'') try out.append(allocator, '\''); + } + try out.append(allocator, '\''); + return try out.toOwnedSlice(allocator); +} diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index c2a0d742..b725cea4 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -25,7 +25,19 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.DuplicateAlias or err == error.RemoveConfirmationUnavailable or err == error.RemoveSelectionRequiresTty or - err == error.InvalidRemoveSelectionInput; + err == error.InvalidRemoveSelectionInput or + err == error.AppLaunchConfigValidationFailed or + err == error.AppIdRequired or + err == error.AppIdNotFound or + err == error.AppExecutableNotFound or + err == error.CodexCliPathNotFound or + err == error.CodexCliPathNotAccessible or + err == error.CodexCliPathNotFile or + err == error.AppLaunchFailed or + err == error.WindowsAppLaunchRequiresWindows or + err == error.WindowsAppPlatformRequiresWindows or + err == error.MacAppPlatformRequiresMacOS or + err == error.WindowsPassthroughArgsUnsupported; } pub fn ensureLiveTty(target: LiveTtyTarget) !void { diff --git a/src/workflows/root.zig b/src/workflows/root.zig index 3e26dcc3..580ad2fb 100644 --- a/src/workflows/root.zig +++ b/src/workflows/root.zig @@ -16,6 +16,7 @@ const live_flow = @import("live.zig"); const help_workflow = @import("help.zig"); const clean_workflow = @import("clean.zig"); const config_workflow = @import("config.zig"); +const app_workflow = @import("app.zig"); const list_workflow = @import("list.zig"); const login_workflow = @import("login.zig"); const import_workflow = @import("import.zig"); @@ -139,6 +140,7 @@ fn runMain(init: std.process.Init.Minimal) !void { else => try cli.help.printCommandHelp(topic), }, .config => |opts| try config_workflow.handleConfig(allocator, codex_home.?, opts), + .app => |opts| try app_workflow.handleApp(allocator, codex_home.?, opts), .list => |opts| try list_workflow.handleList(allocator, codex_home.?, opts), .login => |opts| try login_workflow.handleLogin(allocator, codex_home.?, opts), .import_auth => |opts| try import_workflow.handleImport(allocator, codex_home.?, opts), diff --git a/style.md b/style.md index 960f0471..771d6cc9 100644 --- a/style.md +++ b/style.md @@ -4,11 +4,19 @@ This guide covers user-facing terminal output, including shared output and comma # Text Roles +User-facing output code should use the semantic roles in `src/cli/style.zig` +instead of referencing raw ANSI colors directly. Keep raw ANSI constants as the +low-level palette only; changing a role mapping should update all matching +output without searching business workflows for individual color names. + - **Header / table header:** Use ANSI `cyan`. - **Primary text:** Use the terminal's default foreground color. - **Secondary text:** Use ANSI `dim`. - **Footer / key hints:** Use ANSI `cyan`. - **Live refresh status line:** Use ANSI `cyan`. +- **Status / in-progress action:** Use ANSI `cyan`. +- **Configuration key:** Use ANSI `bold`. +- **Warning / non-fatal status:** Use ANSI `cyan`. # Foreground Colors diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 2f183b92..e8739750 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -75,6 +75,85 @@ fn expectArgv(actual: []const []const u8, expected: []const []const u8) !void { } } +test "Scenario: Given app launch overrides when parsing then IDs and paths are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ + "codex-auth", + "app", + "--app-id", + "OpenAI.Codex", + "--codex-cli-path", + "codex-custom", + "--codex-home", + "/mnt/c/Users/Loong/.codext", + "--platform", + "win", + "--std", + }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .app => |opts| { + try std.testing.expectEqual(cli.types.AppAction.launch, opts.action); + try std.testing.expectEqualStrings("OpenAI.Codex", opts.app_id.?); + try std.testing.expectEqualStrings("codex-custom", opts.codex_cli_path.?); + try std.testing.expectEqualStrings("/mnt/c/Users/Loong/.codext", opts.codex_home.?); + try std.testing.expectEqual(cli.types.AppPlatform.win, opts.platform.?); + try std.testing.expect(opts.inherit_stdio); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given app passthrough args when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "app", "--", "--trace" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .app, "`app` does not accept passthrough arguments."); +} + +test "Scenario: Given removed app launch subcommand when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "app", "launch" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .app, "unexpected argument `launch` for `app`."); +} + +test "Scenario: Given removed app status subcommand when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "app", "status" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .app, "unexpected argument `status` for `app`."); +} + +test "Scenario: Given removed app patch subcommand when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "app", "patch", "--platform", "wsl" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .app, "unexpected argument `patch` for `app`."); +} + +test "Scenario: Given removed app unpatch subcommand when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "app", "unpatch" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .app, "unexpected argument `unpatch` for `app`."); +} + fn expectedImportMarker(outcome: registry.ImportOutcome) []const u8 { return switch (outcome) { .imported => if (builtin.os.tag == .windows) "[+]" else "✓", diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 54ed25f9..4b316bd1 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -3473,3 +3473,108 @@ test "Scenario: Given default api usage when listing accounts then no warning is try std.testing.expect(std.mem.indexOf(u8, result.stdout, "ACCOUNT") != null); try std.testing.expectEqualStrings("", result.stderr); } + +test "Scenario: Given explicit codex home when planning app launch then codex home source is explicit" { + if (builtin.os.tag == .windows or builtin.os.tag == .macos) return error.SkipZigTest; + + 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(".codex"); + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + + const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ + "app", + "--app-id", + "OpenAI.Codex", + "--codex-cli-path", + "/bin/true", + "--codex-home", + codex_home, + }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stdout); + + const home_label_index = std.mem.indexOf(u8, result.stderr, " Codex Home:") orelse return error.TestUnexpectedResult; + const home_tail = result.stderr[home_label_index..]; + try std.testing.expect(std.mem.indexOf(u8, home_tail, "(explicit)") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "via --codex-home") == null); +} + +test "Scenario: Given inherited codex home when planning app launch then codex home is omitted" { + if (builtin.os.tag == .windows or builtin.os.tag == .macos) return error.SkipZigTest; + + 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(".codex"); + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + + const result = try runCliWithIsolatedHomeAndCodexHome(gpa, project_root, home_root, codex_home, &[_][]const u8{ + "app", + "--app-id", + "OpenAI.Codex", + "--codex-cli-path", + "/bin/true", + }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stdout); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Environment Configuration") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, " Codex Home:") == null); +} + +test "Scenario: Given missing explicit codex CLI path when launching app then command fails before launch plan" { + if (builtin.os.tag == .windows or builtin.os.tag == .macos) return error.SkipZigTest; + + 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 missing_cli_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "missing-codex" }); + defer gpa.free(missing_cli_path); + + const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ + "app", + "--app-id", + "OpenAI.Codex", + "--codex-cli-path", + missing_cli_path, + }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stdout); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "ERROR: --codex-cli-path: Path does not exist\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, " \"") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, missing_cli_path) != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Environment Configuration") == null); +}