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);
+}