#!/usr/bin/env bash # Copyright (c) 2026 Circle Internet Services, Inc. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # SPDX-License-Identifier: MIT # # Install the CircleCI CLI. # https://github.com/CircleCI-Public/circleci-cli # # Usage: # curl -fsSL https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/main/install.sh | bash # # Environment overrides: # VERSION Specific version to install (e.g. 1.2.3), without a leading "v". # Defaults to the latest non-prerelease. Preview/prerelease builds # are NOT resolved automatically — set VERSION explicitly for those. # DESTDIR Install directory for the `circleci` binary. Default: /usr/local/bin. # This names the directory, not the binary path. A legacy value # ending in /circleci (e.g. /usr/local/bin/circleci) is accepted # for backward compatibility and reinterpreted as its parent dir. # REPO GitHub "owner/name". Default: CircleCI-Public/circleci-cli. # # Dependencies: curl, tar, grep, install, and one of sha256sum / shasum. set -o errexit set -o nounset set -o pipefail # Whole installation lives in a function that is only invoked on the final # line. If the download to a pipe (curl | bash) is truncated, bash never # reaches the call and nothing executes half-way. install_cli() { REPO="${REPO:-CircleCI-Public/circleci-cli}" GITHUB_BASE_URL="https://github.com/${REPO}" DESTDIR="${DESTDIR:-/usr/local/bin}" # --- colours (only when stderr is a TTY and NO_COLOR is unset) --------- if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then BOLD=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RESET=$(printf '\033[0m') else BOLD=""; DIM=""; RESET="" fi info() { echo "${DIM}==>${RESET} $*" >&2; } fail() { echo "${BOLD}error:${RESET} $*" >&2; exit 1; } # version_lt A B — true when version A precedes B, comparing dot-separated # numeric components (any -pre / +meta suffix is ignored). 10# forces # base-10 so components with leading zeros are not read as octal. version_lt() { [ "$1" = "$2" ] && return 1 local IFS=. a b i x y # Deliberate split on IFS=. into numeric components; values are digits. # shellcheck disable=SC2206 a=(${1%%[-+]*}); b=(${2%%[-+]*}) for ((i = 0; i < ${#a[@]} || i < ${#b[@]}; i++)); do x=${a[i]:-0}; x=${x//[!0-9]/} y=${b[i]:-0}; y=${y//[!0-9]/} (( 10#${x:-0} < 10#${y:-0} )) && return 0 (( 10#${x:-0} > 10#${y:-0} )) && return 1 done return 1 } # Backward-compat: the legacy install.sh treated DESTDIR as install(1)'s # DEST argument, so DESTDIR=/usr/local/bin/circleci meant the full binary # path. DESTDIR now names the install directory. If it ends in /circleci and # isn't an existing directory, assume legacy usage, strip the trailing # component, and warn. if [ "$(basename "${DESTDIR}")" = "circleci" ] && [ ! -d "${DESTDIR}" ]; then info "${BOLD}DESTDIR${RESET} now names the install directory, not the binary path." info "Interpreting ${DESTDIR} as $(dirname "${DESTDIR}") for backward compatibility." DESTDIR="$(dirname "${DESTDIR}")" fi command -v curl >/dev/null 2>&1 || fail "curl is required but was not found in PATH." command -v tar >/dev/null 2>&1 || fail "tar is required but was not found in PATH." # --- detect platform ---------------------------------------------------- case "$(uname -s)" in Linux) OS=linux ;; Darwin) OS=darwin ;; *) fail "Unsupported operating system: $(uname -s). Use Homebrew, Snap, or Chocolatey instead." ;; esac case "$(uname -m)" in x86_64 | amd64) ARCH=amd64 ;; aarch64 | arm64) ARCH=arm64 ;; *) fail "Unsupported architecture: $(uname -m)." ;; esac # --- resolve version ---------------------------------------------------- if [ -z "${VERSION:-}" ]; then info "Resolving latest release" # Follow the redirect from /releases/latest to /releases/tag/vX.Y.Z and # keep the final path segment. Avoids a jq dependency on the API JSON. effective_url=$(curl -fsSLI -o /dev/null -w '%{url_effective}' "${GITHUB_BASE_URL}/releases/latest") \ || fail "Could not reach GitHub to determine the latest version." tag="${effective_url##*/}" VERSION="${tag#v}" [ -n "$VERSION" ] && [ "$VERSION" != "latest" ] \ || fail "Could not determine the latest version. Set VERSION explicitly and re-run." fi info "Installing CircleCI CLI ${BOLD}v${VERSION}${RESET} (${OS}/${ARCH})" ARCHIVE="circleci-cli_${VERSION}_${OS}_${ARCH}.tar.gz" CHECKSUMS="circleci-cli_${VERSION}_checksums.txt" DL_BASE="${GITHUB_BASE_URL}/releases/download/v${VERSION}" # --- scratch dir; keep it on failure for debugging ---------------------- SCRATCH=$(mktemp -d 2>/dev/null || mktemp -d -t circleci-cli) keep_scratch() { echo "${BOLD}error:${RESET} installation failed. Files left in ${SCRATCH} for debugging." >&2 } trap keep_scratch ERR # --- download archive + checksums --------------------------------------- info "Downloading ${ARCHIVE}" curl -fsSL --retry 3 -o "${SCRATCH}/${ARCHIVE}" "${DL_BASE}/${ARCHIVE}" \ || fail "Download failed: ${DL_BASE}/${ARCHIVE} (does v${VERSION} have a ${OS}/${ARCH} build?)" curl -fsSL --retry 3 -o "${SCRATCH}/${CHECKSUMS}" "${DL_BASE}/${CHECKSUMS}" \ || fail "Could not download checksums: ${DL_BASE}/${CHECKSUMS}" # --- verify SHA-256 ----------------------------------------------------- info "Verifying checksum" expected=$(grep " ${ARCHIVE}\$" "${SCRATCH}/${CHECKSUMS}" | awk '{print $1}') [ -n "$expected" ] || fail "No checksum entry for ${ARCHIVE} in ${CHECKSUMS}." if command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "${SCRATCH}/${ARCHIVE}" | awk '{print $1}') elif command -v shasum >/dev/null 2>&1; then actual=$(shasum -a 256 "${SCRATCH}/${ARCHIVE}" | awk '{print $1}') else fail "Need sha256sum or shasum to verify the download." fi [ "$expected" = "$actual" ] \ || fail "Checksum mismatch for ${ARCHIVE} (expected ${expected}, got ${actual})." # --- unpack ------------------------------------------------------------- # One cutover governs the v0 line: v0.1.47860. Before it, v0 shipped a # `circleci` binary wrapped in a top-level directory that must be stripped. # From it, v0's binary is renamed to `circleci-v0` (so it coexists with the # v1 `circleci`) and sits at the archive root. Every v1+ release is a plain # `circleci` at the root — only the 0.1.x line renames. BINARY=circleci wrapped=false case "${VERSION}" in 0.1.*) if version_lt "${VERSION}" "0.1.47860"; then wrapped=true # legacy v0: `circleci` inside a top-level dir else BINARY=circleci-v0 # new-format v0: renamed, at the archive root fi ;; esac if [ "${wrapped}" = true ]; then tar -xzf "${SCRATCH}/${ARCHIVE}" -C "${SCRATCH}" --strip-components 1 \ || fail "Could not extract ${ARCHIVE}." else tar -xzf "${SCRATCH}/${ARCHIVE}" -C "${SCRATCH}" "${BINARY}" \ || fail "Could not extract '${BINARY}' from ${ARCHIVE}." fi [ -f "${SCRATCH}/${BINARY}" ] || fail "'${BINARY}' binary not found after extracting ${ARCHIVE}." # --- install, escalating with sudo only if needed ----------------------- info "Installing to ${BOLD}${DESTDIR}${RESET}" if [ -w "${DESTDIR}" ] || { [ ! -e "${DESTDIR}" ] && mkdir -p "${DESTDIR}" 2>/dev/null; }; then install "${SCRATCH}/${BINARY}" "${DESTDIR}/${BINARY}" elif command -v sudo >/dev/null 2>&1; then info "${DESTDIR} is not writable; using sudo" sudo install "${SCRATCH}/${BINARY}" "${DESTDIR}/${BINARY}" else fail "${DESTDIR} is not writable and sudo is unavailable. Re-run with DESTDIR set to a writable dir." fi trap - ERR rm -rf "${SCRATCH}" installed="${DESTDIR}/${BINARY}" info "Installed: ${BOLD}v${VERSION}${RESET} as ${BOLD}${BINARY}${RESET}" case ":${PATH}:" in *":${DESTDIR}:"*) command -v "${BINARY}" >/dev/null 2>&1 || true ;; *) info "Note: ${DESTDIR} is not on your PATH. Add it, or run ${installed} directly." ;; esac } install_cli