#!/bin/sh # conch installer — macOS & Linux # # curl -fsSL https://conch-site-gamma.vercel.app/install | sh # # Installs conch (the local-first AI shell) into an isolated environment # using the first available manager — uv, pipx, or a private venv — and # puts `conch` on your PATH for zsh and bash. No sudo, ever. Re-running # upgrades in place. Uninstall (keeps your state, config and keys, and # removes the PATH entry it added; add --purge [--yes] to delete the state # directory too): # # curl -fsSL https://conch-site-gamma.vercel.app/install | sh -s -- --uninstall # # Environment overrides: # CONCH_INSTALL_REF git ref to install (default: edge, the release line; # vX.Y.Z installs that release's sdist asset) # CONCH_PACKAGE_SPEC full pip requirement spec (overrides the ref; # set to `conch-shell` once it ships on PyPI) # CONCH_TARBALL_SHA256 expected sha256 of the download; mismatch aborts # CONCH_INSTALL_METHOD force one of: uv | pipx | venv # NO_COLOR disable colored output # # Integrity: the tarball is downloaded first and its sha256 computed. # Release tags are checked against the `.sha256` asset published with the # release when one exists; a branch head has no published checksum and # the installer warns so plainly rather than inventing one. # # Windows is not supported; WSL2 works via the Linux path. # # The entire script is wrapped in main() and dispatched on the last # line, so a partially downloaded script executes nothing. set -eu # pipefail where the shell has it (bash/zsh; dash does not) — the script # avoids relying on it, this only tightens behavior when available. # shellcheck disable=SC3040 (set -o pipefail) 2>/dev/null && set -o pipefail || true REPO="detournement/conch" PACKAGE_NAME="conch-shell" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/conch" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/conch" METHOD_FILE="$STATE_DIR/install-method" SOURCE_FILE="$STATE_DIR/install-source" VENV_DIR="$DATA_DIR/venv" LOCAL_BIN="$HOME/.local/bin" CONCH_TOOLS="conch conch-ask conch-chat conch-edge conch-controller conch-worker conch-hostctl" PATH_MARKER="# added by the conch installer" # ── output ─────────────────────────────────────────────────────────────────── if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_CYAN="$(printf '\033[1;36m')"; C_GREEN="$(printf '\033[1;32m')" C_YELLOW="$(printf '\033[1;33m')"; C_RED="$(printf '\033[1;31m')" C_DIM="$(printf '\033[2m')"; C_BOLD="$(printf '\033[1m')" C_RST="$(printf '\033[0m')" else C_CYAN=""; C_GREEN=""; C_YELLOW=""; C_RED=""; C_DIM=""; C_BOLD=""; C_RST="" fi say() { printf '%s\n' "${C_CYAN}▸${C_RST} $*"; } ok() { printf '%s\n' "${C_GREEN}✓${C_RST} $*"; } warn() { printf '%s\n' "${C_YELLOW}!${C_RST} $*"; } fail() { printf '%s\n' "${C_RED}✗${C_RST} $*" >&2; exit 1; } # ── platform ───────────────────────────────────────────────────────────────── check_platform() { _os="$(uname -s)" _arch="$(uname -m)" case "$_os" in Darwin) ok "macOS ($_arch)" ;; Linux) ok "Linux ($_arch)" ;; MINGW*|MSYS*|CYGWIN*) fail "Windows isn't supported. Use WSL2 and run this installer inside it." ;; *) fail "Unsupported platform: $_os. conch supports macOS and Linux." ;; esac } pkg_hint() { # Actionable per-distro hint for missing system pieces ($1: packages). if command -v apt-get >/dev/null 2>&1; then printf 'sudo apt-get install -y %s' "$1" elif command -v dnf >/dev/null 2>&1; then printf 'sudo dnf install -y %s' "$1" elif command -v yum >/dev/null 2>&1; then printf 'sudo yum install -y %s' "$1" elif [ "$(uname -s)" = "Darwin" ]; then printf 'xcode-select --install (or: brew install python3)' else printf 'install %s with your package manager' "$1" fi } check_python() { if ! command -v python3 >/dev/null 2>&1; then fail "python3 is required but not found. Try: $(pkg_hint 'python3 python3-venv')" fi # macOS ships a python3 shim that errors until Command Line Tools exist; # run it once to make sure it's real. if ! python3 -c 'import sys; assert sys.version_info >= (3, 9)' 2>/dev/null; then if ! python3 -c 'print(1)' >/dev/null 2>&1; then fail "python3 exists but can't run. On macOS run: xcode-select --install" fi fail "conch needs Python 3.9+ (found $(python3 -c 'import sys; print(sys.version.split()[0])' 2>/dev/null || echo 'unknown'))." fi ok "Python $(python3 -c 'import sys; print(sys.version.split()[0])')" } # ── source: download + verify ──────────────────────────────────────────────── # # The tarball is downloaded here (not by pip) so its sha256 can be checked # before anything is installed. Release tags install the published sdist # asset and verify it against the `.sha256` asset published next to it # when one exists; a branch head (the default, `edge`) has no published # checksum, so the installer says so plainly — it never invents one. # CONCH_TARBALL_SHA256= pins the expected digest for any source. fetch() { # $1: url, $2: destination. Non-zero when the URL cannot be fetched. if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2" elif command -v wget >/dev/null 2>&1; then wget -q "$1" -O "$2" else return 2 fi } sha256_of() { # Prints the hex digest of $1, or nothing when no tool can compute it. if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' fi } resolve_source() { # Sets SRC_URL (tarball), SUM_URL (its .sha256 sidecar, or empty) and # SRC_KIND (release|branch). `edge` is the maintained release line # (the `main` branch is a stale early snapshot). CONCH_SOURCE_BASE # swaps github.com for a mirror or a test server. _ref="${CONCH_INSTALL_REF:-edge}" _base="${CONCH_SOURCE_BASE:-https://github.com/$REPO}" case "$_ref" in v[0-9]*) SRC_KIND="release" _ver="${_ref#v}" SRC_URL="$_base/releases/download/$_ref/conch_shell-$_ver.tar.gz" SUM_URL="$SRC_URL.sha256" ;; *) SRC_KIND="branch" SRC_URL="$_base/archive/refs/heads/$_ref.tar.gz" SUM_URL="" ;; esac } download_and_verify() { # Downloads SRC_URL to TARBALL and verifies it. Fails closed whenever # an expected digest is known and cannot be confirmed. WORK_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t conch-install)" TARBALL="$WORK_DIR/conch-shell.tar.gz" say "Downloading ${C_DIM}$SRC_URL${C_RST}" if ! fetch "$SRC_URL" "$TARBALL"; then if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then fail "neither curl nor wget is available to download conch. Try: $(pkg_hint 'curl')" fi fail "download failed: $SRC_URL" fi [ -s "$TARBALL" ] || fail "download was empty: $SRC_URL" TARBALL_SHA256="$(sha256_of "$TARBALL")" _expected="${CONCH_TARBALL_SHA256:-}" _from="CONCH_TARBALL_SHA256" if [ -z "$_expected" ] && [ -n "$SUM_URL" ]; then if fetch "$SUM_URL" "$TARBALL.sha256" 2>/dev/null; then # Accept `` or ` ` (sha256sum format). _expected="$(awk 'NR==1 {print $1}' "$TARBALL.sha256" | tr 'A-F' 'a-f')" _from="$SUM_URL" fi fi VERIFIED="no" if [ -n "$_expected" ]; then case "$_expected" in *[!0-9a-f]*) fail "The checksum from $_from is not a sha256 hex digest; refusing to install." ;; esac [ "${#_expected}" -eq 64 ] || fail "The checksum from $_from is not a sha256 hex digest; refusing to install." [ -n "$TARBALL_SHA256" ] || fail "A checksum is published for this download but this system has no sha256sum, shasum, or openssl to verify it with. Nothing was installed." if [ "$TARBALL_SHA256" != "$_expected" ]; then fail "sha256 mismatch for $SRC_URL expected $_expected ($_from) got $TARBALL_SHA256 Nothing was installed. The download may be corrupt or tampered with; re-run later or report it." fi ok "sha256 verified ${C_DIM}($_from)${C_RST}" VERIFIED="yes" else if [ "$SRC_KIND" = "release" ]; then warn "Release ${CONCH_INSTALL_REF:-} publishes no .sha256 asset, so this download is NOT checksum-verified." else warn "The ${CONCH_INSTALL_REF:-edge} branch head has no published checksum, so this download is NOT checksum-verified." fi warn "Integrity rests on TLS to github.com. To pin a known digest: CONCH_TARBALL_SHA256= ... | sh" if [ -n "$TARBALL_SHA256" ]; then say "sha256 of the downloaded tarball: ${C_DIM}$TARBALL_SHA256${C_RST}" fi fi } record_source() { # What was installed, from where, with which digest — for later audit. mkdir -p "$STATE_DIR" { printf 'installed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" printf 'source=%s\n' "$1" printf 'sha256=%s\n' "${2:-unknown}" printf 'verified=%s\n' "${3:-no}" } > "$SOURCE_FILE" } cleanup_work_dir() { [ -n "${WORK_DIR:-}" ] && rm -rf "$WORK_DIR" 2>/dev/null || true } # ── managers ───────────────────────────────────────────────────────────────── pick_method() { if [ -n "${CONCH_INSTALL_METHOD:-}" ]; then printf '%s' "$CONCH_INSTALL_METHOD" return fi # Re-runs stick with the original manager so upgrades hit the same # install rather than stacking a second one. if [ -f "$METHOD_FILE" ]; then _prev="$(cat "$METHOD_FILE" 2>/dev/null || true)" case "$_prev" in uv|pipx|venv) if [ "$_prev" != "uv" ] || command -v uv >/dev/null 2>&1; then printf '%s' "$_prev" return fi ;; esac fi if command -v uv >/dev/null 2>&1; then printf 'uv' elif command -v pipx >/dev/null 2>&1; then printf 'pipx' else printf 'venv' fi } install_uv() { say "Installing with uv ${C_DIM}(uv tool install)${C_RST}" # --quiet: uv would otherwise print its own "not on your PATH" warning # right before ensure_path handles PATH; errors still surface. uv tool install --quiet --force --reinstall "$1" \ || fail "uv tool install failed (see output above)." UV_BIN="$(uv tool dir --bin 2>/dev/null || printf '%s' "$LOCAL_BIN")" BIN_DIR="$UV_BIN" } install_pipx() { say "Installing with pipx ${C_DIM}(pipx install --force)${C_RST}" pipx install --force "$1" >/dev/null \ || fail "pipx install failed (see output above)." BIN_DIR="${PIPX_BIN_DIR:-$LOCAL_BIN}" } install_venv() { say "Installing into a private venv ${C_DIM}($VENV_DIR)${C_RST}" if ! python3 -c 'import ensurepip' >/dev/null 2>&1; then fail "python3 can't create virtualenvs here (no ensurepip). Fix it with: $(pkg_hint 'python3-venv python3-pip') — then re-run this installer." fi mkdir -p "$DATA_DIR" if [ ! -x "$VENV_DIR/bin/python3" ]; then python3 -m venv "$VENV_DIR" \ || fail "venv creation failed. On Debian/Ubuntu: $(pkg_hint 'python3-venv python3-pip')" fi # --force-reinstall because a moving branch keeps the same version # number; --no-cache-dir so a cached sdist never masks new commits. "$VENV_DIR/bin/python3" -m pip install --quiet --upgrade \ --force-reinstall --no-cache-dir "$1" \ || fail "pip install failed (see output above)." mkdir -p "$LOCAL_BIN" for _tool in $CONCH_TOOLS; do if [ -x "$VENV_DIR/bin/$_tool" ]; then ln -sf "$VENV_DIR/bin/$_tool" "$LOCAL_BIN/$_tool" fi done BIN_DIR="$LOCAL_BIN" } record_method() { mkdir -p "$STATE_DIR" printf '%s\n' "$1" > "$METHOD_FILE" } # ── PATH wiring ────────────────────────────────────────────────────────────── append_path_line() { # $1: rc file. Appends the export once, creating the file if needed. if [ -f "$1" ] && grep -q "conch installer" "$1" 2>/dev/null; then return 0 fi { printf '\n%s\n' "$PATH_MARKER" # $PATH stays literal: it must expand when the rc file is sourced. # shellcheck disable=SC2016 printf 'export PATH="%s:$PATH"\n' "$BIN_DIR" } >> "$1" PATH_CHANGED=1 } ensure_path() { PATH_CHANGED=0 case ":$PATH:" in *":$BIN_DIR:"*) ok "$BIN_DIR is already on your PATH"; return 0 ;; esac _login_shell="$(basename "${SHELL:-/bin/sh}")" case "$_login_shell" in zsh) append_path_line "$HOME/.zshrc" ;; bash) append_path_line "$HOME/.bashrc" # macOS Terminal starts login shells that read .bash_profile. if [ "$(uname -s)" = "Darwin" ]; then append_path_line "$HOME/.bash_profile" fi ;; *) append_path_line "$HOME/.profile" ;; esac if [ "$PATH_CHANGED" = "1" ]; then ok "Added $BIN_DIR to your PATH ${C_DIM}(new terminals pick it up)${C_RST}" fi } # ── uninstall ──────────────────────────────────────────────────────────────── CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/conch" remove_path_block() { # $1: rc file. Drops the installer's PATH block (marker line + the # export line after it, plus the blank line written before it). The # file is rewritten in place through its own path so a symlinked rc # file keeps pointing where it did. [ -f "$1" ] || return 1 grep -qF "$PATH_MARKER" "$1" 2>/dev/null || return 1 _tmp="$(mktemp "${TMPDIR:-/tmp}/conch-rc.XXXXXX")" || return 1 awk -v marker="$PATH_MARKER" ' { lines[NR] = $0 } END { for (i = 1; i <= NR; i++) { if (lines[i] == marker) { drop[i] = 1 if (i + 1 <= NR && lines[i + 1] ~ /^export PATH=/) drop[i + 1] = 1 if (i > 1 && lines[i - 1] == "") drop[i - 1] = 1 } } for (i = 1; i <= NR; i++) if (!drop[i]) print lines[i] }' "$1" > "$_tmp" && cat "$_tmp" > "$1" _status=$? rm -f "$_tmp" return $_status } confirm_purge() { # Purging is irreversible, so it needs an explicit yes: --yes on the # command line, or a typed answer on the terminal. Piped stdin # (`curl | sh`) is the script itself, never an answer — read /dev/tty. if [ "$ASSUME_YES" = "1" ]; then return 0 fi if ( : < /dev/tty ) 2>/dev/null; then printf '%s' "${C_YELLOW}?${C_RST} Delete ${C_BOLD}$STATE_DIR${C_RST} (conversations, memory, missions, journal)? [y/N] " > /dev/tty 2>/dev/null || true read -r _answer < /dev/tty || _answer="" case "$_answer" in y|Y|yes|YES) return 0 ;; esac warn "Not deleting $STATE_DIR (no confirmation)." return 1 fi warn "Not deleting $STATE_DIR: no terminal to confirm on. Re-run with --uninstall --purge --yes to delete it without asking." return 1 } uninstall() { # PURGE=1 also deletes the state dir (after confirmation); ASSUME_YES=1 # skips the question. Config and keys are never deleted here. printf '\n%s\n\n' "${C_BOLD}${C_CYAN} 🐚 conch uninstaller${C_RST}" _method="$(cat "$METHOD_FILE" 2>/dev/null || true)" _removed=0 if [ "$_method" = "uv" ] || command -v uv >/dev/null 2>&1; then if uv tool uninstall "$PACKAGE_NAME" >/dev/null 2>&1; then ok "Removed the uv tool install ($PACKAGE_NAME)"; _removed=1 fi fi if [ "$_method" = "pipx" ] || command -v pipx >/dev/null 2>&1; then if pipx uninstall "$PACKAGE_NAME" >/dev/null 2>&1; then ok "Removed the pipx install ($PACKAGE_NAME)"; _removed=1 fi fi if [ -d "$VENV_DIR" ]; then rm -rf "$VENV_DIR" ok "Removed $VENV_DIR"; _removed=1 fi for _tool in $CONCH_TOOLS; do if [ -L "$LOCAL_BIN/$_tool" ]; then rm -f "$LOCAL_BIN/$_tool" ok "Removed $LOCAL_BIN/$_tool"; _removed=1 fi done # The empty data dir is installer debris once the venv is gone. rmdir "$DATA_DIR" 2>/dev/null && ok "Removed $DATA_DIR (empty)" for _rc in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile"; do if remove_path_block "$_rc"; then ok "Removed the PATH entry from $_rc"; _removed=1 fi done if [ -f "$METHOD_FILE" ]; then rm -f "$METHOD_FILE"; _removed=1 fi rm -f "$SOURCE_FILE" if [ -d "$STATE_DIR" ]; then if [ "$PURGE" = "1" ] && confirm_purge; then rm -rf "$STATE_DIR" ok "Removed $STATE_DIR"; _removed=1 else printf '%s\n' "${C_DIM}·${C_RST} Kept $STATE_DIR ${C_DIM}(conversations, memory, missions, journal — delete with: --uninstall --purge)${C_RST}" fi fi if [ -d "$CONFIG_DIR" ]; then printf '%s\n' "${C_DIM}·${C_RST} Kept $CONFIG_DIR ${C_DIM}(config and API keys — never deleted by this script; remove it yourself for a clean slate)${C_RST}" fi if [ "$_removed" = "1" ]; then ok "conch is uninstalled." else warn "Nothing to uninstall." fi } # ── verify ─────────────────────────────────────────────────────────────────── verify_install() { if [ -x "$BIN_DIR/conch" ]; then _version="$("$BIN_DIR/conch" --version 2>/dev/null || true)" if [ -n "$_version" ]; then ok "$_version" else warn "conch installed but --version failed" fi else fail "conch didn't land in $BIN_DIR — something went wrong above." fi } # ── main ───────────────────────────────────────────────────────────────────── main() { if [ "${1:-}" = "--uninstall" ]; then shift PURGE=0; ASSUME_YES=0 for _flag in "$@"; do case "$_flag" in --purge) PURGE=1 ;; --yes|-y) ASSUME_YES=1 ;; *) fail "Unknown uninstall option '$_flag' (use --purge and/or --yes)." ;; esac done uninstall exit 0 fi if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then printf 'conch installer — macOS & Linux\n\n' printf ' install: curl -fsSL https://conch-site-gamma.vercel.app/install | sh\n' printf ' upgrade: re-run the same line\n' printf ' uninstall: curl -fsSL https://conch-site-gamma.vercel.app/install | sh -s -- --uninstall\n' printf ' keeps your conversations, memory and missions (~/.local/state/conch)\n' printf ' and your config/keys (~/.config/conch); removes the PATH entry it added\n' printf ' purge: ... | sh -s -- --uninstall --purge [--yes] also deletes the state dir\n' printf ' (asks on the terminal unless --yes; config/keys are still kept)\n\n' printf ' env: CONCH_INSTALL_REF (edge | vX.Y.Z), CONCH_PACKAGE_SPEC, CONCH_INSTALL_METHOD (uv|pipx|venv)\n' printf ' CONCH_TARBALL_SHA256= pins the expected digest of the download, NO_COLOR\n\n' printf ' integrity: release tags are verified against the .sha256 asset published with the\n' printf ' release when one exists; the edge branch head has no published checksum\n' printf ' and the installer says so (it never fabricates one). The digest of what\n' printf ' was installed is recorded in ~/.local/state/conch/install-source.\n' exit 0 fi if [ "$#" -gt 0 ]; then fail "Unknown option '$1' (try --help)." fi printf '\n%s\n' "${C_BOLD}${C_CYAN} 🐚 conch${C_RST} ${C_DIM}local-first AI shell — macOS & Linux${C_RST}" printf '%s\n\n' "${C_DIM} isolated install · no sudo · re-run to upgrade${C_RST}" check_platform check_python METHOD="$(pick_method)" case "$METHOD" in uv|pipx|venv) ;; *) fail "Unknown CONCH_INSTALL_METHOD '$METHOD' (use uv, pipx, or venv)." ;; esac if [ -n "${CONCH_PACKAGE_SPEC:-}" ]; then # An explicit requirement spec (e.g. `conch-shell` from PyPI once # it ships there) is handed to the package manager as-is; PyPI # hashes are the manager's business. SPEC="$CONCH_PACKAGE_SPEC" SOURCE_DESC="$SPEC"; TARBALL_SHA256=""; VERIFIED="n/a" else resolve_source trap cleanup_work_dir EXIT download_and_verify SPEC="$TARBALL" SOURCE_DESC="$SRC_URL" fi case "$METHOD" in uv) install_uv "$SPEC" ;; pipx) install_pipx "$SPEC" ;; venv) install_venv "$SPEC" ;; esac record_method "$METHOD" record_source "$SOURCE_DESC" "$TARBALL_SHA256" "$VERIFIED" verify_install ensure_path printf '\n%s\n' "${C_GREEN} All set.${C_RST} Start with:" if [ "${PATH_CHANGED:-0}" = "1" ]; then printf '%s\n' " ${C_BOLD}exec \$SHELL${C_RST} ${C_DIM}(reload PATH)${C_RST}" fi printf '%s\n' " ${C_BOLD}conch${C_RST} ${C_DIM}first run walks you through provider + API key${C_RST}" printf '%s\n\n' " ${C_DIM}then /help for commands, /install for more components${C_RST}" } main "$@"