#!/usr/bin/env bash set -euo pipefail program_name="colimit-install" default_site_base_url="https://install.colimit.ai" default_release_base_url="$default_site_base_url/artifacts" default_colimit_home="${XDG_DATA_HOME:-$HOME/.local/share}/colimit" release_base_url="${COLIMIT_RELEASE_BASE_URL:-${COLIMIT_INSTALL_BASE_URL:-$default_release_base_url}}" install_site_url="${COLIMIT_INSTALL_SITE_URL:-$default_site_base_url}" installer_url="${COLIMIT_INSTALLER_URL:-${install_site_url%/}/colimit-install}" installer_checksum_url="$installer_url.sha256" colimit_home="${COLIMIT_HOME:-$default_colimit_home}" colimit_bin_dir="${COLIMIT_BIN_DIR:-${XDG_BIN_HOME:-$HOME/.local/bin}}" command_name="install" cleanup_tmp_dir="" download_cmd="" checksum_cmd="" log() { printf '%s\n' "$*" } cleanup() { if [ -n "${cleanup_tmp_dir:-}" ]; then rm -rf "$cleanup_tmp_dir" fi } fail() { printf '%s: %s\n' "$program_name" "$*" >&2 exit 1 } has_tty() { { : /dev/tty; } 2>/dev/null } prompt_yes_default() { local prompt="$1" local answer printf '%s [Y/n] ' "$prompt" > /dev/tty IFS= read -r answer < /dev/tty || answer="" case "$answer" in ""|y|Y|yes|YES|Yes) return 0 ;; n|N|no|NO|No) return 1 ;; *) fail "expected y or n" ;; esac } prompt_no_default() { local prompt="$1" local answer printf '%s [y/N] ' "$prompt" > /dev/tty IFS= read -r answer < /dev/tty || answer="" case "$answer" in y|Y|yes|YES|Yes) return 0 ;; ""|n|N|no|NO|No) return 1 ;; *) fail "expected y or n" ;; esac } usage() { cat <<'EOF' Usage: colimit-install [install] colimit-install update colimit-install env colimit-install cleanup Installs, updates, configures, or removes the latest published Colimit bundle. Commands: install Install Colimit, or check for updates if Colimit is already installed. update Check stored SHA-256 sums and update only when the published files changed. env Print shell exports for eval "$(colimit-install env)". cleanup Remove the installed Colimit commands, bundle, and metadata. Environment: COLIMIT_HOME Install root. Defaults to ${XDG_DATA_HOME:-$HOME/.local/share}/colimit. COLIMIT_BIN_DIR Command directory. Defaults to ${XDG_BIN_HOME:-$HOME/.local/bin}. COLIMIT_RELEASE_BASE_URL Release asset base URL. Defaults to https://install.colimit.ai/artifacts. COLIMIT_INSTALL_SITE_URL Installer site URL. Defaults to https://install.colimit.ai. COLIMIT_INSTALLER_URL Installer command URL. Defaults to ${COLIMIT_INSTALL_SITE_URL}/colimit-install. COLIMIT_INSTALL_YES=1 Proceed without prompting. Options: -h, --help Show this help. --version Print the colimit-install SHA-256. Runtime requirements for claude-agent-acp: Node.js >=20 on PATH (older versions cannot parse the bundled agent). EOF } parse_args() { if [ "$#" -gt 0 ]; then case "$1" in install|update|env|cleanup) command_name="$1" shift ;; -h|--help) usage exit 0 ;; --version) command_name="version" shift ;; esac fi while [ "$#" -gt 0 ]; do case "$1" in -h|--help) usage exit 0 ;; --version) command_name="version" shift ;; --) shift [ "$#" -eq 0 ] || fail "unexpected positional arguments: $*" break ;; -*) fail "unknown option: $1" ;; *) fail "unexpected positional argument: $1" ;; esac done } require_command() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required" } require_checksum_tool() { if command -v sha256sum >/dev/null 2>&1; then checksum_cmd="sha256sum" elif command -v shasum >/dev/null 2>&1; then checksum_cmd="shasum" else fail "sha256sum or shasum is required" fi } require_install_tools() { local tool for tool in awk basename chmod cp dirname find grep ln mkdir mktemp mv rm sed tar tr uname; do require_command "$tool" done if command -v curl >/dev/null 2>&1; then download_cmd="curl" elif command -v wget >/dev/null 2>&1; then download_cmd="wget" else fail "curl or wget is required" fi require_checksum_tool } require_env_tools() { require_command sed } require_version_tools() { require_command awk require_command grep require_command tr } require_cleanup_tools() { require_command grep require_command rm } detect_target() { local os arch os="$(uname -s)" arch="$(uname -m)" case "$arch" in x86_64|amd64) arch="x86_64" ;; arm64|aarch64) arch="aarch64" ;; *) fail "unsupported CPU architecture: $arch" ;; esac case "$os" in Linux) printf '%s-unknown-linux-gnu\n' "$arch" ;; Darwin) printf '%s-apple-darwin\n' "$arch" ;; *) fail "unsupported operating system: $os" ;; esac } require_download_url() { case "$1" in https://*) ;; *) if [ "${COLIMIT_ALLOW_INSECURE:-}" != "1" ]; then fail "refusing non-HTTPS download URL: $1" fi ;; esac } download_to() { local url="$1" local output="$2" local label="$3" require_download_url "$url" log "Downloading $label:" log " $url" if [ "$download_cmd" = "curl" ]; then case "$url" in https://*) curl --proto '=https' --tlsv1.2 -fL --progress-bar "$url" -o "$output" ;; *) curl -fL --progress-bar "$url" -o "$output" ;; esac else wget -O "$output" "$url" fi } download_optional_to() { local url="$1" local output="$2" local label="$3" if download_to "$url" "$output" "$label"; then return 0 fi rm -f "$output" return 1 } has_current_install() { [ -e "$colimit_home/current" ] || [ -L "$colimit_home/current" ] } confirm_install() { local action="$1" local target="$2" local asset_url="$3" local checksum_url="$4" cat </dev/null 2>&1; then log "" log "WARNING: 'node' was not found on PATH." log "claude-agent-acp requires Node.js (>=$required_major) to run; synthesis will fail until it is installed." return 0 fi node_version="$(node --version 2>/dev/null || true)" major="$(printf '%s' "$node_version" | sed -E 's/^v([0-9]+).*/\1/')" case "$major" in ''|*[!0-9]*) log "" log "WARNING: could not determine Node.js version from 'node --version' output: $node_version" log "claude-agent-acp requires Node.js (>=$required_major); synthesis will fail on older versions." return 0 ;; esac if [ "$major" -lt "$required_major" ]; then log "" log "WARNING: detected Node.js $node_version on PATH, but claude-agent-acp requires Node.js (>=$required_major)." log "Older Node versions cannot parse the bundled ACP agent (import-attributes syntax); synthesis will fail until Node is upgraded." fi } hash_file() { local file="$1" if [ "$checksum_cmd" = "sha256sum" ]; then sha256sum "$file" | awk '{print $1}' else shasum -a 256 "$file" | awk '{print $1}' fi } parse_checksum() { local checksum_file="$1" local checksum checksum="$(awk 'NF { print $1; exit }' "$checksum_file")" checksum="${checksum#sha256:}" checksum="$(printf '%s' "$checksum" | tr 'A-F' 'a-f')" printf '%s' "$checksum" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid checksum file" printf '%s\n' "$checksum" } checksum_state_file() { local name="$1" case "$name" in ""|*/*) fail "invalid checksum state name: $name" ;; esac printf '%s/checksums/%s.sha256\n' "$colimit_home" "$name" } read_stored_checksum() { local name="$1" local state_file checksum state_file="$(checksum_state_file "$name")" [ -f "$state_file" ] || return 1 checksum="$(awk 'NF { print $1; exit }' "$state_file")" checksum="$(printf '%s' "$checksum" | tr 'A-F' 'a-f')" printf '%s' "$checksum" | grep -Eq '^[0-9a-f]{64}$' || return 1 printf '%s\n' "$checksum" } write_stored_checksum() { local name="$1" local checksum="$2" local state_file state_dir tmp_file printf '%s' "$checksum" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid SHA-256 for $name" state_file="$(checksum_state_file "$name")" state_dir="$(dirname "$state_file")" tmp_file="$state_dir/.$name.$$" mkdir -p "$state_dir" printf '%s %s\n' "$checksum" "$name" > "$tmp_file" mv -f "$tmp_file" "$state_file" } find_payload_root() { local staging="$1" local root="" local count=0 local candidate while IFS= read -r candidate; do root="$candidate" count=$((count + 1)) done < <(find "$staging" -mindepth 1 -maxdepth 1 -type d -print) [ "$count" -eq 1 ] || fail "archive must contain exactly one top-level directory" printf '%s\n' "$root" } validate_payload() { local root="$1" local target="$2" local templates="$root/share/colimit/templates" local colimit_code_data="$root/share/colimit/colimit-code" local root_name [ -x "$root/bin/colimit-code" ] || fail "archive is missing executable bin/colimit-code" [ -x "$root/bin/colimit-spec" ] || fail "archive is missing executable bin/colimit-spec" [ -d "$templates" ] || fail "archive is missing share/colimit/templates" find "$templates" -type f -print -quit | grep -q . || fail "archive template directory is empty" [ -f "$colimit_code_data/themes.json" ] || fail "archive is missing share/colimit/colimit-code/themes.json" [ -f "$root/share/colimit/claude-agent-acp/node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js" ] \ || fail "archive is missing share/colimit/claude-agent-acp" root_name="$(basename "$root")" [ "$root_name" = "colimit-$target" ] || fail "archive root '$root_name' does not match target '$target'" } install_current() { local source_root="$1" local target="$colimit_home/current" local parent tmp_target old_target parent="$(dirname "$target")" tmp_target="$parent/.install.$$" old_target="$parent/.old.$$" mkdir -p "$parent" rm -rf "$tmp_target" "$old_target" mv "$source_root" "$tmp_target" if [ -e "$target" ] || [ -L "$target" ]; then mv "$target" "$old_target" mv "$tmp_target" "$target" rm -rf "$old_target" else mv "$tmp_target" "$target" fi } link_command() { local name="$1" local source="$colimit_home/current/bin/$name" local dest="$colimit_bin_dir/$name" if [ -e "$dest" ] && [ ! -L "$dest" ]; then fail "$dest exists and is not a symlink" fi rm -f "$dest" ln -s "$source" "$dest" } is_generated_wrapper() { local file="$1" grep -Eq '^# Generated by colimit-install(\.sh)?\.$' "$file" 2>/dev/null } is_colimit_install_script() { local file="$1" grep -Eq '^program_name="colimit-install"$' "$file" 2>/dev/null } shell_quote() { printf "'" printf '%s' "$1" | sed "s/'/'\\\\''/g" printf "'" } write_colimit_code_wrapper() { local dest="$colimit_bin_dir/colimit-code" local tmp="$colimit_bin_dir/.colimit-code.$$" local real_binary="$colimit_home/current/bin/colimit-code" local template_dir="$colimit_home/current/share/colimit/templates" local colimit_code_data="$colimit_home/current/share/colimit/colimit-code" if [ -e "$dest" ] && [ ! -L "$dest" ] && ! is_generated_wrapper "$dest"; then fail "$dest exists and was not generated by colimit-install" fi { printf '#!/usr/bin/env sh\n' printf '# Generated by colimit-install.\n' printf 'export COLIMIT_TEMPLATE_DIR=%s\n' "$(shell_quote "$template_dir")" printf 'export colimit_code_datadir=%s\n' "$(shell_quote "$colimit_code_data")" printf 'exec %s "$@"\n' "$(shell_quote "$real_binary")" } > "$tmp" chmod +x "$tmp" rm -f "$dest" mv "$tmp" "$dest" } write_node_agent_wrapper() { local name="$1" local dest="$colimit_bin_dir/$name" local tmp="$colimit_bin_dir/.$name.$$" local entry="$colimit_home/current/share/colimit/$name/node_modules/@agentclientprotocol/$name/dist/index.js" if [ -e "$dest" ] && [ ! -L "$dest" ] && ! is_generated_wrapper "$dest"; then fail "$dest exists and was not generated by colimit-install" fi { printf '#!/usr/bin/env sh\n' printf '# Generated by colimit-install.\n' # shellcheck disable=SC2016 printf 'if [ -z "${CLAUDE_CODE_EXECUTABLE:-}" ] && command -v claude >/dev/null 2>&1; then\n' # shellcheck disable=SC2016 printf ' CLAUDE_CODE_EXECUTABLE="$(command -v claude)"\n' printf ' export CLAUDE_CODE_EXECUTABLE\n' printf 'fi\n' printf 'exec node %s "$@"\n' "$(shell_quote "$entry")" } > "$tmp" chmod +x "$tmp" rm -f "$dest" mv "$tmp" "$dest" } current_script_source() { local source="${BASH_SOURCE[0]:-}" case "$source" in ""|/dev/fd/*|/proc/*/fd/*) return 1 ;; esac [ -f "$source" ] && [ -r "$source" ] || return 1 printf '%s\n' "$source" } write_installer_command() { local require_download="${1:-0}" local expected_checksum="${2:-}" local dest="$colimit_bin_dir/colimit-install" local tmp="$colimit_bin_dir/.colimit-install.$$" local source="" local checksum if [ -e "$dest" ] && [ ! -L "$dest" ] \ && ! is_generated_wrapper "$dest" \ && ! is_colimit_install_script "$dest"; then fail "$dest exists and was not generated by colimit-install" fi source="$(current_script_source || true)" if [ "$require_download" != "1" ] && [ -n "$source" ] && [ "$source" != "$dest" ]; then cp "$source" "$tmp" elif download_optional_to "$installer_url" "$tmp" "installer command"; then : elif [ "$require_download" != "1" ] && [ -n "$source" ]; then cp "$source" "$tmp" else fail "could not download installer command from $installer_url" fi chmod +x "$tmp" checksum="$(hash_file "$tmp")" if [ -n "$expected_checksum" ] && [ "$checksum" != "$expected_checksum" ]; then rm -f "$tmp" fail "checksum mismatch for $installer_url" fi rm -f "$dest" mv "$tmp" "$dest" write_stored_checksum colimit-install "$checksum" } print_env() { local template_dir="$colimit_home/current/share/colimit/templates" local colimit_code_data="$colimit_home/current/share/colimit/colimit-code" printf 'export PATH=%s:"%s"\n' "$(shell_quote "$colimit_bin_dir")" "\$PATH" printf 'export COLIMIT_TEMPLATE_DIR=%s\n' "$(shell_quote "$template_dir")" printf 'export colimit_code_datadir=%s\n' "$(shell_quote "$colimit_code_data")" } path_contains_bin_dir() { case ":$PATH:" in *":$colimit_bin_dir:"*) return 0 ;; *) return 1 ;; esac } start_tmp_dir() { cleanup_tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/colimit-install.XXXXXX")" trap cleanup EXIT HUP INT TERM } install_bundle() { local action="$1" local target="$2" local asset="$3" local asset_url="$4" local checksum_url="$5" local checksum_file="${6:-}" local installer_expected_sha="${7:-}" local tmp_dir="$cleanup_tmp_dir" local archive_file staging payload_root actual_sha expected_sha [ -n "$tmp_dir" ] || fail "internal error: temporary directory is not initialized" confirm_install "$action" "$target" "$asset_url" "$checksum_url" archive_file="$tmp_dir/$asset" staging="$tmp_dir/staging" if [ -z "$checksum_file" ]; then checksum_file="$tmp_dir/$asset.sha256" download_to "$checksum_url" "$checksum_file" "checksum" fi download_to "$asset_url" "$archive_file" "bundle" log "Verifying checksum..." expected_sha="$(parse_checksum "$checksum_file")" actual_sha="$(hash_file "$archive_file")" [ "$actual_sha" = "$expected_sha" ] || fail "checksum mismatch for $asset_url" mkdir -p "$staging" log "Extracting bundle..." tar -xzf "$archive_file" -C "$staging" log "Validating bundle..." payload_root="$(find_payload_root "$staging")" validate_payload "$payload_root" "$target" log "Installing bundle..." install_current "$payload_root" mkdir -p "$colimit_bin_dir" write_colimit_code_wrapper link_command colimit-spec write_node_agent_wrapper claude-agent-acp write_installer_command 0 "$installer_expected_sha" write_stored_checksum "$asset" "$expected_sha" log "Installed latest Colimit bundle for $target" log " colimit-code -> $colimit_bin_dir/colimit-code" log " colimit-spec -> $colimit_bin_dir/colimit-spec" log " claude-agent-acp -> $colimit_bin_dir/claude-agent-acp" log " colimit-install -> $colimit_bin_dir/colimit-install" log " templates -> $colimit_home/current/share/colimit/templates" log " themes -> $colimit_home/current/share/colimit/colimit-code/themes.json" log " claude-agent-acp -> $colimit_home/current/share/colimit/claude-agent-acp" warn_missing_node if ! path_contains_bin_dir; then log "" log "Add Colimit to your shell environment with:" log " eval \"\$($(shell_quote "$colimit_bin_dir/colimit-install") env)\"" fi } run_env() { has_current_install || fail "no installed Colimit bundle found at $colimit_home/current; run colimit-install install first" print_env } run_version() { local source stored_checksum code_binary source="$(current_script_source || true)" if [ -n "$source" ]; then require_checksum_tool hash_file "$source" elif stored_checksum="$(read_stored_checksum colimit-install 2>/dev/null)"; then printf '%s\n' "$stored_checksum" else fail "cannot determine SHA-256 for this colimit-install invocation" fi code_binary="$colimit_bin_dir/colimit-code" if [ -x "$code_binary" ]; then "$code_binary" --version 2>/dev/null || true fi } run_install() { local target asset asset_url checksum_url if has_current_install; then run_update return 0 fi target="$(detect_target)" asset="colimit-${target}.tar.gz" asset_url="${release_base_url%/}/$asset" checksum_url="$asset_url.sha256" start_tmp_dir install_bundle install "$target" "$asset" "$asset_url" "$checksum_url" } run_update() { local target asset asset_url checksum_url checksum_file latest_sha stored_sha local installer_checksum_file latest_installer_sha stored_installer_sha local bundle_changed=0 local installer_changed=0 has_current_install || fail "no installed Colimit bundle found at $colimit_home/current; run colimit-install install first" target="$(detect_target)" asset="colimit-${target}.tar.gz" asset_url="${release_base_url%/}/$asset" checksum_url="$asset_url.sha256" start_tmp_dir checksum_file="$cleanup_tmp_dir/$asset.sha256" download_to "$checksum_url" "$checksum_file" "checksum" latest_sha="$(parse_checksum "$checksum_file")" stored_sha="$(read_stored_checksum "$asset" || true)" if [ "$latest_sha" != "$stored_sha" ]; then bundle_changed=1 fi installer_checksum_file="$cleanup_tmp_dir/colimit-install.sha256" latest_installer_sha="" if download_optional_to "$installer_checksum_url" "$installer_checksum_file" "installer checksum"; then latest_installer_sha="$(parse_checksum "$installer_checksum_file")" stored_installer_sha="$(read_stored_checksum colimit-install || true)" if [ "$latest_installer_sha" != "$stored_installer_sha" ]; then installer_changed=1 fi else log "Installer checksum not available; skipping installer command comparison." fi if [ "$bundle_changed" -eq 0 ] && [ "$installer_changed" -eq 0 ]; then log "Colimit is already up to date for $target." return 0 fi if [ "$bundle_changed" -eq 1 ]; then install_bundle update "$target" "$asset" "$asset_url" "$checksum_url" "$checksum_file" "$latest_installer_sha" return 0 fi confirm_installer_update "$latest_installer_sha" mkdir -p "$colimit_bin_dir" write_installer_command 1 "$latest_installer_sha" log "Updated colimit-install command at $colimit_bin_dir/colimit-install" } remove_installed_command() { local name="$1" local dest="$colimit_bin_dir/$name" if [ ! -e "$dest" ] && [ ! -L "$dest" ]; then return 0 fi if [ -L "$dest" ] || is_generated_wrapper "$dest" \ || { [ "$name" = "colimit-install" ] && is_colimit_install_script "$dest"; }; then rm -f "$dest" return 0 fi fail "$dest exists and was not generated by colimit-install" } run_cleanup() { local root="${colimit_home%/}" case "$root" in ""|"/"|".") fail "refusing to remove unsafe COLIMIT_HOME: $colimit_home" ;; esac if [ "$root" = "$HOME" ]; then fail "refusing to remove COLIMIT_HOME because it is HOME: $colimit_home" fi confirm_cleanup remove_installed_command colimit-code remove_installed_command colimit-spec remove_installed_command claude-agent-acp remove_installed_command colimit-install if [ -e "$root" ] || [ -L "$root" ]; then rm -rf "$root" fi log "Removed Colimit installation from $root" } main() { parse_args "$@" case "$command_name" in install) require_install_tools run_install ;; update) require_install_tools run_update ;; env) require_env_tools run_env ;; version) require_version_tools run_version ;; cleanup) require_cleanup_tools run_cleanup ;; *) fail "unknown command: $command_name" ;; esac } main "$@"