#!/usr/bin/env bash set -euo pipefail # Paperclip installer — zero PyPI, direct download # Usage: curl -fsSL https://paperclip.gxl.ai/install.sh | bash BOLD='\033[1m' DIM='\033[2m' GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' NC='\033[0m' BASE_URL="${PAPERCLIP_BASE_URL:-https://paperclip.gxl.ai}" INSTALL_DIR="${PAPERCLIP_INSTALL_DIR:-$HOME/.paperclip}" BIN_DIR="${HOME}/.local/bin" info() { printf "${BOLD}${GREEN}✓${NC} %s\n" "$*"; } warn() { printf "${BOLD}${YELLOW}⚠${NC} %s\n" "$*"; } fail() { printf "${BOLD}${RED}✗${NC} %s\n" "$*" >&2; exit 1; } printf "\n${BOLD}Paperclip installer${NC}\n" printf "${DIM}─────────────────────────────────────${NC}\n\n" if ! command -v python3 &>/dev/null; then fail "Python 3 is required. Install it from https://python.org" fi PY_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1) PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2) if [ "$PY_MAJOR" -lt 3 ] || { [ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -lt 8 ]; }; then fail "Python 3.8+ is required (found $PY_VERSION)" fi info "Python $PY_VERSION detected" TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT LIB_DIR="${INSTALL_DIR}/lib" _download() { local url="$1" dest="$2" if command -v curl &>/dev/null; then curl -fsSL "$url" -o "$dest" elif command -v wget &>/dev/null; then wget -q "$url" -O "$dest" else fail "curl or wget required" fi } # Download the wheel WHL_PATH="${TMPDIR}/paperclip.whl" info "Downloading Paperclip from ${BASE_URL}..." _download "${BASE_URL}/paperclip.whl" "$WHL_PATH" # Extract wheel into lib info "Installing to ${INSTALL_DIR}..." mkdir -p "$LIB_DIR" python3 -c " import zipfile, shutil, os, posixpath dest = '$LIB_DIR' for d in os.listdir(dest): p = os.path.join(dest, d) if os.path.isdir(p): shutil.rmtree(p) with zipfile.ZipFile('$WHL_PATH') as zf: for member in zf.namelist(): resolved = os.path.realpath(os.path.join(dest, member)) if not resolved.startswith(os.path.realpath(dest) + os.sep) and resolved != os.path.realpath(dest): raise ValueError(f'Unsafe path in wheel: {member}') zf.extractall(dest) " # Install dependencies into lib if not already available system-wide MISSING="" python3 -c "import requests" 2>/dev/null || MISSING="${MISSING} requests" python3 -c "import click" 2>/dev/null || MISSING="${MISSING} click" if [ -n "$MISSING" ]; then info "Installing dependencies:${MISSING}" pip3 install --quiet --target "$LIB_DIR" ${MISSING} 2>/dev/null \ || python3 -m pip install --quiet --target "$LIB_DIR" ${MISSING} 2>/dev/null \ || fail "Could not install${MISSING}. Run: pip3 install${MISSING}" fi info "Dependencies satisfied (requests, click)" # Create wrapper script mkdir -p "$BIN_DIR" cat > "${BIN_DIR}/paperclip" << 'WRAPPER' #!/usr/bin/env python3 import sys, os sys.path.insert(0, os.path.join(os.path.expanduser("~"), ".paperclip", "lib")) from gxl_paperclip.cli import main main() WRAPPER chmod +x "${BIN_DIR}/paperclip" # Verify if [ -f "${BIN_DIR}/paperclip" ]; then info "Paperclip installed successfully" printf "\n ${DIM}Binary:${NC} ${BIN_DIR}/paperclip\n" printf " ${DIM}Library:${NC} ${INSTALL_DIR}/lib/\n" printf " ${DIM}Python SDK:${NC} import gxl_paperclip — PaperclipClient for scripts and notebooks\n" printf " ${DIM}Docs: ${BASE_URL}${NC}\n\n" NEEDS_SHELL_RELOAD="" SOURCE_CMD="" if ! echo "$PATH" | tr ':' '\n' | grep -qx "$BIN_DIR"; then export PATH="${BIN_DIR}:${PATH}" PATH_LINE='export PATH="$HOME/.local/bin:$PATH"' # Detect shell profile to update SHELL_NAME="$(basename "${SHELL:-/bin/sh}")" PROFILE_FILE="" case "$SHELL_NAME" in zsh) PROFILE_FILE="$HOME/.zshrc" ;; bash) if [ -f "$HOME/.bash_profile" ]; then PROFILE_FILE="$HOME/.bash_profile" elif [ -f "$HOME/.bashrc" ]; then PROFILE_FILE="$HOME/.bashrc" else PROFILE_FILE="$HOME/.profile" fi ;; fish) PROFILE_FILE="$HOME/.config/fish/config.fish" PATH_LINE='fish_add_path "$HOME/.local/bin"' ;; *) PROFILE_FILE="$HOME/.profile" ;; esac if [ -n "$PROFILE_FILE" ] && ! grep -qF '.local/bin' "$PROFILE_FILE" 2>/dev/null; then printf "\n ${BOLD}${PATH_LINE}${NC}\n\n" printf " Add to ${BOLD}${PROFILE_FILE}${NC}? [Y/n] " read -r REPLY < /dev/tty 2>/dev/null || REPLY="y" REPLY="${REPLY:-y}" if [ "$REPLY" = "y" ] || [ "$REPLY" = "Y" ]; then mkdir -p "$(dirname "$PROFILE_FILE")" printf '\n# Added by Paperclip installer\n%s\n' "$PATH_LINE" >> "$PROFILE_FILE" info "Added to ${PROFILE_FILE}" NEEDS_SHELL_RELOAD="1" if [ "$SHELL_NAME" = "fish" ]; then SOURCE_CMD="source ${PROFILE_FILE}" else SOURCE_CMD="source ${PROFILE_FILE}" fi else warn "${BIN_DIR} is not in your PATH. Add it manually:" printf " ${BOLD}${PATH_LINE}${NC}\n\n" NEEDS_SHELL_RELOAD="1" fi elif [ -n "$PROFILE_FILE" ]; then info "${BIN_DIR} already in ${PROFILE_FILE}" fi fi # Authenticate (skip if already logged in) if [ -f "${HOME}/.paperclip/credentials.json" ]; then info "Already authenticated" else printf "${BOLD}─────────────────────────────────────${NC}\n" PAPERCLIP_BASE_URL="${BASE_URL}" "${BIN_DIR}/paperclip" login --no-install < /dev/tty fi # Install the coding-agent skill printf "${BOLD}─────────────────────────────────────${NC}\n" printf "${BOLD}Install the Paperclip skill for your coding agent${NC}\n" printf "${DIM}This lets Cursor, Claude Code, or Codex use Paperclip directly.${NC}\n\n" PAPERCLIP_BASE_URL="${BASE_URL}" "${BIN_DIR}/paperclip" install < /dev/tty # Final completion message printf "${BOLD}─────────────────────────────────────${NC}\n" printf "${GREEN}${BOLD}Installation complete!${NC}\n\n" if [ -n "$NEEDS_SHELL_RELOAD" ] && [ -n "$SOURCE_CMD" ]; then printf " To start using Paperclip, restart your terminal or run:\n\n" printf " ${BOLD}${SOURCE_CMD}${NC}\n\n" elif [ -n "$NEEDS_SHELL_RELOAD" ]; then printf " To start using Paperclip, restart your terminal.\n\n" else printf " You're all set. Try:\n\n" printf " ${BOLD}paperclip search \"CRISPR base editing\"${NC}\n\n" fi else fail "Installation failed" fi