#!/bin/sh # snyvi, in one line, on Linux and macOS: # # curl -fsSL https://raw.githubusercontent.com/snymrova/snyvi/main/install.sh | sh # # It is short enough to read first. It does what the README's Install section says # to do by hand, choosing the path for the machine it is on: # # macOS with Homebrew brew install --cask snymrova/snyvi/snyvi # macOS without snyvi.app into /Applications, `snyvi` linked on PATH # Linux snyvi into ~/.local/bin, the window beside it when this # machine can show one, a menu entry and a user unit; no # root, and it updates itself from then on # Linux, snyvi as a .deb stays a package: both .debs upgraded in one apt run # # Every download is checked against the .sha256 published beside it. Running # it again installs the newer version over the old one and restarts the # daemon if one was running; nothing you sent is touched. # # sh install.sh --deb the two .debs instead (Debian, Ubuntu; needs root) # sh install.sh --tar the per-user install, even over a .deb; later # runs keep it, the .deb left where it is # sh install.sh --no-app no window # sh install.sh --no-init do not register with Claude Code # sh install.sh --version 1.3.0 a particular release, not the latest # sh install.sh --bin-dir DIR where the per-user install goes (~/.local/bin) # # SNYVI_ASSET_DIR=DIR takes the downloads from a folder instead of GitHub, and # checks them the same way; CI runs this script that way in three distributions. # # Windows: `scoop install snyvi` from the snymrova/scoop-snyvi bucket, or the # installer on the releases page. The README has both. set -eu repo=${SNYVI_REPO:-snymrova/snyvi} version=${SNYVI_VERSION:-} bin_dir=${SNYVI_BIN_DIR:-$HOME/.local/bin} asset_dir=${SNYVI_ASSET_DIR:-} mode=auto bin_dir_given=${SNYVI_BIN_DIR:+1} want_app=1 want_init=1 snyvi= while [ $# -gt 0 ]; do case $1 in --tar) mode=tar ;; --deb) mode=deb ;; --no-app) want_app=0 ;; --no-init) want_init=0 ;; --version) version=${2:?--version needs a value}; shift ;; --version=*) version=${1#--version=} ;; --bin-dir) bin_dir=${2:?--bin-dir needs a value}; bin_dir_given=1; shift ;; --bin-dir=*) bin_dir=${1#--bin-dir=}; bin_dir_given=1 ;; -h|--help) awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"; exit 0 ;; *) echo "install.sh: unknown option $1 (try --help)" >&2; exit 2 ;; esac shift done say() { printf '%s\n' "$*"; } die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } # A path with its links resolved, where the system can say. real() { readlink -f "$1" 2>/dev/null || printf '%s\n' "$1"; } # The release to install: `latest` resolves on GitHub's side, and a pinned # version is the same file names under that tag. Both are the names that do # not move -- snyvi-linux-x64.tar.gz, snyvi-macos-arm64.tar.gz -- which every # release since 1.0 has carried beside its versioned copies. version=${version#v} if [ -n "$version" ]; then base=https://github.com/$repo/releases/download/v$version else base=https://github.com/$repo/releases/latest/download fi os=$(uname -s) case $(uname -m) in x86_64|amd64) arch=x64 ;; aarch64|arm64) arch=arm64 ;; *) die "no build for $(uname -m); the releases page lists what there is: https://github.com/$repo/releases" ;; esac if have curl; then fetch() { curl -fsSL --proto '=https' --tlsv1.2 -o "$2" "$1"; } elif have wget; then fetch() { wget -q -O "$2" "$1"; } elif [ -z "$asset_dir" ]; then die "curl or wget is needed to download the release" fi # `-c` and nothing else: busybox's sha256sum has no --quiet, and the OK line # is the only thing a successful check prints, so it goes to /dev/null. if have sha256sum; then check() { (cd "$(dirname "$1")" && sha256sum -c "$(basename "$1").sha256" >/dev/null); } elif have shasum; then check() { (cd "$(dirname "$1")" && shasum -a 256 -c "$(basename "$1").sha256" >/dev/null); } else die "sha256sum or shasum is needed to check the download" fi tmp=$(mktemp -d "${TMPDIR:-/tmp}/snyvi-install.XXXXXX") trap 'rm -rf "$tmp"' EXIT INT TERM # apt reads the .deb as its own unprivileged user, so the folder must be # readable by everyone; a warning otherwise, not a failure, but a loud one. chmod 755 "$tmp" # Download one asset and its checksum, and refuse a file that does not match. get() { if [ -n "$asset_dir" ]; then say " taking $1 from $asset_dir" cp "$asset_dir/$1" "$tmp/$1" 2>/dev/null || die "no $1 in $asset_dir" cp "$asset_dir/$1.sha256" "$tmp/$1.sha256" 2>/dev/null || die "no $1.sha256 in $asset_dir" else say " fetching $1" fetch "$base/$1" "$tmp/$1" || die "could not download $base/$1" fetch "$base/$1.sha256" "$tmp/$1.sha256" || die "could not download $base/$1.sha256" fi check "$tmp/$1" || die "$1 does not match its published sha256; not installing it" } sudo_if_needed() { if [ "$(id -u)" = 0 ]; then "$@" elif have sudo; then say " (sudo, for apt)"; sudo "$@" else die "root is needed to install a .deb; run as root, or leave out --deb for the per-user install in $bin_dir" fi } # ---------- what is here already ---------- # The daemon, found by asking it rather than by PATH: a daemon started from # another copy of snyvi, or from a folder PATH does not name, is still the # one to restart. `ask health` prints its answer, or nothing. port=${SNYVI_PORT:-7777} ask() { url=http://127.0.0.1:$port/api/$1 if have curl; then curl -fsS --max-time 2 "$url" 2>/dev/null || true elif have wget; then wget -q -T 2 -O - "$url" 2>/dev/null || true else # Neither: any snyvi here can ask for us, pretty-printed. for s in "$(command -v snyvi 2>/dev/null || true)" "$bin_dir/snyvi" /usr/bin/snyvi; do if [ -n "$s" ] && [ -x "$s" ]; then if [ "$1" = health ]; then "$s" status 2>/dev/null | sed -n '/^{/,/^}/p' || true; fi return 0 fi done fi } # One top-level field out of JSON, compact or pretty, without a JSON tool. field() { tr ',' '\n' | sed -n "s/.*\"$1\": *\"\{0,1\}\([^\",}]*\).*/\1/p" | head -n 1; } health=$(ask health) running_pid=$(printf '%s' "$health" | field pid) running_version=$(printf '%s' "$health" | field version) running_bin= [ -n "$running_pid" ] && running_bin=$(ask about | field binary) # Any snyvi at all, before this run: an update, not a first install. What # decides between refreshing Claude Code's registration and making one. previous= for s in "$(command -v snyvi 2>/dev/null || true)" "$bin_dir/snyvi" /usr/bin/snyvi; do if [ -n "$s" ] && [ -x "$s" ]; then previous=$s; break; fi done [ -z "$previous" ] && [ -n "$running_pid" ] && previous=running deb_installed() { have dpkg-query && [ "$(dpkg-query -W -f='${Status}' "$1" 2>/dev/null)" = "install ok installed" ] } display() { [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; } # The receipt a per-user install left, which the daemon reads too (see # src/update.rs): its channel, and the folder it went in. conf=${SNYVI_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/snyvi} receipt_channel= receipt_bin= if [ -f "$conf/install.json" ]; then receipt_channel=$(field channel < "$conf/install.json") receipt_bin=$(field bin_dir < "$conf/install.json") fi say "snyvi: ${version:-latest} for $os $arch" if [ -n "$running_pid" ]; then say " snyvi ${running_version:-?} is running (pid $running_pid)${running_bin:+ from $running_bin}" fi switched=0 deb_left=0 app=0 told= case $os in Darwin) if [ "$mode" = auto ] && have brew; then if brew list --cask snyvi >/dev/null 2>&1; then brew upgrade --cask snymrova/snyvi/snyvi || true else brew install --cask snymrova/snyvi/snyvi fi snyvi=$(brew --prefix)/bin/snyvi else # The tarball is the bundle: both executables inside snyvi.app, and # `install-cli` links the command onto PATH from wherever the app went. # Unpacked by tar it carries no quarantine, so Gatekeeper has nothing # to refuse. get "snyvi-macos-$arch.tar.gz" tar -C "$tmp" -xzf "$tmp/snyvi-macos-$arch.tar.gz" bundle=$(find "$tmp" -maxdepth 2 -name snyvi.app -type d | head -n 1) [ -n "$bundle" ] || die "the tarball did not contain snyvi.app" if [ -w /Applications ]; then dest=/Applications; else dest=$HOME/Applications; mkdir -p "$dest"; fi rm -rf "$dest/snyvi.app" mv "$bundle" "$dest/snyvi.app" say " snyvi.app is in $dest" snyvi=$dest/snyvi.app/Contents/MacOS/snyvi "$snyvi" install-cli fi ;; Linux) # A machine that has snyvi as a package keeps it as one: nobody's # install is moved behind their back. --tar is how to move -- and once # moved, it stays moved: a receipt from --tar, with its snyvi still in # the folder it names, wins over a package left installed beside it. # Without that, the next plain run put the package back in charge, a # step back to a version that does not update itself. if [ "$mode" = auto ]; then if [ "$receipt_channel" = tar ] && [ -n "$receipt_bin" ] && [ -x "$receipt_bin/snyvi" ]; then mode=tar [ -n "$bin_dir_given" ] || bin_dir=$receipt_bin if deb_installed snyvi; then told="kept the per-user install in $receipt_bin, as $conf/install.json says it was made; the .deb beside it was left alone" fi elif deb_installed snyvi; then mode=deb told="installed as a package; snyvi tells you when a new one is out. \`sh install.sh --tar\` moves to the per-user install, which updates itself" else mode=tar fi fi if [ "$mode" = deb ]; then { have dpkg && have apt-get; } || die "--deb needs dpkg and apt-get, which this Linux does not have; the per-user install needs neither: sh install.sh" get "snyvi-linux-$arch.deb" debs="$tmp/snyvi-linux-$arch.deb" # The window: when asked for and there is a display to show it on, # or when it is installed already and would otherwise fall behind. if [ "$want_app" = 1 ] && { display || deb_installed snyvi-app; }; then get "snyvi-app-linux-$arch.deb" debs="$debs $tmp/snyvi-app-linux-$arch.deb" app=1 elif [ "$want_app" = 1 ]; then say " no display here, so no window; run this again from the desktop for one" fi # One apt transaction for both, so WebKitGTK resolves and the two # never disagree; a path rather than a name, so apt reads the file. # shellcheck disable=SC2086 chmod 644 $debs # shellcheck disable=SC2086 sudo_if_needed apt-get install -y $debs snyvi=/usr/bin/snyvi [ -x "$bin_dir/snyvi" ] && switched=1 else get "snyvi-linux-$arch.tar.gz" tar -C "$tmp" -xzf "$tmp/snyvi-linux-$arch.tar.gz" bin= for f in "$tmp"/snyvi-*/snyvi "$tmp"/snyvi; do if [ -f "$f" ]; then bin=$f; break; fi done [ -n "$bin" ] || die "the tarball did not contain a snyvi binary" mkdir -p "$bin_dir" # Beside the old file and renamed over it: a running daemon keeps the # file it started from, and a write into a running executable fails. place() { install -m 755 "$1" "$bin_dir/.$2.new" && mv -f "$bin_dir/.$2.new" "$bin_dir/$2"; } place "$bin" snyvi snyvi=$bin_dir/snyvi say " snyvi is in $bin_dir" # A package still here: a move, unless the daemon running is already # this copy -- a re-run after the move, which restarts it as any # update does rather than stopping it. if deb_installed snyvi; then deb_left=1 if [ -z "$running_bin" ] || [ "$(real "$running_bin")" != "$(real "$bin_dir/snyvi")" ]; then switched=1; fi fi # The window, beside it, when this machine can run one: a display, # WebKitGTK 4.1, and a glibc as new as the one it was built against. # One already here is always kept in step with the daemon. if [ "$want_app" = 1 ] || [ -x "$bin_dir/snyvi-app" ]; then glibc=$(getconf GNU_LIBC_VERSION 2>/dev/null | sed -n 's/^glibc //p') webkit=0 if { ldconfig -p 2>/dev/null || /sbin/ldconfig -p 2>/dev/null; } | grep -q 'libwebkit2gtk-4\.1\.so\.0'; then webkit=1; fi gmaj=${glibc%%.*}; gmin=${glibc#*.}; gmin=${gmin%%.*} if [ -z "$glibc" ]; then say " the window needs glibc, which this Linux does not use; snyvi opens in a browser here" elif [ "$gmaj" -lt 2 ] || { [ "$gmaj" = 2 ] && [ "$gmin" -lt 34 ]; }; then say " the window needs glibc 2.34 or newer and this has $glibc; snyvi opens in a browser here" elif [ ! -x "$bin_dir/snyvi-app" ] && ! display; then say " no display here, so no window; run this again from the desktop for one" elif [ "$webkit" = 0 ]; then distro=$(sed -n 's/^ID_LIKE=//p; s/^ID=//p' /etc/os-release 2>/dev/null | tr -d '"' | tr '\n' ' ') case " $distro " in *" fedora "*|*" rhel "*) line="sudo dnf install webkit2gtk4.1" ;; *" arch "*) line="sudo pacman -S webkit2gtk-4.1" ;; *" suse "*|*" opensuse "*) line="sudo zypper install libwebkit2gtk-4_1-0" ;; *" debian "*|*" ubuntu "*) line="sudo apt install libwebkit2gtk-4.1-0" ;; *) line="your distribution's WebKitGTK 4.1 package" ;; esac say " the window needs WebKitGTK 4.1, which is not installed: $line, then run this again" else get "snyvi-app-linux-$arch.tar.gz" mkdir "$tmp/app" tar -C "$tmp/app" -xzf "$tmp/snyvi-app-linux-$arch.tar.gz" win= for f in "$tmp"/app/snyvi-*/snyvi-app "$tmp"/app/snyvi-app; do if [ -f "$f" ]; then win=$f; break; fi done [ -n "$win" ] || die "the window's tarball did not contain snyvi-app" place "$win" snyvi-app app=1 say " the window is beside it" fi fi # What a package puts under /usr/share, in this home: the menu entry, # the icon, snyvi:// links, a user unit (written, not enabled). "$snyvi" install-desktop | sed 's/^/ /' # The receipt the daemon reads to know how it was installed, and so # what an update may do to it. See src/update.rs. mkdir -p "$conf" esc=$(real "$bin_dir" | sed 's/\\/\\\\/g; s/"/\\"/g') if [ "$app" = 1 ]; then app_json=true; else app_json=false; fi printf '{ "channel": "tar", "bin_dir": "%s", "app": %s }\n' "$esc" "$app_json" > "$conf/install.json" fi ;; *) die "no installer for $os here; Windows has \`scoop install snyvi\`, and the releases page has everything: https://github.com/$repo/releases" ;; esac [ -x "$snyvi" ] || die "installed, but $snyvi is not there to finish with; open a new terminal and run \`snyvi status\`" new_version=$("$snyvi" --version 2>/dev/null || true) new_version=${new_version##* } # The daemon that was up is the old code still serving; the new file takes # over. When it runs from the same path, `restart` waits for the desks to be # quiet and brings the Claude panels back. When it runs from another copy -- # a move between the package and the per-user install -- that copy is # stopped and this one started, since a restart would start the old path. restarted= if [ -n "$running_pid" ]; then if [ "$switched" = 1 ] || { [ -n "$running_bin" ] && [ "$(real "$running_bin")" != "$(real "$snyvi")" ]; }; then "$snyvi" stop >/dev/null 2>&1 || true if "$snyvi" restart >/dev/null; then restarted=moved; fi elif "$snyvi" restart; then restarted=same fi fi if [ "$want_init" = 1 ]; then if [ -n "$previous" ]; then # An update: whatever Claude Code has of snyvi is pointed here, and # nothing is added that the reader took out. "$snyvi" init-claude --refresh | sed 's/^/ /' elif have claude || [ -d "$HOME/.claude" ]; then "$snyvi" init-claude --auto else say " Claude Code is not here; \`snyvi init-claude --auto\` when it is, or \`snyvi init \` for another agent" fi fi # The last lines say what is true, not what was hoped: which file runs when # `snyvi` is typed in a new terminal, and whether it updates itself. say "" case $os/$mode in Linux/tar) with= [ "$app" = 1 ] && with=", with the window beside it" say "snyvi $new_version is in $bin_dir$with, and updates itself from now on." ;; Linux/deb) say "snyvi $new_version is installed as a package; snyvi tells you when a new one is out." ;; *) say "snyvi $new_version is installed, and updates itself from now on." ;; esac [ -n "$told" ] && say " $told" case $restarted in same) say " The daemon that was running is now $new_version." ;; moved) say " The daemon that ran from ${running_bin:-the other copy} was stopped; this one is running now." ;; esac # Not run for the reader: piped from curl there is no tty to ask for a # password on, and taking out someone's package is theirs to do. if [ "$os/$mode" = Linux/tar ] && [ "$deb_left" = 1 ]; then say " The .deb is still installed. Two installs pull different ways -- apt" say " upgrades /usr/bin/snyvi, this one updates itself -- and whichever comes" say " first on PATH is the one a terminal runs. To take the package out:" # Only what is installed: neither package is in a repository, so apt # cannot even find the name of one that never was. pkgs=snyvi if deb_installed snyvi-app; then pkgs="$pkgs snyvi-app"; fi say " sudo apt remove $pkgs" fi found=$(command -v snyvi 2>/dev/null || true) if [ -z "$found" ]; then say " $(dirname "$snyvi") is not on your PATH yet; add it, or call $snyvi by that name." elif [ "$(real "$found")" != "$(real "$snyvi")" ]; then say " But \`snyvi\` runs $found, which comes first on your PATH; call $snyvi by that name, or put $(dirname "$snyvi") first." fi say " snyvi app open the window, and give your first project a desk" say " snyvi status what is running, what is registered" # One question, asked only where it can be answered: a desktop to open the # window on, the window installed, and a terminal. `curl | sh` has no stdin # of its own, so the answer is read from /dev/tty, and no tty is no question. # A Mac always has a desktop, and its window is inside the bundle. if { [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ] || [ "$os" = Darwin ]; } && { [ "${app:-0}" = 1 ] || have snyvi-app || [ -d "${dest:-/nonexistent}/snyvi.app" ]; } && ( : /dev/null; then printf "\nOpen the window now? [Y/n] " >/dev/tty answer= read -r answer /dev/null 2>&1 & ;; esac fi