#!/usr/bin/env bash # Panorama installer for macOS (Apple Silicon only). # # curl -fsSL $SITE/install.sh | bash ($SITE is set just below) # # Downloads the latest release zip, installs it to /Applications (or # ~/Applications), clears the quarantine flag that Gatekeeper puts on # un-notarized downloads, and adds a `pano` command: # # pano open the current folder as a workspace # pano ~/code/app open a specific folder # # Options (pass after `bash -s --` when piping): # --version 0.2.1 install a specific release # --with-agents also npm-install Claude Code, Codex and Gemini CLI # --no-launch don't open the app afterwards # -h, --help show this help # # curl -fsSL $SITE/install.sh | bash -s -- --version 0.2.1 set -euo pipefail # The site this installer talks to (manifest, download, licence). Change it when the domain does. SITE="https://getpanorama.app" APP="Panorama" VERSION="latest" WITH_AGENTS=0 LAUNCH=1 bold=$'\033[1m'; dim=$'\033[2m'; green=$'\033[32m'; red=$'\033[31m'; reset=$'\033[0m' say() { printf '%s\n' "${bold}›${reset} $*"; } die() { printf '%s\n' "${red}✖${reset} $*" >&2; exit 1; } # Printed from a heredoc rather than read back from $0: when piped into bash, $0 is "bash". usage() { cat <&2; usage >&2; exit 1 ;; esac done # Accept "v1.2.3" and "1.2.3"; reject anything else before it reaches a URL. VERSION="${VERSION#v}" if [ "$VERSION" != "latest" ] && ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then die "'$VERSION' is not a valid version. Use something like 0.2.1, or leave --version off for the latest." fi [ "$(uname -s)" = "Darwin" ] || die "Panorama is macOS only." case "$(uname -m)" in arm64) ARCH="arm64" ;; x86_64) # Rosetta shells report x86_64 on Apple Silicon; the native build still works. if [ "$(sysctl -in sysctl.proc_translated 2>/dev/null || echo 0)" = "1" ]; then ARCH="arm64" else die "Panorama is built for Apple Silicon Macs. There is no Intel build." fi ;; *) die "Unsupported CPU: $(uname -m)" ;; esac # Read the release manifest first so a misconfigured deployment fails with a # clear message instead of a bare download error. MANIFEST="$(curl -fsSL "$SITE/api/version" || true)" if [ -z "$MANIFEST" ]; then die "No Panorama release is published yet — check back soon." fi json_field() { printf '%s' "$MANIFEST" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1; } PUBLISHED="$(json_field version)" SHA256="$(json_field sha256)" URL="$SITE/api/download" if [ "$VERSION" != "latest" ]; then URL="$URL?version=$VERSION" TARGET="$VERSION" else TARGET="${PUBLISHED:-latest}" fi # Ask the download endpoint (without following the redirect) whether the release exists. CODE="$(curl -s -o /dev/null -w '%{http_code}' "$URL" || true)" case "$CODE" in 302|301|307|308) ;; 404) die "Panorama version $VERSION was not found. Run without --version for the latest (${PUBLISHED:-unknown})." ;; *) die "Couldn't reach the Panorama download (HTTP ${CODE:-none}). Check your connection and try again." ;; esac printf '\n%s\n' "${bold}Panorama${reset} ${dim}— every coding agent on one board · free during early access${reset}" printf '%s\n\n' "${dim}By installing Panorama you agree to the licence: $SITE/license${reset}" say "Downloading $APP ($ARCH, $TARGET)…" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT curl -fL --progress-bar "$URL" -o "$TMP/$APP.zip" || die "Download failed: $URL" # The manifest carries the checksum of the *latest* release; check it when that is what we got. if [ -n "$SHA256" ] && [ "$TARGET" = "$PUBLISHED" ]; then GOT="$(shasum -a 256 "$TMP/$APP.zip" | awk '{print $1}')" [ "$GOT" = "$SHA256" ] || die "Checksum mismatch (expected $SHA256, got $GOT). Try again, and report it if it persists." fi ditto -x -k "$TMP/$APP.zip" "$TMP/unpacked" [ -d "$TMP/unpacked/$APP.app" ] || die "Archive didn't contain $APP.app" DEST="/Applications" if [ ! -w "$DEST" ]; then DEST="$HOME/Applications" mkdir -p "$DEST" fi if pgrep -xq "$APP"; then say "Quitting the running copy…" osascript -e "quit app \"$APP\"" >/dev/null 2>&1 || true for _ in 1 2 3 4 5 6 7 8 9 10; do pgrep -xq "$APP" || break sleep 1 done fi say "Installing to $DEST/$APP.app" # Copy beside the destination first so a failed copy never leaves the machine without an app. STAGE="$DEST/.$APP.app.new" trap 'rm -rf "$TMP" "$STAGE"' EXIT rm -rf "$STAGE" ditto "$TMP/unpacked/$APP.app" "$STAGE" rm -rf "$DEST/$APP.app" mv "$STAGE" "$DEST/$APP.app" # Builds are ad-hoc signed, not notarized: clear the download quarantine so # Gatekeeper doesn't block the first launch. xattr -dr com.apple.quarantine "$DEST/$APP.app" 2>/dev/null || true # `pano` launcher BIN="/usr/local/bin" if [ ! -w "$BIN" ]; then BIN="$HOME/.local/bin" mkdir -p "$BIN" fi cat > "$BIN/pano" <&2; exit 1; } open -a "$DEST/$APP.app" "\$(cd "\$dir" && pwd)" EOS chmod +x "$BIN/pano" say "Added the ${bold}pano${reset} launcher to $BIN" case ":$PATH:" in *":$BIN:"*) ;; *) printf ' %s\n' "${dim}Add it to your PATH: echo 'export PATH=\"$BIN:\$PATH\"' >> ~/.zshrc${reset}" ;; esac # Panorama used to be called CCViewer. Never delete anything of the user's: just say how. OLD=() for old in /Applications/CCViewer.app "$HOME/Applications/CCViewer.app" /usr/local/bin/ccv "$HOME/.local/bin/ccv"; do [ -e "$old" ] && OLD+=("$old") done if [ ${#OLD[@]} -gt 0 ]; then printf -v OLD_LIST '%q ' "${OLD[@]}" say "The old CCViewer is still here. Panorama replaces it; to remove it: rm -rf $OLD_LIST" fi if [ "$WITH_AGENTS" = 1 ]; then if command -v npm >/dev/null 2>&1; then say "Installing agent CLIs (Claude Code, Codex, Gemini CLI)…" npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli || say "Some agents failed to install — Panorama will show install hints for them." else say "npm not found — skipping agent CLIs. Install Node.js first (brew install node)." fi fi printf '\n%s\n' "${green}✔${reset} ${bold}Panorama is installed.${reset} Run ${bold}pano${reset} inside any project, or open it from Applications." if [ "$LAUNCH" = 1 ]; then open -a "$DEST/$APP.app"; fi exit 0