#!/bin/sh # Installs Cueball: curl -fsSL https://cueball.dev/install.sh | sh # # What it does, all in your home folder (nothing needs sudo): # 1. downloads the latest release for your Mac's chip and checks its SHA-256 # 2. unpacks it into ~/.local/share/cueball/versions// and points # ~/.local/share/cueball/current at it (the one before: previous), and # ~/.local/bin/cueball at current/cueball # 3. checks for what Cueball needs (tmux, a coding agent) and says how to get what's missing # 4. runs `cueball setup`, which asks before changing anything # It never edits your shell's startup files. `cueball uninstall` takes it all out again. # # CUEBALL_RELEASES_URL: where the releases are (https://cueball.dev/releases). # CUEBALL_NO_SETUP=1: stop after installing (also when there's no terminal to ask in). set -eu RELEASES=${CUEBALL_RELEASES_URL:-https://cueball.dev/releases} SHARE="$HOME/.local/share/cueball" BIN="$HOME/.local/bin" say() { printf '%s\n' "$@"; } die() { printf 'cueball install: %s\n' "$*" >&2; exit 1; } [ "$(uname -s)" = Darwin ] || die "Cueball runs on macOS only, for now." case "$(uname -m)" in arm64) arch=arm64 ;; # An Apple silicon Mac in a Rosetta terminal says x86_64: it gets the arm64 build all the same. x86_64) if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = 1 ]; then arch=arm64; else arch=x64; fi ;; *) die "unknown chip: $(uname -m)" ;; esac tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT # The feed: latest.json names the version and each chip's file and checksum (macOS's plutil reads JSON). curl -fsSL "$RELEASES/latest.json" -o "$tmp/latest.json" || die "couldn't fetch $RELEASES/latest.json" field() { plutil -extract "$1" raw -o - "$tmp/latest.json" 2>/dev/null; } version=$(field version) || die "latest.json has no version" url=$(field "assets.darwin-$arch.url") || die "there's no release for this Mac (darwin-$arch) yet" sha=$(field "assets.darwin-$arch.sha256") || die "latest.json has no checksum for darwin-$arch" case "$url" in *://*) ;; *) url="$RELEASES/$url" ;; esac say "Downloading Cueball $version ($arch)…" curl -fSL --progress-bar "$url" -o "$tmp/cueball.tar.gz" || die "the download failed: $url" got=$(shasum -a 256 "$tmp/cueball.tar.gz" | cut -d' ' -f1) [ "$got" = "$sha" ] || die "the download's checksum is wrong (expected $sha, got $got): nothing was installed" # A cueball of yours already there: a link (a checkout's bin/cueball) is replaced, and said; a file isn't. was=$(readlink "$BIN/cueball" 2>/dev/null || true) [ -e "$BIN/cueball" ] && [ -z "$was" ] && die "$BIN/cueball is a file of yours: move it away and run this again" # Unpacked beside the other versions, then the links switched in one step each (a link made under # a temporary name, then renamed over the old one), so a failure leaves the old version working. mkdir -p "$SHARE/versions" "$BIN" tar -xzf "$tmp/cueball.tar.gz" -C "$tmp" [ -x "$tmp/cueball-$version/cueball" ] || die "the download doesn't hold cueball-$version/cueball" previous=$(readlink "$SHARE/current" 2>/dev/null || true) rm -rf "$SHARE/versions/$version.old" [ -e "$SHARE/versions/$version" ] && mv "$SHARE/versions/$version" "$SHARE/versions/$version.old" mv "$tmp/cueball-$version" "$SHARE/versions/$version" rm -rf "$SHARE/versions/$version.old" ln -sfn "versions/$version" "$SHARE/current.new" && mv -fh "$SHARE/current.new" "$SHARE/current" # The one it replaced becomes `previous` (what `cueball update --rollback` goes back to). if [ -n "$previous" ] && [ "$previous" != "versions/$version" ]; then ln -sfn "$previous" "$SHARE/previous.new" && mv -fh "$SHARE/previous.new" "$SHARE/previous" fi previous=$(readlink "$SHARE/previous" 2>/dev/null || true) ln -sfn "$SHARE/current/cueball" "$BIN/cueball.new" && mv -fh "$BIN/cueball.new" "$BIN/cueball" [ -n "$was" ] && [ "$was" != "$SHARE/current/cueball" ] && say "($BIN/cueball pointed at $was; it's the installed one now)" # Kept: this version and `previous` (to go back to); older ones go. for dir in "$SHARE/versions"/*; do name=${dir##*/} [ "$name" = "$version" ] || [ "versions/$name" = "$previous" ] || rm -rf "$dir" done say "Installed Cueball $version in $SHARE/versions/$version" # What Cueball needs that isn't Cueball's to install. missing=0 if ! command -v tmux >/dev/null 2>&1; then say "" "Cueball runs in tmux, which isn't installed: brew install tmux" missing=1 else tmux_version=$(tmux -V | sed 's/[^0-9.]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/') case "$tmux_version" in [0-2].* | 3.[0-7]) say "" "tmux $tmux_version works, but Cueball's classic look needs 3.8 or later: brew upgrade tmux" ;; esac fi if ! command -v claude >/dev/null 2>&1 && ! command -v codex >/dev/null 2>&1; then say "" "Cueball talks to Claude Code or Codex, and neither is installed. Either one:" \ " Claude Code: curl -fsSL https://claude.ai/install.sh | bash" \ " Codex: brew install codex" fi if [ "$missing" = 1 ] && ! command -v brew >/dev/null 2>&1; then say " (brew is Homebrew: https://brew.sh)" fi # ~/.local/bin on the PATH: said, never done for you. case ":$PATH:" in *":$BIN:"*) cueball="cueball" ;; *) cueball="$BIN/cueball" say "" "$BIN isn't on your PATH. Add this line to your shell's startup file (~/.zshrc for zsh):" \ " export PATH=\"\$HOME/.local/bin:\$PATH\"" ;; esac # Setup asks its questions in this terminal (curl | sh has the script on its input, so it reads # the terminal itself); with no terminal, or CUEBALL_NO_SETUP, it's left for later. if [ -z "${CUEBALL_NO_SETUP:-}" ] && [ "$missing" = 0 ] && (: /dev/null; then say "" "$BIN/cueball" setup