#!/bin/sh # Klaus CLI installer. # # curl -fsSL https://klaus-ai.cn/install.sh | sh # # POSIX sh on purpose, not bash: the one-liner above is pasted into whatever # shell the user has, and macOS ships bash 3.2 while some minimal Linux images # ship no bash at all. Nothing below uses arrays, [[ ]] or ${var,,}. # # What it does, in order: work out which artifact this machine needs, read the # published manifest to find it, download it, verify its sha256, unpack it into # a private directory, and only then move it into place. Nothing outside the # install directory is written, and nothing needs sudo. set -eu BASE_URL="${KLAUS_BASE_URL:-https://klaus-ai.cn}" INSTALL_DIR="${KLAUS_INSTALL_DIR:-$HOME/.klaus/bin}" BIN_NAME="klaus" # Colour only when stderr is a terminal, so a log or a pipe stays clean. if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then C_DIM='\033[2m'; C_BOLD='\033[1m'; C_RED='\033[31m'; C_GREEN='\033[32m'; C_OFF='\033[0m' else C_DIM=''; C_BOLD=''; C_RED=''; C_GREEN=''; C_OFF='' fi say() { printf '%b\n' "$*" >&2; } note() { say "${C_DIM}$*${C_OFF}"; } die() { say "${C_RED}error${C_OFF} $*"; exit 1; } # ---- what this machine needs ------------------------------------------------- detect_platform() { os=$(uname -s 2>/dev/null || echo unknown) arch=$(uname -m 2>/dev/null || echo unknown) case "$os" in Darwin) os=darwin ;; Linux) os=linux ;; # A Windows shell reaching this script means the user copied the wrong # one-liner; say so rather than reporting "unsupported platform". MINGW*|MSYS*|CYGWIN*) die "Windows detected. Use the Windows installer instead:\n ${C_BOLD}curl -fsSLo install.bat ${BASE_URL}/install.bat && install.bat${C_OFF}" ;; *) die "unsupported operating system: $os" ;; esac case "$arch" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=x86_64 ;; *) die "unsupported architecture: $arch" ;; esac printf '%s-%s' "$os" "$arch" } # ---- tools ------------------------------------------------------------------- have() { command -v "$1" >/dev/null 2>&1; } # Fetch into a file and report the HTTP status separately. # # The status matters: a 404 from the manifest endpoint means "nothing published # yet", while a connection failure means the network. Collapsing both into one # error told users to check their network when the real answer was that the build # had not shipped. fetch_status() { if have curl; then curl -sS -o "$2" -w '%{http_code}' "$1" 2>/dev/null || printf '000' elif have wget; then if wget -qO "$2" "$1"; then printf '200'; else printf '000'; fi else die "need curl or wget to download" fi } fetch_to_file() { # `--progress-bar` on curl and `--show-progress` on wget: a 90 MB download with # no feedback looks like a hang, and this is the step users wait on. if have curl; then if [ -t 2 ]; then curl -fL --progress-bar -o "$2" "$1"; else curl -fsSL -o "$2" "$1"; fi elif have wget; then if [ -t 2 ]; then wget -q --show-progress -O "$2" "$1"; else wget -qO- "$1" > "$2"; fi else die "need curl or wget to download" fi } sha256_of() { if have shasum; then shasum -a 256 "$1" | cut -d' ' -f1 elif have sha256sum; then sha256sum "$1" | cut -d' ' -f1 else printf '' fi } # Pull one string field out of the manifest for the platform we resolved. # # Deliberately not a JSON parser: `python3 -c` is not on every image and `jq` is # on almost none. The manifest is written by our own server with a fixed shape, # so a scoped `sed` is enough — and it is scoped, by cutting the object for this # platform out first, so a value from a neighbouring platform cannot be read by # mistake. manifest_field() { printf '%s' "$1" \ | tr -d '\n ' \ | sed -n "s/.*\"$2\":{\([^}]*\)}.*/\1/p" \ | sed -n "s/.*\"$3\":\"\([^\"]*\)\".*/\1/p" } manifest_number() { printf '%s' "$1" \ | tr -d '\n ' \ | sed -n "s/.*\"$2\":{\([^}]*\)}.*/\1/p" \ | sed -n "s/.*\"$3\":\([0-9]*\).*/\1/p" } # ---- run --------------------------------------------------------------------- platform=$(detect_platform) note "platform $platform" tmp=$(mktemp -d 2>/dev/null || mktemp -d -t klaus-cli) # Any exit past this point cleans up: a failed verify must not leave a half # downloaded archive behind for a later run to trip over. trap 'rm -rf "$tmp"' EXIT INT TERM status=$(fetch_status "$BASE_URL/api/releases/cli/latest" "$tmp/manifest.json") case "$status" in 200) ;; 000) die "cannot reach $BASE_URL — check the network and try again" ;; 404) say "" say "${C_BOLD}Klaus CLI is not published yet.${C_OFF}" say "The desktop app covers macOS and Windows today: ${C_BOLD}${BASE_URL}/download${C_OFF}" exit 1 ;; *) die "$BASE_URL answered HTTP $status for the release manifest" ;; esac manifest=$(cat "$tmp/manifest.json") [ -n "$manifest" ] || die "the release manifest is empty; the CLI may not be published yet" version=$(printf '%s' "$manifest" | tr -d '\n ' | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') artifact=$(manifest_field "$manifest" "$platform" "file") url_path=$(manifest_field "$manifest" "$platform" "url") want_sha=$(manifest_field "$manifest" "$platform" "sha256") size=$(manifest_number "$manifest" "$platform" "sizeBytes") if [ -z "$artifact" ] || [ -z "$url_path" ]; then say "" say "${C_BOLD}Klaus CLI is not published for $platform yet.${C_OFF}" say "Published today:$(printf '%s' "$manifest" | tr -d '\n ' | sed -n 's/.*"artifacts":{//p' | tr ',' '\n' | sed -n 's/^"\([a-z0-9_-]*\)":{.*/ \1/p' | tr -d '\n')" say "" say "The desktop app covers macOS and Windows today: ${C_BOLD}${BASE_URL}/download${C_OFF}" exit 1 fi note "version $version" [ -n "$size" ] && note "download $artifact ($(( size / 1048576 )) MB)" archive="$tmp/$artifact" fetch_to_file "$BASE_URL$url_path" "$archive" || die "download failed" if [ -n "$want_sha" ]; then got_sha=$(sha256_of "$archive") if [ -z "$got_sha" ]; then note "no shasum/sha256sum found — skipping checksum verification" elif [ "$got_sha" != "$want_sha" ]; then die "checksum mismatch\n expected $want_sha\n got $got_sha\nThe download was corrupted or tampered with; nothing was installed." else note "checksum ok" fi fi case "$artifact" in *.tar.gz|*.tgz) tar -xzf "$archive" -C "$tmp" ;; *.zip) have unzip || die "need unzip to unpack $artifact"; unzip -q "$archive" -d "$tmp" ;; *) die "unknown archive format: $artifact" ;; esac unpacked=$(find "$tmp" -type f -name "$BIN_NAME" -perm -u+x 2>/dev/null | head -1) [ -n "$unpacked" ] || die "the archive did not contain a '$BIN_NAME' executable" mkdir -p "$INSTALL_DIR" target="$INSTALL_DIR/$BIN_NAME" # Install to a sibling then rename: `mv` onto a running binary fails with ETXTBSY # on Linux, and overwriting in place would leave a truncated file if the disk # filled. A rename within one directory is atomic, so a concurrent shell either # sees the old binary or the new one. staged="$INSTALL_DIR/.$BIN_NAME.new" cp "$unpacked" "$staged" chmod +x "$staged" mv -f "$staged" "$target" # macOS quarantines anything a browser or curl brought in; left on, the first run # is a Gatekeeper dialog the user cannot answer from a terminal. if [ "$(uname -s)" = "Darwin" ] && have xattr; then xattr -d com.apple.quarantine "$target" 2>/dev/null || true fi say "" say "${C_GREEN}installed${C_OFF} ${C_BOLD}$target${C_OFF}" # ---- PATH -------------------------------------------------------------------- # Put the install dir on PATH, by writing the shell profile. # # This step used to only PRINT the export line, on the principle that an # installer should not edit a user's profile. The result was that anyone who ran # the published one-liner and then typed `klaus` got `command not found` — while # install.bat, two files over, writes the per-user PATH itself. One published # command has to end in a working `klaus` on every platform, so this writes the # line too, names the file it touched, and gives the command for the shell that # is already open. KLAUS_NO_MODIFY_PATH=1 keeps the print-only behaviour. # Which file to write is a guess from $SHELL. macOS login shells read # .bash_profile and never .bashrc, Linux interactive shells the other way round, # so for bash whichever already exists wins. profile_path() { case "${SHELL:-}" in */zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zshrc" ;; */bash) if [ -f "$HOME/.bash_profile" ]; then printf '%s' "$HOME/.bash_profile" else printf '%s' "$HOME/.bashrc" fi ;; */fish) printf '%s' "$HOME/.config/fish/config.fish" ;; *) printf '%s' "$HOME/.profile" ;; esac } path_line() { case "${SHELL:-}" in */fish) printf 'fish_add_path %s' "$1" ;; *) printf 'export PATH="%s:$PATH"' "$1" ;; esac } # How the install dir is spelled in the profile: `$HOME/...` when it sits under # the home directory, expanded otherwise. That is the form people write by hand, # and both spellings are accepted below — matching only the expanded one appended # a second line next to a hand-written `$HOME/.klaus/bin` that already worked. install_dir_ref() { case "$INSTALL_DIR" in "$HOME"/*) printf '$HOME%s' "${INSTALL_DIR#"$HOME"}" ;; *) printf '%s' "$INSTALL_DIR" ;; esac } case ":$PATH:" in *":$INSTALL_DIR:"*) say "Run ${C_BOLD}$BIN_NAME${C_OFF} to start." ;; *) profile=$(profile_path) dir_ref=$(install_dir_ref) line=$(path_line "$dir_ref") activate="Open a new terminal, or run ${C_BOLD}$line${C_OFF} in this one." if [ -n "${KLAUS_NO_MODIFY_PATH:-}" ]; then say "" say "$INSTALL_DIR is not on your PATH. Add it to $profile:" say " ${C_BOLD}$line${C_OFF}" say "" say "Or run it directly: ${C_BOLD}$target${C_OFF}" # Matched on the directory rather than a marker comment, so re-running is a # no-op while a genuinely different KLAUS_INSTALL_DIR still gets its line. elif [ -f "$profile" ] && { grep -Fq "$INSTALL_DIR" "$profile" || grep -Fq "$dir_ref" "$profile"; }; then say "" say "$profile already puts $INSTALL_DIR on PATH." say "$activate" elif mkdir -p "$(dirname "$profile")" 2>/dev/null && printf '\n# added by the Klaus installer\n%s\n' "$line" >>"$profile" 2>/dev/null; then say "" say "${C_GREEN}PATH${C_OFF} $INSTALL_DIR added to ${C_BOLD}$profile${C_OFF}" say "$activate" else # An unwritable profile is not a failed install: the binary is already in # place, so say what to add and where the binary is. say "" say "could not write $profile — add this line yourself:" say " ${C_BOLD}$line${C_OFF}" say "" say "Or run it directly: ${C_BOLD}$target${C_OFF}" fi ;; esac