#!/usr/bin/env bash # # SageOx (ox) installation script # Usage: curl -sSL https://raw.githubusercontent.com/sageox/ox/main/scripts/install.sh | bash # # IMPORTANT: This script must be EXECUTED, never SOURCED # WRONG: source install.sh (will exit your shell on errors) # CORRECT: bash install.sh # CORRECT: curl -sSL ... | bash set -e # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color REPO="sageox/ox" BINARY="ox" # bundled adapter binaries shipped alongside ox in the release tarball ADAPTER_BINARIES="ox-adapter-claude-code ox-adapter-gemini ox-adapter-codex ox-adapter-amp ox-adapter-opencode ox-adapter-pi ox-adapter-omp ox-adapter-aider ox-adapter-droid ox-adapter-goose" LAST_INSTALL_PATH="" log_info() { echo -e "${BLUE}==>${NC} $1" } log_success() { echo -e "${GREEN}==>${NC} $1" } log_warning() { echo -e "${YELLOW}==>${NC} $1" } log_error() { echo -e "${RED}Error:${NC} $1" >&2 } # Print PATH guidance for AI coding tool hooks, naming the shell startup # file that a non-interactive hook shell actually reads. No-op if the # binary's directory is already on PATH. # # AI coding tools run hooks in a non-interactive shell (e.g. `zsh -c # '...'`), which sources ~/.zshenv but never ~/.zshrc/~/.bash_profile/etc. # A binary that only ends up on PATH via an interactive rc file works fine # at the terminal but stays invisible to those hooks. # # zsh is the only shell here with a startup file a non-interactive `-c` # invocation always reads (~/.zshenv). bash has no such file by default — # non-interactive, non-login bash sources nothing unless $BASH_ENV is set, # and we deliberately don't tell users to set that: it's obscure and it # would affect every non-interactive bash invocation on the machine, not # just hooks. So for bash/fish/unknown we give the honest explanation # instead of the zsh-specific one, plus a restart reminder: the hook tool # inherits the environment of the terminal that launched it, so adding the # export to the interactive rc file and then restarting the tool from a # fresh terminal works, even though the file itself is never read directly # by the hook shell. Mirrors internal/constants/agent.go's # oxNotOnPathFallback, cmd/ox/hooks_git.go's # oxGitHookNotOnPathFallback, internal/doctor/checks/ox_in_path.go's # explanationFor/shellRCFor, and the Makefile's install target — keep the # wording identical across all five. print_path_warning() { local binary_path=$1 local install_dir install_dir=$(dirname "$binary_path") if [[ ":$PATH:" == *":$install_dir:"* ]]; then return 0 fi local shell_name rc_file path_line restart_line explanation shell_name=$(basename "${SHELL:-}") restart_line="" # rc_file is display text only (never sourced or written to by this # script), so the tilde is intentionally left unexpanded. # shellcheck disable=SC2088 case "$shell_name" in zsh) rc_file="~/.zshenv" path_line="export PATH=\"\$PATH:$install_dir\"" explanation="AI coding tools run hooks in a non-interactive shell, which reads ~/.zshenv but not ~/.zshrc." ;; bash) rc_file="~/.bashrc" path_line="export PATH=\"\$PATH:$install_dir\"" restart_line="Then restart your AI coding tool from a new terminal so it picks up the change." explanation="AI coding tools inherit the environment of the terminal they were started from, not any change made after they launched." ;; fish) rc_file="~/.config/fish/config.fish" path_line="fish_add_path -- \"$install_dir\"" restart_line="Then restart your AI coding tool from a new terminal so it picks up the change." explanation="AI coding tools inherit the environment of the terminal they were started from, not any change made after they launched." ;; *) rc_file="the startup file for your shell" path_line="export PATH=\"\$PATH:$install_dir\"" restart_line="Then restart your AI coding tool from a new terminal so it picks up the change." explanation="AI coding tools inherit the environment of the terminal they were started from, not any change made after they launched." ;; esac echo "" log_warning "$BINARY is installed at $binary_path but is not on PATH for non-interactive shells." echo "$explanation" echo "Add this line to $rc_file:" echo " $path_line" if [[ -n "$restart_line" ]]; then echo "$restart_line" fi echo "" } # Point users at `ox doctor` unconditionally on every successful install — # not just when print_path_warning fires. The PATH check above only sees # THIS shell's PATH; it can't catch a fix that only touched an interactive # rc file (e.g. ~/.zshrc), which looks fine here and still leaves a hook # shell unable to find ox. `ox doctor` spawns a scrubbed, non-interactive # shell to check for exactly that gap, so it's the one thing in this flow # that can actually confirm the install worked for AI coworker hooks. print_doctor_hint() { echo "Next: run \`$BINARY doctor\` to confirm your AI coworker can actually see this install." echo "" } release_has_asset() { local release_json=$1 local asset_name=$2 if echo "$release_json" | grep -Fq "\"name\": \"$asset_name\""; then return 0 fi return 1 } # Re-sign binary for macOS to avoid slow Gatekeeper checks resign_for_macos() { local binary_path=$1 # Only run on macOS if [[ "$(uname -s)" != "Darwin" ]]; then return 0 fi # Check if codesign is available if ! command -v codesign &> /dev/null; then log_warning "codesign not found, skipping re-signing" return 0 fi log_info "Re-signing binary for macOS..." codesign --remove-signature "$binary_path" 2>/dev/null || true if codesign --force --sign - "$binary_path"; then log_success "Binary re-signed for this machine" else log_warning "Failed to re-sign binary (non-fatal)" fi } # Detect OS and architecture detect_platform() { local os arch case "$(uname -s)" in Darwin) os="darwin" ;; Linux) os="linux" ;; FreeBSD) os="freebsd" ;; *) log_error "Unsupported operating system: $(uname -s)" log_error "For Windows, download manually from https://github.com/$REPO/releases" exit 1 ;; esac case "$(uname -m)" in x86_64|amd64) arch="amd64" ;; aarch64|arm64) arch="arm64" ;; *) log_error "Unsupported architecture: $(uname -m)" exit 1 ;; esac echo "${os}_${arch}" } # Download and install from GitHub releases install_from_release() { log_info "Installing $BINARY from GitHub releases..." local platform=$1 local tmp_dir tmp_dir=$(mktemp -d) # Get latest release version log_info "Fetching latest release..." local latest_url="https://api.github.com/repos/$REPO/releases/latest" local version local release_json if command -v curl &> /dev/null; then release_json=$(curl -fsSL "$latest_url") elif command -v wget &> /dev/null; then release_json=$(wget -qO- "$latest_url") else log_error "Neither curl nor wget found. Please install one of them." return 1 fi version=$(echo "$release_json" | grep '"tag_name"' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/') if [ -z "$version" ]; then log_error "Failed to fetch latest version" return 1 fi log_info "Latest version: $version" # Download URL local archive_name="ox_${version#v}_${platform}.tar.gz" local download_url="https://github.com/$REPO/releases/download/${version}/${archive_name}" if ! release_has_asset "$release_json" "$archive_name"; then log_warning "No prebuilt archive available for platform ${platform}. Falling back to source installation methods." rm -rf "$tmp_dir" return 1 fi log_info "Downloading $archive_name..." cd "$tmp_dir" if command -v curl &> /dev/null; then if ! curl -fsSL -o "$archive_name" "$download_url"; then log_error "Download failed" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 1 fi elif command -v wget &> /dev/null; then if ! wget -q -O "$archive_name" "$download_url"; then log_error "Download failed" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 1 fi fi # Verify the archive before extracting or replacing any installed binary. # A missing or unverifiable checksum is a hard failure: this script is # commonly run through curl, so integrity must not be advisory. local checksums_url="https://github.com/$REPO/releases/download/${version}/checksums.txt" if command -v curl &> /dev/null; then if ! curl -fsSL -o checksums.txt "$checksums_url"; then log_error "Failed to download release checksums; refusing to install unverified binaries" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 1 fi elif command -v wget &> /dev/null; then if ! wget -q -O checksums.txt "$checksums_url"; then log_error "Failed to download release checksums; refusing to install unverified binaries" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 1 fi fi if ! grep -E "(^|[[:space:]*])${archive_name}$" checksums.txt > archive-checksum.txt; then log_error "Release checksums do not include $archive_name; refusing to install" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 1 fi log_info "Verifying checksum..." if command -v sha256sum &> /dev/null; then if ! sha256sum -c archive-checksum.txt --quiet; then log_error "Checksum verification failed; refusing to install" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 1 fi elif command -v shasum &> /dev/null; then if ! shasum -a 256 -c archive-checksum.txt --quiet; then log_error "Checksum verification failed; refusing to install" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 1 fi else log_error "No SHA-256 verification tool found; refusing to install" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 1 fi # Extract archive log_info "Extracting archive..." if ! tar -xzf "$archive_name"; then log_error "Failed to extract archive" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 1 fi # Determine install location local install_dir if [[ -w /usr/local/bin ]]; then install_dir="/usr/local/bin" else install_dir="$HOME/.local/bin" mkdir -p "$install_dir" fi # Install ox binary and bundled adapter binaries log_info "Installing to $install_dir..." for bin in "$BINARY" $ADAPTER_BINARIES; do if [[ ! -f "$bin" ]]; then # adapters may not exist in older releases — skip silently continue fi if [[ -w "$install_dir" ]]; then mv "$bin" "$install_dir/" else sudo mv "$bin" "$install_dir/" fi chmod +x "$install_dir/$bin" resign_for_macos "$install_dir/$bin" done LAST_INSTALL_PATH="$install_dir/$BINARY" log_success "$BINARY installed to $install_dir/$BINARY" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 0 } # Check if Go is installed and meets minimum version check_go() { if command -v go &> /dev/null; then local go_version=$(go version | awk '{print $3}' | sed 's/go//') log_info "Go detected: $(go version)" local major=$(echo "$go_version" | cut -d. -f1) local minor=$(echo "$go_version" | cut -d. -f2) if [ "$major" -eq 1 ] && [ "$minor" -lt 24 ]; then log_error "Go 1.24 or later is required (found: $go_version)" echo "" echo "Please upgrade Go:" echo " - Download from https://go.dev/dl/" echo " - Or use your package manager to update" echo "" return 1 fi return 0 else return 1 fi } # Install using go install (fallback) install_with_go() { log_info "Installing $BINARY and adapters using 'go install'..." # install ox and all bundled adapters in one go install invocation local packages="github.com/$REPO/cmd/ox@latest" for adapter in $ADAPTER_BINARIES; do packages="$packages github.com/$REPO/cmd/${adapter}@latest" done if go install $packages; then log_success "$BINARY and adapters installed via go install" local gobin gobin=$(go env GOBIN 2>/dev/null || true) if [ -n "$gobin" ]; then bin_dir="$gobin" else bin_dir="$(go env GOPATH)/bin" fi LAST_INSTALL_PATH="$bin_dir/$BINARY" # re-sign all binaries for macOS resign_for_macos "$bin_dir/$BINARY" for adapter in $ADAPTER_BINARIES; do resign_for_macos "$bin_dir/$adapter" done return 0 else log_error "go install failed" return 1 fi } # Build from source (last resort) build_from_source() { log_info "Building $BINARY from source..." local tmp_dir tmp_dir=$(mktemp -d) cd "$tmp_dir" log_info "Cloning repository..." if git clone --depth 1 https://github.com/$REPO.git; then cd ox log_info "Building binaries..." # build ox and all bundled adapters local build_targets="./cmd/ox" for adapter in $ADAPTER_BINARIES; do build_targets="$build_targets ./cmd/${adapter}" done if go build $build_targets; then # Determine install location local install_dir if [[ -w /usr/local/bin ]]; then install_dir="/usr/local/bin" else install_dir="$HOME/.local/bin" mkdir -p "$install_dir" fi log_info "Installing to $install_dir..." for bin in "$BINARY" $ADAPTER_BINARIES; do if [[ ! -f "$bin" ]]; then continue fi if [[ -w "$install_dir" ]]; then mv "$bin" "$install_dir/" else sudo mv "$bin" "$install_dir/" fi resign_for_macos "$install_dir/$bin" done log_success "$BINARY installed to $install_dir/$BINARY" LAST_INSTALL_PATH="$install_dir/$BINARY" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 0 else log_error "Build failed" cd - > /dev/null || cd "$HOME" rm -rf "$tmp_dir" return 1 fi else log_error "Failed to clone repository" rm -rf "$tmp_dir" return 1 fi } # Verify installation verify_installation() { if command -v "$BINARY" &> /dev/null; then log_success "$BINARY is installed and ready!" echo "" $BINARY version 2>/dev/null || echo "$BINARY (development build)" echo "" echo "Get started:" echo " cd your-project" echo " $BINARY login" echo " $BINARY init" echo "" return 0 else log_error "$BINARY was installed but is not in PATH" return 1 fi } # Main installation flow main() { echo "" echo "SageOx (ox) Installer" echo "" log_info "Detecting platform..." local platform platform=$(detect_platform) log_info "Platform: $platform" # Try downloading from GitHub releases first if install_from_release "$platform"; then verify_installation print_path_warning "$LAST_INSTALL_PATH" print_doctor_hint exit 0 fi log_warning "Failed to install from releases, trying alternative methods..." # Try go install as fallback if check_go; then if install_with_go; then verify_installation print_path_warning "$LAST_INSTALL_PATH" print_doctor_hint exit 0 fi fi # Try building from source as last resort log_warning "Falling back to building from source..." if ! check_go; then log_warning "Go is not installed" echo "" echo "$BINARY requires Go 1.24 or later to build from source. You can:" echo " 1. Install Go from https://go.dev/dl/" echo " 2. Use your package manager:" echo " - macOS: brew install go" echo " - Ubuntu/Debian: sudo apt install golang" echo " - Other Linux: Check your distro's package manager" echo "" echo "After installing Go, run this script again." exit 1 fi if build_from_source; then verify_installation print_path_warning "$LAST_INSTALL_PATH" print_doctor_hint exit 0 fi # All methods failed log_error "Installation failed" echo "" echo "Manual installation:" echo " 1. Download from https://github.com/$REPO/releases/latest" echo " 2. Extract and move '$BINARY' to your PATH" echo "" echo "Or install from source:" echo " 1. Install Go from https://go.dev/dl/" echo " 2. Run: go install github.com/$REPO/cmd/ox@latest" echo "" exit 1 } main "$@"