From 0858ff517af6243427a45ee175f76abd596291ce Mon Sep 17 00:00:00 2001 From: Ara Date: Fri, 10 Oct 2025 20:55:35 -0700 Subject: [PATCH] Fix CLI installation script (#6760) * feat(install): improve CLI release detection and error handling - Redirect error messages to stderr for proper error stream handling - Filter releases to only match tags ending in '-cli' suffix when fetching latest - Add explicit .tar.gz extension matching in download URL detection - Improve error messages to be more specific about missing packages - Add informative messages about which CLI release is being installed This ensures the install script correctly identifies CLI-specific releases and provides better feedback when releases or platform packages are not found. The stderr redirection prevents error messages from being captured in command substitutions. * feat(install): improve shell detection and PATH configuration - Add support for fish shell and XDG_CONFIG_HOME standard - Check if bin directory is already in current PATH before modifying config - Detect shell from $SHELL variable instead of relying on version variables - Create default config file if none exists for the detected shell - Use grep -Fq for more reliable PATH entry detection - Support multiple possible config file locations per shell (zsh, bash, fish) This improves the installation experience across different shell environments and prevents duplicate PATH entries when re-running the installer. * refactor * better install script --------- Co-authored-by: pashpashpash --- scripts/install.sh | 714 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 560 insertions(+), 154 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 61f1431806..0503c5ca0f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,45 +1,56 @@ -#!/bin/bash -# Cline Installation Script -# Usage: curl -fsSL https://raw.githubusercontent.com/cline/cline/main/scripts/install.sh | bash - -set -e +#!/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="${CLINE_INSTALL_DIR:-$HOME/.cline/cli}" GITHUB_REPO="cline/cline" -RELEASE_TAG="${CLINE_VERSION:-latest}" +requested_version="${CLINE_VERSION:-}" +FORCE_INSTALL="${FORCE_INSTALL:-false}" # Detect OS and architecture -detect_platform() { - local os=$(uname -s | tr '[:upper:]' '[:lower:]') - local arch=$(uname -m) - - case "$os" in - darwin) - case "$arch" in - x86_64) echo "darwin-x64" ;; - arm64) echo "darwin-arm64" ;; - *) echo "unsupported" ;; - esac - ;; - linux) - case "$arch" in - x86_64) echo "linux-x64" ;; - *) echo "unsupported" ;; - esac - ;; - *) - echo "unsupported" - ;; - esac -} +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() { @@ -48,77 +59,308 @@ print_message() { echo -e "${color}$@${NC}" } -# Print error and exit -error_exit() { - print_message "$RED" "Error: $1" - exit 1 +# Print step +print_step() { + local message=$1 + echo -e "${CYAN}→${NC} ${DIM}$message${NC}" } -# Check if command exists -command_exists() { - command -v "$1" >/dev/null 2>&1 +# 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_message "$BLUE" "Checking prerequisites..." + print_step "Checking prerequisites" - if ! command_exists curl; then - error_exit "curl is required but not installed. Please install curl and try again." - fi + 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 - if ! command_exists tar; then - error_exit "tar is required but not installed. Please install tar and try again." - fi - - print_message "$GREEN" "✓ Prerequisites satisfied" + print_ok "Prerequisites satisfied" } -# Get download URL for the release -get_download_url() { - local platform=$1 - local api_url + +# Check GitHub API rate limit status and return details +check_rate_limit() { + local rate_limit_response=$(curl -s "https://api.github.com/rate_limit" 2>/dev/null) - if [ "$RELEASE_TAG" = "latest" ]; then - api_url="https://api.github.com/repos/$GITHUB_REPO/releases/latest" - else - api_url="https://api.github.com/repos/$GITHUB_REPO/releases/tags/$RELEASE_TAG" + if [ -z "$rate_limit_response" ]; then + return 1 # Can't determine rate limit status fi - print_message "$BLUE" "Fetching release information..." >&2 + if command -v jq >/dev/null 2>&1; then + local remaining=$(echo "$rate_limit_response" | jq -r '.rate.remaining' 2>/dev/null) + + if [ "$remaining" = "0" ]; then + return 0 # Rate limited + fi + fi - local release_data=$(curl -fsSL "$api_url") - local download_url=$(echo "$release_data" | grep -o "\"browser_download_url\": \"[^\"]*${platform}[^\"]*\"" | head -1 | cut -d'"' -f4) + return 1 # Not rate limited +} + +# Show detailed rate limit error +show_rate_limit_error() { + local rate_limit_response=$(curl -s "https://api.github.com/rate_limit" 2>/dev/null) + + if command -v jq >/dev/null 2>&1; then + local remaining=$(echo "$rate_limit_response" | jq -r '.rate.remaining' 2>/dev/null) + local limit=$(echo "$rate_limit_response" | jq -r '.rate.limit' 2>/dev/null) + local reset=$(echo "$rate_limit_response" | jq -r '.rate.reset' 2>/dev/null) + local used=$(echo "$rate_limit_response" | jq -r '.rate.used' 2>/dev/null) + + print_error "GitHub API rate limit exceeded" + echo "" + echo -e "${YELLOW}Rate Limit Status:${NC}" + echo -e " ${CYAN}Used:${NC} ${BOLD}$used${NC} / $limit requests" + echo -e " ${CYAN}Remaining:${NC} ${RED}${BOLD}$remaining${NC}" + echo -e " ${CYAN}Resets at:${NC} ${BOLD}$(date -r $reset 2>/dev/null || date -d @$reset 2>/dev/null)${NC}" + echo "" + + # Calculate time until reset + local now=$(date +%s) + local seconds_until_reset=$((reset - now)) + local minutes_until_reset=$((seconds_until_reset / 60)) + + if [ $seconds_until_reset -gt 0 ]; then + echo -e "${YELLOW}Your rate limit will reset in ${BOLD}~$minutes_until_reset minutes${NC}" + echo "" + fi + + echo -e "${CYAN}Options:${NC}" + echo -e " ${DIM}1.${NC} Wait for the rate limit to reset" + echo -e " ${DIM}2.${NC} Use a GitHub Personal Access Token for 5,000 requests/hour:" + echo "" + echo -e " ${GREEN}GITHUB_TOKEN=your_token bash scripts/install.sh${NC}" + echo "" + echo -e " ${DIM}Create a token at: https://github.com/settings/tokens${NC}" + echo "" + else + print_error "GitHub API rate limit exceeded" + echo "" + echo -e "${YELLOW}You've used all 60 requests. Please wait ~1 hour or use a GitHub token.${NC}" + echo "" + fi +} + +# Get download URL and version +get_release_info() { + if [ -z "$requested_version" ]; then + print_step "Fetching latest CLI release" + + # Build auth header if token provided + local auth_header="" + if [ -n "${GITHUB_TOKEN:-}" ]; then + auth_header="-H \"Authorization: Bearer $GITHUB_TOKEN\"" + fi + + # Use jq if available for more reliable parsing + if command -v jq >/dev/null 2>&1; then + local response=$(eval curl -fsSL $auth_header "https://api.github.com/repos/$GITHUB_REPO/releases" 2>&1) + local curl_exit=$? + + # If curl failed, diagnose why + if [ $curl_exit -ne 0 ]; then + # Check if it's a rate limit issue + if check_rate_limit; then + show_rate_limit_error + exit 1 + fi + + # Check if it's a network issue + if ! curl -s --connect-timeout 5 "https://api.github.com" >/dev/null 2>&1; then + print_error "Could not connect to GitHub" + 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 GitHub" + 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 GitHub API" + echo "" + echo -e "${DIM}Response preview:${NC}" + echo "$response" | head -5 + echo "" + + # Double-check rate limit + if check_rate_limit; then + show_rate_limit_error + fi + 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: https://github.com/$GITHUB_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: https://github.com/$GITHUB_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: fetch specific release by tag (similar error handling) + local releases_data=$(eval curl -fsSL $auth_header "https://api.github.com/repos/$GITHUB_REPO/releases" 2>&1) + local curl_exit=$? + + if [ $curl_exit -ne 0 ]; then + if check_rate_limit; then + show_rate_limit_error + exit 1 + fi + + print_error "Failed to fetch releases from GitHub" + 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=$(eval curl -fsSL $auth_header "https://api.github.com/repos/$GITHUB_REPO/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 + # Similar logic for specific version... + print_step "Fetching version ${MAGENTA}$requested_version${NC}" + cli_tag="$requested_version" + + local auth_header="" + if [ -n "${GITHUB_TOKEN:-}" ]; then + auth_header="-H \"Authorization: Bearer $GITHUB_TOKEN\"" + fi + + if command -v jq >/dev/null 2>&1; then + download_url=$(eval curl -fsSL $auth_header "https://api.github.com/repos/$GITHUB_REPO/releases/tags/$requested_version" | \ + jq -r '.assets[] | select(.name | contains("'"$platform"'") and endswith(".tar.gz")) | .browser_download_url' | head -1) + else + local release_data=$(eval curl -fsSL $auth_header "https://api.github.com/repos/$GITHUB_REPO/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 - error_exit "Could not find download URL for platform: $platform" + print_error "Could not find $platform package in release $cli_tag" + echo -e "${DIM}Visit: https://github.com/$GITHUB_REPO/releases/tag/$cli_tag${NC}" + exit 1 fi - - echo "$download_url" } -# Download and extract Cline -install_cline() { - local platform=$1 - local download_url=$2 +# 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 - print_message "$BLUE" "Installing Cline to $INSTALL_DIR..." + if [ -d "$INSTALL_DIR/bin" ] && [ -f "$INSTALL_DIR/bin/cline" ]; then + # Extract version from cline binary + local installed_version=$("$INSTALL_DIR/bin/cline" 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 "Cline ${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_cline() { + print_step "Installing Cline" # Create temporary directory local tmp_dir=$(mktemp -d) trap "rm -rf $tmp_dir" EXIT - # Download package - print_message "$BLUE" "Downloading Cline..." + # Download with progress bar + echo -e "${MAGENTA}${BOLD}" local package_file="$tmp_dir/cline.tar.gz" - - if ! curl -fsSL -o "$package_file" "$download_url"; then - error_exit "Failed to download Cline package" + + 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 - print_message "$YELLOW" "Removing existing installation..." rm -rf "$INSTALL_DIR" fi @@ -126,141 +368,305 @@ install_cline() { mkdir -p "$INSTALL_DIR" # Extract package - print_message "$BLUE" "Extracting package..." + print_step "Extracting package" if ! tar -xzf "$package_file" -C "$INSTALL_DIR" --strip-components=0; then - error_exit "Failed to extract package" + print_error "Failed to extract package" + exit 1 fi # Make binaries executable - chmod +x "$INSTALL_DIR/bin/"* - - # Copy platform-specific native modules to node_modules - if [ -d "$INSTALL_DIR/binaries/$platform/node_modules" ]; then - print_message "$BLUE" "Installing platform-specific native modules..." - if ! cp -r "$INSTALL_DIR/binaries/$platform/node_modules/"* "$INSTALL_DIR/node_modules/"; then - error_exit "Failed to install platform-specific native modules" - fi - print_message "$GREEN" "✓ Native modules installed" + 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 - print_message "$GREEN" "✓ Cline installed successfully" + + # 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 "Cline installed to ${MAGENTA}${BOLD}$INSTALL_DIR${NC}" } # Configure PATH configure_path() { local bin_dir="$INSTALL_DIR/bin" - local shell_rc="" + local XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} - # Detect shell configuration file - if [ -n "$BASH_VERSION" ]; then - if [ -f "$HOME/.bashrc" ]; then - shell_rc="$HOME/.bashrc" - elif [ -f "$HOME/.bash_profile" ]; then - shell_rc="$HOME/.bash_profile" + 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 - elif [ -n "$ZSH_VERSION" ]; then - shell_rc="$HOME/.zshrc" + 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 - if [ -z "$shell_rc" ]; then - print_message "$YELLOW" "⚠ Could not detect shell configuration file" - print_message "$YELLOW" "Please manually add the following to your shell configuration:" - print_message "$YELLOW" " export PATH=\"$bin_dir:\$PATH\"" - return + # 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# Cline CLI\nfish_add_path $bin_dir" >> "$config_file" + ;; + *) + echo -e "\n# Cline 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 - # Check if PATH is already configured - if grep -q "CLINE_INSTALL_DIR" "$shell_rc" 2>/dev/null; then - print_message "$GREEN" "✓ PATH already configured in $shell_rc" - return + # Add to GitHub Actions PATH if applicable + if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then + echo "$bin_dir" >> "$GITHUB_PATH" fi - - # Add to PATH - print_message "$BLUE" "Configuring PATH in $shell_rc..." - cat >> "$shell_rc" << EOF - -# Cline CLI -export PATH="$bin_dir:\$PATH" -EOF - - print_message "$GREEN" "✓ PATH configured in $shell_rc" - print_message "$YELLOW" "⚠ Please restart your shell or run: source $shell_rc" } # Verify installation verify_installation() { - print_message "$BLUE" "Verifying installation..." + print_step "Verifying installation" local cline_bin="$INSTALL_DIR/bin/cline" if [ ! -f "$cline_bin" ]; then - error_exit "Installation verification failed: cline binary not found" + print_error "Binary not found at $cline_bin" + exit 1 fi if [ ! -x "$cline_bin" ]; then - error_exit "Installation verification failed: cline binary not executable" + chmod +x "$cline_bin" fi - # Check version (the binary now handles service management internally) - local version_output=$("$cline_bin" version 2>&1 || true) - if [ -z "$version_output" ]; then - error_exit "Installation verification failed: could not get version" + 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 - print_message "$GREEN" "✓ Installation verified" + # 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