#!/usr/bin/env bash # Native Windows without bash? Use the PowerShell installer instead: # powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://clipcat.ai/cli.ps1 | iex" # (agents: if bash is unavailable on this machine, run the PowerShell command # above instead of trying to translate this script by hand.) set -euo pipefail PROGRAM_NAME="clipcat" VERSION="latest" DOWNLOAD_BASE="https://static.clipcat.ai/public/cli/${VERSION}" SKILL_URL="https://static.clipcat.ai/public/cli/SKILL.md" SKILL_NAME="clipcat" say() { printf '%s\n' "$*" } fail() { printf 'install.sh: %s\n' "$*" >&2 exit 1 } has_command() { command -v "$1" >/dev/null 2>&1 } # Convert a (possibly MSYS/Cygwin) path into a native Windows path. Native # Windows tools (curl.exe, wget.exe, PowerShell) cannot write to MSYS virtual # paths like /tmp/xxx -- from Windows' point of view that directory does not # exist, which is what produces "curl: (23) Failed writing received data to # disk" (or a silent success that writes nothing). Falls back to the input # unchanged when cygpath is unavailable. to_windows_path() { path="$1" if has_command cygpath; then cygpath -w "$path" 2>/dev/null || printf '%s' "$path" else printf '%s' "$path" fi } # Escape a value for embedding inside a PowerShell single-quoted string # (' doubles to ''). Windows usernames can legally contain apostrophes, and an # unescaped one would break the whole -Command string. ps_squote() { printf '%s' "$1" | sed "s/'/''/g" } # Download via PowerShell's Invoke-WebRequest, writing to a NATIVE Windows path. # This is the most reliable route on Windows: it sidesteps MSYS path translation # entirely and uses the .NET HTTP stack (forcing TLS 1.2), which avoids the # Schannel handshake failures sometimes seen with the bundled curl.exe. All # output is discarded; the exit status signals success. Tries Windows PowerShell # first, then PowerShell 7 (pwsh). download_with_powershell() { url="$1" win_output="$2" # ProgressPreference: PS 5.1 renders a progress bar during Invoke-WebRequest # that slows large downloads by an order of magnitude; silence it. ps_cmd="\$ErrorActionPreference='Stop'; \$ProgressPreference='SilentlyContinue'; [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -UseBasicParsing -Uri '$(ps_squote "$url")' -OutFile '$(ps_squote "$win_output")'" if has_command powershell.exe; then MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoProfile -Command "$ps_cmd" >/dev/null 2>&1 && return 0 fi if has_command pwsh; then MSYS2_ARG_CONV_EXCL='*' pwsh -NoProfile -Command "$ps_cmd" >/dev/null 2>&1 && return 0 fi return 1 } download_file() { url="$1" output="$2" # On Windows (Git Bash / MSYS / Cygwin) the `curl`/`wget` on PATH are usually # the native .exe builds, which need a native Windows destination path and can # trip over Schannel TLS. Prefer PowerShell, then fall back to curl/wget with a # converted path. Each attempt is verified to have produced a non-empty file so # a downloader that "succeeds" without writing anything is treated as failure. # # CLIPCAT_DOWNLOADER=powershell|curl|wget forces a single method (skipping the # others) so each code path can be tested in isolation; unset = try all in # order. On success we print which method was used. case "${PLATFORM:-}" in windows_*) win_output="$(to_windows_path "$output")" forced="${CLIPCAT_DOWNLOADER:-}" if [ -z "$forced" ] || [ "$forced" = "powershell" ]; then if download_with_powershell "$url" "$win_output" && [ -s "$output" ]; then say " (downloaded via PowerShell)" return 0 fi [ "$forced" = "powershell" ] && return 1 fi if { [ -z "$forced" ] || [ "$forced" = "curl" ]; } && has_command curl; then if curl -fsSL "$url" -o "$win_output" >/dev/null 2>&1 && [ -s "$output" ]; then say " (downloaded via curl)" return 0 fi [ "$forced" = "curl" ] && return 1 fi if { [ -z "$forced" ] || [ "$forced" = "wget" ]; } && has_command wget; then if wget -qO "$win_output" "$url" >/dev/null 2>&1 && [ -s "$output" ]; then say " (downloaded via wget)" return 0 fi [ "$forced" = "wget" ] && return 1 fi return 1 ;; esac if has_command curl; then curl -fsSL "$url" -o "$output" return fi if has_command wget; then wget -qO "$output" "$url" return fi fail "curl or wget is required for installation" } append_line_if_missing() { file="$1" line="$2" mkdir -p "$(dirname "$file")" touch "$file" if ! grep -Fqx "$line" "$file"; then printf '\n%s\n' "$line" >>"$file" fi } pick_unix_rc_file() { shell_name="$(basename "${SHELL:-}")" case "$shell_name" in zsh) printf '%s\n' "${ZDOTDIR:-$HOME}/.zshrc" ;; bash) if [ -f "$HOME/.bashrc" ] || [ ! -f "$HOME/.bash_profile" ]; then printf '%s\n' "$HOME/.bashrc" else printf '%s\n' "$HOME/.bash_profile" fi ;; *) printf '%s\n' "$HOME/.profile" ;; esac } ensure_unix_path() { install_dir="$1" case ":$PATH:" in *":$install_dir:"*) say "PATH already contains $install_dir" return ;; esac rc_file="$(pick_unix_rc_file)" append_line_if_missing "$rc_file" "export PATH=\"$install_dir:\$PATH\"" say "Added $install_dir to PATH in: $rc_file" say "Please restart your terminal or run: export PATH=\"$install_dir:\$PATH\"" } # Windows (Git Bash / MSYS / Cygwin): persist INSTALL_DIR into the *user* PATH # via PowerShell's Environment API (idempotent). Editing rc files like the unix # path does nothing on Windows, so without this the binary installs but `clipcat` # is not callable by name. All failures are non-fatal: we fall back to printing # a manual instruction. ensure_windows_path() { install_dir="$1" # Native Windows form (C:\Users\...\bin) for both PATH storage and display. win_dir="$install_dir" if has_command cygpath; then win_dir="$(cygpath -w "$install_dir" 2>/dev/null || printf '%s' "$install_dir")" fi updated=0 if has_command powershell.exe; then # -notcontains is case-insensitive, matching Windows PATH semantics. ps_cmd="\$d='$(ps_squote "$win_dir")'; \$p=[Environment]::GetEnvironmentVariable('Path','User'); if(-not \$p){\$p=''}; if((\$p -split ';') -notcontains \$d){[Environment]::SetEnvironmentVariable('Path', \$p.TrimEnd(';')+';'+\$d,'User')}" if MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoProfile -Command "$ps_cmd" >/dev/null 2>&1; then updated=1 fi fi if [ "$updated" = "1" ]; then say "Added $win_dir to your Windows user PATH" say "Open a NEW terminal for the PATH change to take effect." else say "Could not auto-update PATH. Add this directory to your PATH manually:" say " $win_dir" fi } # True when running inside WSL (uname reports Linux there, so the script installs # the Linux binary into the WSL filesystem — correct for a WSL-resident agent, # but invisible to a native-Windows agent). is_wsl() { case "$(uname -r 2>/dev/null)" in *icrosoft*|*WSL*) return 0 ;; esac if [ -f /proc/sys/kernel/osrelease ] && \ grep -qiE "microsoft|wsl" /proc/sys/kernel/osrelease 2>/dev/null; then return 0 fi return 1 } clear_macos_quarantine() { target="$1" if ! has_command xattr; then return fi xattr -d com.apple.quarantine "$target" >/dev/null 2>&1 || true } detect_platform() { os="$(uname -s)" arch="$(uname -m)" case "$os" in Darwin) case "$arch" in x86_64) PLATFORM="darwin_amd64" DOWNLOAD_NAME="${PROGRAM_NAME}_darwin_amd64" TARGET_NAME="$PROGRAM_NAME" DEFAULT_INSTALL_DIR="${CLIPCAT_INSTALL_DIR:-$HOME/.local/bin}" ;; arm64|aarch64) PLATFORM="darwin_arm64" DOWNLOAD_NAME="${PROGRAM_NAME}_darwin_arm64" TARGET_NAME="$PROGRAM_NAME" DEFAULT_INSTALL_DIR="${CLIPCAT_INSTALL_DIR:-$HOME/.local/bin}" ;; *) fail "Unsupported macOS architecture: $arch" ;; esac ;; Linux) case "$arch" in x86_64) PLATFORM="linux_amd64" DOWNLOAD_NAME="${PROGRAM_NAME}_linux_amd64" TARGET_NAME="$PROGRAM_NAME" DEFAULT_INSTALL_DIR="${CLIPCAT_INSTALL_DIR:-$HOME/.local/bin}" ;; aarch64|arm64) PLATFORM="linux_arm64" DOWNLOAD_NAME="${PROGRAM_NAME}_linux_arm64" TARGET_NAME="$PROGRAM_NAME" DEFAULT_INSTALL_DIR="${CLIPCAT_INSTALL_DIR:-$HOME/.local/bin}" ;; *) fail "Unsupported Linux architecture: $arch" ;; esac ;; MINGW*|MSYS*|CYGWIN*) case "$arch" in x86_64|amd64) PLATFORM="windows_amd64" DOWNLOAD_NAME="${PROGRAM_NAME}_windows_amd64.exe" TARGET_NAME="${PROGRAM_NAME}.exe" DEFAULT_INSTALL_DIR="${CLIPCAT_INSTALL_DIR:-$HOME/bin}" ;; *) fail "Unsupported Windows architecture: $arch" ;; esac ;; *) fail "Unsupported OS: $os" ;; esac DOWNLOAD_URL="${DOWNLOAD_BASE}/${DOWNLOAD_NAME}" INSTALL_DIR="$DEFAULT_INSTALL_DIR" } # Download SKILL.md once, then copy it into every installed agent's skill # directory plus the neutral ~/.agents/skills fallback. Each agent is checked # independently, so all present agents receive a copy. install_skill_everywhere() { tmp_skill="$1" if [ ! -f "$tmp_skill" ]; then say "Warning: SKILL.md not available, skipping skill installation" return fi installed_any=0 # One line per agent: "|" agents=" $HOME/.claude|$HOME/.claude/skills/$SKILL_NAME $HOME/.openclaw|$HOME/.openclaw/skills/$SKILL_NAME $HOME/.codex|$HOME/.codex/skills/$SKILL_NAME $HOME/.workbuddy|$HOME/.workbuddy/skills/$SKILL_NAME $HOME/.gemini|$HOME/.gemini/skills/$SKILL_NAME $HOME/.config/opencode|$HOME/.config/opencode/skills/$SKILL_NAME $HOME/.config/goose|$HOME/.config/goose/skills/$SKILL_NAME " # On Windows the XDG-style agents (~/.config/*) usually store config under # %APPDATA% instead. Probe those too. Non-existent dirs are skipped below, so # adding extra candidates can only help — it never breaks the unix behaviour. case "${PLATFORM:-}" in windows_*) win_appdata="${APPDATA:-}" if [ -n "$win_appdata" ] && has_command cygpath; then win_appdata="$(cygpath -u "$win_appdata" 2>/dev/null || printf '%s' "$win_appdata")" fi if [ -n "$win_appdata" ]; then agents="$agents $win_appdata/opencode|$win_appdata/opencode/skills/$SKILL_NAME $win_appdata/goose|$win_appdata/goose/skills/$SKILL_NAME " fi ;; esac # Feed the loop with a here-doc rather than `printf | while`: a piped while # runs in a subshell, so installed_any would be lost when the pipe ends. The # here-doc keeps the loop in the current shell so the flag survives. while IFS='|' read -r home dest; do [ -z "$home" ] && continue if [ -d "$home" ]; then mkdir -p "$dest" cp "$tmp_skill" "$dest/SKILL.md" say "Installed clipcat skill -> $dest" installed_any=1 fi done < $agents_fallback (universal fallback)" else say "Skipped ~/.agents/skills fallback (already installed to explicit agent dirs)" fi } main() { detect_platform if is_wsl; then say "Note: detected WSL -- installing into the WSL (Linux) environment." say "If your AI agent is a native Windows app, run this installer from a Windows shell (Git Bash / PowerShell) instead." fi tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT mkdir -p "$INSTALL_DIR" tmp_binary="$tmp_dir/$TARGET_NAME" target_path="$INSTALL_DIR/$TARGET_NAME" say "Downloading $DOWNLOAD_URL" if ! download_file "$DOWNLOAD_URL" "$tmp_binary" || [ ! -s "$tmp_binary" ]; then fail "Failed to download $DOWNLOAD_URL (no data written). Check your network/proxy and try again." fi say "Installing to $target_path" case "$PLATFORM" in windows_*) # Windows cannot delete/overwrite a running exe -- and during # `clipcat update` the running clipcat.exe is exactly this target -- but # it CAN be renamed. Move the old binary aside first (the running process # keeps working from the renamed file), then drop the new one in place. # The stale .old from a previous update is removed best-effort: it only # deletes once no process holds it. if [ -f "$target_path" ]; then rm -f "$target_path.old" 2>/dev/null || true mv -f "$target_path" "$target_path.old" 2>/dev/null || true fi ;; esac mv "$tmp_binary" "$target_path" || fail "Could not write $target_path (is another program locking it?)" chmod +x "$target_path" say "Downloading SKILL.md" tmp_skill="$tmp_dir/SKILL.md" if download_file "$SKILL_URL" "$tmp_skill"; then install_skill_everywhere "$tmp_skill" else say "Warning: failed to download SKILL.md (non-fatal)" fi case "$PLATFORM" in darwin_*|linux_*) ensure_unix_path "$INSTALL_DIR" ;; windows_*) ensure_windows_path "$INSTALL_DIR" ;; esac if [ "$(uname -s)" = "Darwin" ]; then clear_macos_quarantine "$target_path" fi say "" say "clipcat installed to: $target_path" say "" say "Next step -- configure your API key:" say " clipcat config --api-key --base-url https://clipcat.ai" say "" say "Get your API key at: https://clipcat.ai/workspace?modal=settings&tab=apikeys" say "" say "Quick start:" say " clipcat -h" say " clipcat search --query 'fashion' --limit 5" say "" say "Tip: restart your AI agent (Codex / Claude Code / etc.) so it rescans skills." } main "$@"