#!/bin/sh # Installer for sortie - spec-first agent orchestrator. # # Usage: # curl -sSL https://get.sortie-ai.com/install.sh | sh # curl -sSL https://get.sortie-ai.com/install.sh | sh -s -- --help # # Environment (each has an equivalent flag, see --help): # SORTIE_VERSION Pin a specific release, with or without the leading # "v" (e.g. 1.18.0 or v1.18.0). # SORTIE_INSTALL_DIR Override install directory # (default: /usr/local/bin as root, ~/.local/bin otherwise). # SORTIE_NO_VERIFY Set to 1 to skip checksum verification. set -eu REPO="sortie-ai/sortie" BIN="sortie" BINARY="" FORCE=0 setup_colors() { if [ -t 1 ] && [ "${TERM-}" != "dumb" ]; then BOLD='\033[1m' DIM='\033[2m' RED='\033[31m' GREEN='\033[32m' YELLOW='\033[33m' CYAN='\033[36m' RESET='\033[0m' else BOLD='' DIM='' RED='' GREEN='' YELLOW='' CYAN='' RESET='' fi } info() { printf '%b%s\n' "${BOLD}${CYAN}:: ${RESET}" "$*"; } ok() { printf '%b%s\n' "${BOLD}${GREEN}:: ${RESET}" "$*"; } warn() { printf '%b%s\n' "${BOLD}${YELLOW}warning: ${RESET}" "$*" >&2; } err() { printf '%b%s\n' "${BOLD}${RED}error: ${RESET}" "$*" >&2; } die() { err "$@"; exit 1; } usage() { cat < Install a specific release (default: latest) -d, --install-dir Install into -b, --binary Install a local binary instead of downloading -f, --force Reinstall even if that version is already present --no-verify Skip checksum verification Flags override the SORTIE_VERSION, SORTIE_INSTALL_DIR and SORTIE_NO_VERIFY environment variables. Examples: curl -sSL https://get.sortie-ai.com/install.sh | sh curl -sSL https://get.sortie-ai.com/install.sh | sh -s -- --version 1.18.0 EOF } parse_args() { while [ $# -gt 0 ]; do case $1 in -h|--help) usage; exit 0 ;; -v|--version) [ $# -ge 2 ] || die "$1 requires an argument" SORTIE_VERSION=$2; shift 2 ;; -d|--install-dir) [ $# -ge 2 ] || die "$1 requires an argument" SORTIE_INSTALL_DIR=$2; shift 2 ;; -b|--binary) [ $# -ge 2 ] || die "$1 requires an argument" BINARY=$2; shift 2 ;; -f|--force) FORCE=1; shift ;; --no-verify) SORTIE_NO_VERIFY=1; shift ;; *) die "unknown option: $1 (try --help)" ;; esac done } detect_platform() { OS=$(uname -s) case "$OS" in Linux*) OS=linux ;; Darwin*) OS=darwin ;; *) die "unsupported OS: $OS" ;; esac ARCH=$(uname -m) case "$ARCH" in x86_64|amd64) ARCH=amd64 ;; aarch64|arm64) ARCH=arm64 ;; *) die "unsupported architecture: $ARCH" ;; esac # On macOS, detect Rosetta 2: if the shell runs as x86_64 under translation # on an Apple Silicon Mac, prefer the native arm64 binary. if [ "$OS" = "darwin" ] && [ "$ARCH" = "amd64" ]; then if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = "1" ]; then ARCH=arm64 fi fi } need_cmd() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"; } # Checking the checksum tool here rather than at its point of use keeps a # missing sha256sum from surfacing only after the archive has been downloaded. check_dependencies() { _missing="" for _cmd in uname tar; do command -v "$_cmd" >/dev/null 2>&1 \ || _missing="${_missing:+$_missing, }$_cmd" done command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ || _missing="${_missing:+$_missing, }curl or wget" if [ "${SORTIE_NO_VERIFY-}" != "1" ] && [ -z "$(sha256_cmd)" ]; then _missing="${_missing:+$_missing, }sha256sum or shasum" fi [ -z "$_missing" ] || die "required commands not found: ${_missing}" } fetch() { _url=$1 _out=${2:-} if command -v curl >/dev/null 2>&1; then if [ -n "$_out" ]; then curl -fsSL -o "$_out" "$_url" else curl -fsSL "$_url"; fi elif command -v wget >/dev/null 2>&1; then if [ -n "$_out" ]; then wget -qO "$_out" "$_url" else wget -qO- "$_url"; fi else die "curl or wget is required" fi } # The releases/latest HTML endpoint redirects to the tagged release and, unlike # the GitHub API, is not rate-limited per IP - which is what breaks on shared # CI runners. curl only: wget cannot report a Location header portably. latest_tag_via_redirect() { command -v curl >/dev/null 2>&1 || return 1 _loc=$(curl -fsSI -o /dev/null -w '%{redirect_url}' \ "https://github.com/${REPO}/releases/latest") || return 1 case "$_loc" in */releases/tag/?*) printf '%s' "${_loc##*/}" ;; *) return 1 ;; esac } resolve_tag() { if [ -n "${SORTIE_VERSION-}" ]; then printf '%s' "$SORTIE_VERSION" return fi if _redirect_tag=$(latest_tag_via_redirect); then printf '%s' "$_redirect_tag" return fi _json=$(fetch "https://api.github.com/repos/${REPO}/releases/latest") \ || die "GitHub API request failed (rate-limited? set SORTIE_VERSION to skip)" printf '%s' "$_json" \ | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ | head -n1 } # Version of an already-installed binary; empty when absent or not runnable. installed_version() { [ -x "$1" ] || return 0 "$1" --version 2>/dev/null | awk 'NR == 1 { print $2 }' } # Name of the available SHA-256 tool, empty when neither is installed. sha256_cmd() { if command -v sha256sum >/dev/null 2>&1; then printf 'sha256sum' elif command -v shasum >/dev/null 2>&1; then printf 'shasum' fi } verify_checksum() { _file=$1 _sums=$2 _want=$(awk -v f="$(basename "$_file")" '$2 == f {print $1}' "$_sums") [ -n "$_want" ] || die "no checksum entry for $(basename "$_file")" case $(sha256_cmd) in sha256sum) _got=$(sha256sum "$_file" | awk '{print $1}') ;; shasum) _got=$(shasum -a 256 "$_file" | awk '{print $1}') ;; *) die "sha256sum or shasum is required" ;; esac [ "$_want" = "$_got" ] \ || die "checksum mismatch (expected ${_want}, got ${_got})" } resolve_install_dir() { if [ -n "${SORTIE_INSTALL_DIR-}" ]; then printf '%s' "$SORTIE_INSTALL_DIR" return fi # Root (e.g. Docker) → /usr/local/bin, the FHS standard for local binaries. if [ "$(id -u)" -eq 0 ]; then printf '%s' "/usr/local/bin" return fi # Non-root → ~/.local/bin (XDG convention, same as pip, mise, pipx). printf '%s' "${HOME}/.local/bin" } # Detect rc file for the current shell so PATH hint is copy-pasteable. shell_rc() { case "${SHELL-}" in */zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zshrc" ;; */bash) printf '%s' "${HOME}/.bashrc" ;; */fish) printf '%s' "${HOME}/.config/fish/config.fish" ;; *) printf '%s' "${HOME}/.profile" ;; esac } # Physical path of a file, so that two spellings of one location compare equal. canonical_file() { _cf_dir=$(CDPATH='' cd -- "$(dirname -- "$1")" 2>/dev/null && pwd -P) || return 1 printf '%s/%s' "$_cf_dir" "$(basename -- "$1")" } # Paths are compared rather than versions: reading the version means running # the file a PATH entry resolved to, and that entry may be one the person # installing does not control. Installing to /usr/local/bin means root. warn_if_shadowed() { _found=$(command -v "$BIN" 2>/dev/null) || return 0 [ -n "$_found" ] || return 0 _found_real=$(canonical_file "$_found") || return 0 _target_real=$(canonical_file "${_dir}/${BIN}") || return 0 [ "$_found_real" != "$_target_real" ] || return 0 warn "${BIN} on PATH is ${_found}, not the copy just installed" printf ' %bRemove that file, or put %s earlier in PATH.%b\n' \ "${DIM}" "$_dir" "${RESET}" >&2 } cleanup() { [ -d "${TMPDIR_INSTALL-}" ] && rm -rf "$TMPDIR_INSTALL"; } install_local() { [ -f "$BINARY" ] || die "binary not found: ${BINARY}" info "Source: ${BINARY}" mkdir -p "$_dir" install -m 755 "$BINARY" "${_dir}/${BIN}" _version=$(installed_version "${_dir}/${BIN}") _tag=${_version:-local} } install_release() { check_dependencies detect_platform info "Platform: ${OS}/${ARCH}" _resolved=$(resolve_tag) [ -n "$_resolved" ] || die "could not determine latest release" _version=${_resolved#[vV]} # _tag is the label both install paths report; the tag form itself matters # only when building the download URL. _tag=$_version info "Release: ${_version}" if [ "$FORCE" != 1 ] && [ "$(installed_version "${_dir}/${BIN}")" = "$_version" ]; then _already_installed=1 return 0 fi _archive="${BIN}_${_version}_${OS}_${ARCH}.tar.gz" # Releases from 1.19.0 on are tagged "v1.19.0"; earlier ones are tagged # "1.18.0". A pinned version comes from the user and may use either form, # so try the current convention first and fall back to the legacy one. A # tag discovered from the latest release is already exact - use it as is. if [ -n "${SORTIE_VERSION-}" ]; then _candidates="v${_version} ${_version}" else _candidates=$_resolved fi TMPDIR_INSTALL=$(mktemp -d) trap cleanup EXIT trap 'exit 1' INT TERM info "Downloading ${_archive}" # Errors are held back while candidates are tried: a 404 on the first form # only means the release uses the other one, and printing it would alarm a # user whose install then succeeds. If every form fails, the last error is # released so a genuine network fault stays diagnosable. _found=0 for _candidate in $_candidates; do _base="https://github.com/${REPO}/releases/download/${_candidate}" if fetch "${_base}/${_archive}" "${TMPDIR_INSTALL}/${_archive}" \ 2>"${TMPDIR_INSTALL}/fetch.err"; then _found=1 break fi done if [ "$_found" != 1 ]; then if [ -s "${TMPDIR_INSTALL}/fetch.err" ]; then cat "${TMPDIR_INSTALL}/fetch.err" >&2 fi die "download failed - verify release ${_version} has asset for ${OS}/${ARCH}" fi if [ "${SORTIE_NO_VERIFY-}" != "1" ]; then fetch "${_base}/checksums.txt" "${TMPDIR_INSTALL}/checksums.txt" \ || die "failed to download checksums" verify_checksum "${TMPDIR_INSTALL}/${_archive}" "${TMPDIR_INSTALL}/checksums.txt" info "Checksum verified" fi tar -xzf "${TMPDIR_INSTALL}/${_archive}" -C "${TMPDIR_INSTALL}" mkdir -p "$_dir" install -m 755 "${TMPDIR_INSTALL}/${BIN}" "${_dir}/${BIN}" } main() { setup_colors parse_args "$@" need_cmd install _dir=$(resolve_install_dir) _already_installed="" if [ -n "$BINARY" ]; then install_local else install_release fi if [ -n "$_already_installed" ]; then ok "${BIN} ${_tag} is already installed at ${_dir}/${BIN}" else ok "Installed ${BIN} ${_tag} to ${_dir}/${BIN}" fi warn_if_shadowed case ":${PATH}:" in *":${_dir}:"*) ;; *) if [ "${GITHUB_ACTIONS-}" = "true" ] && [ -n "${GITHUB_PATH-}" ]; then printf '%s\n' "$_dir" >> "$GITHUB_PATH" info "Added ${_dir} to \$GITHUB_PATH" else _rc=$(shell_rc) printf '\n' info "Add to your PATH to get started:" # shellcheck disable=SC2016 printf ' %becho '\''export PATH="%s:$PATH"'\'' >> %s%b\n\n' \ "${DIM}" "$_dir" "$_rc" "${RESET}" fi ;; esac printf '\n' _utf8=false case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *[Uu][Tt][Ff]8*|*[Uu][Tt][Ff]-8*) _utf8=true ;; esac # Skip decorative art in CI pipelines and non-interactive terminals. printf '\n' if [ -z "${CI-}" ] && [ -t 1 ] && [ "${TERM-}" != "dumb" ]; then if [ "$_utf8" = "true" ]; then printf '\033[36m ███████╗ ██████╗ ██████╗ ████████╗██╗███████╗\n' printf ' ██╔════╝██╔═══██╗██╔══██╗╚══██╔══╝██║██╔════╝\n' printf ' ███████╗██║ ██║██████╔╝ ██║ ██║█████╗\n' printf ' ╚════██║██║ ██║██╔══██╗ ██║ ██║██╔══╝\n' printf ' ███████║╚██████╔╝██║ ██║ ██║ ██║███████╗\n' printf ' ╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝\033[0m\n' else printf ' Sortie\n' fi fi printf ' %bTurns issue tickets into autonomous sessions.%b\n\n' "${DIM}" "${RESET}" printf '\n %bDocs:%b https://docs.sortie-ai.com\n' "${BOLD}${YELLOW}" "${RESET}" if [ -n "$_version" ]; then printf ' %bChangelog:%b https://docs.sortie-ai.com/changelog/#%s\n' "${BOLD}${YELLOW}" "${RESET}" "$_version" fi printf ' %bGitHub:%b https://github.com/%s\n' "${BOLD}${YELLOW}" "${RESET}" "$REPO" if [ "$_utf8" = "true" ]; then printf '\n Happy hacking! %b♠%b\n\n' "${CYAN}" "${RESET}" else printf '\n Happy hacking!\n\n' fi } main "$@"