#!/bin/sh set -eu error() { printf 'error: %s\n' "$*" >&2 exit 1 } warn() { printf 'warning: %s\n' "$*" >&2 } is_true() { case ${1:-} in 1 | true) return 0 ;; *) return 1 ;; esac } info() { if ! is_true "${COMPOSIO_QUIET:-}"; then printf '%s\n' "$*" fi } debug() { if is_true "${COMPOSIO_DEBUG:-}"; then printf '+ %s\n' "$*" >&2 fi } # Replays a captured subprocess log on the debug channel. The installer owns # its own presentation, so delegated output is suppressed by default and stays # available for troubleshooting through COMPOSIO_DEBUG. debug_captured_output() { debug_captured_label=$1 debug_captured_file=$2 if ! is_true "${COMPOSIO_DEBUG:-}" || [ ! -s "$debug_captured_file" ]; then return 0 fi debug "$debug_captured_label" sed 's/^/+ /' "$debug_captured_file" >&2 || : } tildify() { case $1 in "$HOME"/*) printf '%s/%s\n' '~' "${1#"$HOME"/}" ;; *) printf '%s\n' "$1" ;; esac } print_usage() { printf '%s\n' \ 'Usage: install.sh [--agent] [--no-plugins] [version-tag]' \ '' \ 'Options:' \ ' --agent Sign up or log in as a Composio agent after installation.' \ ' --no-plugins Skip agent plugin installation (the default).' \ ' -h, --help Show this help.' \ '' \ 'Set COMPOSIO_INSTALL_SHELL=auto|zsh|bash|fish|none to control automatic shell setup' \ "(default auto: detect the login shell from \$SHELL; none: install only)." \ 'Version tags may be stable or beta, for example 0.3.1 or @composio/cli@0.3.1-beta.329.' } validate_identifier() { printf '%s\n' "$2" | grep -Eq '^[A-Za-z0-9._-]+$' || error "$1 contains invalid characters (got \"$2\")" } url_authority() { printf '%s\n' "$1" | sed -e 's#^[a-zA-Z][a-zA-Z0-9+.-]*://##' -e 's#[/?#].*$##' } is_allowed_http_authority() { case $1 in localhost | localhost:* | 127.0.0.1 | 127.0.0.1:* | '[::1]' | '[::1]':*) return 0 ;; esac if [ -n "${COMPOSIO_INSTALL_ALLOW_HTTP_HOST:-}" ]; then case $1 in "$COMPOSIO_INSTALL_ALLOW_HTTP_HOST" | "$COMPOSIO_INSTALL_ALLOW_HTTP_HOST":*) return 0 ;; esac fi return 1 } validate_url() { validate_url_value=$1 case $validate_url_value in *[![:print:]]* | *[[:space:]]*) return 1 ;; esac validate_url_authority=$(url_authority "$validate_url_value") [ -n "$validate_url_authority" ] || return 1 case $validate_url_authority in *@*) return 1 ;; esac case $validate_url_value in https://*) return 0 ;; http://*) is_allowed_http_authority "$validate_url_authority" ;; *) return 1 ;; esac } # Callers must validate_url first, so the scheme is https, or http on an allowed host. curl_proto_flags() { case $1 in https://*) printf '%s\n' '=https' ;; *) printf '%s\n' '=http,https' ;; esac } curl_fetch() { curl_fetch_url=$1 validate_url "$curl_fetch_url" || error "Refusing unsafe URL \"$curl_fetch_url\"" debug "curl GET $curl_fetch_url" curl --fail --silent --location --proto "$(curl_proto_flags "$curl_fetch_url")" \ --proto-redir '=https' "$curl_fetch_url" } curl_download() { curl_download_url=$1 curl_download_output=$2 curl_download_quiet=${3:-0} validate_url "$curl_download_url" || error "Refusing unsafe URL \"$curl_download_url\"" debug "curl GET $curl_download_url -> $curl_download_output" if [ "$curl_download_quiet" = 1 ] || is_true "${COMPOSIO_QUIET:-}"; then curl_download_ui=--silent else curl_download_ui=--progress-bar fi curl --fail --location "$curl_download_ui" --proto "$(curl_proto_flags "$curl_download_url")" \ --proto-redir '=https' --output "$curl_download_output" "$curl_download_url" } normalize_version() { normalize_version_value=$1 case $normalize_version_value in @composio/cli@*) normalize_version_bare=${normalize_version_value#@composio/cli@} ;; *) normalize_version_bare=$normalize_version_value ;; esac printf '%s\n' "$normalize_version_bare" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?$' || error "Invalid Composio CLI version \"$normalize_version_value\". Expected X.Y.Z or X.Y.Z-beta.N." printf '@composio/cli@%s\n' "$normalize_version_bare" } resolve_latest_cli_release() { resolve_page=1 while [ "$resolve_page" -le 5 ]; do resolve_url="$github_api_repo/releases?per_page=100&page=$resolve_page" resolve_json=$(curl_fetch "$resolve_url") || return 1 resolve_release=$(printf '%s\n' "$resolve_json" | sed 's/"tag_name"/\ "tag_name"/g; s/"browser_download_url"/\ "browser_download_url"/g' | awk -v asset_name="$archive_name" ' BEGIN { tag = "" stable = "^@composio/cli@[0-9]+\\.[0-9]+\\.[0-9]+$" } /"tag_name":[[:space:]]*"/ { tag = $0 sub(/^.*"tag_name":[[:space:]]*"/, "", tag) sub(/".*$/, "", tag) if (tag !~ stable) tag = "" } tag != "" && /"browser_download_url":[[:space:]]*"/ && index($0, "/" asset_name "\"") > 0 { url = $0 sub(/^.*"browser_download_url":[[:space:]]*"/, "", url) sub(/".*$/, "", url) print tag print url exit } ') if [ -n "$resolve_release" ]; then printf '%s\n' "$resolve_release" return 0 fi printf '%s\n' "$resolve_json" | grep -q '"tag_name"' || break resolve_page=$((resolve_page + 1)) done return 1 } detect_target() { platform=$(uname -ms) case $platform in 'MINGW64'* | 'MSYS'* | 'CYGWIN'*) error 'Windows is not supported. Use WSL (https://learn.microsoft.com/windows/wsl/install) and run this script inside your WSL distribution.' ;; 'Darwin x86_64') target=darwin-x64 ;; 'Darwin arm64') target=darwin-aarch64 ;; 'Linux aarch64' | 'Linux arm64') target=linux-aarch64 ;; 'Linux x86_64') target=linux-x64 ;; *) error "Unsupported platform: $platform" ;; esac if [ "$target" = darwin-x64 ]; then translated=$(sysctl -n sysctl.proc_translated 2>/dev/null || printf '0') if [ "$translated" = 1 ]; then target=darwin-aarch64 info "Your shell is running in Rosetta 2. Downloading for $target instead" fi fi } verify_checksum() { checksum_archive=$1 checksum_manifest=$2 checksum_name=$3 checksum_expected=$(awk -v name="$checksum_name" '$2 == name || $2 == "*" name { print $1; exit }' "$checksum_manifest") if [ -z "$checksum_expected" ]; then if [ "$official_release_source" = 1 ]; then error "checksums.txt has no entry for $checksum_name. Official releases always publish complete checksums, so this release cannot be verified. Refusing to install." fi warn "No checksum entry found for $checksum_name; continuing without verification" return 0 fi printf '%s\n' "$checksum_expected" | grep -Eq '^[0-9a-fA-F]{64}$' || error "Malformed checksum for $checksum_name" if command -v sha256sum >/dev/null 2>&1; then checksum_actual=$(sha256sum "$checksum_archive" | awk '{ print $1 }') elif command -v shasum >/dev/null 2>&1; then checksum_actual=$(shasum -a 256 "$checksum_archive" | awk '{ print $1 }') else warn 'Checksum verification skipped: no SHA-256 utility (sha256sum or shasum) is available on this system' return 0 fi [ "$checksum_expected" = "$checksum_actual" ] || error "Checksum mismatch for $checksum_name (expected $checksum_expected, got $checksum_actual)" info 'Checksum verified' } resolve_directory() { resolve_directory_value=$1 mkdir -p "$resolve_directory_value" || error "Failed to create directory \"$resolve_directory_value\"" (cd "$resolve_directory_value" && pwd -P) } publish_staged_entry() { publish_source=$1 publish_name=${publish_source##*/} publish_target=$resolved_install_dir/$publish_name if [ -d "$publish_source" ] && [ ! -L "$publish_source" ] && { [ -e "$publish_target" ] || [ -L "$publish_target" ]; }; then publish_aside=$stage/.composio-aside.$publish_name mv "$publish_target" "$publish_aside" || error "Failed to move existing install entry aside: $publish_target" if mv "$publish_source" "$publish_target"; then rm -rf "$publish_aside" || warn "Published install entry; previous contents retained at $publish_aside" return 0 fi if mv "$publish_aside" "$publish_target"; then error "Failed to publish install entry: $publish_target" fi preserve_stage=1 error "Failed to publish install entry and restore the previous entry. Recover it from $publish_aside" fi mv "$publish_source" "$publish_target" || error "Failed to publish install entry: $publish_target" } install_bundle() { install_bundle_root=$1 install_bundle_dir=$install_bundle_root/composio-$target if [ ! -f "$install_bundle_dir/composio" ]; then rm -f "$install_bundle_root/$archive_name" "$install_bundle_root/checksums.txt" install_bundle_dir=$install_bundle_root fi [ -f "$install_bundle_dir/composio" ] || error 'Binary not found in extracted archive' if ! find "$install_bundle_dir" -mindepth 1 ! -name composio -print -quit | grep -q .; then warn 'This release archive has no bundled support files. Some CLI features may be unavailable.' fi stage=$(mktemp -d "$resolved_install_dir/.composio-install.XXXXXX") || error "Failed to create staging directory in \"$resolved_install_dir\"" debug "install staging directory: $stage" # Staging briefly holds a second copy of the bundle until the binary is published last. cp -Rp "$install_bundle_dir"/. "$stage/" || error "Failed to stage the CLI bundle in \"$resolved_install_dir\"" chmod +x "$stage/composio" || error 'Failed to set permissions on staged executable' printf '%s\n' "$version" >"$stage/release-tag.txt" || error "Failed to stage install metadata in \"$resolved_install_dir\"" for staged_entry in "$stage"/* "$stage"/.[!.]* "$stage"/..?*; do if [ ! -e "$staged_entry" ] && [ ! -L "$staged_entry" ]; then continue fi case ${staged_entry##*/} in composio | release-tag.txt) continue ;; esac publish_staged_entry "$staged_entry" done publish_staged_entry "$stage/release-tag.txt" publish_staged_entry "$stage/composio" rmdir "$stage" || warn "Published CLI; retained recovery staging directory at $stage" stage= } install_entry_point() { entry_point=$resolved_bin_dir/composio if [ "$resolved_bin_dir" = "$resolved_install_dir" ]; then return 0 fi if [ -d "$entry_point" ]; then error "Cannot replace entry point \"$entry_point\" because it is a directory" fi ln -sf "$resolved_install_dir/composio" "$entry_point" || error "Failed to create entry point \"$entry_point\"" } inherited_path_contains_bin_dir() { case :$inherited_path: in *:"$resolved_bin_dir":*) return 0 ;; *) return 1 ;; esac } # Renders a value as one POSIX-safe shell word so recovery commands stay # copy-paste safe even when the installed path contains whitespace. shell_quote() { case $1 in '') printf "''\n" ;; *[!A-Za-z0-9_./-]*) printf '%s\n' "$1" | sed "s/'/'\\\\''/g; s/^/'/; s/\$/'/" ;; *) printf '%s\n' "$1" ;; esac } # Follows symlinks and resolves the parent directory physically, so two paths # compare equal exactly when they name the same executable: the final block's # installed-vs-shadowed verdict must not change just because one side reaches # the binary through a symlink alias. resolve_physical_path() { resolve_physical_target=$1 resolve_physical_steps=0 while [ -L "$resolve_physical_target" ] && [ "$resolve_physical_steps" -lt 40 ]; do resolve_physical_link=$(readlink "$resolve_physical_target") || break case $resolve_physical_link in /*) resolve_physical_target=$resolve_physical_link ;; *) resolve_physical_target=$(dirname "$resolve_physical_target")/$resolve_physical_link ;; esac resolve_physical_steps=$((resolve_physical_steps + 1)) done resolve_physical_base=$(basename "$resolve_physical_target") if resolve_physical_dir=$(cd "$(dirname "$resolve_physical_target")" 2>/dev/null && pwd -P); then printf '%s/%s\n' "$resolve_physical_dir" "$resolve_physical_base" else printf '%s\n' "$resolve_physical_target" fi } # Resolves the composio command against the PATH snapshot taken before any # installer code could modify PATH: what the invoking terminal can run. resolve_inherited_command() { PATH=$inherited_path command -v composio 2>/dev/null } compute_inherited_resolution() { inherited_command=$(resolve_inherited_command) || inherited_command= inherited_resolution=unresolved if [ -n "$inherited_command" ]; then if [ "$(resolve_physical_path "$inherited_command")" = "$(resolve_physical_path "$exe")" ]; then inherited_resolution=installed else inherited_resolution=shadowed fi fi } # Reject bin dirs that cannot be embedded safely in a managed rc line. The dir # is only ever emitted inside double quotes -- `export PATH=":$PATH"` for # bash/zsh and `set --export PATH "" $PATH` for fish -- so the set is # deliberately narrow and mirrors UNSAFE_PATH_CHARS in # ts/packages/cli/src/commands/install.cmd.ts, keeping this inline fallback and # `composio install` on one contract: # # ` $ " \ the only characters bash and zsh still expand inside double # quotes (fish expands a strict subset). Everything else -- `;`, # `|`, `&`, `(`, `)`, `'` -- is literal there, so a bin dir like # /Users/o'brien/.local/bin is written verbatim, not rejected. # CR LF structural: either would split the managed block into extra lines. # : structural: the PATH separator would silently prepend two entries. # # Aborting rather than escaping is what keeps the two branches equivalent: a dir # the CLI refuses must not slip into an rc file just because the CLI could not # run. It also means the value reaching append_path_block can no longer carry a # character that double quotes would interpret, so no escaping pass is needed # (and `${var//x/y}` is a bashism this POSIX script cannot use anyway). is_unsafe_path() { case $1 in *':'* | *'`'* | *'$'* | *'"'* | *\\*) return 0 ;; esac unsafe_path_cr=$(printf '\r') case $1 in *"$unsafe_path_cr"* | *' '*) return 0 ;; esac return 1 } # Renders the home directory itself and paths under it with a literal $HOME # prefix. Must stay in lockstep with the CLI's renderWithHome (install.cmd.ts): # delegated_setup_verified compares the CLI-written line against this rendering # byte for byte, so any disagreement makes every delegated install look stale # and forces a needless inline rewrite. # # Callers must run is_unsafe_path first, so the only `$` in the result is the # `$HOME` this function introduces -- it survives as a live variable reference # in the rc line without any user-supplied `$` riding along with it. render_bin_dir() { render_bin_dir_value=$1 case $render_bin_dir_value in "$HOME") render_bin_dir_value=\$HOME ;; "$HOME"/*) render_bin_dir_value=\$HOME/${render_bin_dir_value#"$HOME"/} ;; esac printf '%s\n' "$render_bin_dir_value" } path_block_line() { case $1 in fish) printf "set --export PATH \"%s\" \$PATH\n" "$2" ;; *) printf "export PATH=\"%s:\$PATH\"\n" "$2" ;; esac } # Succeeds only when the file holds exactly one managed marker block and that # block already names the expected line. path_block_current() { awk -v expected="$2" ' $0 == "# Composio CLI" { markers++; pending = 1; next } pending { pending = 0; if ($0 == expected) matches++ } END { exit !(markers == 1 && matches == 1) } ' "$1" 2>/dev/null } # Reconciles the single managed PATH block in one startup file: keeps an # already-current block, replaces stale managed blocks, preserves unmanaged # content, and reports every failure through its return status. write_path_block() { write_path_file=$1 write_path_line=$(path_block_line "$2" "$3") || return 1 mkdir -p "$(dirname "$write_path_file")" 2>/dev/null || return 1 if [ -e "$write_path_file" ] && [ ! -f "$write_path_file" ]; then return 1 fi if [ ! -f "$write_path_file" ]; then touch "$write_path_file" 2>/dev/null || return 1 fi # Dotfile managers keep startup files as symlinks; rewrite the physical # target so the tmp+rename replace below cannot detach the symlink. write_path_target=$(resolve_physical_path "$write_path_file") if path_block_current "$write_path_target" "$write_path_line"; then info "$(tildify "$write_path_file") is already up to date." return 0 fi write_path_tmp=$write_path_target.composio.tmp.$$ # cp -p seeds the tmp with the original permission bits; the awk rewrite # below truncates its content while the copied mode survives the rename. if ! cp -p "$write_path_target" "$write_path_tmp" 2>/dev/null; then rm -f "$write_path_tmp" return 1 fi if ! awk ' function is_managed_path_assignment(line) { return line ~ /^[[:space:]]*export PATH=".*:\$PATH"[[:space:]]*$/ || line ~ /^[[:space:]]*set --export PATH ".*" \$PATH[[:space:]]*$/ } # The previously released installer wrote a three-line managed block: # the marker, an install-dir export, then a PATH line referencing it. # Recognizing that exact pair migrates the whole legacy block instead # of orphaning the two export lines once the marker is consumed. function is_legacy_install_dir_assignment(line) { return line ~ /^[[:space:]]*export COMPOSIO_INSTALL_DIR=".*"[[:space:]]*$/ || line ~ /^[[:space:]]*set --export COMPOSIO_INSTALL_DIR ".*"[[:space:]]*$/ } function is_legacy_pair(first, second) { if (first ~ /^[[:space:]]*export COMPOSIO_INSTALL_DIR=".*"[[:space:]]*$/) return second ~ /^[[:space:]]*export PATH="\$COMPOSIO_INSTALL_DIR:\$PATH"[[:space:]]*$/ return second ~ /^[[:space:]]*set --export PATH \$COMPOSIO_INSTALL_DIR \$PATH[[:space:]]*$/ } holding { holding = 0 if (is_legacy_pair(held, $0)) { held = ""; next } print held held = "" } pending { pending = 0 if (is_managed_path_assignment($0)) next if (is_legacy_install_dir_assignment($0)) { held = $0; holding = 1; next } } $0 == "# Composio CLI" { pending = 1; next } { print } END { if (holding) print held } ' "$write_path_target" >"$write_path_tmp" 2>/dev/null; then rm -f "$write_path_tmp" return 1 fi if ! printf '\n# Composio CLI\n%s\n' "$write_path_line" >>"$write_path_tmp" 2>/dev/null; then rm -f "$write_path_tmp" return 1 fi if ! mv "$write_path_tmp" "$write_path_target" 2>/dev/null; then rm -f "$write_path_tmp" return 1 fi info "Updated $(tildify "$write_path_file")." return 0 } # Names the bash startup file a login shell reads the managed block from. # A login bash reads /etc/profile and then only the first existing of # ~/.bash_profile, ~/.bash_login, ~/.profile; it never reads ~/.bashrc, and # macOS Terminal.app starts exactly such a shell. An override that already # exists is reused; otherwise ~/.bash_profile is the file to create. ~/.profile # is never a target: every POSIX shell shares it. bash_login_path_file() { if [ ! -f "$HOME/.bash_profile" ] && [ -f "$HOME/.bash_login" ]; then printf '%s\n' "$HOME/.bash_login" else printf '%s\n' "$HOME/.bash_profile" fi } # Single source of truth for the startup files the managed PATH block lands in, # in write order. Both setup paths and the confirmation message read it, so the # files the installer configures are exactly the files it verifies and names. shell_path_files() { case $1 in zsh) printf '%s\n' "$HOME/.zshrc" ;; fish) printf '%s\n' "$HOME/.config/fish/config.fish" ;; bash) printf '%s\n' "$HOME/.bashrc" "$(bash_login_path_file)" ;; *) return 1 ;; esac } # A ~/.bash_profile the installer creates shadows an existing ~/.profile, which # login bash read while no override existed. Seed the new file so it keeps # sourcing it; ~/.profile itself is left untouched. seed_bash_login_file() { seed_login_shell=$1 seed_login_file=$2 if [ "$seed_login_shell" != bash ] || [ "$seed_login_file" != "$HOME/.bash_profile" ]; then return 0 fi if [ -e "$seed_login_file" ] || [ ! -f "$HOME/.profile" ]; then return 0 fi cat >"$seed_login_file" 2>/dev/null <<'SEED_BASH_LOGIN_FILE' || return 1 # Created by the Composio CLI installer. # Bash reads this file instead of ~/.profile in login shells. if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" fi SEED_BASH_LOGIN_FILE return 0 } inline_shell_setup() { inline_setup_shell=$1 inline_setup_bin_dir=$2 if is_unsafe_path "$inline_setup_bin_dir"; then return 1 fi inline_setup_rendered=$(render_bin_dir "$inline_setup_bin_dir") inline_setup_list=$(shell_path_files "$inline_setup_shell") || return 1 inline_setup_status=0 while IFS= read -r inline_setup_file; do [ -n "$inline_setup_file" ] || continue if ! seed_bash_login_file "$inline_setup_shell" "$inline_setup_file"; then inline_setup_status=1 continue fi write_path_block "$inline_setup_file" "$inline_setup_shell" "$inline_setup_rendered" || inline_setup_status=1 done <&1 | grep -q -- '--shell'; then debug "delegating shell setup to $exe install --shell $requested_shell" # The delegated CLI prints its own branded report for a command the # user never ran. Capture both of its streams so the installer keeps # sole ownership of the presentation, and replay them under # COMPOSIO_DEBUG so genuine failures stay diagnosable. setup_delegated_log=$tmpdir/shell-setup-delegated.log setup_delegated_status=0 COMPOSIO_CLI_INVOCATION_ORIGIN=installer COMPOSIO_BIN_DIR="$resolved_bin_dir" \ "$exe" install --shell "$requested_shell" >"$setup_delegated_log" 2>&1 || setup_delegated_status=$? debug_captured_output \ "delegated shell setup exited $setup_delegated_status; captured output:" \ "$setup_delegated_log" if [ "$setup_delegated_status" -eq 0 ] && delegated_setup_verified; then debug "shell setup source: cli" report_configured_shell return 0 fi fi debug "falling back to inline $requested_shell shell setup" if inline_shell_setup "$requested_shell" "$resolved_bin_dir"; then debug "shell setup source: fallback" report_configured_shell return 0 fi return 1 } # Final action block for every non-failure flow: one truthful ending chosen # from the inherited-resolution and setup-outcome state. It must be the last # output — the closing block is the instruction users copy, so nothing may # print after it. Suppressible because it only covers normal success. print_post_install_help() { [ "${COMPOSIO_INSTALL_HELP:-1}" != 0 ] || return 0 if is_true "${COMPOSIO_QUIET:-}"; then return 0 fi compute_inherited_resolution printf '\n' if [ "$install_agent" = 1 ]; then printf 'Composio agent login complete.\n' if [ "$inherited_resolution" != installed ]; then if [ "$shell_setup_outcome" = success ]; then printf 'Open a new terminal to use the composio command.\n' else printf 'Run composio from its installed location:\n\n %s --help\n' "$(shell_quote "$exe")" fi fi return 0 fi # Case A: the invoking terminal already resolves the installed executable. if [ "$inherited_resolution" = installed ]; then if [ "$shell_setup_outcome" = success ] || [ "$shell_setup_mode" = none ]; then printf 'composio is ready.\n\n composio login\n' return 0 fi fi if [ "$shell_setup_outcome" = success ]; then if [ "$inherited_resolution" = shadowed ]; then printf 'Another composio command at %s takes precedence in this terminal.\n' "$inherited_command" printf 'To use the newly installed CLI, run:\n\n %s login\n' "$(shell_quote "$exe")" else # Case B: configured for future terminals, vocabulary-free. printf 'Open a new terminal, then run:\n\n composio login\n' fi return 0 fi # Install-only guidance: COMPOSIO_INSTALL_SHELL=none or an unrecognized # login shell. Never point the user at a shadowed bare command. if [ "$shell_setup_mode" = none ]; then printf 'Shell setup was skipped (COMPOSIO_INSTALL_SHELL=none).\n' else printf 'Automatic shell setup is not available for your shell.\n' fi case $inherited_resolution in shadowed) printf 'Another composio command at %s takes precedence in this terminal.\n' "$inherited_command" ;; unresolved) if ! inherited_path_contains_bin_dir; then printf 'Add %s to your PATH to use composio in new terminals.\n' "$(tildify "$resolved_bin_dir")" fi ;; esac printf 'To get started now, run:\n\n %s login\n' "$(shell_quote "$exe")" } # Setup failure never fails the install. Recovery travels the stderr warn # channel so quiet mode and COMPOSIO_INSTALL_HELP=0 cannot suppress it, and the # trusted --version-verified installed executable is always the last output. print_setup_failure_ending() { if [ "$install_agent" = 1 ] && [ "${COMPOSIO_INSTALL_HELP:-1}" != 0 ] && ! is_true "${COMPOSIO_QUIET:-}"; then printf '\nComposio agent login complete.\n' fi warn "Automatic PATH setup for $requested_shell failed. The Composio CLI is installed and unaffected." # The delegated CLI's own output is captured, so point at the channel that # replays it instead of leaving the failure undiagnosable. if ! is_true "${COMPOSIO_DEBUG:-}"; then warn 'Re-run with COMPOSIO_DEBUG=1 for details.' fi if [ "$install_agent" = 1 ]; then printf '\nRun composio from its installed location:\n\n %s --help\n' "$(shell_quote "$exe")" >&2 else printf '\nTo get started, run:\n\n %s login\n' "$(shell_quote "$exe")" >&2 fi } cleanup() { if [ -n "${tmpdir:-}" ] && [ -d "$tmpdir" ]; then rm -rf "$tmpdir" fi if [ "${preserve_stage:-0}" != 1 ] && [ -n "${stage:-}" ] && [ -d "$stage" ]; then rm -rf "$stage" fi } cleanup_on_signal() { cleanup_signal=$1 trap - 0 1 2 3 15 cleanup || : exit $((128 + cleanup_signal)) } main() { # Snapshot the PATH the invoking terminal handed us before any installer # code can modify it; every final-state decision uses only this snapshot. inherited_path=${PATH:-} install_agent=0 install_plugins=${COMPOSIO_INSTALL_PLUGINS:-0} version_arg= requested_shell=${COMPOSIO_INSTALL_SHELL:-} case $install_plugins in 0 | 1) ;; *) error 'COMPOSIO_INSTALL_PLUGINS must be 1 or 0' ;; esac case $requested_shell in '' | auto | zsh | bash | fish | none) ;; *) error "COMPOSIO_INSTALL_SHELL must be auto, zsh, bash, fish, or none (got \"$requested_shell\")" ;; esac while [ "$#" -gt 0 ]; do case $1 in --agent) install_agent=1 ;; --no-plugins) install_plugins=0 ;; -h | --help) print_usage return 0 ;; --*) error "Unknown option: $1" ;; *) [ -z "$version_arg" ] || error 'Too many arguments. Expected at most one version tag.' version_arg=$1 ;; esac shift done # Resolve the setup mode right after argument parsing: auto (the default) # infers the login shell from $SHELL and degrades to install-only when it # is unset or unrecognized; none is the documented install-only opt-out. shell_setup_mode=${requested_shell:-auto} case $shell_setup_mode in auto) login_shell=$(basename "${SHELL:-}" 2>/dev/null) || login_shell= case $login_shell in zsh | bash | fish) requested_shell=$login_shell ;; *) requested_shell= ;; esac ;; none) requested_shell= ;; esac COMPOSIO_GITHUB_OWNER=${COMPOSIO_GITHUB_OWNER-ComposioHQ} COMPOSIO_GITHUB_REPO=${COMPOSIO_GITHUB_REPO-composio} COMPOSIO_GITHUB_URL=${COMPOSIO_GITHUB_URL-https://github.com} COMPOSIO_GITHUB_API_BASE_URL=${COMPOSIO_GITHUB_API_BASE_URL:-} COMPOSIO_INSTALL_DIR=${COMPOSIO_INSTALL_DIR:-"$HOME/.composio"} COMPOSIO_BIN_DIR=${COMPOSIO_BIN_DIR:-"$HOME/.local/bin"} validate_identifier COMPOSIO_GITHUB_OWNER "$COMPOSIO_GITHUB_OWNER" validate_identifier COMPOSIO_GITHUB_REPO "$COMPOSIO_GITHUB_REPO" validate_url "$COMPOSIO_GITHUB_URL" || error "COMPOSIO_GITHUB_URL must use https or an explicitly allowed test host (got \"$COMPOSIO_GITHUB_URL\")" if [ -n "$COMPOSIO_GITHUB_API_BASE_URL" ]; then validate_url "$COMPOSIO_GITHUB_API_BASE_URL" || error "COMPOSIO_GITHUB_API_BASE_URL must use https or an explicitly allowed test host (got \"$COMPOSIO_GITHUB_API_BASE_URL\")" fi detect_target command -v curl >/dev/null 2>&1 || error 'curl is required to install Composio CLI' command -v unzip >/dev/null 2>&1 || error 'unzip is required to install Composio CLI' github_repo=${COMPOSIO_GITHUB_URL%/}/$COMPOSIO_GITHUB_OWNER/$COMPOSIO_GITHUB_REPO if [ -n "$COMPOSIO_GITHUB_API_BASE_URL" ]; then github_api_base=${COMPOSIO_GITHUB_API_BASE_URL%/} elif [ "$COMPOSIO_GITHUB_URL" = https://github.com ]; then github_api_base=https://api.github.com else github_api_base=${COMPOSIO_GITHUB_URL%/}/api/v3 fi github_api_repo=$github_api_base/repos/$COMPOSIO_GITHUB_OWNER/$COMPOSIO_GITHUB_REPO archive_name=composio-$target.zip # Official ComposioHQ releases always publish a complete checksums.txt, so # a missing manifest or entry is a hard error there. Any overridden source # (mirror, test host, custom API base) keeps the lenient warn-and-continue # behavior, since its manifests are outside our control. official_release_source=0 if [ "$COMPOSIO_GITHUB_URL" = https://github.com ] && [ "$COMPOSIO_GITHUB_OWNER" = ComposioHQ ] && [ "$COMPOSIO_GITHUB_REPO" = composio ] && [ -z "$COMPOSIO_GITHUB_API_BASE_URL" ]; then official_release_source=1 fi requested_version=$version_arg if [ -z "$requested_version" ]; then requested_version=${COMPOSIO_INSTALL_VERSION:-} fi if [ -n "$requested_version" ]; then version=$(normalize_version "$requested_version") archive_url=$github_repo/releases/download/$version/$archive_name else info 'Finding latest stable CLI release...' latest_release=$(resolve_latest_cli_release) || error "Failed to determine the latest CLI release with a $archive_name asset. Specify a version manually." version=$(printf '%s\n' "$latest_release" | sed -n '1p') archive_url=$(printf '%s\n' "$latest_release" | sed -n '2p') if [ -z "$version" ] || [ -z "$archive_url" ]; then error 'The release API returned an incomplete CLI release' fi info "Found latest version: $version" fi validate_url "$archive_url" || error "Release API returned an unsafe archive URL \"$archive_url\"" checksums_url=$github_repo/releases/download/$version/checksums.txt validate_url "$checksums_url" || error "Refusing unsafe checksum URL \"$checksums_url\"" tmpdir=$(mktemp -d) || error 'Failed to create a temporary directory' trap cleanup 0 trap 'cleanup_on_signal 1' 1 trap 'cleanup_on_signal 2' 2 trap 'cleanup_on_signal 3' 3 trap 'cleanup_on_signal 15' 15 debug "temporary directory: $tmpdir" info "Installing Composio CLI $version for $target" curl_download "$archive_url" "$tmpdir/$archive_name" 0 || error "Failed to download from \"$archive_url\"" if curl_download "$checksums_url" "$tmpdir/checksums.txt" 1; then verify_checksum "$tmpdir/$archive_name" "$tmpdir/checksums.txt" "$archive_name" elif [ "$official_release_source" = 1 ]; then error "Failed to download checksums.txt from \"$checksums_url\". Official releases always publish checksums, so this release cannot be verified. Refusing to install." else warn 'No checksums.txt in this release; continuing without verification' fi info 'Extracting bundle...' unzip -oqd "$tmpdir" "$tmpdir/$archive_name" || error 'Failed to extract archive' resolved_install_dir=$(resolve_directory "$COMPOSIO_INSTALL_DIR") resolved_bin_dir=$(resolve_directory "$COMPOSIO_BIN_DIR") install_bundle "$tmpdir" install_entry_point exe=$resolved_install_dir/composio "$exe" --version >/dev/null 2>&1 || error 'The installed Composio CLI failed its version check' info "Composio CLI was installed to $(tildify "$exe")" if [ "$resolved_bin_dir" != "$resolved_install_dir" ]; then info "The composio entry point is $(tildify "$resolved_bin_dir/composio")" fi # Delegated CLI invocations below may spawn composio subprocesses; make the # fresh entry point resolvable for them. Final-state decisions keep using # the inherited snapshot taken at the top of main(). PATH=$resolved_bin_dir:$PATH export PATH if [ "$install_plugins" = 1 ]; then info 'Installing plugins for detected agent hosts...' COMPOSIO_CLI_INVOCATION_ORIGIN=installer "$exe" setup --target auto --yes --if-present || error "Composio CLI was installed, but agent plugin setup failed. Retry with \`composio setup --target auto --yes\`." fi if [ "$install_agent" = 1 ]; then info 'Setting up Composio agent login...' COMPOSIO_CLI_INVOCATION_ORIGIN=installer "$exe" login --agent --no-skill-install || error 'Failed to sign up or log in as a Composio agent.' fi shell_setup_outcome=skipped if [ -n "$requested_shell" ]; then if setup_requested_shell; then shell_setup_outcome=success else shell_setup_outcome=failure fi fi if [ "$shell_setup_outcome" = failure ]; then print_setup_failure_ending else print_post_install_help fi } main "$@"