#!/usr/bin/env bash # install.sh - Install basecamp CLI # # Usage: # curl -fsSL https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.sh | bash # # Options (via environment): # BASECAMP_BIN_DIR Where to install binary # (default: ~/bin if on PATH, else ~/.local/bin if on PATH; # otherwise ~/bin on Windows, ~/.local/bin elsewhere) # BASECAMP_VERSION Specific version to install (default: latest) # BASECAMP_SKIP_SETUP Set to 1 to skip first-time setup after install # (still runs `basecamp setup agents` to install the skill # and connect coding agents) # BASECAMP_NONINTERACTIVE # Set to 1 or true to use non-interactive setup # (still runs `basecamp setup agents` to install the skill # and connect coding agents) # BASECAMP_SETUP_AGENT # Which coding agent(s) `setup agents` connects: # claude | codex | grok | all | none. Unset = auto-detect (connect # a single detected agent; if several, install the skill # only and surface the per-agent commands). # Piped install sets it for the interpreter, not the fetch: # curl -fsSL https://basecamp.com/install-cli | BASECAMP_SETUP_AGENT=codex bash set -euo pipefail REPO="basecamp/basecamp-cli" BIN_DIR="${BASECAMP_BIN_DIR:-}" VERSION="${BASECAMP_VERSION:-}" CURL_SCHANNEL_FALLBACK_FLAG="" CURL_LAST_ERROR="" CURL_FALLBACK_NOTED=0 # Color helpers — respect NO_COLOR (https://no-color.org) if [[ -z "${NO_COLOR:-}" ]] && [[ -t 1 ]]; then bold() { printf '\033[1m%s\033[0m' "$1"; } green() { printf '\033[32m%s\033[0m' "$1"; } red() { printf '\033[31m%s\033[0m' "$1"; } dim() { printf '\033[2m%s\033[0m' "$1"; } else bold() { printf '%s' "$1"; } green() { printf '%s' "$1"; } red() { printf '%s' "$1"; } dim() { printf '%s' "$1"; } fi info() { echo " $(green "✓") $1"; } step() { echo " $(bold "→") $1"; } warn() { echo " $(bold "!") $1" >&2; } error() { echo " $(red "✗ ERROR:") $1" >&2; exit 1; } env_value_is_true() { case "$1" in 1|[Tt][Rr][Uu][Ee]) return 0 ;; *) return 1 ;; esac } can_run_first_time_setup() { ! env_value_is_true "${BASECAMP_NONINTERACTIVE:-}" && [[ -t 1 ]] && [[ -t 2 ]] && { : /dev/null } run_first_time_setup() { local binary="$1" if "$binary" setup; then return 0 fi warn "First-time setup did not finish. Run it again with: basecamp setup" } find_sha256_cmd() { if command -v sha256sum &>/dev/null; then echo "sha256sum" elif command -v shasum &>/dev/null; then echo "shasum -a 256" else error "No SHA256 tool found (need sha256sum or shasum)" fi } # NOTE: Keep the installer helper functions below in sync with scripts/ensure-basecamp.sh. # These scripts stay self-contained on purpose so they can run without sourcing extra files. path_contains_dir() { local dir="$1" [[ ":$PATH:" == *":$dir:"* ]] } default_bin_dir() { local platform="$1" if path_contains_dir "$HOME/bin"; then echo "$HOME/bin" return 0 fi if path_contains_dir "$HOME/.local/bin"; then echo "$HOME/.local/bin" return 0 fi if [[ "$platform" == windows_* ]]; then echo "$HOME/bin" else echo "$HOME/.local/bin" fi } detect_platform() { local os arch os=$(uname -s | tr '[:upper:]' '[:lower:]') case "$os" in darwin) os="darwin" ;; linux) os="linux" ;; freebsd) os="freebsd" ;; openbsd) os="openbsd" ;; mingw*|msys*|cygwin*) os="windows" ;; *) error "Unsupported OS: $os" ;; esac arch=$(uname -m) case "$arch" in x86_64|amd64) arch="amd64" ;; aarch64|arm64) arch="arm64" ;; *) error "Unsupported architecture: $arch" ;; esac echo "${os}_${arch}" } detect_curl_fallback() { local version_output help_output version_output=$(curl --version 2>/dev/null || true) if [[ "$version_output" != *[Ss]channel* ]]; then return 0 fi help_output=$(curl --help all 2>/dev/null || true) if [[ "$help_output" == *"--ssl-revoke-best-effort"* ]]; then CURL_SCHANNEL_FALLBACK_FLAG="--ssl-revoke-best-effort" elif [[ "$help_output" == *"--ssl-no-revoke"* ]]; then CURL_SCHANNEL_FALLBACK_FLAG="--ssl-no-revoke" fi } curl_run() { # --show-error guarantees curl writes errors to stderr even if a future caller # passes -s without -S. The Schannel revocation detection below depends on # finding CRYPT_E_NO_REVOCATION_CHECK in stderr; without --show-error a -s # caller would silently lose the fallback. local err_file status err err_file=$(mktemp "${TMPDIR:-/tmp}/basecamp-curl.XXXXXX") if curl --show-error "$@" 2>"$err_file"; then rm -f "$err_file" CURL_LAST_ERROR="" return 0 else status=$? fi err=$(<"$err_file") rm -f "$err_file" if [[ $status -ne 0 ]] && [[ -n "$CURL_SCHANNEL_FALLBACK_FLAG" ]] && [[ "$err" == *"CRYPT_E_NO_REVOCATION_CHECK"* ]]; then if [[ $CURL_FALLBACK_NOTED -eq 0 ]]; then echo " $(bold "→") Windows certificate revocation checks are unavailable; retrying curl with ${CURL_SCHANNEL_FALLBACK_FLAG}" >&2 CURL_FALLBACK_NOTED=1 fi err_file=$(mktemp "${TMPDIR:-/tmp}/basecamp-curl.XXXXXX") if curl --show-error "$CURL_SCHANNEL_FALLBACK_FLAG" "$@" 2>"$err_file"; then rm -f "$err_file" CURL_LAST_ERROR="" return 0 else status=$? fi err=$(<"$err_file") rm -f "$err_file" fi CURL_LAST_ERROR="$err" return "$status" } get_latest_version() { local url version api_json # Follow the releases/latest redirect to get the version from the final URL. # Avoids the GitHub API (no rate limiting) and grep/sed (better Windows compat). if url=$(curl_run -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest"); then version="${url##*/}" version="${version#v}" if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "$version" return 0 fi fi # Fallback to the GitHub API if redirect parsing fails. Whitespace-tolerant # regex so a future GitHub format change (pretty-print, extra spaces) doesn't # silently break the fallback. Pure bash so no GNU-awk dependency. if api_json=$(curl_run -fsSL -H 'Accept: application/vnd.github+json' -H 'User-Agent: basecamp-cli-installer' "https://api.github.com/repos/${REPO}/releases/latest"); then if [[ $api_json =~ \"tag_name\"[[:space:]]*:[[:space:]]*\"v?([^\"]+)\" ]]; then version="${BASH_REMATCH[1]}" if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "$version" return 0 fi fi fi error "Could not determine latest version. ${CURL_LAST_ERROR:+curl said: ${CURL_LAST_ERROR}. }If native Windows curl fails, try Scoop or PowerShell. If using Git Bash, try /usr/bin/curl instead." } # cosign_version prints the installed cosign's version (e.g. "v2.6.0"), empty # when it can't be determined. The trailing `|| true` is load-bearing: under # `set -euo pipefail` a broken cosign (nonzero `cosign version`) would # otherwise abort the whole installer from inside the caller's command # substitution — an unusable cosign must degrade to warn-and-skip, not abort. cosign_version() { cosign version 2>/dev/null | awk -F': *' '/^GitVersion/ {print $2; exit}' || true } # cosign_bundle_support decides how to verify the release's Sigstore bundle, # which is the new (protobuf, v0.3+json) format. Prints the extra verify-blob # flag to use ("" for none) and returns 0 when verification can proceed: # v3+ → new-format parsing is the default; no flag # v2.6 – v2.x → needs --new-bundle-format=true (v2.x defaults it to false) # < v2.6 → cannot verify (v2.4 chokes on the bundle's tlog key type, # v2.2 lacks the flag entirely); caller warns and skips cosign_bundle_support() { local version="$1" major minor if [[ "$version" =~ ^v?([0-9]+)\.([0-9]+)\. ]]; then major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}" else return 1 fi if (( major >= 3 )); then echo "" elif (( major == 2 && minor >= 6 )); then echo "--new-bundle-format=true" else return 1 fi } verify_checksums() { local version="$1" local tmp_dir="$2" local archive_name="$3" local base_url="https://github.com/${REPO}/releases/download/v${version}" step "Verifying checksums..." if ! curl_run -fsSL "${base_url}/checksums.txt" -o "${tmp_dir}/checksums.txt"; then error "Failed to download checksums.txt${CURL_LAST_ERROR:+ (${CURL_LAST_ERROR})}" fi # Verify SHA256 checksum of the downloaded archive local expected actual expected=$(awk -v f="$archive_name" '$2 == f || $2 == ("*" f) {print $1; exit}' "${tmp_dir}/checksums.txt") actual=$(cd "$tmp_dir" && $(find_sha256_cmd) "$archive_name" | awk '{print $1}') [[ -n "$expected" && "$expected" == "$actual" ]] \ || error "Checksum verification failed for $archive_name" info "Checksum verified" # If cosign is available and understands the bundle format, verify the signature if command -v cosign &>/dev/null; then local cosign_ver bundle_flag cosign_ver=$(cosign_version) if bundle_flag=$(cosign_bundle_support "$cosign_ver"); then step "Verifying cosign signature..." if ! curl_run -fsSL "${base_url}/checksums.txt.bundle" -o "${tmp_dir}/checksums.txt.bundle"; then error "Failed to download checksums.txt.bundle${CURL_LAST_ERROR:+ (${CURL_LAST_ERROR})}" fi local -a cosign_args=(verify-blob --bundle "${tmp_dir}/checksums.txt.bundle") if [[ -n "$bundle_flag" ]]; then cosign_args+=("$bundle_flag") fi cosign_args+=( \ --certificate-identity "https://github.com/basecamp/basecamp-cli/.github/workflows/release.yml@refs/tags/v${version}" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ "${tmp_dir}/checksums.txt") cosign "${cosign_args[@]}" \ || error "Cosign signature verification failed" info "Signature verified" else step "Skipping signature verification: cosign ${cosign_ver:-unknown} can't verify this release's bundle format (need cosign >= 2.6)" fi fi } download_binary() { local version="$1" local platform="$2" local tmp_dir="$3" local url archive_name ext # Determine archive extension if [[ "$platform" == windows_* ]]; then ext="zip" else ext="tar.gz" fi archive_name="basecamp_${version}_${platform}.${ext}" url="https://github.com/${REPO}/releases/download/v${version}/${archive_name}" step "Downloading basecamp v${version} for ${platform}..." if ! curl_run -fsSL "$url" -o "${tmp_dir}/${archive_name}"; then error "Failed to download from $url${CURL_LAST_ERROR:+ (${CURL_LAST_ERROR})}" fi # Verify integrity before extraction verify_checksums "$version" "$tmp_dir" "$archive_name" # Extract binary step "Extracting..." if [[ "$ext" == "zip" ]]; then unzip -q "${tmp_dir}/${archive_name}" -d "$tmp_dir" else tar -xzf "${tmp_dir}/${archive_name}" -C "$tmp_dir" fi # Find and install binary local binary_name="basecamp" if [[ "$platform" == windows_* ]]; then binary_name="basecamp.exe" fi if [[ ! -f "${tmp_dir}/${binary_name}" ]]; then error "Binary not found in archive" fi mkdir -p "$BIN_DIR" mv "${tmp_dir}/${binary_name}" "$BIN_DIR/" chmod +x "$BIN_DIR/$binary_name" info "Installed basecamp to $BIN_DIR/$binary_name" } setup_path() { # Check if BIN_DIR is in PATH if [[ ":$PATH:" == *":$BIN_DIR:"* ]]; then return 0 fi step "Adding $BIN_DIR to PATH" local shell_rc="" case "${SHELL:-}" in */zsh) shell_rc="$HOME/.zshrc" ;; */bash) shell_rc="$HOME/.bashrc" ;; *) shell_rc="$HOME/.profile" ;; esac local path_line="export PATH=\"$BIN_DIR:\$PATH\"" if [[ -f "$shell_rc" ]] && grep -qF "$BIN_DIR" "$shell_rc" 2>/dev/null; then info "PATH already configured in $shell_rc" else echo "" >> "$shell_rc" echo "# Added by basecamp installer" >> "$shell_rc" echo "$path_line" >> "$shell_rc" info "Added to $shell_rc" info "Run: source $shell_rc" fi } verify_install() { local platform="$1" local binary_name="basecamp" if [[ "$platform" == windows_* ]]; then binary_name="basecamp.exe" fi local installed_version err_file err_file=$(mktemp) if installed_version=$("$BIN_DIR/$binary_name" --version 2>"$err_file"); then rm -f "$err_file" info "$(green "${installed_version} installed")" return 0 fi local run_error run_error=$(cat "$err_file") rm -f "$err_file" local detail="Installation failed - basecamp not working" if [[ -n "$run_error" ]]; then detail="$detail: $run_error" fi if [[ "$platform" == windows_* ]]; then detail="$detail Windows may have blocked the unsigned executable: Smart App Control only runs code-signed binaries. Either install inside WSL2 (curl -fsSL https://basecamp.com/install-cli | bash) or see https://github.com/basecamp/basecamp-cli#windows-smart-app-control-and-smartscreen" fi error "$detail" } setup_theme() { local basecamp_theme_dir="$HOME/.config/basecamp/theme" local omarchy_theme_dir="$HOME/.config/omarchy/current/theme" # Skip if basecamp theme already configured if [[ -e "$basecamp_theme_dir" ]]; then return 0 fi # Link to Omarchy theme if available if [[ -d "$omarchy_theme_dir" ]]; then step "Linking basecamp theme to system theme" mkdir -p "$HOME/.config/basecamp" ln -s "$omarchy_theme_dir" "$basecamp_theme_dir" || info "Note: Could not link theme (continuing anyway)" fi } show_banner() { # Skip braille art if terminal is too narrow (logo 32 + gap 3 + text 8 = 43) local cols cols=$(tput cols 2>/dev/null || echo 80) if [[ "$cols" -ge 44 ]]; then local y="" b="" r="" if [[ -z "${NO_COLOR:-}" ]] && [[ -t 1 ]]; then y=$'\033[38;2;232;162;23m' # brand yellow #e8a217 b=$'\033[1m' # bold r=$'\033[0m' # reset fi local logo=( "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣤⣶⣶⣶⣶⣶⣶⣦⣤⣀" "⠀⠀⠀⠀⠀⠀⠀⢀⣴⣾⣿⣿⣿⠿⠿⠛⠛⠛⠻⠿⣿⣿⣿⣦⣀" "⠀⠀⠀⠀⠀⢀⣴⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠻⣿⣿⣦⡀" "⠀⠀⠀⠀⣴⣿⣿⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⣿⣿⣄" "⠀⠀⢀⣼⣿⣿⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣤⡀⠀⠀⠀⠀⢻⣿⣿⣆" "⠀⢀⣾⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⠃⠀⠀⠀⠀⠀⢻⣿⣿⡄" "⠀⣼⣿⣿⠃⠀⠀⣠⣶⣿⣷⣦⣄⠀⠀⢀⣼⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⢿⣿⣿⡀" "⢸⣿⣿⠇⠀⢠⣾⣿⡿⠛⠻⣿⣿⣷⣤⣾⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣇" "⠈⠉⠉⠀⢠⣿⣿⡟⠁⠀⠀⠈⠻⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣿⣿" "⠀⠀⠀⢠⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⡇" "⠀⠀⠀⢻⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣼⣿⣿⠇" "⠀⠀⠀⠀⠙⠿⣿⣿⣷⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣤⣶⣿⣿⡿⠋" "⠀⠀⠀⠀⠀⠀⠈⠛⠿⣿⣿⣿⣿⣶⣶⣶⣶⣶⣶⣶⣶⣶⣿⣿⣿⣿⡿⠟⠉" "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠛⠛⠿⠿⠿⠿⠿⠿⠟⠛⠛⠉⠉" ) local text_line=6 echo "" if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then # Animated reveal on TTY (skip cursor movement when NO_COLOR is set) for line in "${logo[@]}"; do echo "${y}${line}${r}" sleep 0.03 done # Type "Basecamp" to the right of the logo via cursor repositioning sleep 0.1 local text="Basecamp" local lines_up=$(( ${#logo[@]} - text_line )) printf "\033[${lines_up}A\033[36G" for (( i=0; i<${#text}; i++ )); do printf "${b}${text:$i:1}${r}" sleep 0.03 done printf "\033[${lines_up}B\r" else # Static output when piped — no sleeps, no cursor movement for i in "${!logo[@]}"; do if [[ "$i" -eq "$text_line" ]]; then echo "${logo[$i]} Basecamp" else echo "${logo[$i]}" fi done fi echo "" else echo "" echo "Basecamp CLI" echo "" fi } main() { show_banner # Check for curl if ! command -v curl &>/dev/null; then error "curl is required but not installed" fi local platform version tmp_dir platform=$(detect_platform) detect_curl_fallback if [[ -z "$BIN_DIR" ]]; then BIN_DIR=$(default_bin_dir "$platform") fi if [[ -n "$VERSION" ]]; then version="$VERSION" if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then error "Invalid version '${version}'. Expected semver format (e.g. 1.2.3 or 1.2.3-rc.1)." fi else version=$(get_latest_version) fi tmp_dir=$(mktemp -d) trap "rm -rf '${tmp_dir}'" EXIT local binary_name="basecamp" if [[ "$platform" == windows_* ]]; then binary_name="basecamp.exe" fi download_binary "$version" "$platform" "$tmp_dir" setup_path setup_theme verify_install "$platform" echo "" # Run first-time setup when a controlling terminal can handle OAuth approval. # Non-interactive environments (CI, redirected output, coding agents like Claude # Code or Codex) get the baseline skill installed, a best-effort agent connection # via `setup agents`, and next-step instructions instead. if [[ "${BASECAMP_SKIP_SETUP:-}" == "1" ]]; then step "Skipping first-time setup (BASECAMP_SKIP_SETUP=1)" post_install_setup "$binary_name" echo "" echo " Next steps:" echo " $(bold "basecamp auth login") Authenticate with Basecamp" echo " $(bold "basecamp setup") Run first-time setup" echo "" elif can_run_first_time_setup; then # The canonical `curl ... | bash` install owns stdin while Bash reads the # script. Give setup the controlling terminal so OAuth can complete. run_first_time_setup "$BIN_DIR/$binary_name" /dev/null | grep -qE '^[[:space:]]+agents[[:space:]]' } # binary_supports_setup_agent reports whether the binary exposes a per-agent # `setup ` subcommand for the given agent id (claude, codex or grok). binary_supports_setup_agent() { "$1" setup --help 2>/dev/null | grep -qE "^[[:space:]]+$2[[:space:]]" } # post_install_setup installs the baseline skill and connects coding agents # without prompting. It honors BASECAMP_SETUP_AGENT (claude|codex|grok|all|none; # unset = auto-detect). Never runs the interactive wizard. # # Cross-version: newer binaries get the intent-neutral `setup agents`. Older # release binaries (no `setup agents`) fall back WITHOUT reintroducing the # Claude-first bug — only an *explicitly* selected agent is connected. `all` # runs every per-agent setup the binary supports; an unset, auto, or ambiguous # selector installs the shared skill only (`skill install`). # # Every real invocation carries BASECAMP_NO_KEYRING=1, per-command rather than # exported: these calls never touch credentials, but release binaries up to # v0.7.2 probe the OS keyring on startup for every command, and on a locked # headless keychain (CI, ssh) that probe blocks forever. The `setup --help` # capability probes stay bare — help short-circuits before the probe. post_install_setup() { local binary_name="$1" local bin="$BIN_DIR/$binary_name" if binary_supports_setup_agents "$bin"; then BASECAMP_NO_KEYRING=1 "$bin" setup agents || true return 0 fi case "${BASECAMP_SETUP_AGENT:-}" in claude|codex|grok) # Capability-check first: an old `setup` parent accepts an unadvertised # agent id as a stray positional arg and launches the INTERACTIVE wizard, # violating the non-interactive contract. Degrade to the shared skill. if binary_supports_setup_agent "$bin" "${BASECAMP_SETUP_AGENT}"; then BASECAMP_NO_KEYRING=1 "$bin" setup "${BASECAMP_SETUP_AGENT}" || true else BASECAMP_NO_KEYRING=1 "$bin" skill install || true fi ;; all) # Explicit "every agent": dispatch each per-agent setup the binary knows, # falling back to the shared skill if it supports none of them. local ran_agent=0 agent for agent in claude codex grok; do if binary_supports_setup_agent "$bin" "$agent"; then BASECAMP_NO_KEYRING=1 "$bin" setup "$agent" || true ran_agent=1 fi done [[ "$ran_agent" -eq 1 ]] || BASECAMP_NO_KEYRING=1 "$bin" skill install || true ;; *) # Intent-neutral on old binaries: install the shared skill, never pick an # agent. The user connects one via the printed "Next steps". BASECAMP_NO_KEYRING=1 "$bin" skill install || true ;; esac } # Guard so sourcing the script (e.g. from tests) doesn't run the installer. # The if-form is required: `[[ … ]] && main` returns 1 when sourced, which # trips `set -e` in the sourcing shell. The `:-$0` fallback is also # required: bash executing from stdin (curl | bash) or -c leaves BASH_SOURCE # unset, and the bare expansion aborts under `set -u` (#568). if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then main "$@" fi