diff --git a/README.md b/README.md
index eb4ace74f2..733c915d43 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
npm i -g @openai/codex
or brew install --cask codex
+curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash
or brew install --cask codex
or npm i -g @openai/codex
Codex CLI is a coding agent from OpenAI that runs locally on your computer.
@@ -15,6 +15,13 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), ("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 {
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 {
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)
+ );
}
}
diff --git a/docs/install.md b/docs/install.md
index 20d8b54a54..d0306ccbf3 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -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.
diff --git a/installer/README.md b/installer/README.md
new file mode 100644
index 0000000000..34ac29a040
--- /dev/null
+++ b/installer/README.md
@@ -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//`
+- `CODEX_HOME/versions//bin/`
+- `CODEX_HOME/tools///`
+- `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//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//`
+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//bin/`
+- Third-party tools that may be shared across versions:
+ - Place in `CODEX_HOME/tools///`
+ - 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//` 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`.
diff --git a/installer/install.sh b/installer/install.sh
new file mode 100755
index 0000000000..7005392c07
--- /dev/null
+++ b/installer/install.sh
@@ -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 "$@"
diff --git a/installer/lib.sh b/installer/lib.sh
new file mode 100755
index 0000000000..d55682c05f
--- /dev/null
+++ b/installer/lib.sh
@@ -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" <>> 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
+}
diff --git a/installer/update.sh b/installer/update.sh
new file mode 100755
index 0000000000..3cdee7e81f
--- /dev/null
+++ b/installer/update.sh
@@ -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 "$@"