#!/bin/sh # Installs the selan CLI. Published to https://dl.selan.ai/install.sh set -eu DL_HOST="dl.selan.ai" BINARY="selan" # The same binary under a second name: running it means `$BINARY claude # --dangerously-skip-permissions`, which it decides from argv[0]. The 'd' is # inserted rather than appended so both channels read the same way — selan → # seland, selan-dev → seland-dev. DANGER_BINARY="seland${BINARY#selan}" DEST="${SELAN_INSTALL_DIR:-/usr/local/bin}" os=$(uname -s | tr '[:upper:]' '[:lower:]') case "$os" in darwin|linux) ;; *) echo "error: unsupported operating system: $os" >&2; exit 1 ;; esac case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; *) echo "error: unsupported architecture: $(uname -m)" >&2; exit 1 ;; esac # macOS ships shasum; most Linux images ship sha256sum. Both support -c. if command -v shasum >/dev/null 2>&1; then checksum="shasum -a 256" elif command -v sha256sum >/dev/null 2>&1; then checksum="sha256sum" else echo "error: need shasum or sha256sum to verify the download" >&2 exit 1 fi # `latest` is served no-cache, so this is never stale. Everything below is # fetched from an immutable versioned path. version=$(curl -fsSL "https://$DL_HOST/latest") if [ -z "$version" ]; then echo "error: couldn't determine the latest version from https://$DL_HOST/latest" >&2 exit 1 fi # The tarball always contains a file named "selan" regardless of channel — # only the name it gets installed as (BINARY, below) differs, so a dev and a # prod install can coexist on the same machine without one clobbering the # other. asset="selan_${os}_${arch}.tar.gz" tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT echo "downloading $BINARY $version ($os/$arch)..." curl -fsSL -o "$tmp/$asset" "https://$DL_HOST/v$version/$asset" curl -fsSL -o "$tmp/checksums.txt" "https://$DL_HOST/v$version/checksums.txt" echo "verifying checksum..." # Capture the matching line first rather than piping grep straight into the # checksum tool: in a pipeline, the exit status is the checksum tool's alone, # so a grep match failure would be silently swallowed. Worse, macOS's # /sbin/sha256sum exits 0 on empty input, so an empty match would read as a # pass. This is the only integrity control in a curl-|-sh install. line=$(cd "$tmp" && grep " $asset\$" checksums.txt || true) if [ -z "$line" ]; then echo "error: no checksum entry for $asset in checksums.txt" >&2 exit 1 fi if ! printf '%s\n' "$line" | (cd "$tmp" && $checksum -c -) >/dev/null 2>&1; then echo "error: checksum verification failed for $asset" >&2 exit 1 fi tar -xzf "$tmp/$asset" -C "$tmp" # A destination that does not exist yet is not writable either, so without # this a perfectly ordinary SELAN_INSTALL_DIR=$HOME/.local/bin — a directory # many people simply have not created — would ask for sudo to write inside # the user's own home. Just attempt it: checking the parent instead would # still fail for a nested path like ~/.local/bin where ~/.local is missing # too. If we lack permission the mkdir fails harmlessly and the sudo path # below handles it, exactly as before. if [ ! -d "$DEST" ]; then mkdir -p "$DEST" 2>/dev/null || true fi # A relative symlink, not a second copy: it cannot go stale between updates, # and it stays valid if $DEST is ever moved wholesale. Windows gets a copy # instead — see install.ps1.tmpl for why. if [ -w "$DEST" ]; then install -m 0755 "$tmp/selan" "$DEST/$BINARY" ln -sf "$BINARY" "$DEST/$DANGER_BINARY" else echo "installing to $DEST (needs sudo)" sudo install -m 0755 "$tmp/selan" "$DEST/$BINARY" sudo ln -sf "$BINARY" "$DEST/$DANGER_BINARY" fi echo "installed $BINARY $version to $DEST/$BINARY" echo "and $DANGER_BINARY, the same launch with permissions skipped" # Before this change, every channel installed a binary literally named # "selan" — a dev install and a prod install clobbered each other. Existing # dev users therefore have a $DEST/selan that is really an old dev build, # now orphaned: it stays at its old version forever and keeps nagging, and # nothing in this script ever touches it, because $BINARY is "selan-dev" now. # We cannot tell that apart from a deliberately installed prod "selan" on the # same machine, so we only ever print — never delete a binary we didn't just # write ourselves. if [ "$BINARY" != "selan" ] && [ -e "$DEST/selan" ]; then echo echo "note: $DEST/selan is also present. If that's a leftover from before" echo "$BINARY existed as a separate name, it won't be updated by this" echo "installer and can be removed:" echo echo " rm $DEST/selan" fi case ":$PATH:" in *":$DEST:"*) # $DEST is on PATH, but that is not enough on a first install: zsh and bash # hash command lookups per PATH directory, so a shell that was already # running when we wrote the binary keeps reporting "command not found" # until its hash is cleared. That is what makes people reach for # `source ~/.zshrc`, which only works as a side effect. fish rescans on its # own and has no `hash`, so telling it to run one would just error. if [ "$(basename "${SHELL:-unknown}")" != "fish" ]; then echo echo "if your shell can't find $BINARY yet, run:" echo echo " hash -r" fi ;; *) shell_name=$(basename "${SHELL:-unknown}") export_line="export PATH=\"$DEST:\$PATH\"" # The line to paste into the shell that is running right now. `hash -r` is # the part people miss: zsh and bash cache command lookups, including # failed ones, so a shell that has already tried "$BINARY" once keeps # reporting "command not found" even after PATH is fixed. fish needs # neither — fish_add_path affects the running session and persists. if [ "$shell_name" = "fish" ]; then now_line="fish_add_path \"$DEST\"" else now_line="$export_line; hash -r" fi rc_file="" rc_file2="" case "$shell_name" in zsh) rc_file="$HOME/.zshrc" ;; bash) # .bashrc is what non-login/interactive shells source on both Linux # and macOS, and is where a PATH addition is most likely to already # live, so it is always the primary target. But on macOS, Terminal.app # (and any other login shell) reads .bash_profile ONLY — not .bashrc — # unless .bash_profile itself sources .bashrc. Writing only .bashrc # would then silently fail to fix PATH for exactly the shell most # macOS users actually get. So: if .bash_profile exists and does not # already reference .bashrc, fix it too, in addition to (not instead # of) .bashrc — plenty of bash setups, especially on Linux, have no # .bash_profile at all and rely on .bashrc alone. rc_file="$HOME/.bashrc" if [ -f "$HOME/.bash_profile" ] && ! grep -q '\.bashrc' "$HOME/.bash_profile"; then rc_file2="$HOME/.bash_profile" fi ;; fish) rc_file="$HOME/.config/fish/config.fish" ;; *) rc_file="" ;; esac echo if [ -z "$rc_file" ]; then echo "$DEST is not on your PATH. Add this to your shell's startup file:" echo echo " $export_line" echo echo "then restart your shell, or open a new terminal." echo echo "to use $BINARY in THIS shell without restarting, run:" echo echo " $now_line" else if [ "$shell_name" = "fish" ]; then line="fish_add_path \"$DEST\"" else line="$export_line" fi for f in "$rc_file" "$rc_file2"; do [ -n "$f" ] || continue mkdir -p "$(dirname "$f")" touch "$f" # -x for an exact whole-line match: a plain -F also matches inside a # line the user has commented out, which would wrongly report PATH as # already fixed and leave it broken. if grep -qxF "$line" "$f"; then echo "$DEST is not on your PATH, but $f already adds it." else printf '\n# added by the selan installer\n%s\n' "$line" >> "$f" echo "$DEST was not on your PATH, so the selan installer added it to $f:" echo echo " $line" fi echo done # A child process cannot change its parent's environment, so this # script physically cannot put $DEST on the PATH of the shell that ran # it — only on the PATH of shells started later. Printing the line that # does it now saves the user from a "command not found" on the very # first thing they try. echo "restart your shell, or run this to use $BINARY right now:" echo echo " $now_line" fi ;; esac # selan launches Claude Code, it does not bundle it, and it finds it exactly # the way this check does — a PATH lookup. Worth saying now rather than leaving # it to the first `$BINARY claude`. # # A note, never a gate. `command -v` runs under this script's /bin/sh, which # has none of the user's interactive shell configuration, so a claude that # exists only as an alias or a shell function is invisible here: exiting # non-zero would refuse to install on a machine that works perfectly. And # installing selan first is a legitimate order anyway — login and whoami need # nothing else. if command -v claude >/dev/null 2>&1; then echo echo "next: $BINARY claude" elif [ -n "${HOME:-}" ] && [ -x "$HOME/.local/bin/claude" ]; then # Where Claude Code's own installer puts its launcher. Reaching this branch # means it is installed but not on PATH — command -v above would have found # it otherwise. That distinction matters: $BINARY resolves claude by PATH # lookup and would fail at launch even on a machine where the user's own # `claude` works, via an alias or a full path. echo echo "note: found $HOME/.local/bin/claude, but that directory is not on your" echo "PATH. $BINARY resolves claude by PATH lookup, so add it:" echo if [ "$(basename "${SHELL:-unknown}")" = "fish" ]; then echo " fish_add_path \"\$HOME/.local/bin\"" else echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" fi echo echo "then: $BINARY claude" else echo echo "note: couldn't find claude on your PATH. $BINARY launches Claude Code" echo "rather than bundling it, so install that too:" echo echo " curl -fsSL https://claude.ai/install.sh | bash" echo echo "then: $BINARY claude" fi