#!/usr/bin/env bash set -euo pipefail APP=clipai REPO="ryangerardwilson/clipai" APP_HOME="$HOME/.${APP}" INSTALL_DIR="$APP_HOME/bin" APP_DIR="$APP_HOME/app" CONFIG_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/clipai/config.json" UNIT_NAME="clipai-clipboard-watcher.service" MUTED='\033[0;2m' RED='\033[0;31m' ORANGE='\033[38;5;214m' NC='\033[0m' usage() { cat < Install a specific version (e.g., 0.1.0 or v0.1.0) -b, --binary Install from a local binary instead of downloading --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) Examples: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash -s -- --version 0.1.0 ./install.sh --binary /path/to/${APP} EOF } requested_version=${VERSION:-} no_modify_path=false binary_path="" while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage; exit 0 ;; -v|--version) [[ -n "${2:-}" ]] || { echo -e "${RED}Error: --version requires an argument${NC}"; exit 1; } requested_version="$2" shift 2 ;; -b|--binary) [[ -n "${2:-}" ]] || { echo -e "${RED}Error: --binary requires a path${NC}"; exit 1; } binary_path="$2" shift 2 ;; --no-modify-path) no_modify_path=true shift ;; *) echo -e "${ORANGE}Warning: Unknown option '$1'${NC}" >&2 shift ;; esac done print_message() { local level=$1 local message=$2 local color="${NC}" [[ "$level" == "error" ]] && color="${RED}" echo -e "${color}${message}${NC}" } mkdir -p "$INSTALL_DIR" installed_version="" if [[ -x "${INSTALL_DIR}/${APP}" ]]; then installed_version=$("${INSTALL_DIR}/${APP}" --version 2>/dev/null || true) fi # Install from local binary if [[ -n "$binary_path" ]]; then [[ -f "$binary_path" ]] || { print_message error "Binary not found: $binary_path"; exit 1; } print_message info "\n${MUTED}Installing ${NC}${APP}${MUTED} from local binary: ${NC}${binary_path}" cp "$binary_path" "${INSTALL_DIR}/${APP}" chmod 755 "${INSTALL_DIR}/${APP}" specific_version="local" else raw_os=$(uname -s) arch=$(uname -m) if [[ "$raw_os" != "Linux" ]]; then print_message error "Unsupported OS: $raw_os (this installer supports Linux only)" exit 1 fi if [[ "$arch" != "x86_64" ]]; then print_message error "Unsupported arch: $arch (this installer supports x86_64 only)" exit 1 fi command -v curl >/dev/null 2>&1 || { print_message error "'curl' is required but not installed."; exit 1; } command -v tar >/dev/null 2>&1 || { print_message error "'tar' is required but not installed."; exit 1; } filename="${APP}-linux-x64.tar.gz" mkdir -p "$APP_DIR" if [[ -z "$requested_version" ]]; then url="https://github.com/${REPO}/releases/latest/download/${filename}" specific_version="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ | sed -n 's/.*"tag_name": *"v\([^\"]*\)".*/\1/p' || true)" [[ -n "$specific_version" ]] || specific_version="latest" else requested_version="${requested_version#v}" url="https://github.com/${REPO}/releases/download/v${requested_version}/${filename}" specific_version="${requested_version}" http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/${REPO}/releases/tag/v${requested_version}") if [[ "$http_status" == "404" ]]; then print_message error "Release v${requested_version} not found" print_message info "${MUTED}See available releases: ${NC}https://github.com/${REPO}/releases" exit 1 fi fi if [[ -n "$installed_version" && "$installed_version" == "${specific_version}" ]]; then print_message info "${MUTED}${APP} version ${NC}${specific_version}${MUTED} already installed.${NC}" exit 0 fi print_message info "\n${MUTED}Installing ${NC}${APP} ${MUTED}version: ${NC}${specific_version}" tmp_dir="${TMPDIR:-/tmp}/${APP}_install_$$" mkdir -p "$tmp_dir" curl -# -L -o "$tmp_dir/$filename" "$url" tar -xzf "$tmp_dir/$filename" -C "$tmp_dir" if [[ ! -f "$tmp_dir/${APP}/${APP}" ]]; then print_message error "Archive did not contain expected directory '${APP}/${APP}'" print_message info "Expected: $tmp_dir/${APP}/${APP}" exit 1 fi rm -rf "$APP_DIR" mkdir -p "$APP_DIR" mv "$tmp_dir/${APP}" "$APP_DIR" rm -rf "$tmp_dir" cat > "${INSTALL_DIR}/${APP}" </dev/null; then print_message info "${MUTED}PATH entry already present in ${NC}$config_file" elif [[ -w "$config_file" ]]; then { echo "" echo "# ${APP}" echo "$command" } >> "$config_file" print_message info "${MUTED}Added ${NC}${APP}${MUTED} to PATH in ${NC}$config_file" else print_message info "Add this to your shell config:" print_message info " $command" fi } if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then export PATH="$INSTALL_DIR:$PATH" print_message info "${MUTED}Temporarily added ${NC}$INSTALL_DIR${MUTED} to PATH for current shell.${NC}" fi if [[ "$no_modify_path" != "true" ]]; then XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} current_shell=$(basename "${SHELL:-bash}") case "$current_shell" in zsh) config_candidates=("$HOME/.zshrc" "$HOME/.zshenv" "$XDG_CONFIG_HOME/zsh/.zshrc" "$XDG_CONFIG_HOME/zsh/.zshenv") ;; bash) config_candidates=("$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "$XDG_CONFIG_HOME/bash/.bashrc" "$XDG_CONFIG_HOME/bash/.bash_profile") ;; fish) config_candidates=("$HOME/.config/fish/config.fish") ;; *) config_candidates=("$HOME/.profile" "$HOME/.bashrc") ;; esac config_file="" for f in "${config_candidates[@]}"; do if [[ -f "$f" ]]; then config_file="$f"; break; fi done if [[ -z "$config_file" ]]; then print_message info "${MUTED}No shell config file found. Manually add:${NC}" print_message info " export PATH=$INSTALL_DIR:\$PATH" else if [[ "$current_shell" == "fish" ]]; then add_to_path "$config_file" "fish_add_path $INSTALL_DIR" else add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH" fi fi fi # Ensure config file exists (XDG compliant) ensure_config() { local cfg_path="$1" local cfg_dir cfg_dir=$(dirname "$cfg_path") mkdir -p "$cfg_dir" if [[ ! -f "$cfg_path" ]]; then cat > "$cfg_path" <<'JSON' { "openai_api_key": "", "model": "gpt-5.2", "system_instruction": "Your role is to simply return a concise code snippet", "strip_code_fences": true } JSON chmod 600 "$cfg_path" print_message info "${MUTED}Created config at${NC} $cfg_path" fi } ensure_config "$CONFIG_PATH" # Optionally prompt for key if interactive and missing if [[ -t 0 ]]; then current_key=$(CONFIG_PATH="$CONFIG_PATH" python3 - <<'PY' import json, os cfg_path = os.environ.get('CONFIG_PATH') with open(cfg_path) as f: data = json.load(f) print(data.get('openai_api_key', '')) PY ) if [[ -z "$current_key" ]]; then read -r -p "Enter OpenAI API key (leave blank to skip): " new_key if [[ -n "$new_key" ]]; then CONFIG_PATH="$CONFIG_PATH" NEW_KEY="$new_key" python3 - <<'PY' import json, os cfg_path = os.environ['CONFIG_PATH'] new_key = os.environ.get('NEW_KEY', '') with open(cfg_path) as f: data = json.load(f) data['openai_api_key'] = new_key with open(cfg_path, 'w') as f: json.dump(data, f, indent=2) os.chmod(cfg_path, 0o600) print(f"Updated {cfg_path}") PY fi fi fi # Install systemd unit install_unit() { local unit_dir="$HOME/.config/systemd/user" mkdir -p "$unit_dir" local service_path="${PATH:-/usr/local/bin:/usr/bin:/bin}" # Ensure the PATH includes standard system locations even under systemd user units if [[ ":$service_path:" != *":/usr/local/bin:"* ]]; then service_path="/usr/local/bin:${service_path}" fi if [[ ":$service_path:" != *":/usr/bin:"* ]]; then service_path="/usr/bin:${service_path}" fi if [[ ":$service_path:" != *":/bin:"* ]]; then service_path="/bin:${service_path}" fi service_path=${service_path//%/%%} local service_wayland="${WAYLAND_DISPLAY:-}" service_wayland=${service_wayland//%/%%} local service_display="${DISPLAY:-}" service_display=${service_display//%/%%} local service_runtime="${XDG_RUNTIME_DIR:-}" service_runtime=${service_runtime//%/%%} local unit_file="$unit_dir/$UNIT_NAME" local env_lines=() env_lines+=("Environment=\"PATH=${service_path}\"") if [[ -n "$service_wayland" ]]; then env_lines+=("Environment=\"WAYLAND_DISPLAY=${service_wayland}\"") fi if [[ -n "$service_display" ]]; then env_lines+=("Environment=\"DISPLAY=${service_display}\"") fi if [[ -n "$service_runtime" ]]; then env_lines+=("Environment=\"XDG_RUNTIME_DIR=${service_runtime}\"") fi { cat < "$unit_file" } install_unit start_service() { if command -v systemctl >/dev/null 2>&1; then if systemctl --user daemon-reload >/dev/null 2>&1; then systemctl --user enable --now "$UNIT_NAME" >/dev/null 2>&1 || true else print_message info "${ORANGE}Could not daemon-reload user units. Start manually:${NC}" print_message info " systemctl --user daemon-reload" print_message info " systemctl --user enable --now $UNIT_NAME" fi else print_message info "${ORANGE}systemctl not found. Start manually if desired.${NC}" fi } start_service # wl-clipboard check if ! command -v wl-copy >/dev/null 2>&1 || ! command -v wl-paste >/dev/null 2>&1; then print_message info "${ORANGE}wl-clipboard not found. Install it for clipboard integration.${NC}" fi echo "" print_message info "${MUTED}Installed ${NC}${APP}${MUTED} to ${NC}${INSTALL_DIR}/${APP}" print_message info "${MUTED}Config:${NC} $CONFIG_PATH" print_message info "${MUTED}Service:${NC} $UNIT_NAME (user)" print_message info "${MUTED}Logs:${NC} journalctl --user -u $UNIT_NAME -f" echo ""