#!/bin/sh # Install or uninstall CatPaw, a headless-first browser for AI agents. # # curl -fsSL https://catpaw.sh/install.sh | sh # curl -fsSL https://catpaw.sh/install.sh | sh -s -- [options] # # It downloads a release from GitHub and checks it against the release's # SHA256SUMS, then shows what it will write and asks before writing # anything. The one file it writes is the catpaw binary, by default in # ~/.catpaw/bin. It needs no root and changes no shell profile. Running it # again upgrades in place. # # Options (a flag wins over its environment variable): # -y, --yes do not ask CATPAW_YES=1 # --dir DIR install into DIR CATPAW_INSTALL_DIR # --version vX.Y.Z install that release CATPAW_VERSION # --uninstall remove catpaw from the directory instead # -h, --help show this help # Also CATPAW_TARGET (a release target instead of this machine's) and # CATPAW_DOWNLOAD_BASE (the URL the release files are fetched from). # # https://github.com/KernelErr/CatPaw - Apache-2.0 OR MIT set -eu CATPAW_REPO="https://github.com/KernelErr/CatPaw" CATPAW_SCRIPT="https://catpaw.sh/install.sh" usage() { cat <&2 exit 1 } has() { command -v "$1" >/dev/null 2>&1 } # One line of a plan: a label and what it says. row() { printf ' %-13s%s\n' "$1" "$2" } # Whether a terminal can be asked: when piped into sh, stdin is the script. tty_ok() { (: /dev/null } # ask : asks on the terminal; yes only for y or yes. ask() { printf '%s [y/N] ' "$1" >/dev/tty ask_answer="" IFS= read -r ask_answer : as ask, but --yes answers it. confirm() { if [ -n "$yes" ]; then printf '%s [y/N] yes (--yes)\n' "$1" return 0 fi ask "$1" } # need_terminal : stops when there is nobody to ask. need_terminal() { if [ -z "$yes" ] && ! tty_ok; then die "there is no terminal to ask on. To go ahead without being asked, run curl -fsSL $CATPAW_SCRIPT | sh -s -- $1 or set CATPAW_YES=1." fi } on_windows_shell() { case "$(uname -s)" in MINGW* | MSYS* | CYGWIN* | Windows_NT) return 0 ;; *) return 1 ;; esac } # Prints the release target for this machine, or explains why there is none. detect_target() { dt_os=$(uname -s) dt_arch=$(uname -m) case "$dt_os" in Linux) case "$dt_arch" in x86_64 | amd64) echo x86_64-unknown-linux-gnu ;; aarch64 | arm64) echo aarch64-unknown-linux-gnu ;; *) die "there is no release for Linux on $dt_arch yet; build from source: $CATPAW_REPO#readme" ;; esac ;; Darwin) # A shell running under Rosetta reports x86_64 on Apple silicon. if [ "$dt_arch" = x86_64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = 1 ]; then dt_arch=arm64 fi case "$dt_arch" in arm64 | aarch64) echo aarch64-apple-darwin ;; x86_64) printf '%s\n' \ 'catpaw-install: there is no release for Intel Macs. Build from source instead:' \ '' \ " git clone $CATPAW_REPO" \ ' cd CatPaw' \ ' cargo build --release -p catpaw' \ '' \ 'This needs Rust 1.89 or later (https://rustup.rs) and the Xcode command line tools.' \ 'The binary is target/release/catpaw.' >&2 exit 1 ;; *) die "there is no release for macOS on $dt_arch" ;; esac ;; *) die "there is no release for $dt_os yet; build from source: $CATPAW_REPO#readme" ;; esac } # fetch [quiet] fetch() { fe_url=$1 fe_out=$2 fe_quiet=${3:-} if has curl; then set -- --fail --location --retry 3 --output "$fe_out" case "$fe_url" in https://*) set -- "$@" --proto =https --proto-redir =https --tlsv1.2 ;; *) ;; esac if [ -z "$fe_quiet" ] && [ -t 2 ]; then set -- "$@" --progress-bar else set -- "$@" --silent --show-error fi curl "$@" "$fe_url" elif has wget; then set -- -O "$fe_out" if [ -n "$fe_quiet" ] || [ ! -t 2 ]; then set -- "$@" -q fi wget "$@" "$fe_url" else die "neither curl nor wget is installed; install one of them and run this again" fi } sha256_of() { if has sha256sum; then sha256sum "$1" | awk '{ print $1 }' elif has shasum; then shasum -a 256 "$1" | awk '{ print $1 }' else die "neither sha256sum nor shasum is installed, so the download cannot be checked" fi } # Where CatPaw keeps its approval key: catpaw/ in the user's data directory. data_dir() { if [ "$(uname -s)" = Darwin ]; then if [ -n "${HOME:-}" ]; then printf '%s\n' "$HOME/Library/Application Support/catpaw"; fi elif [ -n "${XDG_DATA_HOME:-}" ]; then printf '%s\n' "$XDG_DATA_HOME/catpaw" elif [ -n "${HOME:-}" ]; then printf '%s\n' "$HOME/.local/share/catpaw" fi } # Prints the line that puts $1 on PATH for the user's shell. path_hint() { ph_dir=$1 case "$ph_dir" in "${HOME:-/nonexistent}"/*) ph_shown="\$HOME/${ph_dir#"$HOME"/}" ;; *) ph_shown=$ph_dir ;; esac ph_line="export PATH=\"$ph_shown:\$PATH\"" # The ~ is printed for the user to run, not expanded here. # shellcheck disable=SC2088 case "$(basename "${SHELL:-sh}")" in zsh) ph_rc="~/.zshrc" ;; bash) if [ "$(uname -s)" = Darwin ]; then ph_rc="~/.bash_profile"; else ph_rc="~/.bashrc"; fi ;; fish) printf ' fish_add_path %s\n' "$ph_dir" return ;; *) ph_rc="~/.profile" ;; esac printf " echo '%s' >> %s\n" "$ph_line" "$ph_rc" } parse_args() { opt_yes="" opt_dir="" opt_version="" uninstall="" while [ $# -gt 0 ]; do case "$1" in -y | --yes) opt_yes=1 ;; --dir) [ $# -ge 2 ] || die "--dir needs a directory" opt_dir=$2 shift ;; --dir=*) opt_dir=${1#--dir=} ;; --version) [ $# -ge 2 ] || die "--version needs a release tag such as v0.1.0" opt_version=$2 shift ;; --version=*) opt_version=${1#--version=} ;; --uninstall) uninstall=1 ;; -h | --help) usage exit 0 ;; *) die "unknown option: $1 (see --help)" ;; esac shift done yes=$opt_yes if [ -z "$yes" ]; then case "${CATPAW_YES:-}" in 1 | y | Y | yes | YES | true | TRUE) yes=1 ;; *) ;; esac fi install_dir=${opt_dir:-${CATPAW_INSTALL_DIR:-}} default_dir="" if [ -z "$install_dir" ]; then [ -n "${HOME:-}" ] || die "HOME is not set; pass --dir with the directory to use" install_dir="$HOME/.catpaw/bin" default_dir=1 fi case "$install_dir" in "~") install_dir=${HOME:-} ;; "~"/*) install_dir="${HOME:-}/${install_dir#"~"/}" ;; *) ;; esac case "$install_dir" in /*) ;; *) install_dir="$(pwd)/$install_dir" ;; esac while [ "${install_dir%/}" != "$install_dir" ] && [ "$install_dir" != / ]; do install_dir=${install_dir%/} done dest="$install_dir/catpaw" version=${opt_version:-${CATPAW_VERSION:-}} case "$version" in [0-9]*) version="v$version" ;; *) ;; esac } do_install() { if [ -n "${CATPAW_TARGET:-}" ]; then target=$CATPAW_TARGET elif on_windows_shell; then die "this is Windows: in PowerShell, run irm https://catpaw.sh/install.ps1 | iex" else target=$(detect_target) fi case "$target" in *-windows-*) die "$target is a Windows target: in PowerShell, run irm https://catpaw.sh/install.ps1 | iex" ;; *-linux-*) if [ -z "${CATPAW_TARGET:-}" ] && ls /lib/ld-musl-* >/dev/null 2>&1 && ! ls /lib*/ld-linux-* >/dev/null 2>&1; then say "warning: this looks like a musl system (such as Alpine); the release binaries need glibc" fi ;; *) ;; esac asset="catpaw-$target.tar.gz" if [ -n "${CATPAW_DOWNLOAD_BASE:-}" ]; then base=${CATPAW_DOWNLOAD_BASE%/} elif [ -n "$version" ]; then base="$CATPAW_REPO/releases/download/$version" else base="$CATPAW_REPO/releases/latest/download" fi case "$base" in https://*) ;; *) say "warning: $base is not https" ;; esac has tar || die "tar is not installed" has awk || die "awk is not installed" need_terminal "--yes" tmp=$(mktemp -d 2>/dev/null || mktemp -d -t catpaw-install) if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then die "could not make a temporary directory" fi trap 'rm -rf -- "$tmp"' EXIT trap 'exit 130' INT trap 'exit 143' TERM HUP # Download and check everything first; nothing goes into the install # directory until the user has said yes. say "downloading $base/$asset" fetch "$base/SHA256SUMS" "$tmp/SHA256SUMS" quiet || die "could not download $base/SHA256SUMS" fetch "$base/$asset" "$tmp/$asset" || die "could not download $base/$asset" expected=$(tr -d '\r' <"$tmp/SHA256SUMS" | awk -v name="$asset" '$2 == name || $2 == "*" name { print $1; exit }' | tr 'A-F' 'a-f') [ -n "$expected" ] || die "SHA256SUMS has no line for $asset" actual=$(sha256_of "$tmp/$asset") if [ "$actual" != "$expected" ]; then die "the checksum of $asset does not match SHA256SUMS (expected $expected, got $actual); nothing was installed" fi mkdir "$tmp/unpack" tar -xzf "$tmp/$asset" -C "$tmp/unpack" || die "could not unpack $asset (tar needs gzip)" binary="$tmp/unpack/catpaw-$target/catpaw" [ -f "$binary" ] || die "$asset does not hold catpaw-$target/catpaw" # Nothing downloaded runs before the user says yes. previous="" if [ -e "$dest" ]; then previous=$("$dest" --version 2>/dev/null || true) fi printf '\nCatPaw will be installed like this:\n\n' row "Download" "$base/$asset" row "" "SHA-256 $actual, as in SHA256SUMS" row "Release" "${version:-the latest}" row "Writes" "$dest" if [ ! -d "$install_dir" ]; then row "" "(creating $install_dir)"; fi if [ -e "$dest" ]; then row "Replaces" "the catpaw already there${previous:+ ($previous)}" fi row "Does not" "use sudo, change shell profiles or PATH, or write any other file" printf '\n' if ! confirm "Install?"; then say "nothing was installed" exit 0 fi mkdir -p "$install_dir" || die "could not create $install_dir" # Copy next to the old binary, then rename over it: a catpaw that is # running keeps the file it started from. cp "$binary" "$dest.new.$$" || die "could not write to $install_dir" chmod 755 "$dest.new.$$" if ! mv -f "$dest.new.$$" "$dest"; then rm -f "$dest.new.$$" die "could not replace $dest" fi current=$("$dest" --version 2>/dev/null || true) if [ -z "$current" ]; then say "installed $dest (it does not run on this machine; the target is $target)" elif [ -n "$previous" ] && [ "$previous" != "$current" ]; then say "replaced $previous with $current in $dest" else say "installed $current in $dest" fi command_name="catpaw" case ":${PATH:-}:" in *":$install_dir:"*) ;; *) command_name="$dest" printf '\n' say "$install_dir is not on your PATH. To add it, run" path_hint "$install_dir" printf ' and open a new terminal.\n' ;; esac printf '\n' say "next, register it with your agent host:" printf ' %s setup claude-code # prints the claude mcp add command\n' "$command_name" printf ' %s setup codex --write # adds it to ~/.codex/config.toml\n' "$command_name" printf ' %s setup cursor --write # adds it to ~/.cursor/mcp.json\n' "$command_name" printf ' More: https://catpaw.sh/#connect\n' } do_uninstall() { if [ -z "${CATPAW_TARGET:-}" ] && on_windows_shell; then die "this is Windows: in PowerShell, run & ([scriptblock]::Create((irm https://catpaw.sh/install.ps1))) -Uninstall" fi need_terminal "--uninstall --yes" if [ -e "$dest" ]; then installed=$("$dest" --version 2>/dev/null || true) printf '\nCatPaw will be removed like this:\n\n' row "Removes" "$dest${installed:+ ($installed)}" row "" "$install_dir, if nothing else is left in it" if [ -n "$default_dir" ]; then row "" "$HOME/.catpaw, if nothing else is left in it" fi row "Keeps" "everything else (CatPaw's own data is asked about next)" printf '\n' if ! confirm "Uninstall?"; then say "nothing was removed" exit 0 fi rm -f "$dest" || die "could not remove $dest" say "removed $dest" if rmdir "$install_dir" 2>/dev/null; then say "removed $install_dir" if [ -n "$default_dir" ] && rmdir "$HOME/.catpaw" 2>/dev/null; then say "removed $HOME/.catpaw" fi else say "kept $install_dir: other files are in it" fi else say "there is no catpaw in $install_dir" fi ddir=$(data_dir) printf '\n' if [ -n "$ddir" ] && [ -e "$ddir/approval-key" ]; then say "CatPaw's own data: its approval key, made the first time it was needed, is in" printf ' %s\n' "$ddir/approval-key" if [ -n "$yes" ] || ! tty_ok; then say "kept it (--yes does not delete data). To delete it yourself: rm -r \"$ddir\"" elif ask "Delete it too?"; then rm -f "$ddir/approval-key" || die "could not remove $ddir/approval-key" say "deleted $ddir/approval-key" if rmdir "$ddir" 2>/dev/null; then say "removed $ddir" else say "kept $ddir: other files are in it" fi else say "kept it" fi elif [ -n "$ddir" ]; then say "CatPaw's own data: there is no approval key at $ddir/approval-key" fi printf '\n' say "if you registered CatPaw with an agent host, remove it there yourself:" row "Claude Code" "claude mcp remove catpaw (or the catpaw entry in a project's .mcp.json)" row "Codex" "the [mcp_servers.catpaw] table in ~/.codex/config.toml" row "Cursor" "the catpaw entry in ~/.cursor/mcp.json" say "profiles and flight logs are wherever you pointed --profile and --flight-log; they were not touched" } main() { parse_args "$@" if [ -n "$uninstall" ]; then do_uninstall else do_install fi } main "$@"