Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/bin/sh
# Cotal installer. The open web for agents.
#
#   curl -fsSL https://get.cotal.ai | sh
#
# Read this before you run it. That is the point of serving it as plain text:
#   source   https://github.com/Cotal-AI/Cotal/blob/main/install.sh
#   browser  https://get.cotal.ai  (served as text/plain so you can read it in a tab)
#
# WHAT IT DOES
#   1. Works out your platform, and refuses to guess if it is one we do not ship for.
#   2. Finds a Node >= 22.10. If your machine has one, it uses it. If not, it downloads the
#      official Node LTS build into Cotal's own directory and verifies it against the
#      SHASUMS256.txt published beside it on nodejs.org.
#   3. Installs the `cotal-ai` package from npm into a prefix Cotal owns.
#   4. Writes one launcher to ~/.local/bin/cotal that pins the Node it just verified.
#   5. Adds that directory to PATH, once, in a marked block you can delete.
#
# WHAT IT DOES NOT DO
#   No sudo. No system directories. No package manager. Nothing outside your home
#   directory. `curl ... | sudo sh` is refused outright; see require_unprivileged below.
#
# WHY THE LAUNCHER PINS NODE
#   npm silently skips an optional dependency whose `engines` the running Node does not
#   satisfy. Cotal's NATS broker binary is such a dependency. Install under Node 18 and
#   you get a Cotal with no broker and a confusing crash later. Pinning the Node that the
#   install was verified against is what makes that failure impossible rather than rare.
#
# UNINSTALL
#   rm -rf ~/.local/share/cotal ~/.local/bin/cotal   # this installer's files
#   rm -rf ~/.cotal                                  # your meshes, agents and credentials
#   then delete the `# cotal` block from your shell rc.
#
# Apache-2.0. Everything below runs inside main(), invoked by the brace-wrapped last line.
# A compound command is not executed until its closing brace is parsed, so any prefix that is
# missing a syntactically meaningful byte of that line defines some functions and then does
# nothing. (A prefix missing only the trailing newline is the whole program, and runs, with
# exactly the arguments the caller passed. What can never happen is main running with argv
# the caller did not give it, which is what a bare `main` at the end would allow.)

set -eu

# ---------------------------------------------------------------------------------------
# What we install
# ---------------------------------------------------------------------------------------

COTAL_PKG="cotal-ai"
NODE_MIN="22.10"                # cotal-ai's engines floor
NODE_MIN_RANK=22010             # the same floor as major*1000+minor, for one integer compare
NODE_PIN="v24.18.0"             # Node LTS ("Krypton"), vendored only when the box has none
NODE_DIST="https://nodejs.org/dist"
DOCS_URL="https://docs.cotal.ai"
ISSUES_URL="https://github.com/Cotal-AI/Cotal/issues"

# ---------------------------------------------------------------------------------------
# Terminal
#
# Colors match the Cotal CLI exactly (implementations/cli/src/lib/theme.ts): brand blue
# #58a6ff, success green #3fb950, 24-bit when the terminal advertises it and 16-color
# otherwise. Piped output is plain text; NO_COLOR keeps the layout but drops the color.
# ---------------------------------------------------------------------------------------

ESC=$(printf '\033')
C_OFF="" C_BRAND="" C_BOLD="" C_DIM="" C_GREEN="" C_RED="" C_YELLOW=""
TTY=0   # stdout is a terminal: drives the banner, the spinner, and in-place redraws
COLOR=0 # ...and we are also allowed to color it
UTF8=0
S_OK="+" S_NO="x" S_WARN="!" S_DOT="-" S_SEP="-"
SPIN_FRAMES="- \\ | /"

init_term() {
  [ -t 1 ] && TTY=1
  # NO_COLOR means plain text, not a downgrade to no feedback at all: someone who turns
  # color off still wants to watch the download happen.
  [ "$TTY" = 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ] && COLOR=1
  if [ "$COLOR" = 1 ]; then
    # Braces are load-bearing: `$ESC[0m` would be read as a name that includes the bracket.
    C_OFF="${ESC}[0m" C_BOLD="${ESC}[1m" C_DIM="${ESC}[2m"
    case "${COLORTERM:-}" in
      truecolor | 24bit)
        C_BRAND="${ESC}[38;2;88;166;255m"
        C_GREEN="${ESC}[38;2;63;185;80m"
        ;;
      *)
        C_BRAND="${ESC}[34m"
        C_GREEN="${ESC}[32m"
        ;;
    esac
    C_RED="${ESC}[31m"
    C_YELLOW="${ESC}[33m"
  fi
  # Effective locale, in POSIX precedence order. Matching all three at once let LC_ALL=C
  # with LANG=en_US.UTF-8 still print Unicode, which is exactly backwards.
  _loc="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
  case "$_loc" in
    *UTF-8* | *utf8* | *UTF8* | *utf-8*) UTF8=1 ;;
  esac
  if [ "$UTF8" = 1 ]; then
    S_OK="✓" S_NO="✗" S_WARN="!" S_DOT="·" S_SEP="·"
    SPIN_FRAMES="⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏"
  fi
}

# Every writer goes through printf '%s' so nothing we print is ever read as a format string.
blank() { printf '\n'; }
ok_line() { printf '  %s%s%s %s\n' "$C_GREEN" "$S_OK" "$C_OFF" "$*"; }
warn_line() { printf '  %s%s%s %s\n' "$C_YELLOW" "$S_WARN" "$C_OFF" "$*"; }
dim_line() { printf '  %s%s%s\n' "$C_DIM" "$*" "$C_OFF"; }
head_line() { printf '  %s%s%s\n' "$C_BOLD" "$*" "$C_OFF"; }

# A failure always names the cause on the first line and the way out on the next.
die() {
  spin_stop
  blank
  printf '  %s%s %s%s\n' "$C_RED" "$S_NO" "$1" "$C_OFF"
  shift
  for line in "$@"; do printf '    %s\n' "$line"; done
  blank
  printf '  %sStuck? %s%s\n' "$C_DIM" "$ISSUES_URL" "$C_OFF"
  blank
  exit 1
}

# A value emitted as shell SOURCE (the launcher, an rc line) must be a safe literal, not
# pasted between quotes. A path may legally contain " $ ` \ and a newline, and pasting one
# into COTAL_ROOT="..." produces a launcher that does not parse, or worse, one that runs
# something. Single quotes with the '"'"' escape are the only form with no metacharacters.
shquote() {
  printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
}

# `~/x` instead of `/home/someone/x`, so paths stay readable and shareable.
tildify() {
  case "$1" in
    "$HOME"/*) printf '~%s\n' "${1#"$HOME"}" ;;
    *) printf '%s\n' "$1" ;;
  esac
}

# ---------------------------------------------------------------------------------------
# Spinner: a background writer, always reaped by the EXIT trap.
# ---------------------------------------------------------------------------------------

SPIN_PID=""

spin_start() {
  if [ "$TTY" != 1 ]; then
    dim_line "$1"
    return 0
  fi
  # shellcheck disable=SC2016  # frames are re-read each loop, not expanded once
  (
    while :; do
      for frame in $SPIN_FRAMES; do
        printf '\r  %s%s%s %s' "$C_BRAND" "$frame" "$C_OFF" "$1"
        sleep 0.08 2>/dev/null || sleep 1
      done
    done
  ) &
  SPIN_PID=$!
}

spin_stop() {
  [ -n "$SPIN_PID" ] || return 0
  kill "$SPIN_PID" 2>/dev/null || true
  wait "$SPIN_PID" 2>/dev/null || true
  SPIN_PID=""
  [ "$TTY" = 1 ] && printf '\r%s[2K' "$ESC"
  return 0
}

TMPDIR_SELF=""
LOCKDIR=""
# shellcheck disable=SC2329,SC2317  # invoked from the EXIT/INT/TERM/HUP traps set in main
cleanup() {
  spin_stop
  [ -n "$TMPDIR_SELF" ] && [ -d "$TMPDIR_SELF" ] && rm -rf "$TMPDIR_SELF"
  [ -n "$LOCKDIR" ] && [ -L "$LOCKDIR" ] && rm -f "$LOCKDIR"
  return 0
}

# Two installers writing the same prefix at once corrupt each other: npm stages and renames
# inside it, so a concurrent pair can finish "successfully" and leave no working cotal at
# all. Observed intermittently, which is worse than a clean failure. Creating a symlink is
# atomic, so one install proceeds and the rest say so and stop.
# The lock owner, as "<host>:<pid>". A bare pid says nothing off this machine: on a shared or
# NFS home, or across PID namespaces, another host's live pid simply does not exist here and
# `kill -0` would call a running install stale. Staleness is only ever judged for our own host.
lock_owner_id() {
  printf '%s:%s\n' "$(uname -n 2>/dev/null || printf 'unknown')" "$$"
}

acquire_install_lock() {
  _lock="$INSTALL_DIR/.install-lock"
  _tries=0
  while :; do
    # `ln -s target name` creates name INSIDE name when name is an existing directory, and
    # would then report success while holding no lock at all. A directory here is not ours
    # (an older version left it, or something unrelated did), so refuse rather than guess.
    if [ -d "$_lock" ] && [ ! -L "$_lock" ]; then
      die "Something is in the way of the install lock at $(tildify "$_lock")." \
        "Remove it if no other install is running:" \
        "  rm -rf $_lock"
    fi
    # A symlink, not a directory: creating one is a single atomic operation that fails if the
    # name already exists, AND its target string carries the owner's pid. `mkdir` then
    # write-a-pid-file has a window between the two where the lock is held but unattributed,
    # and a second installer arriving in that window reads no owner, concludes "stale", and
    # steals a live lock. There is no such window here: the owner ships with the lock.
    if ln -s "$(lock_owner_id)" "$_lock" 2>/dev/null; then
      LOCKDIR="$_lock"
      return 0
    fi
    # ln -s failing with nothing at that path means the failure was not "already exists":
    # the filesystem has no symlinks, or the directory is not writable. Say which, rather
    # than blame a lock that is not there.
    if [ ! -e "$_lock" ] && [ ! -L "$_lock" ]; then
      die "Could not create the install lock at $(tildify "$_lock")." \
        "The filesystem may not support symlinks, or $(tildify "$INSTALL_DIR") may not be" \
        "writable by you. Install somewhere else:" \
        "  curl -fsSL https://get.cotal.ai | COTAL_INSTALL_DIR=/path/you/own sh"
    fi
    _owner=$(readlink "$_lock" 2>/dev/null || printf '')
    if [ -z "$_owner" ]; then
      die "Something is in the way of the install lock at $(tildify "$_lock")." \
        "Remove it if no other install is running:" \
        "  rm -rf $_lock"
    fi
    _owner_host=${_owner%:*}
    _owner_pid=${_owner##*:}
    # Another host holds it: we cannot ask whether that process is alive, so we never assume
    # it is dead. Refusing is the only safe answer on a shared or networked home.
    if [ "$_owner_host" != "$(uname -n 2>/dev/null || printf 'unknown')" ]; then
      die "Another Cotal install holds the lock from host \"$_owner_host\" (pid $_owner_pid)." \
        "This install directory is shared with another machine, and whether that install is" \
        "still running cannot be determined from here." \
        "" \
        "Wait for it to finish. If you are sure it is gone, clear the lock:" \
        "  rm -f $_lock"
    fi
    # kill -0 answers "may I signal this pid", not "is this my installer". A pid recycled by
    # an unrelated process therefore reads as a live owner. That is rare, and fail-closed is
    # the right default, but it must never be a dead end: print the way out every time.
    if kill -0 "$_owner_pid" 2>/dev/null; then
      die "Another Cotal install is already running (pid $_owner_pid)." \
        "They would overwrite each other inside $(tildify "$INSTALL_DIR")." \
        "Wait for it to finish, then run this again." \
        "" \
        "If you are sure no other install is running, clear the lock:" \
        "  rm -f $_lock"
    fi
    # The owner is gone, so the lock is stale. Drop it and retry rather than assume the retry
    # wins: another installer may be doing exactly the same thing right now.
    rm -f "$_lock" 2>/dev/null || true
    _tries=$((_tries + 1))
    [ "$_tries" -lt 5 ] || die "Could not claim the install lock at $(tildify "$_lock")." \
      "Remove it by hand if no other install is running:" \
      "  rm -f $_lock"
  done
}

# ---------------------------------------------------------------------------------------
# Options
# ---------------------------------------------------------------------------------------

OPT_YES=0
OPT_SETUP=1
OPT_MODIFY_PATH=1
OPT_VENDOR_NODE=0
OPT_DRY_RUN=0
OPT_VERSION="latest"

usage() {
  cat <<EOF

  Cotal installer. The open web for agents.

  Usage
    curl -fsSL https://get.cotal.ai | sh
    curl -fsSL https://get.cotal.ai | sh -s -- [options]

  Options
    -y, --yes             non-interactive; take every default and skip guided setup
        --version <ver>   install a specific cotal-ai version (default: latest)
        --vendor-node     always install Cotal's own Node, even if this machine has one
        --no-modify-path  do not touch any shell rc; print the PATH line instead
        --no-setup        install only; do not run guided setup afterwards
        --dry-run         report what would happen and change nothing
    -h, --help            this text

  Environment
    COTAL_INSTALL_DIR     install root      (default: \${XDG_DATA_HOME:-~/.local/share}/cotal)
    COTAL_BIN_DIR         launcher location (default: ~/.local/bin)
    NO_COLOR              disable color

  Docs $DOCS_URL

EOF
  exit 0
}

parse_args() {
  while [ $# -gt 0 ]; do
    case "$1" in
      -y | --yes) OPT_YES=1 ;;
      --no-setup) OPT_SETUP=0 ;;
      --no-modify-path) OPT_MODIFY_PATH=0 ;;
      --vendor-node) OPT_VENDOR_NODE=1 ;;
      --dry-run) OPT_DRY_RUN=1 ;;
      --version)
        [ $# -ge 2 ] || die "--version needs a value" "For example: --version 0.14.2"
        OPT_VERSION="$2"
        shift
        ;;
      --version=*) OPT_VERSION="${1#--version=}" ;;
      -h | --help) usage ;;
      *)
        die "Unknown option: $1" "Run with --help to see what this installer accepts."
        ;;
    esac
    shift
  done
}

# ---------------------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------------------

have() { command -v "$1" >/dev/null 2>&1; }

# Piping an installer into a root shell is the thing every security guide tells you not to
# do, so we make it impossible rather than discouraged. It is also simply wrong here: it
# would install into root's home and leave every file unwritable by you.
require_unprivileged() {
  [ "$(id -u)" = "0" ] || return 0
  if [ -n "${COTAL_ALLOW_ROOT:-}" ]; then
    warn_line "running as root because COTAL_ALLOW_ROOT is set; installing into $HOME"
    return 0
  fi
  die "Do not run this installer as root." \
    "Cotal installs entirely inside your home directory and never needs elevation." \
    "Running it under sudo would install into root's home and leave the files" \
    "unwritable by you." \
    "" \
    "Run it as yourself:" \
    "  curl -fsSL https://get.cotal.ai | sh" \
    "" \
    "If you really do intend a root-owned install, set COTAL_ALLOW_ROOT=1."
}

# Only the vendored-Node path downloads and unpacks anything. A machine that already has a
# good Node needs none of this, and slim container images routinely ship Node without curl,
# so the requirement is stated where it is actually used, not as a blanket gate up front.
require_download_tools() {
  have curl || have wget ||
    die "Need curl or wget to download the Node runtime." \
      "This machine has no Node $NODE_MIN+, so one has to be fetched." \
      "Install curl or wget and run this again."
  have tar || die "Need tar to unpack the Node runtime." "Install tar and run this again."
  have mktemp || die "Need mktemp to stage the download safely." \
    "Install coreutils and run this again."
  have sha256sum || have shasum || have openssl ||
    die "Need a SHA-256 tool to verify the Node download." \
      "Install coreutils (sha256sum), perl (shasum), or openssl." \
      "This installer will not skip verification."
}

# One downloader interface over curl and wget so nothing below has to care which exists.
# --proto '=https' and --tlsv1.2 pin the transport: no plaintext, no protocol downgrade.
fetch_to() {
  _url=$1
  _out=$2
  if have curl; then
    curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
      --retry 3 --retry-connrefused --connect-timeout 15 -o "$_out" "$_url"
  else
    wget --quiet --https-only --tries=3 --timeout=15 -O "$_out" "$_url"
  fi
}

fetch_stdout() {
  if have curl; then
    curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
      --retry 3 --retry-connrefused --connect-timeout 15 "$1"
  else
    wget --quiet --https-only --tries=3 --timeout=15 -O - "$1"
  fi
}

# Visible byte progress for the one download big enough to notice.
fetch_with_progress() {
  if have curl && [ "$TTY" = 1 ]; then
    curl --fail --show-error --location --proto '=https' --tlsv1.2 \
      --retry 3 --retry-connrefused --connect-timeout 15 --progress-bar -o "$2" "$1"
  else
    fetch_to "$1" "$2"
  fi
}

sha256_of() {
  if have sha256sum; then
    sha256sum "$1" | cut -d' ' -f1
  elif have shasum; then
    shasum -a 256 "$1" | cut -d' ' -f1
  else
    openssl dgst -sha256 "$1" | sed 's/.*= *//'
  fi
}

# ---------------------------------------------------------------------------------------
# Platform
# ---------------------------------------------------------------------------------------

OS="" ARCH="" PLATFORM_LABEL="" PLATFORM_NOTE=""

detect_platform() {
  _uname_s=$(uname -s)
  _uname_m=$(uname -m)

  case "$_uname_s" in
    Darwin) OS="darwin" ;;
    Linux) OS="linux" ;;
    MINGW* | MSYS* | CYGWIN*)
      die "Windows shells are not supported by this installer." \
        "On Windows, install Node 22.10+ from https://nodejs.org and then run:" \
        "  npm install -g cotal-ai" \
        "Cotal itself runs fine on Windows; only this shell installer does not."
      ;;
    *)
      die "Unsupported operating system: $_uname_s" \
        "Cotal ships builds for macOS and Linux." \
        "If you have Node 22.10+ already: npm install -g cotal-ai"
      ;;
  esac

  case "$_uname_m" in
    x86_64 | amd64) ARCH="x64" ;;
    arm64 | aarch64) ARCH="arm64" ;;
    *)
      die "Unsupported CPU architecture: $_uname_m" \
        "Cotal ships x86_64 and arm64 builds." \
        "If you have Node 22.10+ already: npm install -g cotal-ai"
      ;;
  esac

  # An x86_64 process on Apple silicon is Rosetta. Install the native build, not the
  # emulated one, or every agent Cotal launches inherits the translation tax.
  if [ "$OS" = "darwin" ] && [ "$ARCH" = "x64" ] &&
    [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = "1" ]; then
    ARCH="arm64"
    PLATFORM_NOTE="this shell is under Rosetta; installing the native arm64 build"
  fi

  if [ "$OS" = "darwin" ]; then
    PLATFORM_LABEL="macOS $(sw_vers -productVersion 2>/dev/null || echo '') $S_SEP $ARCH"
  else
    # shellcheck source=/dev/null
    _distro=$(. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-Linux}" || printf 'Linux')
    PLATFORM_LABEL="$_distro $S_SEP $ARCH"
    case "$(uname -r)" in
      *microsoft* | *Microsoft* | *WSL*) PLATFORM_NOTE="running under WSL" ;;
    esac
  fi
  # Collapse the double space a missing sw_vers would leave behind.
  PLATFORM_LABEL=$(printf '%s' "$PLATFORM_LABEL" | sed 's/  */ /g')

  # Checked here, not only on the download path: a musl box with a perfectly good system
  # Node still cannot run Cotal.
  is_musl && refuse_musl
  return 0
}

# Cotal does not run on musl. Its terminal layer (node-pty) ships prebuilt native binaries
# for glibc only, so on Alpine `cotal` installs cleanly and then throws on first use, with
# or without this installer; `npm i -g cotal-ai` fails there the same way. Refusing up front
# is the honest outcome: better no install than one that cannot start.
#
# Official Node builds are glibc-only too, so this also covers the vendored runtime.
is_musl() {
  [ "$OS" = "linux" ] || return 1
  if have ldd && ldd --version 2>&1 | grep -qi musl; then return 0; fi
  [ -e /lib/ld-musl-x86_64.so.1 ] || [ -e /lib/ld-musl-aarch64.so.1 ]
}

refuse_musl() {
  die "Cotal does not support musl systems (Alpine and similar) yet." \
    "Its terminal layer ships prebuilt native binaries for glibc only, so Cotal would" \
    "install here and then fail to start. That is true of \`npm i -g cotal-ai\` too, not" \
    "just this installer." \
    "" \
    "Use a glibc distribution (Debian, Ubuntu, Fedora, Arch) or a glibc container:" \
    "  docker run -it debian:stable bash" \
    "" \
    "Track musl support: $ISSUES_URL"
}

# ---------------------------------------------------------------------------------------
# Node
# ---------------------------------------------------------------------------------------

# ${HOME:-} rather than $HOME: these are evaluated when the script loads, before main() can
# run its checks, and under `set -u` a bare $HOME with HOME unset would abort here with a raw
# "HOME: unbound variable" instead of the readable message main() has waiting.
INSTALL_DIR="${COTAL_INSTALL_DIR:-${XDG_DATA_HOME:-${HOME:-}/.local/share}/cotal}"
BIN_DIR="${COTAL_BIN_DIR:-${HOME:-}/.local/bin}"

# The launcher bakes these paths in and is then run from every directory on the machine, so a
# relative override (COTAL_INSTALL_DIR=./cotal) has to become absolute before anything records
# it. Otherwise the install works from the directory it was made in and nowhere else.
absolutize() {
  case "$1" in
    /*) printf '%s\n' "$1" ;;
    ./*) printf '%s\n' "$PWD/${1#./}" ;;
    *) printf '%s\n' "$PWD/$1" ;;
  esac
}
INSTALL_DIR=$(absolutize "$INSTALL_DIR")
BIN_DIR=$(absolutize "$BIN_DIR")

# Resolve a path COMPLETELY, following the final component too. Used for anything we are
# about to write through, because `>>` follows a symlink and lands wherever it points:
# ~/.zshrc can be a link out of $HOME, and containment has to see the real destination.
# The opposite of canonical_file below, which is for paths we only need to name.
resolved_target() {
  _rt="$1"
  _rt_hops=0
  while [ -L "$_rt" ]; do
    # Linux follows 40 links. Stopping short and then treating the still-unresolved link as
    # canonical is how a long chain walks straight past a containment check, so running out
    # of hops is a refusal, not an answer.
    [ "$_rt_hops" -lt 40 ] || return 1
    _rt_link=$(readlink "$_rt" 2>/dev/null) || return 1
    case "$_rt_link" in
      /*) _rt="$_rt_link" ;;
      *) _rt="$(dirname "$_rt")/$_rt_link" ;;
    esac
    _rt_hops=$((_rt_hops + 1))
  done
  canonical_file "$_rt"
}

# Resolve a file's DIRECTORY physically (killing `..` and symlinks) while keeping its
# basename, so a path becomes canonical without repointing at a symlink's versioned target.
# Falls back to the input when the directory does not exist; callers check what they need.
canonical_file() {
  _cf_dir=$(cd "$(dirname "$1")" 2>/dev/null && pwd -P) || {
    printf '%s\n' "$1"
    return 0
  }
  [ -n "$_cf_dir" ] || {
    printf '%s\n' "$1"
    return 0
  }
  printf '%s\n' "$_cf_dir/$(basename "$1")"
}
NODE_BIN="" NPM_BIN="" NODE_LABEL="" NODE_VENDORED=0
LOG=""

# `22.10.0` ranks 22010 and `22.9.0` ranks 22009, so the floor's minor is part of the compare.
node_rank() { "$1" -p 'const [m,n]=process.versions.node.split(".");m*1000+ +n' 2>/dev/null || echo 0; }

# Prefer a Node already on the box: it is instant, and it is the Node the user maintains.
# Fall through to a vendored runtime only when there is nothing here good enough.
resolve_node() {
  if [ "$OPT_VENDOR_NODE" = 0 ] && have node && have npm; then
    _sys_node=$(canonical_file "$(command -v node)")
    _sys_rank=$(node_rank "$_sys_node")
    if [ "$_sys_rank" -ge "$NODE_MIN_RANK" ] 2>/dev/null; then
      NODE_BIN="$_sys_node"
      NPM_BIN=$(canonical_file "$(command -v npm)")
      NODE_LABEL="$("$NODE_BIN" -v) $S_SEP already on this machine"
      return 0
    fi
  fi
  vendor_node
}

vendor_node() {
  require_download_tools
  TMPDIR_SELF=$(mktemp -d "${TMPDIR:-/tmp}/cotal-install.XXXXXX")
  chmod 700 "$TMPDIR_SELF"

  _dir="$INSTALL_DIR/runtime/node-$NODE_PIN-$OS-$ARCH"
  if [ -x "$_dir/bin/node" ] && [ -x "$_dir/bin/npm" ]; then
    NODE_BIN="$_dir/bin/node"
    NPM_BIN="$_dir/bin/npm"
    NODE_LABEL="$NODE_PIN $S_SEP already installed by Cotal"
    NODE_VENDORED=1
    return 0
  fi

  # .tar.gz rather than the smaller .tar.xz: every tar in the wild reads gzip, while xz
  # support depends on the build. One code path that always works beats a probe.
  _file="node-$NODE_PIN-$OS-$ARCH.tar.gz"
  _url="$NODE_DIST/$NODE_PIN/$_file"
  _tmp="$TMPDIR_SELF/$_file"

  if [ "$TTY" = 1 ]; then
    if [ "$OPT_VENDOR_NODE" = 1 ]; then
      _why="--vendor-node"
    else
      _why="this machine has no Node $NODE_MIN+"
    fi
    printf '  %s%s%s Downloading Node %s (%s)\n' \
      "$C_BRAND" "$S_DOT" "$C_OFF" "$NODE_PIN" "$_why"
  fi
  fetch_with_progress "$_url" "$_tmp" ||
    die "Could not download Node $NODE_PIN." \
      "Tried: $_url" \
      "Check your connection or any proxy, then run this again."
  [ "$TTY" = 1 ] && printf '%s[1A%s[2K' "$ESC" "$ESC"

  spin_start "Verifying Node $NODE_PIN"
  # The authority is the SHASUMS256.txt the Node project publishes beside the tarball,
  # fetched over the same pinned TLS. A mismatch aborts; it is never a warning.
  _want=$(fetch_stdout "$NODE_DIST/$NODE_PIN/SHASUMS256.txt" | grep " $_file\$" | cut -d' ' -f1) ||
    die "Could not fetch Node's published checksums." \
      "Tried: $NODE_DIST/$NODE_PIN/SHASUMS256.txt"
  spin_stop
  [ -n "$_want" ] ||
    die "Node's checksum file does not list $_file." \
      "That should not happen for a published release; please report it:" \
      "$ISSUES_URL"
  _got=$(sha256_of "$_tmp")
  [ "$_want" = "$_got" ] ||
    die "Checksum mismatch on the Node download. Refusing to install it." \
      "expected $_want" \
      "got      $_got" \
      "Delete any proxy cache in front of nodejs.org and try again."

  spin_start "Unpacking Node $NODE_PIN"
  mkdir -p "$INSTALL_DIR/runtime"
  rm -rf "$_dir.partial"
  mkdir -p "$_dir.partial"
  tar -xzf "$_tmp" -C "$_dir.partial" --strip-components=1 ||
    die "Could not unpack the Node archive." "The download may be truncated; run this again."
  rm -rf "$_dir"
  mv "$_dir.partial" "$_dir"
  spin_stop

  if [ ! -x "$_dir/bin/node" ] || [ ! -x "$_dir/bin/npm" ]; then
    die "The unpacked Node archive has no node/npm where they were expected." \
      "Looked in: $_dir/bin"
  fi

  # A correct checksum says the bytes are authentic, not that they run here. Official Node 22.10+
  # is built against glibc 2.28, so on an older distribution the download and the checksum both
  # succeed and npm then dies with "version GLIBC_2.28 not found" several steps later, after we
  # have already announced the runtime as good. Ask the binary itself, now.
  if ! _node_check=$("$_dir/bin/node" -v 2>&1); then
    rm -rf "$_dir"
    die "Node $NODE_PIN downloaded and verified, but it cannot run on this system." \
      "$(printf '%s' "$_node_check" | head -1)" \
      "" \
      "Official Node $NODE_MIN+ builds require glibc 2.28 or newer (Debian 10," \
      "Ubuntu 18.10, RHEL 8 and later). This machine is older than that." \
      "" \
      "Install a Node $NODE_MIN+ built for this distribution, then run this again;" \
      "it will use yours instead of downloading one."
  fi

  NODE_BIN="$_dir/bin/node"
  NPM_BIN="$_dir/bin/npm"
  NODE_LABEL="$NODE_PIN $S_SEP downloaded and verified against nodejs.org"
  NODE_VENDORED=1
}

# An installer from before the umask in main, run under a umask of 0002, left the install
# group-writable, and a folder another account can write makes Cotal refuse the Node runtime in
# it as an endpoint handler. Remove that write from this account's own entries; links are not
# followed.
repair_install_modes() {
  find "$INSTALL_DIR" \( -type d -o -type f \) -user "$(id -u)" \( -perm -020 -o -perm -002 \) \
    -exec chmod go-w {} + ||
    warn_line "could not remove group and other write from everything in $(tildify "$INSTALL_DIR")"
}

# A directory holding node, npm and npx and nothing else. The launcher puts this on PATH,
# so Cotal's child processes can find npm without us reordering the user's whole PATH and
# shadowing their other tools.
link_node_bin() {
  _nb="$INSTALL_DIR/nodebin"
  mkdir -p "$_nb"
  ln -sf "$NODE_BIN" "$_nb/node"
  ln -sf "$NPM_BIN" "$_nb/npm"
  _npx=$(dirname "$NPM_BIN")/npx
  [ -e "$_npx" ] && ln -sf "$_npx" "$_nb/npx"
  return 0
}

# ---------------------------------------------------------------------------------------
# Cotal
# ---------------------------------------------------------------------------------------

INSTALLED_VERSION="" PREVIOUS_VERSION=""

read_installed_version() {
  _pkg="$INSTALL_DIR/lib/node_modules/$COTAL_PKG/package.json"
  [ -f "$_pkg" ] || return 0
  # Read the file, do not resolve it as a module: cotal-ai is ESM, and this must not depend
  # on how the running Node treats `require` in an eval context.
  "$NODE_BIN" -e \
    'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).version||"")' \
    "$_pkg" 2>/dev/null || true
}

install_cotal() {
  PREVIOUS_VERSION=$(read_installed_version)
  _spec="$COTAL_PKG"
  [ "$OPT_VERSION" = "latest" ] || _spec="$COTAL_PKG@$OPT_VERSION"

  if [ -n "$PREVIOUS_VERSION" ]; then
    spin_start "Updating Cotal (installed: $PREVIOUS_VERSION)"
  else
    spin_start "Installing Cotal"
  fi

  # nodebin goes on PATH for exactly this call. npm's own entry point resolves `node` through
  # PATH, so on a machine with no system Node the vendored npm cannot start itself without
  # it. Prepending only that one directory keeps every other tool the user has intact.
  #
  # --prefix keeps this entirely inside a directory Cotal owns, which is what removes the
  # need for sudo. npm verifies the registry tarball against its published sha512 integrity
  # on the way in, so the package itself arrives checked without us re-implementing it.
  if ! PATH="$INSTALL_DIR/nodebin:$PATH" "$NPM_BIN" install -g --prefix "$INSTALL_DIR" \
    --no-fund --no-audit --loglevel=error "$_spec" >>"$LOG" 2>&1; then
    spin_stop
    show_log_tail
    die "npm could not install $_spec." \
      "Full output: $(tildify "$LOG")"
  fi
  spin_stop

  INSTALLED_VERSION=$(read_installed_version)
  [ -n "$INSTALLED_VERSION" ] ||
    die "npm reported success but $COTAL_PKG is not in the install prefix." \
      "Looked in: $(tildify "$INSTALL_DIR/lib/node_modules")" \
      "Full output: $(tildify "$LOG")"
  [ -x "$INSTALL_DIR/bin/cotal" ] || [ -L "$INSTALL_DIR/bin/cotal" ] ||
    die "$COTAL_PKG@$INSTALLED_VERSION installed without its cotal executable." \
      "Looked for: $(tildify "$INSTALL_DIR/bin/cotal")"
}

show_log_tail() {
  [ -f "$LOG" ] || return 0
  blank
  dim_line "last lines of $(tildify "$LOG"):"
  tail -n 12 "$LOG" | while IFS= read -r line; do printf '    %s%s%s\n' "$C_DIM" "$line" "$C_OFF"; done
  return 0
}

# The launcher is the whole reason a Cotal installed today still works after you change
# your system Node tomorrow. It pins the runtime, and it points npm at Cotal's own prefix
# so `cotal update` and `cotal ext add` keep writing where this installer wrote.
write_launcher() {
  mkdir -p "$BIN_DIR"
  _tmp="$BIN_DIR/.cotal.$$"
  _root_lit=$(shquote "$INSTALL_DIR")
  cat >"$_tmp" <<EOF
#!/bin/sh
# Cotal launcher, written by https://get.cotal.ai
# Pins the Node this install was verified against, so a change to the system Node can never
# leave Cotal running on a runtime it was not installed for.
COTAL_ROOT=${_root_lit}

# When the pin was a Node this installer did not own, the user can still remove it later.
# Say so in one clear line instead of letting them hit the raw
# "/usr/bin/env: 'node': No such file or directory", which is the exact confusion this
# whole installer exists to prevent.
if [ ! -x "\$COTAL_ROOT/nodebin/node" ]; then
  echo "cotal: the Node runtime this install was pinned to is no longer there." >&2
  echo "       (expected \$COTAL_ROOT/nodebin/node)" >&2
  echo "" >&2
  echo "Repair it by re-running the installer:" >&2
  echo "  curl -fsSL https://get.cotal.ai | sh" >&2
  echo "" >&2
  echo "To stop depending on the system Node entirely, add --vendor-node:" >&2
  echo "  curl -fsSL https://get.cotal.ai | sh -s -- --vendor-node" >&2
  exit 1
fi

PATH="\$COTAL_ROOT/nodebin:\$PATH"
export PATH
# Tells cotal-ai's own Node-version preflight that this install pinned a runtime, so if the
# pin is ever REPLACED by an older Node (rather than removed, which is caught above) it can
# point at the installer instead of at generic nvm advice.
COTAL_LAUNCHER=1
export COTAL_LAUNCHER
# Keep \`cotal update\` and \`cotal ext add\` inside the prefix this installer created,
# rather than a system-wide npm root that would need sudo.
npm_config_prefix="\$COTAL_ROOT"
export npm_config_prefix
exec "\$COTAL_ROOT/bin/cotal" "\$@"
EOF
  chmod 755 "$_tmp"
  mv "$_tmp" "$BIN_DIR/cotal"
}

# ---------------------------------------------------------------------------------------
# PATH
# ---------------------------------------------------------------------------------------

PATH_STATE="" # already | added:<file> | manual
PATH_RC=""

on_path() {
  case ":$PATH:" in
    *":$BIN_DIR:"*) return 0 ;;
    *) return 1 ;;
  esac
}

# One marked block, appended once. Delete the block to undo it; re-running this installer
# will not stack a second copy.
setup_path() {
  if on_path; then
    PATH_STATE="already"
    return 0
  fi
  if [ "$OPT_MODIFY_PATH" = 0 ]; then
    PATH_STATE="manual"
    return 0
  fi

  _shell=$(basename "${SHELL:-sh}")
  _marker="# cotal"
  case "$_shell" in
    zsh)
      PATH_RC="${ZDOTDIR:-$HOME}/.zshrc"
      _line="export PATH=$(shquote "$BIN_DIR"):\"\$PATH\""
      ;;
    bash)
      # Linux logs you into an interactive shell reading .bashrc; macOS Terminal starts a
      # login shell reading .bash_profile. Prefer whichever this box already uses.
      #
      # Held in two variables rather than one space-separated list on purpose: a $HOME
      # containing a space would word-split the list, and the fallback would then name a
      # truncated path OUTSIDE the home directory and append the PATH block to it.
      if [ "$OS" = "darwin" ]; then
        _first="$HOME/.bash_profile"
        _second="$HOME/.bashrc"
      else
        _first="$HOME/.bashrc"
        _second="$HOME/.bash_profile"
      fi
      if [ -f "$_first" ]; then
        PATH_RC="$_first"
      elif [ -f "$_second" ]; then
        PATH_RC="$_second"
      else
        PATH_RC="$_first"
      fi
      _line="export PATH=$(shquote "$BIN_DIR"):\"\$PATH\""
      ;;
    fish)
      PATH_RC="${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish"
      _line="fish_add_path $(shquote "$BIN_DIR")"
      ;;
    *)
      PATH_STATE="manual"
      return 0
      ;;
  esac

  # Containment, enforced rather than assumed: this installer writes nothing outside $HOME,
  # so a computed rc path that escaped it is a bug, and appending to it would be the damage.
  # Refuse and fall back to printing the line for the user to add.
  #
  # Three checks, because a lexical prefix test alone is not containment. ZDOTDIR (or
  # XDG_CONFIG_HOME) can be $HOME/../elsewhere, which starts with "$HOME/" and still lands
  # outside it; and either could be a symlink pointing anywhere. So: reject `..` outright,
  # THEN prefix-test (both before anything is created), and finally re-test the physically
  # resolved path against the physically resolved $HOME to catch a symlinked escape.
  case "$PATH_RC" in
    ../* | */../* | */..)
      PATH_STATE="manual"
      return 0
      ;;
  esac
  case "$PATH_RC" in
    "$HOME"/*) ;;
    *)
      PATH_STATE="manual"
      return 0
      ;;
  esac

  _real_home=$(cd "$HOME" 2>/dev/null && pwd -P) || _real_home="$HOME"

  # Resolve the nearest EXISTING ancestor before creating anything. mkdir -p on a path that
  # runs through a symlink out of $HOME creates directories out there, and discovering the
  # escape afterwards does not un-create them. `$HOME/link -> /tmp/outside` with
  # ZDOTDIR=$HOME/link/newdir made /tmp/outside/newdir before the check ever ran.
  _anc=$(dirname "$PATH_RC")
  while [ ! -d "$_anc" ] && [ "$_anc" != "/" ] && [ "$_anc" != "." ]; do
    _anc=$(dirname "$_anc")
  done
  _anc_real=$(cd "$_anc" 2>/dev/null && pwd -P) || _anc_real=""
  case "$_anc_real" in
    "$_real_home" | "$_real_home"/*) ;;
    *)
      PATH_STATE="manual"
      return 0
      ;;
  esac

  mkdir -p "$(dirname "$PATH_RC")" 2>/dev/null || {
    PATH_STATE="manual"
    return 0
  }

  # And the final destination itself, following the rc file if it is a link. A refusal from
  # resolved_target (an unresolvable chain) is a refusal here too.
  _rc_real=$(resolved_target "$PATH_RC") || {
    PATH_STATE="manual"
    return 0
  }
  case "$_rc_real" in
    "$_real_home"/*) ;;
    *)
      PATH_STATE="manual"
      return 0
      ;;
  esac

  # Checking for the marker and then appending is a read-modify-write, and two installers
  # racing can both see "absent" and both append, leaving two blocks. mkdir is atomic on
  # every POSIX filesystem, so it is the lock. A stale lock is not worth a reaper: fall back
  # to printing the line rather than risk a duplicate.
  _lock="$PATH_RC.cotal-lock"
  if ! mkdir "$_lock" 2>/dev/null; then
    PATH_STATE="manual"
    return 0
  fi

  if [ -f "$PATH_RC" ] && grep -qF "$_marker" "$PATH_RC" 2>/dev/null; then
    rmdir "$_lock" 2>/dev/null || true
    PATH_STATE="already"
    return 0
  fi
  if ! { printf '\n%s\n%s\n' "$_marker" "$_line" >>"$PATH_RC"; } 2>/dev/null; then
    rmdir "$_lock" 2>/dev/null || true
    PATH_STATE="manual"
    return 0
  fi
  rmdir "$_lock" 2>/dev/null || true
  PATH_STATE="added:$PATH_RC"
  PATH="$BIN_DIR:$PATH"
  export PATH
}

# The child shell can inspect PATH, but cannot clear its parent's command cache or aliases.
report_path_selection() {
  _selected=$(command -v cotal 2>/dev/null || true)
  _selected_target=""
  case "$_selected" in
    /*) _selected_target=$(resolved_target "$_selected") || _selected_target="" ;;
  esac
  _installed_target=$(resolved_target "$BIN_DIR/cotal") || _installed_target=""
  if [ -z "$_selected_target" ] || [ "$_selected_target" != "$_installed_target" ]; then
    warn_line "Your PATH does not select the Cotal just installed."
    dim_line "Use $(shquote "$BIN_DIR/cotal") instead of plain cotal."
  fi
  dim_line "Check \`type -a cotal\` in your shell; this installer cannot change its cached commands, aliases or functions."
}

# ---------------------------------------------------------------------------------------
# What is already on this machine
#
# The agents Cotal has a connector for. Anything else is real, but we would be claiming a
# connection we do not have, so we do not list it.
# ---------------------------------------------------------------------------------------

AGENTS_FOUND=""
AGENTS_COUNT=0

detect_agents() {
  for _pair in "claude:Claude Code" "opencode:OpenCode" "pi:pi" "hermes:Hermes"; do
    _bin=${_pair%%:*}
    _name=${_pair#*:}
    if have "$_bin"; then
      AGENTS_FOUND="$AGENTS_FOUND$_bin|$_name
"
      AGENTS_COUNT=$((AGENTS_COUNT + 1))
    fi
  done
}

# ---------------------------------------------------------------------------------------
# Presentation
# ---------------------------------------------------------------------------------------

banner() {
  [ "$TTY" = 1 ] || return 0
  printf '%s' "$C_BRAND"
  cat <<'WORDMARK'

            _        _
   ___ ___ | |_ __ _| |
  / __/ _ \| __/ _`| |
 | (_| (_) | || (_| | |
  \___\___/ \__\__,_|_|
WORDMARK
  printf '%s\n' "$C_OFF"
  printf '  %sthe web for agents%s\n' "$C_DIM" "$C_OFF"
  printf '  %s%s%s\n\n' "$C_BRAND" "$(rule)" "$C_OFF"
}

rule() {
  if [ "$UTF8" = 1 ]; then
    printf '%s' '──────────────────────────────────────────'
  else
    printf '%s' '------------------------------------------'
  fi
}

summary() {
  blank
  if [ -n "$PREVIOUS_VERSION" ] && [ "$PREVIOUS_VERSION" != "$INSTALLED_VERSION" ]; then
    head_line "Cotal $PREVIOUS_VERSION → $INSTALLED_VERSION"
  else
    head_line "Cotal $INSTALLED_VERSION"
  fi
  dim_line "$(tildify "$BIN_DIR/cotal") $S_SEP installed in ${ELAPSED}s"

  if [ -n "$AGENTS_FOUND" ]; then
    blank
    head_line "Agents on this machine"
    printf '%s' "$AGENTS_FOUND" | while IFS='|' read -r _bin _name; do
      [ -n "$_bin" ] || continue
      printf '    %s%-12s%s %s\n' "$C_GREEN" "$_bin" "$C_OFF" "$_name"
    done
    blank
    if [ "$AGENTS_COUNT" -gt 1 ]; then
      dim_line "They cannot see each other yet. Cotal is where they meet."
    else
      dim_line "Put it on the mesh, then give it teammates."
    fi
  fi
}

next_steps() {
  _cotal=$(shquote "$BIN_DIR/cotal")
  blank
  head_line "Next"
  case "$PATH_STATE" in
    added:*)
      _rc=${PATH_STATE#added:}
      printf '    %s%s%s   %sreload your shell, once%s\n' "$C_BRAND" "exec $(basename "${SHELL:-sh}")" "$C_OFF" "$C_DIM" "$C_OFF"
      printf '    %s%s%s          %s%s%s\n' "$C_BRAND" "$_cotal setup" "$C_OFF" "$C_DIM" "get your machine ready" "$C_OFF"
      blank
      dim_line "PATH updated in $(tildify "$_rc")"
      ;;
    manual)
      printf '    %s%s%s\n' "$C_BRAND" "export PATH=\"$BIN_DIR:\$PATH\"" "$C_OFF"
      printf '    %s%s%s          %s%s%s\n' "$C_BRAND" "$_cotal setup" "$C_OFF" "$C_DIM" "get your machine ready" "$C_OFF"
      blank
      dim_line "Add that export to your shell rc to make it permanent."
      ;;
    *)
      printf '    %s%s%s          %s%s%s\n' "$C_BRAND" "$_cotal setup" "$C_OFF" "$C_DIM" "get your machine ready" "$C_OFF"
      printf '    %s%s%s     %s%s%s\n' "$C_BRAND" "$_cotal up --detach" "$C_OFF" "$C_DIM" "start your mesh" "$C_OFF"
      printf '    %s%s%s          %s%s%s\n' "$C_BRAND" "$_cotal spawn" "$C_OFF" "$C_DIM" "put an agent on it" "$C_OFF"
      ;;
  esac
  blank
  dim_line "Docs $DOCS_URL"
  blank
}

# `curl … | sh` gives the script a pipe on stdin, so an interactive child would read the
# rest of the script instead of the keyboard. /dev/tty is the terminal itself; this is the
# same technique the Deno installer uses for its shell setup.
hand_off_to_setup() {
  [ "$OPT_SETUP" = 1 ] || return 1
  [ "$OPT_YES" = 0 ] || return 1
  [ -z "${CI:-}" ] || return 1
  [ -r /dev/tty ] || return 1
  [ "$TTY" = 1 ] || return 1

  blank
  dim_line "Starting guided setup. Ctrl-C to stop; run $(shquote "$BIN_DIR/cotal") setup whenever you like."
  blank
  "$BIN_DIR/cotal" setup </dev/tty || return 1
  return 0
}

# ---------------------------------------------------------------------------------------
# Dry run
# ---------------------------------------------------------------------------------------

# A real path-boundary test. "$HOME"* would call /tmp/h-escape a child of /tmp/h.
under_home() {
  [ -n "${HOME:-}" ] || return 1
  case "$1" in
    "$HOME" | "$HOME"/*) return 0 ;;
    *) return 1 ;;
  esac
}

plan_line() { printf '    %s%-46s%s %s%s%s\n' "$C_OFF" "$1" "$C_OFF" "$C_DIM" "$2" "$C_OFF"; }

dry_run_report() {
  blank
  head_line "Dry run. Nothing was changed."
  blank
  ok_line "$PLATFORM_LABEL"
  [ -n "$PLATFORM_NOTE" ] && dim_line "  $PLATFORM_NOTE"
  if [ "$NODE_VENDORED" = 1 ]; then
    warn_line "would download Node $NODE_PIN from $NODE_DIST and verify its SHA-256"
  else
    ok_line "Node $NODE_LABEL"
  fi
  blank
  head_line "Would write"
  plan_line "$(tildify "$INSTALL_DIR")/lib/node_modules/$COTAL_PKG" "the npm package, into a prefix Cotal owns"
  plan_line "$(tildify "$INSTALL_DIR")/nodebin/{node,npm,npx}" "symlinks to the Node above"
  plan_line "$(tildify "$BIN_DIR/cotal")" "the launcher that pins that Node"
  if [ "$OPT_MODIFY_PATH" = 1 ] && ! on_path; then
    plan_line "a '# cotal' block in your shell rc" "so \`cotal\` is on your PATH"
  fi
  blank
  head_line "Would not touch"
  # Report the boundary that actually applies. The default install is contained in $HOME, but
  # COTAL_INSTALL_DIR / COTAL_BIN_DIR / XDG_DATA_HOME can point anywhere, and claiming
  # containment we are not delivering would be the wrong kind of reassuring.
  if under_home "$INSTALL_DIR" && under_home "$BIN_DIR"; then
    dim_line "  anything outside \$HOME $S_SEP any system directory $S_SEP sudo"
  else
    dim_line "  any system directory $S_SEP sudo"
    warn_line "you have redirected the install outside \$HOME, so the usual containment does not apply"
  fi
  blank
}

# ---------------------------------------------------------------------------------------

ELAPSED=0

main() {
  # Nothing this installer creates is writable by group or others, even under a umask of 0002.
  # Without a user private group, a group-writable install tree is one other accounts can change.
  umask 022
  init_term
  parse_args "$@"
  trap 'cleanup' EXIT
  trap 'cleanup; exit 130' INT TERM HUP

  banner
  if [ -z "${HOME:-}" ] || [ ! -d "$HOME" ]; then
    die "HOME is not set to a directory this installer can write to." \
      "Cotal installs entirely under \$HOME; set it and run this again."
  fi
  require_unprivileged
  detect_platform
  detect_agents

  _t0=$(date +%s)

  if [ "$OPT_DRY_RUN" = 1 ]; then
    # Report the runtime decision without acting on it: probe the system Node only.
    if [ "$OPT_VENDOR_NODE" = 0 ] && have node && have npm &&
      [ "$(node_rank "$(command -v node)")" -ge "$NODE_MIN_RANK" ] 2>/dev/null; then
      NODE_LABEL="$(node -v) $S_SEP already on this machine"
    else
      NODE_VENDORED=1
    fi
    dry_run_report
    exit 0
  fi

  # First write of the run. A read-only or unwritable home is common enough (locked-down
  # images, a misowned $HOME) that it deserves a real message rather than a raw mkdir error.
  if ! mkdir -p "$INSTALL_DIR" 2>/dev/null; then
    die "Cannot create $(tildify "$INSTALL_DIR")." \
      "This installer writes only inside your home directory, so it needs that to be" \
      "writable by you. Check the permissions:" \
      "  ls -ld $(dirname "$INSTALL_DIR")" \
      "" \
      "Or send it somewhere else:" \
      "  curl -fsSL https://get.cotal.ai | COTAL_INSTALL_DIR=/path/you/own sh"
  fi
  acquire_install_lock
  repair_install_modes
  LOG="$INSTALL_DIR/install.log"
  : >"$LOG" 2>/dev/null || die "Cannot write $(tildify "$LOG")." \
    "The install directory exists but is not writable by you."

  ok_line "$PLATFORM_LABEL"
  [ -n "$PLATFORM_NOTE" ] && dim_line "  $PLATFORM_NOTE"

  resolve_node
  ok_line "Node $NODE_LABEL"
  link_node_bin

  install_cotal
  ok_line "Cotal $INSTALLED_VERSION"

  write_launcher
  setup_path
  case "$PATH_STATE" in
    already) ok_line "$(tildify "$BIN_DIR") is on your PATH" ;;
    added:*) ok_line "Added $(tildify "$BIN_DIR") to your PATH" ;;
    manual) warn_line "$(tildify "$BIN_DIR") is not on your PATH yet" ;;
  esac

  report_path_selection
  ELAPSED=$(($(date +%s) - _t0))

  summary
  if hand_off_to_setup; then
    exit 0
  fi
  next_steps
}

# The brace group is what makes truncation safety hold at BYTE granularity, not just at line
# boundaries. A bare `main "$@"` can be cut to a bare `main`, which is a complete command: the
# installer would then run with NO arguments, silently turning `sh -s -- --dry-run` into a real
# install. A compound command is not executed until its closing brace is parsed, so every
# partial prefix of this line is a syntax error that does nothing.
{ main "$@"; exit $?; }