#!/usr/bin/env bash # # scripts/install.sh — one-liner installer for the cass-tui binary. # # Served at https://cass.tools/install and invoked via: # # curl -fsSL https://cass.tools/install | bash # # Flow (closed-beta, manifest-driven): # # 1. Detect OS + arch, map to a Rust target triple. # 2. Fetch https://cass.tools/manifest.json — the authoritative release # metadata (version, per-target url + sha256). # 3. Extract the url + sha256 for this machine's target. # 4. Download the binary to a tempfile. # 5. Verify SHA-256 matches the manifest entry (primary integrity check). # 6. Verify ELF / Mach-O magic bytes (secondary check — catches CDN 404 # HTML pages served with HTTP 200). # 7. chmod +x, atomic rename into the install dir, print next steps. # # DESIGN NOTES (Micheline-grade): # # 1. set -euo pipefail — fail fast and loudly. Silent partial success on an # install is the worst outcome; a mid-download crash should leave the # user with the OLD binary, not a half-written new one. # # 2. Platform detection via `uname -s` and `uname -m`. Each supported # (os, arch) pair is listed explicitly in the case statement. Anything # unmatched errors out with a clear message pointing at cass.tools/issues # — better than silently downloading the wrong binary and failing on # first run with an opaque "Exec format error". # # 3. $HOME/.local/bin is the default install dir because: # - It's in the default PATH on modern Linux distros (XDG spec). # - No sudo, so we don't need to prompt mid-curl-pipe. # - User-scoped install/uninstall doesn't affect other users. # Override with CASS_INSTALL_DIR for a different prefix. # # 4. Manifest parsing prefers jq (fast, purpose-built) with a Python 3 # fallback (universally available on modern Linux and macOS). If # neither is present the script errors out with an actionable install # hint — see parse_manifest(). We deliberately do NOT hand-roll a # pure-sh JSON parser; this script's integrity matters too much to trust # a 30-line regex hack with arbitrary server-side input. # # 5. SHA-256 verification is the PRIMARY integrity check. The manifest is # fetched over HTTPS from cass.tools (TLS + CF edge cert), and the # sha256 it contains is computed in CI on the same runner that built # the binary. A mismatched download or a CDN that swapped the binary # underneath us will fail here. # # The ELF / Mach-O magic byte check is a SECONDARY sanity check: it # catches the failure mode where a misconfigured CDN serves an HTML # error page with HTTP 200. sha256 would also catch that (the hash # of an HTML page is not the hash of the binary), but the magic byte # check is cheap and gives a clearer error message. # # 6. We do NOT automatically modify ~/.bashrc / ~/.zshrc to add the install # dir to PATH. If it isn't on PATH we print the exact export line the # user can add themselves. Quiet modification of shell config files is # invasive and a surprise; a warning is better than a surprise. # # 7. The script is idempotent — running it twice produces the same result # (same binary at the same path). This doubles as an upgrade command. set -euo pipefail BASE_URL="${CASS_BASE_URL:-https://cass.tools}" INSTALL_DIR="${CASS_INSTALL_DIR:-$HOME/.local/bin}" MANIFEST_URL="${BASE_URL}/manifest.json" # Platform detection: map (uname -s, uname -m) -> Rust target triple. # Keep in sync with crates/cass-tools/src/update.rs::target_triple() and # the CI release matrix in .github/workflows/release.yml. OS="$(uname -s | tr '[:upper:]' '[:lower:]')" ARCH="$(uname -m)" case "$OS-$ARCH" in linux-x86_64) TARGET="x86_64-unknown-linux-gnu" ;; linux-aarch64) TARGET="aarch64-unknown-linux-gnu" ;; linux-arm64) TARGET="aarch64-unknown-linux-gnu" ;; darwin-x86_64) TARGET="x86_64-apple-darwin" ;; darwin-arm64) TARGET="aarch64-apple-darwin" ;; *) echo "ERROR: Unsupported platform: $OS-$ARCH" >&2 echo "Supported: linux x86_64, linux aarch64, macos x86_64, macos arm64" >&2 echo "If you want a build for $OS-$ARCH, file an issue at https://cass.tools/issues" >&2 exit 1 ;; esac # parse_manifest # Extracts .binaries[]. from the manifest JSON passed on # stdin. Prints to stdout. Errors and exits the whole script if neither # jq nor python3 is present, because we refuse to hand-roll JSON parsing # for a critical integrity path. parse_manifest() { local target="$1" field="$2" if command -v jq >/dev/null 2>&1; then jq -r --arg t "$target" --arg f "$field" '.binaries[$t][$f] // empty' elif command -v python3 >/dev/null 2>&1; then python3 -c " import sys, json try: data = json.load(sys.stdin) entry = data['binaries'].get('$target', {}) value = entry.get('$field', '') print(value) except Exception as e: sys.stderr.write(f'manifest parse error: {e}\n') sys.exit(1) " else echo "ERROR: this installer needs jq or python3 to parse the release manifest" >&2 echo "Install one of:" >&2 echo " Debian/Ubuntu : sudo apt install jq" >&2 echo " Fedora/RHEL : sudo dnf install jq" >&2 echo " macOS : brew install jq" >&2 echo " (python3 is also acceptable and usually pre-installed)" >&2 exit 1 fi } echo "cass-tui installer" echo " platform : $OS-$ARCH ($TARGET)" echo " manifest : $MANIFEST_URL" echo # Fetch manifest.json into a variable so we can parse it multiple times # (once for version, once for url, once for sha256) without three HTTP # round-trips. The manifest is tiny (< 2KB) so the memory cost is nil. MANIFEST="$(curl -fsSL "$MANIFEST_URL" 2>&1)" || { echo "ERROR: failed to fetch manifest from $MANIFEST_URL" >&2 echo "Details: $MANIFEST" >&2 echo "Check your connection or report at https://cass.tools/issues" >&2 exit 1 } VERSION="$(echo "$MANIFEST" | parse_manifest "$TARGET" version || true)" if [ -z "$VERSION" ]; then # Fall back to top-level .version if per-target version isn't set. if command -v jq >/dev/null 2>&1; then VERSION="$(echo "$MANIFEST" | jq -r '.version // empty')" elif command -v python3 >/dev/null 2>&1; then VERSION="$(echo "$MANIFEST" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version',''))")" fi fi URL="$(echo "$MANIFEST" | parse_manifest "$TARGET" url)" SHA256="$(echo "$MANIFEST" | parse_manifest "$TARGET" sha256)" # INSTALL-TERM-MANIFEST-01 (2026-05-05): manifest may also expose a # cass-term binary (Vulkan-rendered terminal sibling). v0.2.98 retrofit: # install.sh fetches both `cass` and `cass-term` so the sibling-exec # logic in cass main.rs (TERM-REEXEC-01) can find cass-term in the same # install dir. Backward-compatible: if the manifest doesn't expose # `term_url`/`term_sha256`, we just skip cass-term and the user gets a # working `cass` that runs the TUI directly (silent fall-through path # in main.rs, line ~171). Old auto-updaters ignore these fields. TERM_URL="$(echo "$MANIFEST" | parse_manifest "$TARGET" term_url || true)" TERM_SHA256="$(echo "$MANIFEST" | parse_manifest "$TARGET" term_sha256 || true)" if [ -z "$URL" ] || [ -z "$SHA256" ]; then echo "ERROR: manifest has no entry for target $TARGET" >&2 echo "Manifest URL: $MANIFEST_URL" >&2 echo "This usually means the release hasn't published a binary for your" >&2 echo "platform yet. File an issue at https://cass.tools/issues." >&2 exit 1 fi # INSTALL-NAME-FIX-01 (2026-05-05): the `[[bin]]` section of crates/cass/Cargo.toml # names the binary `cass`, NOT `cass-tui`. Pre-fix, install.sh saved the binary # as `~/.local/bin/cass-tui`, which meant users had no `cass` command at all # (Jake reported: "users run `cass` after installing — it isn't there"). The # `cass-tui` name lives only as the binary's --version self-name (CLI legacy). # Fix: install as `cass`, then create a `cass-tui` symlink for the legacy name. # Both invocations route to the same binary, which does its own sibling-cass-term # detection and re-exec at startup. DEST="${INSTALL_DIR}/cass" LEGACY_DEST="${INSTALL_DIR}/cass-tui" echo " version : $VERSION" echo " cass url : $URL" echo " sha256 : $SHA256" if [ -n "$TERM_URL" ] && [ -n "$TERM_SHA256" ]; then echo " term url : $TERM_URL" echo " term sha : $TERM_SHA256" fi echo " dest : $DEST (+ symlink at $LEGACY_DEST)" echo mkdir -p "$INSTALL_DIR" # Download to a temp file first so a partial/failed download doesn't leave # a corrupt binary at the destination. The tempfile lives in the same # directory as the destination so the final mv is an atomic rename — # a cross-filesystem mv would fall back to copy+delete which is NOT atomic # and leaves a window where the binary is half-written. TMP="$(mktemp "${INSTALL_DIR}/cass.XXXXXX")" TMP_TERM="" # WHY trap removes both tempfiles: we may have created TMP_TERM by the time # a downstream step fails. Cleanup must cover the union, not just TMP. trap 'rm -f "$TMP" "$TMP_TERM"' EXIT if ! curl -fsSL "$URL" -o "$TMP"; then echo "ERROR: download failed from $URL" >&2 echo "Check your connection or report at https://cass.tools/issues" >&2 exit 1 fi # PRIMARY integrity check: SHA-256 against the manifest entry. # The sha256 was computed in CI on the same runner that built this binary. # If this check fails, EITHER the CDN served a different file than what # was uploaded (mitm / cache poisoning) OR the manifest is out of sync # with the binary (broken release). Either way, abort. if command -v sha256sum >/dev/null 2>&1; then ACTUAL_SHA="$(sha256sum "$TMP" | awk '{print $1}')" elif command -v shasum >/dev/null 2>&1; then # macOS ships shasum (perl) but not sha256sum in the default path. ACTUAL_SHA="$(shasum -a 256 "$TMP" | awk '{print $1}')" else echo "ERROR: neither sha256sum nor shasum is available - cannot verify binary integrity" >&2 echo "Refusing to install an unverified binary." >&2 exit 1 fi if [ "$ACTUAL_SHA" != "$SHA256" ]; then echo "ERROR: sha256 mismatch - refusing to install" >&2 echo " expected: $SHA256" >&2 echo " actual : $ACTUAL_SHA" >&2 echo "This means the downloaded binary does not match the release manifest." >&2 echo "Your network or CDN may be serving a tampered or stale file." >&2 exit 1 fi # SECONDARY sanity check: magic bytes. The primary check (sha256) already # caught any wrong-file condition, but this produces a clearer error if # the CDN is serving an HTML error page with HTTP 200 — "expected ELF, # got HTML" is much more actionable than a bare hash mismatch. MAGIC="$(head -c 4 "$TMP" | od -An -tx1 | tr -d ' \n')" case "$OS-$MAGIC" in linux-7f454c46) # ELF magic - looks valid ;; darwin-cffaedfe|darwin-cefaedfe|darwin-feedface|darwin-feedfacf) # Mach-O magic in either endianness - looks valid ;; *) echo "ERROR: downloaded file is not a valid binary for $OS" >&2 echo "Got magic bytes: $MAGIC (expected ELF or Mach-O)" >&2 echo "The CDN may have served a 404 page with the wrong content type," >&2 echo "but the sha256 check above also passed - this is inconsistent." >&2 echo "Report at https://cass.tools/issues with this output." >&2 exit 1 ;; esac chmod +x "$TMP" mv "$TMP" "$DEST" # INSTALL-LEGACY-SYMLINK-01 (2026-05-05): create `cass-tui` as a symlink to # `cass` so users who type the legacy name get the same binary. `ln -sf` # overwrites any existing target — important for upgrade-from-pre-fix-install # where the user has an OLD `cass-tui` ELF binary at LEGACY_DEST that needs # to be replaced with a symlink pointing at the new `cass`. ln -sf cass "$LEGACY_DEST" # INSTALL-TERM-FETCH-01 (2026-05-05): if the manifest exposed a cass-term # entry, fetch + verify + install it as a sibling. Same integrity discipline # as the `cass` binary: sha256 against manifest entry, ELF/Mach-O magic byte # check, atomic rename. Skipped silently if the manifest doesn't expose # cass-term — older manifests pre-2026-05-05 won't have it, and the cass # binary's TERM-REEXEC-01 logic falls through cleanly when no sibling exists # (silent fall-through to the current terminal — no error noise). if [ -n "$TERM_URL" ] && [ -n "$TERM_SHA256" ]; then TERM_DEST="${INSTALL_DIR}/cass-term" TMP_TERM="$(mktemp "${INSTALL_DIR}/cass-term.XXXXXX")" echo echo "Fetching cass-term sibling (Vulkan-rendered terminal)..." if ! curl -fsSL "$TERM_URL" -o "$TMP_TERM"; then echo "WARNING: cass-term download failed from $TERM_URL" >&2 echo " continuing without cass-term — cass will run TUI directly" >&2 # WHY warning not fatal: cass works fine without cass-term (silent # fall-through path in main.rs). The user just doesn't get the # Vulkan-rendered background; TUI is fully functional. rm -f "$TMP_TERM" TMP_TERM="" else # PRIMARY integrity: sha256. if command -v sha256sum >/dev/null 2>&1; then ACTUAL_TERM_SHA="$(sha256sum "$TMP_TERM" | awk '{print $1}')" elif command -v shasum >/dev/null 2>&1; then ACTUAL_TERM_SHA="$(shasum -a 256 "$TMP_TERM" | awk '{print $1}')" else ACTUAL_TERM_SHA="missing-hash-tools" fi if [ "$ACTUAL_TERM_SHA" != "$TERM_SHA256" ]; then echo "WARNING: cass-term sha256 mismatch — refusing to install cass-term" >&2 echo " expected: $TERM_SHA256" >&2 echo " actual : $ACTUAL_TERM_SHA" >&2 echo " cass will run TUI directly (no Vulkan terminal)" >&2 rm -f "$TMP_TERM" TMP_TERM="" else # SECONDARY: ELF/Mach-O magic byte check (same logic as cass binary). TERM_MAGIC="$(head -c 4 "$TMP_TERM" | od -An -tx1 | tr -d ' \n')" case "$OS-$TERM_MAGIC" in linux-7f454c46) chmod +x "$TMP_TERM" mv "$TMP_TERM" "$TERM_DEST" TMP_TERM="" echo "Installed cass-term sibling at $TERM_DEST" ;; darwin-cffaedfe|darwin-cefaedfe|darwin-feedface|darwin-feedfacf) chmod +x "$TMP_TERM" mv "$TMP_TERM" "$TERM_DEST" TMP_TERM="" echo "Installed cass-term sibling at $TERM_DEST" ;; *) echo "WARNING: cass-term magic bytes invalid ($TERM_MAGIC) — skipping" >&2 rm -f "$TMP_TERM" TMP_TERM="" ;; esac fi fi fi trap - EXIT echo echo "Installed cass $VERSION to $DEST" echo " legacy alias: $LEGACY_DEST → cass" if [ -x "${INSTALL_DIR}/cass-term" ]; then echo " cass-term sibling: ${INSTALL_DIR}/cass-term" fi echo # INSTALL-PATH-EXPORT-01 (2026-05-05): auto-append a PATH export to the user's # shell rc files when $INSTALL_DIR isn't already on PATH. Pre-fix (b3937 and # earlier) the script printed a "NOTE: not in PATH" warning and asked users # to add the line themselves. Real-world result: users typed `cass` after # install, got "command not found, but can be installed with snap install cass" # (which is a DIFFERENT, unrelated project — PHP cass) — they never made it # past first launch. Every modern curl|bash installer (rustup, uv, cargo- # binstall) modifies shell rc files for exactly this reason. The alternative — # trust the user to read the warning and act on it — is empirically broken. # # What we add to the rc file is idempotent: a single block guarded by a marker # comment and a path-already-present check, so re-running install.sh doesn't # stack duplicate exports. ensure_path_in_rc() { # Args: $1 = path to rc file (e.g. ~/.bashrc) local rcfile="$1" # Skip silently if the rc file's parent directory doesn't exist yet # (e.g. zshrc on a system without zsh installed). [ -d "$(dirname "$rcfile")" ] || return 0 # Marker grep: if our block is already there, do nothing. if [ -f "$rcfile" ] && grep -q "# >>> cass installer PATH >>>" "$rcfile"; then return 0 fi # Append the block. Touch first so the redirect creates a file with # default mode if it doesn't exist (avoids the bash quirk where >> on a # missing file in some setups carries odd modes). touch "$rcfile" { echo "" echo "# >>> cass installer PATH >>>" echo "# Added by https://cass.tools/install on $(date -u +%Y-%m-%d)." echo "# Adds ~/.local/bin (the cass install dir) to PATH if not already present." echo "if [ -d \"$INSTALL_DIR\" ] && [[ \":\$PATH:\" != *\":$INSTALL_DIR:\"* ]]; then" echo " export PATH=\"$INSTALL_DIR:\$PATH\"" echo "fi" echo "# <<< cass installer PATH <<<" } >> "$rcfile" } # Always run for both bashrc and zshrc — even if the user's current shell # is bash, they may switch to zsh later (or vice versa). Idempotent on both. ensure_path_in_rc "$HOME/.bashrc" ensure_path_in_rc "$HOME/.zshrc" case ":$PATH:" in *":$INSTALL_DIR:"*) # Already on PATH in this shell — nothing more to do. echo "Run 'cass --help' to verify (or the legacy name 'cass-tui')." ;; *) # Not on PATH in CURRENT shell. Future shells will get it from the # rc-file edit above. For THIS shell, the user needs to either open # a new terminal or run the export manually — there's no way to # mutate the parent shell's environment from inside a subshell # (which is what curl|bash is running in). echo "Added $INSTALL_DIR to PATH in ~/.bashrc and ~/.zshrc." echo echo "For this shell session only, run:" echo echo " export PATH=\"$INSTALL_DIR:\$PATH\"" echo echo "Or open a new terminal — new shells pick up the rc-file change" echo "automatically. Then run: cass" ;; esac # ── Arbiter setup: ollama + gemma3:4b ──────────────────────────────── # # WHY: cass includes a constitutional arbiter that uses a local Gemma # model via Ollama to independently evaluate responses for faithful # reporting. Without it, the arbiter silently skips (non-blocking) # and the user loses cross-model verification. Installing ollama + # gemma3:4b here gives a working arbiter on first launch. # # DESIGN: entirely optional — if ollama is already installed we skip # the install. If the user declines or ctrl-c's, cass still works # (arbiter just doesn't fire). gemma3:4b is 3.3 GB on disk; the # prompt warns about the size before pulling. echo echo "── Arbiter Setup ──" echo # ARBITER-INSTALL-01: check for existing ollama if command -v ollama >/dev/null 2>&1; then echo "ollama already installed: $(ollama --version 2>&1 | head -1)" OLLAMA_INSTALLED=true else echo "The cass arbiter uses a local Gemma model (via Ollama) to" echo "independently verify AI responses for faithful reporting." echo echo "This step installs Ollama (~50 MB) and pulls gemma3:4b (~3.3 GB)." echo "It's optional — cass works without it, the arbiter just won't fire." echo printf "Install ollama + gemma3:4b? [y/N] " read -r ARBITER_ANSWER < /dev/tty 2>/dev/null || ARBITER_ANSWER="n" case "$ARBITER_ANSWER" in [yY]|[yY][eE][sS]) echo "Installing Ollama..." # ARBITER-INSTALL-02: download and run the official ollama # installer. Uses sudo if available; if not, tries user-local # install. The installer is idempotent — safe to run twice. if curl -fsSL https://ollama.com/install.sh | bash; then OLLAMA_INSTALLED=true echo "Ollama installed." else echo "WARNING: Ollama install failed. Arbiter will be" echo "unavailable. You can install manually later:" echo " curl -fsSL https://ollama.com/install.sh | bash" OLLAMA_INSTALLED=false fi ;; *) echo "Skipping arbiter setup. Install later with:" echo " curl -fsSL https://ollama.com/install.sh | bash" echo " ollama pull gemma3:4b" OLLAMA_INSTALLED=false ;; esac fi # ARBITER-INSTALL-03: pull gemma3:4b if ollama is installed and model # isn't already present. The pull is ~3.3 GB on first run; subsequent # runs are a no-op (ollama checks the local manifest). if [ "$OLLAMA_INSTALLED" = true ]; then # Start ollama serve in background if it's not already running. # Some installs register a systemd service; others don't. if ! curl -sf http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then echo "Starting Ollama in background..." nohup ollama serve >/dev/null 2>&1 & # ARBITER-INSTALL-04: wait for ollama to be ready (up to 10s). # The serve process needs a moment to bind the port. for i in $(seq 1 10); do sleep 1 if curl -sf http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then break fi done fi # Check if gemma3:4b is already pulled if ollama list 2>/dev/null | grep -q "gemma3:4b"; then echo "gemma3:4b already available." else echo "Pulling gemma3:4b (~3.3 GB)..." if ollama pull gemma3:4b; then echo "Arbiter model ready." else echo "WARNING: gemma3:4b pull failed. Pull manually later:" echo " ollama pull gemma3:4b" fi fi fi echo echo "Done."