Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Bolin
c1ff22bc6c feat: This PR adds a first-party curl-based installer that downloads only the binaries needed for the current platform and wires it into Codex’s existing update UX.
It introduces a dedicated `installer/` folder for non-Rust install/update assets, a `CODEX_HOME`-aware on-disk layout, and a wrapper that makes helper CLIs available to Codex without globally polluting the user’s shell.

## Why

Today the recommended install path is `npm i -g @openai/codex`, but the npm package bundles native binaries for all platforms (and `rg`), leading to very large installs (hundreds of MB unpacked) even though a single platform binary is much smaller.

Homebrew avoids this on macOS by downloading only the matching artifact. This PR brings that same property to a cross-platform one-liner:

```sh
curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash
```

Key goals:

- Download only the user’s platform artifacts.
- Keep curl-managed installs isolated under `CODEX_HOME`.
- Preserve compatibility with npm/brew installs (shadowing is fine, breaking is not).
- Provide a clean place to add helper CLIs that should be available when Codex runs.

## How It Works

### Install root and layout

All curl-managed artifacts live under `CODEX_HOME` (default: `~/.codex`).

The layout is:

- `CODEX_HOME/bin/`
- `CODEX_HOME/versions/<version>/bin/`
- `CODEX_HOME/versions/current` (symlink)
- `CODEX_HOME/tools/bin/`

This design supports atomic version switches and helper CLIs:

- The user’s global `PATH` only needs `CODEX_HOME/bin`.
- The runtime `PATH` is extended inside the wrapper to include helper CLIs.

See `installer/README.md:1`.

### Installer scripts

#### `installer/install.sh`

`installer/install.sh:1` is the public entrypoint:

- Requires Bash explicitly (we use Bash features like `mapfile`).
- Loads `installer/lib.sh` locally when run from a checkout, or downloads it when piped from curl.
- Detects `arch`/`os`, resolves the version (via `CODEX_VERSION` or GitHub Releases), downloads the correct tarball, installs it, activates it, and updates the user’s rc file.

#### `installer/lib.sh`

`installer/lib.sh:10` centralizes the mechanics:

- Resolves `CODEX_HOME` with a fallback to `~/.codex`.
- Detects OS/arch and builds release URLs.
- Idempotently updates the user rc file via a marker block that respects non-default `CODEX_HOME` values: `installer/lib.sh:106`.
- Installs a wrapper at `CODEX_HOME/bin/codex` that:
  - Sets `CODEX_MANAGED_BY_CURL=1`.
  - Extends `PATH` at runtime to include:
    - `CODEX_HOME/bin`
    - `CODEX_HOME/tools/bin`
    - `CODEX_HOME/versions/current/bin`
  - Execs the active versioned binary: `installer/lib.sh:154`.
- Installs the tarball into a versioned `bin/` directory and preserves any additional CLIs present in the tarball (not just `codex`): `installer/lib.sh:181`.
- Keeps the last two installed versions and avoids deleting the `current` symlink: `installer/lib.sh:239`.

#### `installer/update.sh`

`installer/update.sh:1` mirrors the installer flow but always targets the latest release.

### Update UX integration in the CLI

Codex’s update prompt already knows how to run a package-manager-specific update command after the TUI exits.

This PR adds a curl-managed update action in `codex-rs/tui/src/update_action.rs:3`:

- Adds `UpdateAction::CurlInstallerUpdate`.
- Detects curl-managed installs via `CODEX_MANAGED_BY_CURL` (set by the wrapper): `codex-rs/tui/src/update_action.rs:40`.
- Uses the curl updater one-liner for the update command: `codex-rs/tui/src/update_action.rs:21`.

This keeps update behavior consistent with npm/brew while avoiding fragile path heuristics.

## Docs updates

- The curl installer is now the first recommended install path in `README.md:1` and `README.md:18`.
- `docs/install.md:11` adds a curl install section and points to `installer/README.md` for the detailed mechanics.

## Compatibility and safety notes

- This does not modify npm or Homebrew installs.
- The rc-file change only adds `CODEX_HOME/bin` to `PATH`.
- Additional helper CLIs are available to Codex via the wrapper’s runtime `PATH`, rather than being forced into the user’s global shell.
- The installer honors `CODEX_HOME` everywhere, falling back to `~/.codex`.

## Testing

Commands run:

```sh
cd codex-rs
just fmt
cargo test -p codex-tui
```

Notes:

- In this environment, `cargo test -p codex-tui` required running outside the sandbox due to a macOS `SystemConfiguration` panic; it passed once run with escalated permissions.
- No new VS Code diagnostics were reported.

## Follow-ups (explicitly not in this PR)

- Ensure the release tarballs include any helper CLIs we expect to be available at runtime (for example, Windows sandbox helpers).
- Add an explicit `codex self-update` command that delegates to `installer/update.sh`.
- Decide how we want to manage third-party tools like `rg` under `CODEX_HOME/tools/` and whether the CLI should probe a managed fallback path.
2026-01-25 21:09:29 -08:00
7 changed files with 547 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
<p align="center"><code>npm i -g @openai/codex</code><br />or <code>brew install --cask codex</code></p>
<p align="center"><code>curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash</code><br />or <code>brew install --cask codex</code><br />or <code>npm i -g @openai/codex</code></p>
<p align="center"><strong>Codex CLI</strong> is a coding agent from OpenAI that runs locally on your computer.
<p align="center">
<img src="./.github/codex-cli-splash.png" alt="Codex CLI splash" width="80%" />
@@ -15,6 +15,13 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), <a href="http
Install globally with your preferred package manager:
```shell
# Install using curl
curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash
```
If you want to install somewhere other than `~/.codex`, set `CODEX_HOME` first.
```shell
# Install using npm
npm install -g @openai/codex

View File

@@ -7,6 +7,8 @@ pub enum UpdateAction {
BunGlobalLatest,
/// Update via `brew upgrade codex`.
BrewUpgrade,
/// Update via the curl installer.
CurlInstallerUpdate,
}
impl UpdateAction {
@@ -16,6 +18,13 @@ impl UpdateAction {
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]),
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]),
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]),
UpdateAction::CurlInstallerUpdate => (
"bash",
&[
"-c",
"set -euo pipefail; curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/update.sh | bash",
],
),
}
}
@@ -32,12 +41,14 @@ pub(crate) fn get_update_action() -> Option<UpdateAction> {
let exe = std::env::current_exe().unwrap_or_default();
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
let managed_by_curl = std::env::var_os("CODEX_MANAGED_BY_CURL").is_some();
detect_update_action(
cfg!(target_os = "macos"),
&exe,
managed_by_npm,
managed_by_bun,
managed_by_curl,
)
}
@@ -47,11 +58,14 @@ fn detect_update_action(
current_exe: &std::path::Path,
managed_by_npm: bool,
managed_by_bun: bool,
managed_by_curl: bool,
) -> Option<UpdateAction> {
if managed_by_npm {
Some(UpdateAction::NpmGlobalLatest)
} else if managed_by_bun {
Some(UpdateAction::BunGlobalLatest)
} else if managed_by_curl {
Some(UpdateAction::CurlInstallerUpdate)
} else if is_macos
&& (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local"))
{
@@ -68,15 +82,21 @@ mod tests {
#[test]
fn detects_update_action_without_env_mutation() {
assert_eq!(
detect_update_action(false, std::path::Path::new("/any/path"), false, false),
detect_update_action(
false,
std::path::Path::new("/any/path"),
false,
false,
false
),
None
);
assert_eq!(
detect_update_action(false, std::path::Path::new("/any/path"), true, false),
detect_update_action(false, std::path::Path::new("/any/path"), true, false, false),
Some(UpdateAction::NpmGlobalLatest)
);
assert_eq!(
detect_update_action(false, std::path::Path::new("/any/path"), false, true),
detect_update_action(false, std::path::Path::new("/any/path"), false, true, false),
Some(UpdateAction::BunGlobalLatest)
);
assert_eq!(
@@ -84,7 +104,8 @@ mod tests {
true,
std::path::Path::new("/opt/homebrew/bin/codex"),
false,
false
false,
false,
),
Some(UpdateAction::BrewUpgrade)
);
@@ -93,9 +114,14 @@ mod tests {
true,
std::path::Path::new("/usr/local/bin/codex"),
false,
false
false,
false,
),
Some(UpdateAction::BrewUpgrade)
);
assert_eq!(
detect_update_action(false, std::path::Path::new("/any/path"), false, false, true),
Some(UpdateAction::CurlInstallerUpdate)
);
}
}

View File

@@ -8,6 +8,15 @@
| Git (optional, recommended) | 2.23+ for built-in PR helpers |
| RAM | 4-GB minimum (8-GB recommended) |
### Install via curl
```bash
curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash
```
The curl installer writes under `CODEX_HOME` (default: `~/.codex`). See
`installer/README.md` for the detailed layout and mechanics.
### DotSlash
The GitHub Release also contains a [DotSlash](https://dotslash-cli.com/) file for the Codex CLI named `codex`. Using a DotSlash file makes it possible to make a lightweight commit to source control to ensure all contributors use the same version of an executable, regardless of what platform they use for development.

104
installer/README.md Normal file
View File

@@ -0,0 +1,104 @@
# Codex Curl Installer
This folder contains the non-Rust assets for the curl-based Codex installer
and update flow. Rust code lives under `codex-rs/`.
## Goals
- Download only the binaries needed for the current platform.
- Keep the install isolated under `CODEX_HOME` (default: `~/.codex`).
- Avoid breaking npm/brew installs; shadowing is acceptable.
- Support additional helper CLIs without globally polluting `PATH`.
## Install Root (`CODEX_HOME`)
The installer treats `CODEX_HOME` as the install root:
- If `CODEX_HOME` is set, it is used as-is.
- Otherwise, the default is `~/.codex`.
All curl-managed artifacts should live under this root.
## On-Disk Layout
The layout is designed to support multiple versions, helper binaries, and
atomic updates:
- `CODEX_HOME/bin/`
- `CODEX_HOME/versions/<version>/`
- `CODEX_HOME/versions/<version>/bin/`
- `CODEX_HOME/tools/<tool>/<version>/`
- `CODEX_HOME/tools/bin/`
Key conventions:
- The user-facing entrypoint is `CODEX_HOME/bin/codex`.
- `CODEX_HOME/versions/current` is a symlink to the active version directory.
- Helper CLIs that ship with Codex (for example, Windows sandbox helpers) live
in `CODEX_HOME/versions/<version>/bin/`.
- Third-party tools we fetch (for example, `rg`) live under
`CODEX_HOME/tools/...`, with optional shims in `CODEX_HOME/tools/bin/`.
## PATH Strategy
We separate the user's global `PATH` from Codex's runtime `PATH`:
1. The installer ensures `CODEX_HOME/bin` is on the user's `PATH`.
2. The `codex` wrapper augments `PATH` at runtime to include:
- `CODEX_HOME/bin`
- `CODEX_HOME/tools/bin`
- `CODEX_HOME/versions/current/bin`
This keeps helper CLIs available to Codex without exposing them as global
commands in every shell session.
## Versioning And Atomic Updates
Curl-managed installs should be versioned:
1. Download into a versioned directory:
- `CODEX_HOME/versions/<version>/`
2. Link `CODEX_HOME/versions/current` to the new version atomically.
3. Keep a small number of prior versions for rollback.
Because the wrapper resolves through `versions/current`, repointing the
symlink updates the effective version without editing shell rc files again.
## Helper CLI Placement
Any additional CLIs that Codex needs at runtime should follow these rules:
- Bundled CLIs that are version-coupled to Codex:
- Place in `CODEX_HOME/versions/<version>/bin/`
- Third-party tools that may be shared across versions:
- Place in `CODEX_HOME/tools/<tool>/<version>/`
- Optionally add a stable shim in `CODEX_HOME/tools/bin/`
The wrapper then makes them available during execution.
## Ripgrep (`rg`)
The preferred approach is:
- Use a system `rg` when available.
- Otherwise, allow curl-managed installs to place `rg` under
`CODEX_HOME/tools/rg/<version>/` with a shim in `CODEX_HOME/tools/bin/rg`.
Codex CLI can optionally honor an explicit `CODEX_RG_PATH` to point directly
to a managed `rg`.
## Scripts
Planned/expected scripts in this folder:
- `installer/install.sh`
- `installer/lib.sh`
- `installer/update.sh`
The public one-liner should look like:
```sh
curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash
```
All scripts must honor `CODEX_HOME` with a fallback to `~/.codex`.

73
installer/install.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
if [ -z "${BASH_VERSION:-}" ]; then
echo "This installer requires bash." >&2
echo "Re-run with: curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash" >&2
exit 1
fi
set -euo pipefail
INSTALLER_BASE_URL="${CODEX_INSTALLER_BASE_URL:-https://raw.githubusercontent.com/openai/codex/main/installer}"
load_lib() {
local script_dir lib_path tmp_lib
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
lib_path="${script_dir}/lib.sh"
if [ -f "$lib_path" ]; then
# Running from a repo checkout.
# shellcheck disable=SC1090
source "$lib_path"
return
fi
tmp_lib="$(mktemp)"
curl -fsSL "${INSTALLER_BASE_URL}/lib.sh" -o "$tmp_lib"
# shellcheck disable=SC1090
source "$tmp_lib"
rm -f "$tmp_lib"
}
load_lib
main() {
ensure_dirs
local arch os tag version url tarball rc_file resolved_home
arch="$(detect_arch)"
os="$(detect_os)"
if [ -n "${CODEX_VERSION:-}" ]; then
version="$(normalize_version "$CODEX_VERSION")"
tag="$(release_tag_for_version "$version")"
else
tag="$(latest_tag)"
if [ -z "$tag" ]; then
echo "Failed to determine the latest Codex release tag." >&2
exit 1
fi
version="$(normalize_version "$tag")"
fi
url="$(release_url "$version" "$arch" "$os")"
tarball="$(mktemp)"
curl -fsSL "$url" -o "$tarball"
install_version_from_tarball "$version" "$tarball" "$arch" "$os"
activate_version "$version"
install_wrapper
cleanup_old_versions 2
rc_file="$(choose_rc_file)"
resolved_home="$(codex_home)"
ensure_path_block "$rc_file" "$resolved_home"
rm -f "$tarball"
echo "Codex ${version} installed to ${resolved_home}"
echo "Updated PATH in ${rc_file}"
echo "Open a new shell or run: source ${rc_file}"
}
main "$@"

256
installer/lib.sh Executable file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env bash
if [ -z "${BASH_VERSION:-}" ]; then
echo "Codex installer requires bash." >&2
exit 1
fi
set -euo pipefail
CODEX_HOME_DEFAULT="${HOME}/.codex"
CODEX_HOME_RESOLVED="${CODEX_HOME:-$CODEX_HOME_DEFAULT}"
codex_home() {
printf '%s\n' "$CODEX_HOME_RESOLVED"
}
codex_bin_dir() {
printf '%s/bin\n' "$(codex_home)"
}
codex_versions_dir() {
printf '%s/versions\n' "$(codex_home)"
}
codex_tools_dir() {
printf '%s/tools\n' "$(codex_home)"
}
codex_tools_bin_dir() {
printf '%s/bin\n' "$(codex_tools_dir)"
}
current_version_link() {
printf '%s/current\n' "$(codex_versions_dir)"
}
detect_os() {
local uname_s
uname_s="$(uname -s)"
case "$uname_s" in
Darwin) printf '%s\n' "apple-darwin" ;;
Linux) printf '%s\n' "unknown-linux-musl" ;;
*)
echo "Unsupported OS: $uname_s" >&2
exit 1
;;
esac
}
detect_arch() {
local uname_m
uname_m="$(uname -m)"
case "$uname_m" in
arm64|aarch64) printf '%s\n' "aarch64" ;;
x86_64|amd64) printf '%s\n' "x86_64" ;;
*)
echo "Unsupported architecture: $uname_m" >&2
exit 1
;;
esac
}
github_api() {
local path="$1"
curl -fsSL \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/openai/codex/${path}"
}
latest_tag() {
github_api "releases/latest" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1
}
normalize_version() {
local tag="$1"
tag="${tag#rust-v}"
tag="${tag#v}"
printf '%s\n' "$tag"
}
release_tag_for_version() {
local version="$1"
printf '%s\n' "rust-v${version}"
}
expected_binary_name() {
local arch="$1"
local os="$2"
printf '%s\n' "codex-${arch}-${os}"
}
release_url() {
local version="$1"
local arch="$2"
local os="$3"
local tag
tag="$(release_tag_for_version "$version")"
printf '%s\n' "https://github.com/openai/codex/releases/download/${tag}/codex-${arch}-${os}.tar.gz"
}
ensure_dirs() {
mkdir -p "$(codex_bin_dir)" "$(codex_versions_dir)" "$(codex_tools_bin_dir)"
}
ensure_path_block() {
local rc_file="$1"
local resolved_home="$2"
local start_marker="# >>> codex >>>"
if [ ! -f "$rc_file" ]; then
touch "$rc_file"
fi
if grep -Fq "$start_marker" "$rc_file"; then
return
fi
if [ "$resolved_home" = "${HOME}/.codex" ]; then
cat >>"$rc_file" <<'EOF'
# >>> codex >>>
export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
export PATH="$CODEX_HOME/bin:$PATH"
# <<< codex <<<
EOF
else
cat >>"$rc_file" <<EOF
# >>> codex >>>
export CODEX_HOME="${resolved_home}"
export PATH="\$CODEX_HOME/bin:\$PATH"
# <<< codex <<<
EOF
fi
}
choose_rc_file() {
local shell_name
shell_name="$(basename "${SHELL:-}")"
case "$shell_name" in
zsh) printf '%s\n' "${HOME}/.zshrc" ;;
bash)
if [ -f "${HOME}/.bashrc" ]; then
printf '%s\n' "${HOME}/.bashrc"
else
printf '%s\n' "${HOME}/.bash_profile"
fi
;;
*)
printf '%s\n' "${HOME}/.profile"
;;
esac
}
install_wrapper() {
local wrapper_path
wrapper_path="$(codex_bin_dir)/codex"
cat >"$wrapper_path" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
CURRENT_LINK="$CODEX_HOME/versions/current"
TARGET="$CURRENT_LINK/bin/codex"
if [ ! -x "$TARGET" ]; then
echo "Codex is not installed under $CODEX_HOME." >&2
echo "Run the installer again to repair the installation." >&2
exit 1
fi
export CODEX_MANAGED_BY_CURL=1
export PATH="$CODEX_HOME/bin:$CODEX_HOME/tools/bin:$CURRENT_LINK/bin:$PATH"
exec "$TARGET" "$@"
EOF
chmod +x "$wrapper_path"
}
install_version_from_tarball() {
local version="$1"
local tarball="$2"
local arch="$3"
local os="$4"
local versions_dir version_dir bin_dir expected_name tmpdir main_candidate
versions_dir="$(codex_versions_dir)"
version_dir="${versions_dir}/${version}"
bin_dir="${version_dir}/bin"
expected_name="$(expected_binary_name "$arch" "$os")"
rm -rf "$version_dir"
mkdir -p "$bin_dir"
tmpdir="$(mktemp -d)"
tar -xzf "$tarball" -C "$tmpdir"
if [ -f "$tmpdir/$expected_name" ]; then
main_candidate="$tmpdir/$expected_name"
elif [ -f "$tmpdir/codex" ]; then
main_candidate="$tmpdir/codex"
else
main_candidate="$(find "$tmpdir" -type f -name 'codex*' | head -n1 || true)"
fi
if [ -z "$main_candidate" ]; then
echo "Failed to locate codex binary in tarball." >&2
exit 1
fi
local files file base dest
mapfile -t files < <(find "$tmpdir" -type f)
for file in "${files[@]}"; do
base="$(basename "$file")"
if [ "$file" = "$main_candidate" ]; then
dest="$bin_dir/codex"
else
dest="$bin_dir/$base"
fi
mv "$file" "$dest"
chmod +x "$dest" 2>/dev/null || true
done
if [ ! -x "$bin_dir/codex" ]; then
echo "Failed to install codex binary." >&2
exit 1
fi
rm -rf "$tmpdir"
}
activate_version() {
local version="$1"
local link
link="$(current_version_link)"
ln -sfn "$(codex_versions_dir)/$version" "$link"
}
cleanup_old_versions() {
local keep="${1:-2}"
local versions_dir
versions_dir="$(codex_versions_dir)"
if [ ! -d "$versions_dir" ]; then
return
fi
if ! compgen -G "$versions_dir/*" >/dev/null; then
return
fi
# Remove older versions while keeping the most recent N directories.
# We intentionally ignore errors here so cleanup never blocks install.
ls -1dt "$versions_dir"/* 2>/dev/null \
| grep -v '/current$' \
| tail -n +"$((keep + 1))" \
| xargs -I{} rm -rf "{}" 2>/dev/null || true
}

66
installer/update.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
if [ -z "${BASH_VERSION:-}" ]; then
echo "This updater requires bash." >&2
echo "Re-run with: curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/update.sh | bash" >&2
exit 1
fi
set -euo pipefail
INSTALLER_BASE_URL="${CODEX_INSTALLER_BASE_URL:-https://raw.githubusercontent.com/openai/codex/main/installer}"
load_lib() {
local script_dir lib_path tmp_lib
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
lib_path="${script_dir}/lib.sh"
if [ -f "$lib_path" ]; then
# Running from a repo checkout.
# shellcheck disable=SC1090
source "$lib_path"
return
fi
tmp_lib="$(mktemp)"
curl -fsSL "${INSTALLER_BASE_URL}/lib.sh" -o "$tmp_lib"
# shellcheck disable=SC1090
source "$tmp_lib"
rm -f "$tmp_lib"
}
load_lib
main() {
ensure_dirs
local arch os tag version url tarball rc_file resolved_home
arch="$(detect_arch)"
os="$(detect_os)"
tag="$(latest_tag)"
if [ -z "$tag" ]; then
echo "Failed to determine the latest Codex release tag." >&2
exit 1
fi
version="$(normalize_version "$tag")"
url="$(release_url "$version" "$arch" "$os")"
tarball="$(mktemp)"
curl -fsSL "$url" -o "$tarball"
install_version_from_tarball "$version" "$tarball" "$arch" "$os"
activate_version "$version"
install_wrapper
cleanup_old_versions 2
rc_file="$(choose_rc_file)"
resolved_home="$(codex_home)"
ensure_path_block "$rc_file" "$resolved_home"
rm -f "$tarball"
echo "Codex updated to ${version}"
}
main "$@"