#!/usr/bin/env bash set -euo pipefail # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' MAGENTA='\033[0;35m' ORANGE='\033[38;2;255;140;0m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' # No Color # Configuration INSTALL_DIR="${KODER_INSTALL_DIR:-$HOME/.koder/cli}" FLOW_BASE_URL="https://flow.koder.dev" FLOW_REPO="koder/koder-ai-cli" requested_version="${KODER_VERSION:-}" FORCE_INSTALL="${FORCE_INSTALL:-false}" # Detect OS and architecture os=$(uname -s | tr '[:upper:]' '[:lower:]') arch=$(uname -m) # Normalize architecture names if [[ "$arch" == "aarch64" ]]; then arch="arm64" elif [[ "$arch" == "x86_64" ]]; then arch="x64" fi # Determine platform string case "$os" in darwin) [[ "$arch" == "x64" || "$arch" == "arm64" ]] || { echo -e "${RED}${BOLD}ERROR${NC} ${RED}Unsupported architecture: $arch${NC}" >&2 exit 1 } platform="darwin-$arch" ;; linux) [[ "$arch" == "x64" || "$arch" == "arm64" ]] || { echo -e "${RED}${BOLD}ERROR${NC} ${RED}Unsupported architecture: $arch${NC}" >&2 exit 1 } platform="linux-$arch" ;; *) echo -e "${RED}${BOLD}ERROR${NC} ${RED}Unsupported OS: $os${NC}" >&2 exit 1 ;; esac # Print colored message print_message() { local color=$1 shift echo -e "${color}$@${NC}" } # Print step print_step() { local message=$1 echo -e "${CYAN}→${NC} ${DIM}$message${NC}" } # Print success print_ok() { local message=$1 echo -e "${GREEN}✓${NC} $message" } # Print error print_error() { local message=$1 echo -e "${RED}✗${NC} ${RED}$message${NC}" >&2 } # Check prerequisites check_prerequisites() { print_step "Checking prerequisites" for cmd in curl tar; do if ! command -v "$cmd" >/dev/null 2>&1; then print_error "$cmd is required but not installed" exit 1 fi done print_ok "Prerequisites satisfied" } # Check for conflicting koder installations (npm, other package managers, etc.) check_conflicts() { local bin_dir="$INSTALL_DIR/bin" local found_conflict=false # Check if koder command exists and is NOT our installation local existing_koder=$(command -v koder 2>/dev/null || true) if [ -n "$existing_koder" ] && [ "$existing_koder" != "$bin_dir/koder" ]; then found_conflict=true local conflict_source="unknown" local uninstall_hint="" # Detect npm global install if npm list -g koder --depth=0 >/dev/null 2>&1; then conflict_source="npm (global)" uninstall_hint="npm uninstall -g koder" elif echo "$existing_koder" | grep -q "node_modules"; then conflict_source="npm" uninstall_hint="npm uninstall -g koder" # Detect yarn global elif echo "$existing_koder" | grep -q "yarn"; then conflict_source="yarn (global)" uninstall_hint="yarn global remove koder" # Detect pnpm global elif echo "$existing_koder" | grep -q "pnpm"; then conflict_source="pnpm (global)" uninstall_hint="pnpm remove -g koder" # Detect system package elif echo "$existing_koder" | grep -qE "^/(usr|opt)"; then conflict_source="system package" uninstall_hint="check your package manager (apt, pacman, etc.)" fi echo "" print_error "Existing 'koder' command found at: ${BOLD}$existing_koder${NC}" print_message "$YELLOW" " Source: $conflict_source" echo "" print_message "$NC" "This will conflict with the new installation." if [ -n "$uninstall_hint" ]; then print_message "$NC" "Remove it first with:" echo "" print_message "$YELLOW" " ${BOLD}$uninstall_hint" echo "" fi print_message "$DIM" "Or use ${MAGENTA}FORCE_INSTALL=true${DIM} to install anyway (the new binary" print_message "$DIM" "will only take effect if ${CYAN}$bin_dir${DIM} comes first in PATH)." echo "" if [ "$FORCE_INSTALL" != "true" ]; then exit 1 fi print_message "$YELLOW" "Force install requested, continuing..." echo "" fi if [ "$found_conflict" = false ]; then print_ok "No conflicting installations found" fi } # Get download URL and version get_release_info() { local api_url="$FLOW_BASE_URL/api/v1/repos/$FLOW_REPO" # Build auth args as array (safer than eval) local -a auth_args=() if [ -n "${KODER_TOKEN:-}" ]; then auth_args=(-H "Authorization: token $KODER_TOKEN") fi if [ -z "$requested_version" ]; then print_step "Fetching latest CLI release" # Use jq if available for more reliable parsing if command -v jq >/dev/null 2>&1; then local response response=$(curl -fsSL "${auth_args[@]}" "$api_url/releases" 2>&1) local curl_exit=$? # If curl failed, diagnose why if [ $curl_exit -ne 0 ]; then # Check if it's a network issue if ! curl -s --connect-timeout 5 "$FLOW_BASE_URL" >/dev/null 2>&1; then print_error "Could not connect to Koder Flow" echo "" echo -e "${YELLOW}Please check your internet connection and try again.${NC}" echo "" exit 1 fi # Generic error print_error "Failed to fetch releases from Koder Flow" echo "" echo -e "${DIM}Error: $response${NC}" echo "" exit 1 fi # Check if response is valid JSON if ! echo "$response" | jq empty 2>/dev/null; then print_error "Invalid response from Koder Flow API" echo "" echo -e "${DIM}Response preview:${NC}" echo "$response" | head -5 echo "" exit 1 fi # Parse release info local release_info=$(echo "$response" | \ jq -r '.[] | select(.tag_name | endswith("-cli")) | .tag_name + "|" + (.assets[] | select(.name | contains("'"$platform"'") and endswith(".tar.gz")) | .browser_download_url) | select(length > 0)' 2>/dev/null | head -1) if [ -z "$release_info" ]; then # No matching release found - show what's available local latest_cli_tag=$(echo "$response" | jq -r '.[] | select(.tag_name | endswith("-cli")) | .tag_name' 2>/dev/null | head -1) if [ -z "$latest_cli_tag" ]; then print_error "No CLI releases found" echo "" echo -e "${DIM}Visit: $FLOW_BASE_URL/$FLOW_REPO/releases${NC}" echo "" exit 1 fi print_error "No release found for platform: $platform" echo "" echo -e "${YELLOW}Latest CLI release: ${BOLD}$latest_cli_tag${NC}" echo "" echo -e "${CYAN}Available platforms:${NC}" echo "$response" | jq -r '.[] | select(.tag_name | endswith("-cli")) | .assets[].name' 2>/dev/null | grep "\.tar\.gz$" | head -5 | sed 's/^/ /' echo "" echo -e "${DIM}Visit: $FLOW_BASE_URL/$FLOW_REPO/releases/tag/$latest_cli_tag${NC}" echo "" exit 1 fi cli_tag=$(echo "$release_info" | cut -d'|' -f1) download_url=$(echo "$release_info" | cut -d'|' -f2) else # Fallback without jq: fetch releases list local releases_data releases_data=$(curl -fsSL "${auth_args[@]}" "$api_url/releases" 2>&1) local curl_exit=$? if [ $curl_exit -ne 0 ]; then print_error "Failed to fetch releases from Koder Flow" exit 1 fi # Extract the first tag ending in -cli cli_tag=$(echo "$releases_data" | grep -o '"tag_name": "[^"]*-cli"' | head -1 | cut -d'"' -f4) if [ -z "$cli_tag" ]; then print_error "No CLI releases found" exit 1 fi # Fetch the specific release to get assets local release_data release_data=$(curl -fsSL "${auth_args[@]}" "$api_url/releases/tags/$cli_tag") # Extract download URL from the specific release download_url=$(echo "$release_data" | grep -o "\"browser_download_url\": \"[^\"]*${platform}[^\"]*\.tar\.gz\"" | head -1 | cut -d'"' -f4) fi print_ok "Found version ${MAGENTA}${BOLD}$cli_tag${NC}" else # Specific version requested print_step "Fetching version ${MAGENTA}$requested_version${NC}" cli_tag="$requested_version" if command -v jq >/dev/null 2>&1; then download_url=$(curl -fsSL "${auth_args[@]}" "$api_url/releases/tags/$requested_version" | \ jq -r '.assets[] | select(.name | contains("'"$platform"'") and endswith(".tar.gz")) | .browser_download_url' | head -1) else local release_data release_data=$(curl -fsSL "${auth_args[@]}" "$api_url/releases/tags/$requested_version") download_url=$(echo "$release_data" | grep -o "\"browser_download_url\": \"[^\"]*${platform}[^\"]*\.tar\.gz\"" | head -1 | cut -d'"' -f4) fi fi if [ -z "$download_url" ]; then print_error "Could not find $platform package in release $cli_tag" echo -e "${DIM}Visit: $FLOW_BASE_URL/$FLOW_REPO/releases/tag/$cli_tag${NC}" exit 1 fi } # Check if already installed with same version check_existing_installation() { # Skip check if force install if [ "$FORCE_INSTALL" = "true" ]; then print_message "$YELLOW" "Force reinstalling..." echo "" return fi if [ -d "$INSTALL_DIR/bin" ] && [ -f "$INSTALL_DIR/bin/koder" ]; then # Extract version from koder binary local installed_version=$("$INSTALL_DIR/bin/koder" version 2>/dev/null | head -1 | grep -o 'v[0-9]\+\.[0-9]\+\.[0-9]\+' || echo "") # Compare versions (remove -cli suffix for comparison) local cli_tag_version=$(echo "$cli_tag" | sed 's/-cli$//') if [ -n "$installed_version" ] && [ "$installed_version" = "$cli_tag_version" ]; then echo "" print_ok "Koder ${MAGENTA}${BOLD}$installed_version${NC} already installed" echo "" print_message "$DIM" "Installation directory: $INSTALL_DIR" print_message "$DIM" "To reinstall, run: ${MAGENTA}rm -rf $INSTALL_DIR && ${NC}" print_message "$DIM" "Or use: ${MAGENTA}FORCE_INSTALL=true${NC} to force reinstall" echo "" exit 0 elif [ -n "$installed_version" ]; then print_message "$YELLOW" "Upgrading from ${MAGENTA}$installed_version${YELLOW} to ${MAGENTA}${BOLD}$cli_tag_version${NC}" echo "" fi fi } # Download and install install_koder() { print_step "Installing Koder" # Create temporary directory local tmp_dir=$(mktemp -d) trap "rm -rf $tmp_dir" EXIT # Download with progress bar echo -e "${MAGENTA}${BOLD}" local package_file="$tmp_dir/koder.tar.gz" if ! curl -#fSL -o "$package_file" "$download_url"; then echo -e "${NC}" print_error "Failed to download package" echo -e "${DIM}URL: $download_url${NC}" exit 1 fi echo -e "${NC}" # Verify download if [ ! -f "$package_file" ]; then print_error "Download failed: file not found" exit 1 fi local file_size=$(stat -f%z "$package_file" 2>/dev/null || stat -c%s "$package_file" 2>/dev/null) print_ok "Downloaded $(numfmt --to=iec $file_size 2>/dev/null || echo "$file_size bytes")" # Remove existing installation if [ -d "$INSTALL_DIR" ]; then rm -rf "$INSTALL_DIR" fi # Create installation directory mkdir -p "$INSTALL_DIR" # Extract package print_step "Extracting package" if ! tar -xzf "$package_file" -C "$INSTALL_DIR" --strip-components=0; then print_error "Failed to extract package" exit 1 fi # Make binaries executable if [ -d "$INSTALL_DIR/bin" ]; then chmod +x "$INSTALL_DIR/bin/"* 2>/dev/null || true else print_error "No bin directory found" echo -e "${DIM}Contents of $INSTALL_DIR:${NC}" ls -la "$INSTALL_DIR" exit 1 fi # Copy platform-specific native modules if [ -d "$INSTALL_DIR/binaries/$platform/node_modules" ]; then if cp -r "$INSTALL_DIR/binaries/$platform/node_modules/"* "$INSTALL_DIR/node_modules/" 2>/dev/null; then print_ok "Native modules installed" fi fi print_ok "Koder installed to ${MAGENTA}${BOLD}$INSTALL_DIR${NC}" } # Configure PATH configure_path() { local bin_dir="$INSTALL_DIR/bin" local XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} print_step "Configuring PATH" # Detect shell and config files local current_shell=$(basename "${SHELL:-bash}") local config_files="" case $current_shell in fish) config_files="$HOME/.config/fish/config.fish" ;; zsh) config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc" ;; bash) config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc" ;; ash|sh) config_files="$HOME/.profile /etc/profile" ;; *) config_files="$HOME/.profile" ;; esac # Find first existing config file local config_file="" for file in $config_files; do if [ -f "$file" ]; then config_file="$file" break fi done # Create default if none exists if [ -z "$config_file" ]; then case $current_shell in fish) config_file="$HOME/.config/fish/config.fish" mkdir -p "$(dirname "$config_file")" ;; zsh) config_file="$HOME/.zshrc" ;; *) config_file="$HOME/.bashrc" ;; esac touch "$config_file" fi # Add to config if not already present if ! grep -q "$bin_dir" "$config_file" 2>/dev/null; then case $current_shell in fish) echo -e "\n# Koder CLI\nfish_add_path $bin_dir" >> "$config_file" ;; *) echo -e "\n# Koder CLI\nexport PATH=\"$bin_dir:\$PATH\"" >> "$config_file" ;; esac print_ok "Added to PATH in ${CYAN}$(basename $config_file)${NC}" else print_ok "Already in PATH" fi } # Verify installation verify_installation() { print_step "Verifying installation" local koder_bin="$INSTALL_DIR/bin/koder" if [ ! -f "$koder_bin" ]; then print_error "Binary not found at $koder_bin" exit 1 fi if [ ! -x "$koder_bin" ]; then chmod +x "$koder_bin" fi print_ok "Installation verified" } # Smart centered box printer # Smart centered box printer print_box() { local text=$1 local color=$2 local preferred_width=${3:-48} # Default preferred box width of 48 # Try multiple methods to get terminal width local term_width_tput=$(tput cols 2>/dev/null || echo 0) local term_width_stty=$(stty size 2>/dev/null | cut -d' ' -f2 || echo 0) local term_width_env=${COLUMNS:-0} # Build array of valid widths local widths=() [ "$term_width_tput" -gt 0 ] 2>/dev/null && widths+=($term_width_tput) [ "$term_width_stty" -gt 0 ] 2>/dev/null && widths+=($term_width_stty) [ "$term_width_env" -gt 0 ] 2>/dev/null && widths+=($term_width_env) # Smart selection logic local term_width=80 # fallback if [ ${#widths[@]} -gt 0 ]; then # Find min and max local min_width=${widths[0]} local max_width=${widths[0]} for width in "${widths[@]}"; do if [ "$width" -lt "$min_width" ]; then min_width=$width fi if [ "$width" -gt "$max_width" ]; then max_width=$width fi done # If any width is less than preferred (48), use the smallest (most conservative) # Otherwise, use the largest (give more space) if [ "$min_width" -lt "$preferred_width" ]; then term_width=$min_width else term_width=$max_width fi fi # Ensure we have a valid number and reasonable minimum if ! [[ "$term_width" =~ ^[0-9]+$ ]] || [ "$term_width" -lt 20 ]; then term_width=80 fi # Calculate content width (length of longest line in text) local content_width=0 while IFS= read -r line; do # Strip ANSI color codes for accurate length measurement local clean_line=$(echo "$line" | sed 's/\x1b\[[0-9;]*m//g') local line_length=${#clean_line} if [ $line_length -gt $content_width ]; then content_width=$line_length fi done <<< "$text" # Calculate max possible box width (terminal width - 4 chars total padding minimum) local max_box_width=$((term_width - 4)) # Determine internal text padding based on box width # For narrow boxes (< 30), use 1 space padding; otherwise 2 spaces local text_padding_size=2 if [ $max_box_width -lt 30 ]; then text_padding_size=1 fi # Start with preferred width, but respect terminal constraints local box_width=$preferred_width if [ $box_width -gt $max_box_width ]; then box_width=$max_box_width fi # Ensure box is at least wide enough for content (with adaptive padding) local min_width=$((content_width + (text_padding_size * 2) + 2)) # content + padding + borders if [ $box_width -lt $min_width ]; then box_width=$min_width # If even min_width exceeds terminal, shrink to fit if [ $box_width -gt $max_box_width ]; then box_width=$max_box_width fi fi # Absolute minimum box width if [ $box_width -lt 10 ]; then box_width=10 fi # Calculate horizontal padding to center the box in terminal local box_padding=$(( (term_width - box_width) / 2 )) if [ $box_padding -lt 1 ]; then box_padding=1 # Ensure at least 1 character padding fi # Build horizontal line local horizontal_line="═" for ((i=1; i