#!/usr/bin/env bash # ────────────────────────────────────────────────────────────────────── # cmdtap — one-line installer # # Usage: # curl -fsSL https://cmdtap.com/install | bash # # Options (env vars): # CMDTAP_DIR=~/.cmdtap Install directory (default: ~/.cmdtap) # CMDTAP_VERSION=0.2.0 Pin to a version (default: latest) # CMDTAP_NO_RICH=1 Skip rich terminal UI # CMDTAP_PYTHON=python3.12 Custom Python binary # ────────────────────────────────────────────────────────────────────── set -euo pipefail BOLD="\033[1m" CYAN="\033[36m" DIM="\033[2m" RESET="\033[0m" RED="\033[31m" GREEN="\033[32m" info() { printf "${CYAN}${BOLD}cmdtap${RESET} %s\n" "$1"; } ok() { printf "${GREEN}✓${RESET} %s\n" "$1"; } fail() { printf "${RED}✗${RESET} %s\n" "$1" >&2; } # ── Configuration ──────────────────────────────────────────────────── BASE_URL="https://releases.cmdtap.com" INSTALL_DIR="${CMDTAP_DIR:-${HOME}/.cmdtap}" PYTHON_BIN="${CMDTAP_PYTHON:-}" VENV_DIR="${INSTALL_DIR}/.venv" REQUESTED_VERSION="${CMDTAP_VERSION:-latest}" # ── Detect download tool ───────────────────────────────────────────── fetch() { local url="$1" dest="$2" if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o "$dest" elif command -v wget >/dev/null 2>&1; then wget -qO "$dest" "$url" else fail "Neither curl nor wget found. Install one and retry." exit 1 fi } fetch_text() { local url="$1" if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" elif command -v wget >/dev/null 2>&1; then wget -qO- "$url" fi } # ── Find Python 3.9+ ───────────────────────────────────────────────── find_python() { local candidates=("python3.13" "python3.12" "python3.11" "python3.10" "python3.9" "python3" "python") if [[ -n "$PYTHON_BIN" ]]; then candidates=("$PYTHON_BIN") fi for bin in "${candidates[@]}"; do if command -v "$bin" >/dev/null 2>&1; then local version version="$("$bin" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)" || continue local major minor major="${version%%.*}" minor="${version#*.}" if [[ "$major" -ge 3 && "$minor" -ge 9 ]]; then echo "$bin" return 0 fi fi done return 1 } # ── Banner ──────────────────────────────────────────────────────────── printf "\n" printf " ${CYAN}${BOLD}CMD${RESET}${CYAN}${BOLD}TAP${RESET} ${DIM}installer${RESET}\n" printf "\n" # ── Check Python ───────────────────────────────────────────────────── if ! PYTHON_BIN="$(find_python)"; then fail "Python 3.9+ is required but not found." printf "\n" printf " Install Python:\n" printf " macOS: brew install python\n" printf " Linux: sudo apt install python3\n" printf " Other: https://python.org\n" printf "\n" exit 1 fi ok "Python: $PYTHON_BIN ($("$PYTHON_BIN" --version 2>&1))" # ── Resolve version ────────────────────────────────────────────────── if [[ "$REQUESTED_VERSION" == "latest" ]]; then info "Fetching latest version..." VERSION="$(fetch_text "${BASE_URL}/releases/latest/version.txt" 2>/dev/null | tr -d '[:space:]')" || true if [[ -z "$VERSION" ]]; then fail "Could not determine latest version. Set CMDTAP_VERSION=x.y.z to pin." exit 1 fi ok "Latest version: ${VERSION}" else VERSION="$REQUESTED_VERSION" ok "Version: ${VERSION}" fi # ── Download ───────────────────────────────────────────────────────── TARBALL_URL="${BASE_URL}/releases/${VERSION}/cmdtap-${VERSION}.tar.gz" TARBALL_PATH="/tmp/cmdtap-${VERSION}.tar.gz" info "Downloading cmdtap ${VERSION}..." if ! fetch "$TARBALL_URL" "$TARBALL_PATH"; then fail "Download failed: ${TARBALL_URL}" printf "\n" printf " Check the version or try:\n" printf " CMDTAP_VERSION=0.1.0 curl -fsSL https://cmdtap.com/install | bash\n" printf "\n" exit 1 fi ok "Downloaded $(du -h "$TARBALL_PATH" | cut -f1 | xargs)" # ── Extract ────────────────────────────────────────────────────────── info "Installing to ${INSTALL_DIR}..." # Back up existing config if upgrading if [[ -d "$INSTALL_DIR" ]]; then for cfg_file in cmdtap.json .env; do if [[ -f "$INSTALL_DIR/$cfg_file" ]]; then cp "$INSTALL_DIR/$cfg_file" "/tmp/cmdtap-backup-$cfg_file" 2>/dev/null || true fi done rm -rf "$INSTALL_DIR/src" "$INSTALL_DIR/pyproject.toml" "$INSTALL_DIR/setup.py" fi mkdir -p "$INSTALL_DIR" tar -xzf "$TARBALL_PATH" -C "$INSTALL_DIR" --strip-components=1 rm -f "$TARBALL_PATH" # Restore backed-up config for cfg_file in cmdtap.json .env; do if [[ -f "/tmp/cmdtap-backup-$cfg_file" && ! -f "$INSTALL_DIR/$cfg_file" ]]; then cp "/tmp/cmdtap-backup-$cfg_file" "$INSTALL_DIR/$cfg_file" fi rm -f "/tmp/cmdtap-backup-$cfg_file" done ok "Extracted to ${INSTALL_DIR}" # ── Create venv ────────────────────────────────────────────────────── info "Setting up virtual environment..." "$PYTHON_BIN" -m venv "$VENV_DIR" "$VENV_DIR/bin/pip" install --upgrade pip setuptools wheel -q 2>/dev/null || true ok "Virtual environment ready" # ── Install package ────────────────────────────────────────────────── info "Installing cmdtap..." if "$VENV_DIR/bin/pip" install -e "$INSTALL_DIR" -q 2>/dev/null; then ok "Package installed" else "$VENV_DIR/bin/pip" install --no-build-isolation -e "$INSTALL_DIR" -q 2>/dev/null ok "Package installed (fallback)" fi # ── Install extras ─────────────────────────────────────────────────── if [[ "${CMDTAP_NO_RICH:-}" != "1" ]]; then "$VENV_DIR/bin/pip" install rich -q 2>/dev/null && ok "Rich terminal UI" || true fi "$VENV_DIR/bin/pip" install python-docx openpyxl websocket-client -q 2>/dev/null && ok "Extras (docs, slack)" || true # ── Create global command ──────────────────────────────────────────── create_launcher() { local target_dirs=() target_dirs+=("${HOME}/.local/bin") [[ -d "/opt/homebrew/bin" ]] && target_dirs+=("/opt/homebrew/bin") [[ -d "/usr/local/bin" ]] && target_dirs+=("/usr/local/bin") for dir in "${target_dirs[@]}"; do mkdir -p "$dir" 2>/dev/null || continue if [[ -w "$dir" ]]; then cat > "$dir/cmdtap" << LAUNCHER #!/usr/bin/env bash exec "$VENV_DIR/bin/cmdtap" "\$@" LAUNCHER chmod +x "$dir/cmdtap" ok "Command: $dir/cmdtap" # Add to PATH for future shells if ! echo "$PATH" | tr ':' '\n' | grep -qx "$dir"; then local rcfile="" case "$(basename "${SHELL:-}")" in zsh) rcfile="$HOME/.zprofile" ;; bash) rcfile="$HOME/.bash_profile" ;; *) rcfile="$HOME/.profile" ;; esac if [[ -n "$rcfile" ]] && ! grep -qF "$dir" "$rcfile" 2>/dev/null; then printf '\n# Added by cmdtap installer\nexport PATH="%s:$PATH"\n' "$dir" >> "$rcfile" info "Added $dir to PATH in $rcfile" fi fi return 0 fi done return 1 } create_launcher || info "Run directly: $VENV_DIR/bin/cmdtap" # ── Set up global config ───────────────────────────────────────────── GLOBAL_CFG="${HOME}/.config/cmdtap" mkdir -p "$GLOBAL_CFG" if [[ ! -f "$GLOBAL_CFG/cmdtap.json" && -f "$INSTALL_DIR/cmdtap.json.example" ]]; then cp "$INSTALL_DIR/cmdtap.json.example" "$GLOBAL_CFG/cmdtap.json" ok "Config: $GLOBAL_CFG/cmdtap.json" fi # ── Done ───────────────────────────────────────────────────────────── printf "\n" printf " ${GREEN}${BOLD}cmdtap ${VERSION} installed!${RESET}\n" printf "\n" printf " Get started:\n" printf " ${BOLD}cmdtap${RESET} ${DIM}# interactive mode${RESET}\n" printf " ${BOLD}cmdtap --configure${RESET} ${DIM}# set up LLM provider${RESET}\n" printf " ${BOLD}cmdtap --task \"my ip\"${RESET} ${DIM}# one-shot task${RESET}\n" printf "\n" printf " Config: ${DIM}$GLOBAL_CFG/cmdtap.json${RESET}\n" printf " API keys: ${DIM}$GLOBAL_CFG/.env${RESET}\n" printf "\n"