Files
codex/scripts/install/install.sh
efrazer-oai 9d1bf002c6 Significantly improve standalone installer (#17022)
## Summary

This PR significantly improves the standalone installer experience.

The main changes are:

1. We now install the codex binary and other dependencies in a
subdirectory under CODEX_HOME.
(`CODEX_HOME/packages/standalone/releases/...`)

2. We replace the `codex.js` launcher that npm/bun rely on with logic in
the Rust binary that automatically resolves its dependencies (like
ripgrep)

## Motivation

A few design constraints pushed this work.

1. Currently, the entrypoint to codex is through `codex.js`, which
forces a node dependency to kick off our rust app. We want to move away
from this so that the entrypoint to codex does not rely on node or
external package managers.
2. Right now, the native script adds codex and its dependencies directly
to user PATH. Given that codex is likely to add more binary dependencies
than ripgrep, we want a solution which does not add arbitrary binaries
to user PATH -- the only one we want to add is the `codex` command
itself.
3. We want upgrades to be atomic. We do not want scenarios where
interrupting an upgrade command can move codex into undefined state (for
example, having a new codex binary but an old ripgrep binary). This was
~possible with the old script.
4. Currently, the Rust binary uses heuristics to determine which
installer created it. These heuristics are flaky and are tied to the
`codex.js` launcher. We need a more stable/deterministic way to
determine how the binary was installed for standalone.
5. We do not want conflicting codex installations on PATH. For example,
the user installing via npm, then installing via brew, then installing
via standalone would make it unclear which version of codex is being
launched and make it tough for us to determine the right upgrade
command.

## Design

### Standalone package layout

Standalone installs now live under `CODEX_HOME/packages/standalone`:

```text
$CODEX_HOME/
  packages/
    standalone/
      current -> releases/0.111.0-x86_64-unknown-linux-musl
      releases/
        0.111.0-x86_64-unknown-linux-musl/
          codex
          codex-resources/
            rg
```

where `standalone/current` is a symlink to a release directory.

On Windows, the release directory has the same shape, with `.exe` names
and Windows helpers in `codex-resources`:

```text
%CODEX_HOME%\
  packages\
    standalone\
      current -> releases\0.111.0-x86_64-pc-windows-msvc
      releases\
        0.111.0-x86_64-pc-windows-msvc\
          codex.exe
          codex-resources\
            rg.exe
            codex-command-runner.exe
            codex-windows-sandbox-setup.exe
```

This gives us:
- atomic upgrades because we can fully stage a release before switching
`standalone/current`
- a stable way for the binary to recognize a standalone install from its
canonical `current_exe()` path under CODEX_HOME
- a clean place for binary dependencies like `rg`, Windows sandbox
helpers, and, in the future, our custom `zsh` etc

### Command location

On Unix, we add a symlink at `~/.local/bin/codex` which points directly
to the `$CODEX_HOME/packages/standalone/current/codex` binary. This
becomes the main entrypoint for the CLI.

On Windows, we store the link at
`%LOCALAPPDATA%\Programs\OpenAI\Codex\bin`.

### PATH persistence

This is a tricky part of the PR, as there's no ~super reliable way to
ensure that we end up on PATH without significant tradeoffs.

Most Unix variants will have `~/.local/bin` on PATH already, which means
we *should* be fine simply registering the command there in most cases.
However, there are cases where this is not the case. In these cases, we
directly edit the profile depending on the shell we're in.

- macOS zsh: `~/.zprofile`
- macOS bash: `~/.bash_profile`
- Linux zsh: `~/.zshrc`
- Linux bash: `~/.bashrc`
- fallback: `~/.profile`

On Windows, we update the User `Path` environment variable directly and
we don't need to worry about shell profiles.

### Standalone runtime detection

This PR adds a new shared crate, `codex-install-context`, which computes
install ownership once per process and caches it in a `OnceLock`.

That context includes:
- install manager (`Standalone`, `Npm`, `Bun`, `Brew`, `Other`)
- the managed standalone release directory, when applicable
- the managed standalone `codex-resources` directory, when present
- the resolved `rg_command`

The standalone path is detected by canonicalizing `current_exe()`,
canonicalizing CODEX_HOME via `find_codex_home()`, and checking whether
the binary is running from under
`$CODEX_HOME/packages/standalone/releases`.

We intentionally do not use a release metadata file. The binary path is
the source of truth.

### Dependency resolution

For standalone installs, `grep_files` now resolves bundled `rg` from
`codex-resources` next to the Codex binary.

For npm/bun/brew/other installs, `grep_files` falls back to resolving
`rg` from PATH.

For Windows standalone installs, Windows sandbox helpers are still found
as direct siblings when present. If they are not direct siblings, the
lookup also checks the sibling `codex-resources` directory.

### TUI update path

The TUI now has `UpdateAction::StandaloneUnix` and
`UpdateAction::StandaloneWindows`, which rerun the standalone install
commands.

Unix update command:

```sh
sh -c "curl -fsSL https://chatgpt.com/codex/install.sh | sh"
```

Windows update command:

```powershell
powershell -c "irm https://chatgpt.com/codex/install.ps1|iex"
```

The Windows updater runs PowerShell directly. We do this because `cmd
/C` would parse the `|iex` as a cmd pipeline instead of passing it to
PowerShell.

## Additional installer behavior

- standalone installs now warn about conflicting npm/bun/brew-managed
`codex` installs and offer to uninstall them
- same-version reruns do not redownload the release if it is already
staged locally

## Testing

Installer smoke tests run:
- macOS: fresh install into isolated `HOME` and `CODEX_HOME` with
`scripts/install/install.sh --release latest`
- macOS: reran the installer against the same isolated install to verify
the same-version/update path and PATH block idempotence
- macOS: verified the installed `codex --version` and bundled
`codex-resources/rg --version`
- Windows: parsed `scripts/install/install.ps1` with PowerShell via
`[scriptblock]::Create(...)`
- Windows: verified the standalone update action builds a direct
PowerShell command and does not route the `irm ...|iex` command through
`cmd /C`

---------

Co-authored-by: Codex <noreply@openai.com>
2026-04-15 14:44:01 -07:00

769 lines
17 KiB
Bash
Executable File

#!/bin/sh
set -eu
RELEASE="latest"
BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}"
BIN_PATH="$BIN_DIR/codex"
CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}"
STANDALONE_ROOT="$CODEX_HOME_DIR/packages/standalone"
RELEASES_DIR="$STANDALONE_ROOT/releases"
CURRENT_LINK="$STANDALONE_ROOT/current"
LOCK_FILE="$STANDALONE_ROOT/install.lock"
LOCK_DIR="$STANDALONE_ROOT/install.lock.d"
LOCK_STALE_AFTER_SECS=600
path_action="already"
path_profile=""
conflict_manager=""
conflict_path=""
lock_kind=""
tmp_dir=""
step() {
printf '==> %s\n' "$1"
}
warn() {
printf 'WARNING: %s\n' "$1" >&2
}
normalize_version() {
case "$1" in
"" | latest)
printf 'latest\n'
;;
rust-v*)
printf '%s\n' "${1#rust-v}"
;;
v*)
printf '%s\n' "${1#v}"
;;
*)
printf '%s\n' "$1"
;;
esac
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--release)
if [ "$#" -lt 2 ]; then
echo "--release requires a value." >&2
exit 1
fi
RELEASE="$2"
shift
;;
--help | -h)
cat <<EOF
Usage: install.sh [--release VERSION]
EOF
exit 0
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
shift
done
}
download_file() {
url="$1"
output="$2"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" -o "$output"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O "$output" "$url"
return
fi
echo "curl or wget is required to install Codex." >&2
exit 1
}
download_text() {
url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O - "$url"
return
fi
echo "curl or wget is required to install Codex." >&2
exit 1
}
release_url_for_asset() {
asset="$1"
resolved_version="$2"
printf 'https://github.com/openai/codex/releases/download/rust-v%s/%s\n' "$resolved_version" "$asset"
}
release_metadata_url() {
resolved_version="$1"
printf 'https://api.github.com/repos/openai/codex/releases/tags/rust-v%s\n' "$resolved_version"
}
release_asset_digest() {
asset="$1"
resolved_version="$2"
release_json="$(download_text "$(release_metadata_url "$resolved_version")")"
digest="$(printf '%s\n' "$release_json" | awk -v asset="$asset" '
{
if ($0 ~ "\"name\":[[:space:]]*\"" asset "\"") {
in_asset = 1
asset_depth = depth
}
if (in_asset && /"digest":[[:space:]]*"[^"]+"/) {
sub(/^.*"digest":[[:space:]]*"/, "")
sub(/".*$/, "")
digest = $0
}
line = $0
opens = gsub(/\{/, "{", line)
closes = gsub(/\}/, "}", line)
depth += opens - closes
if (in_asset && depth < asset_depth) {
in_asset = 0
}
}
END {
if (digest != "") {
print digest
}
}
')"
case "$digest" in
sha256:????????????????????????????????????????????????????????????????)
printf '%s\n' "${digest#sha256:}"
;;
*)
echo "Could not find SHA-256 digest for release asset $asset." >&2
exit 1
;;
esac
}
file_sha256() {
path="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" | awk '{print $1}'
return
fi
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
return
fi
if command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 "$path" | sed 's/^.*= //'
return
fi
echo "sha256sum, shasum, or openssl is required to verify the Codex download." >&2
exit 1
}
verify_archive_digest() {
archive_path="$1"
expected_digest="$2"
actual_digest="$(file_sha256 "$archive_path")"
if [ "$actual_digest" != "$expected_digest" ]; then
echo "Downloaded Codex archive checksum did not match release metadata." >&2
echo "expected: $expected_digest" >&2
echo "actual: $actual_digest" >&2
exit 1
fi
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "$1 is required to install Codex." >&2
exit 1
fi
}
resolve_version() {
normalized_version="$(normalize_version "$RELEASE")"
if [ "$normalized_version" != "latest" ]; then
printf '%s\n' "$normalized_version"
return
fi
release_json="$(download_text "https://api.github.com/repos/openai/codex/releases/latest")"
resolved="$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name":[[:space:]]*"rust-v\([^"]*\)".*/\1/p' | head -n 1)"
if [ -z "$resolved" ]; then
echo "Failed to resolve the latest Codex release version." >&2
exit 1
fi
printf '%s\n' "$resolved"
}
pick_profile() {
# Use the same shell-specific split Homebrew documents because there is no
# universal startup file across macOS/Linux login and interactive shells.
case "$os:${SHELL:-}" in
darwin:*/zsh)
printf '%s\n' "$HOME/.zprofile"
;;
darwin:*/bash)
printf '%s\n' "$HOME/.bash_profile"
;;
linux:*/zsh)
printf '%s\n' "$HOME/.zshrc"
;;
linux:*/bash)
printf '%s\n' "$HOME/.bashrc"
;;
*)
printf '%s\n' "$HOME/.profile"
;;
esac
}
add_to_path() {
path_action="already"
path_profile=""
case ":$PATH:" in
*":$BIN_DIR:"*)
return
;;
esac
profile="$(pick_profile)"
path_profile="$profile"
begin_marker="# >>> Codex installer >>>"
end_marker="# <<< Codex installer <<<"
path_line="export PATH=\"$BIN_DIR:\$PATH\""
if [ -f "$profile" ] && grep -F "$begin_marker" "$profile" >/dev/null 2>&1; then
if grep -F "$path_line" "$profile" >/dev/null 2>&1; then
path_action="configured"
return
fi
if grep -F "$end_marker" "$profile" >/dev/null 2>&1; then
rewrite_path_block "$profile" "$begin_marker" "$end_marker" "$path_line"
path_action="updated"
return
fi
fi
append_path_block "$profile" "$begin_marker" "$end_marker" "$path_line"
path_action="added"
}
append_path_block() {
profile="$1"
begin_marker="$2"
end_marker="$3"
path_line="$4"
{
printf '\n%s\n' "$begin_marker"
printf '%s\n' "$path_line"
printf '%s\n' "$end_marker"
} >>"$profile"
}
rewrite_path_block() {
profile="$1"
begin_marker="$2"
end_marker="$3"
path_line="$4"
tmp_profile="$tmp_dir/profile.$$.tmp"
awk -v begin="$begin_marker" -v end="$end_marker" -v line="$path_line" '
BEGIN {
in_block = 0
replaced = 0
}
$0 == begin {
if (!replaced) {
print begin
print line
print end
replaced = 1
}
in_block = 1
next
}
in_block {
if ($0 == end) {
in_block = 0
}
next
}
{
print
}
END {
if (in_block != 0) {
exit 1
}
}
' "$profile" >"$tmp_profile"
mv "$tmp_profile" "$profile"
}
mkdir_lock_is_stale() {
[ -d "$LOCK_DIR" ] || return 1
pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
started_at="$(cat "$LOCK_DIR/started_at" 2>/dev/null || true)"
now="$(date +%s 2>/dev/null || printf '0')"
case "$started_at" in
''|*[!0-9]*)
started_at=0
;;
esac
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
return 1
fi
if [ "$started_at" -eq 0 ] || [ "$now" -eq 0 ]; then
return 0
fi
[ $((now - started_at)) -ge "$LOCK_STALE_AFTER_SECS" ]
}
acquire_install_lock() {
mkdir -p "$STANDALONE_ROOT"
if [ "$os" = "darwin" ] && command -v lockf >/dev/null 2>&1; then
: >>"$LOCK_FILE"
exec 9<>"$LOCK_FILE"
lockf 9
lock_kind="lockf"
return
fi
if command -v flock >/dev/null 2>&1; then
exec 9>"$LOCK_FILE"
flock 9
lock_kind="flock"
return
fi
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
if mkdir_lock_is_stale; then
warn "Removing stale installer lock at $LOCK_DIR"
rm -rf "$LOCK_DIR"
continue
fi
sleep 1
done
printf '%s\n' "$$" >"$LOCK_DIR/pid"
date +%s >"$LOCK_DIR/started_at" 2>/dev/null || true
lock_kind="mkdir"
}
release_install_lock() {
if [ "$lock_kind" = "mkdir" ]; then
rm -rf "$LOCK_DIR" 2>/dev/null || true
elif [ "$lock_kind" = "flock" ] || [ "$lock_kind" = "lockf" ]; then
exec 9>&- 2>/dev/null || true
fi
lock_kind=""
}
cleanup_stale_install_artifacts() {
mkdir -p "$RELEASES_DIR" "$STANDALONE_ROOT"
find "$RELEASES_DIR" -mindepth 1 -maxdepth 1 -name '.staging.*' -exec rm -rf {} +
find "$STANDALONE_ROOT" -mindepth 1 -maxdepth 1 -name '.current.*' -exec rm -f {} +
if [ -d "$BIN_DIR" ]; then
find "$BIN_DIR" -mindepth 1 -maxdepth 1 -name '.codex.*' -exec rm -f {} +
fi
}
replace_path_with_symlink() {
link_path="$1"
link_target="$2"
tmp_link="$3"
rm -f "$tmp_link"
ln -s "$link_target" "$tmp_link"
if mv -Tf "$tmp_link" "$link_path" 2>/dev/null; then
return
fi
if mv -hf "$tmp_link" "$link_path" 2>/dev/null; then
return
fi
rm -f "$link_path"
mv -f "$tmp_link" "$link_path"
}
version_from_binary() {
codex_path="$1"
if [ ! -x "$codex_path" ]; then
return 1
fi
"$codex_path" --version 2>/dev/null | sed -n 's/.* \([0-9][0-9A-Za-z.+-]*\)$/\1/p' | head -n 1
}
current_installed_version() {
version="$(version_from_binary "$CURRENT_LINK/codex" || true)"
if [ -n "$version" ]; then
printf '%s\n' "$version"
return 0
fi
return 0
}
resolve_existing_codex() {
command -v codex 2>/dev/null || true
}
classify_existing_codex() {
existing_path="$1"
if [ -z "$existing_path" ] || [ "$existing_path" = "$BIN_PATH" ]; then
return 1
fi
case "$existing_path" in
/opt/homebrew/* | /usr/local/*)
if [ "$os" = "darwin" ]; then
printf 'brew\n'
return 0
fi
;;
esac
if [ -f "$existing_path" ] && grep -F "#!/usr/bin/env node" "$existing_path" >/dev/null 2>&1; then
case "$existing_path" in
*".bun"*)
printf 'bun\n'
;;
*)
printf 'npm\n'
;;
esac
return 0
fi
return 1
}
prompt_yes_no() {
prompt="$1"
if ( : </dev/tty ) 2>/dev/null; then
printf '%s [y/N] ' "$prompt" >/dev/tty
if ! IFS= read -r answer </dev/tty; then
return 1
fi
elif [ -t 0 ]; then
printf '%s [y/N] ' "$prompt"
if ! IFS= read -r answer; then
return 1
fi
else
return 1
fi
case "$answer" in
y | Y | yes | YES)
return 0
;;
*)
return 1
;;
esac
}
print_launch_instructions() {
case "$path_action" in
added)
step "Current terminal: export PATH=\"$BIN_DIR:\$PATH\" && codex"
step "Future terminals: open a new terminal and run: codex"
step "PATH was added to $path_profile"
;;
updated)
step "Current terminal: export PATH=\"$BIN_DIR:\$PATH\" && codex"
step "Future terminals: open a new terminal and run: codex"
step "PATH was updated in $path_profile"
;;
configured)
step "Current terminal: export PATH=\"$BIN_DIR:\$PATH\" && codex"
step "Future terminals: open a new terminal and run: codex"
step "PATH is already configured in $path_profile"
;;
*)
step "Current terminal: codex"
step "Future terminals: open a new terminal and run: codex"
;;
esac
}
maybe_launch_codex_now() {
if prompt_yes_no "Start Codex now?"; then
step "Launching Codex"
"$BIN_PATH"
fi
}
detect_conflicting_install() {
existing_path="$(resolve_existing_codex)"
manager="$(classify_existing_codex "$existing_path" || true)"
if [ -z "$manager" ]; then
return
fi
conflict_manager="$manager"
conflict_path="$existing_path"
step "Detected existing $manager-managed Codex at $existing_path"
warn "Multiple managed Codex installs can be ambiguous because PATH order decides which one runs."
}
handle_conflicting_install() {
if [ -z "$conflict_manager" ]; then
return
fi
case "$conflict_manager" in
brew)
uninstall_cmd="brew uninstall --cask codex"
;;
bun)
uninstall_cmd="bun remove -g @openai/codex"
;;
*)
uninstall_cmd="npm uninstall -g @openai/codex"
;;
esac
if prompt_yes_no "Uninstall the existing $conflict_manager-managed Codex now?"; then
step "Running: $uninstall_cmd"
if ! sh -c "$uninstall_cmd"; then
warn "Failed to uninstall the existing $conflict_manager-managed Codex. Continuing with the standalone install."
fi
else
warn "Leaving the existing $conflict_manager-managed Codex installed. PATH order will determine which codex runs."
fi
}
install_release() {
release_dir="$1"
vendor_root="$2"
stage_release="$RELEASES_DIR/.staging.$(basename "$release_dir").$$"
mkdir -p "$RELEASES_DIR"
rm -rf "$stage_release"
mkdir -p "$stage_release/codex-resources"
cp "$vendor_root/codex/codex" "$stage_release/codex"
cp "$vendor_root/path/rg" "$stage_release/codex-resources/rg"
chmod 0755 "$stage_release/codex"
chmod 0755 "$stage_release/codex-resources/rg"
if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then
rm -rf "$release_dir"
fi
mv "$stage_release" "$release_dir"
}
release_dir_is_complete() {
release_dir="$1"
expected_version="$2"
expected_target="$3"
[ -d "$release_dir" ] &&
[ -x "$release_dir/codex" ] &&
[ -x "$release_dir/codex-resources/rg" ] &&
[ "$(basename "$release_dir")" = "$expected_version-$expected_target" ]
}
update_current_link() {
release_dir="$1"
tmp_link="$STANDALONE_ROOT/.current.$$"
replace_path_with_symlink "$CURRENT_LINK" "$release_dir" "$tmp_link"
}
update_visible_command() {
mkdir -p "$BIN_DIR"
tmp_link="$BIN_DIR/.codex.$$"
replace_path_with_symlink "$BIN_PATH" "$CURRENT_LINK/codex" "$tmp_link"
}
verify_visible_command() {
"$BIN_PATH" --version >/dev/null
}
parse_args "$@"
require_command mktemp
require_command tar
case "$(uname -s)" in
Darwin)
os="darwin"
;;
Linux)
os="linux"
;;
*)
echo "install.sh supports macOS and Linux. Use install.ps1 on Windows." >&2
exit 1
;;
esac
case "$(uname -m)" in
x86_64 | amd64)
arch="x86_64"
;;
arm64 | aarch64)
arch="aarch64"
;;
*)
echo "Unsupported architecture: $(uname -m)" >&2
exit 1
;;
esac
if [ "$os" = "darwin" ] && [ "$arch" = "x86_64" ]; then
if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = "1" ]; then
arch="aarch64"
fi
fi
if [ "$os" = "darwin" ]; then
if [ "$arch" = "aarch64" ]; then
npm_tag="darwin-arm64"
vendor_target="aarch64-apple-darwin"
platform_label="macOS (Apple Silicon)"
else
npm_tag="darwin-x64"
vendor_target="x86_64-apple-darwin"
platform_label="macOS (Intel)"
fi
else
if [ "$arch" = "aarch64" ]; then
npm_tag="linux-arm64"
vendor_target="aarch64-unknown-linux-musl"
platform_label="Linux (ARM64)"
else
npm_tag="linux-x64"
vendor_target="x86_64-unknown-linux-musl"
platform_label="Linux (x64)"
fi
fi
resolved_version="$(resolve_version)"
asset="codex-npm-$npm_tag-$resolved_version.tgz"
download_url="$(release_url_for_asset "$asset" "$resolved_version")"
release_name="$resolved_version-$vendor_target"
release_dir="$RELEASES_DIR/$release_name"
current_version="$(current_installed_version)"
if [ -n "$current_version" ] && [ "$current_version" != "$resolved_version" ]; then
step "Updating Codex CLI from $current_version to $resolved_version"
elif [ -n "$current_version" ]; then
step "Updating Codex CLI"
else
step "Installing Codex CLI"
fi
step "Detected platform: $platform_label"
step "Resolved version: $resolved_version"
detect_conflicting_install
tmp_dir="$(mktemp -d)"
cleanup() {
release_install_lock
if [ -n "$tmp_dir" ]; then
rm -rf "$tmp_dir"
fi
}
trap cleanup EXIT INT TERM
acquire_install_lock
cleanup_stale_install_artifacts
if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target"; then
if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then
warn "Found incomplete existing release at $release_dir; reinstalling."
fi
archive_path="$tmp_dir/$asset"
extract_dir="$tmp_dir/extract"
step "Downloading Codex CLI"
expected_digest="$(release_asset_digest "$asset" "$resolved_version")"
download_file "$download_url" "$archive_path"
verify_archive_digest "$archive_path" "$expected_digest"
mkdir -p "$extract_dir"
tar -xzf "$archive_path" -C "$extract_dir"
step "Installing standalone package to $release_dir"
install_release "$release_dir" "$extract_dir/package/vendor/$vendor_target"
fi
update_current_link "$release_dir"
update_visible_command
add_to_path
verify_visible_command
release_install_lock
handle_conflicting_install
case "$path_action" in
added)
print_launch_instructions
;;
updated)
print_launch_instructions
;;
configured)
print_launch_instructions
;;
*)
step "$BIN_DIR is already on PATH"
print_launch_instructions
;;
esac
printf 'Codex CLI %s installed successfully.\n' "$resolved_version"
maybe_launch_codex_now