diff --git a/openclaw/install.sh b/openclaw/install.sh index d94194ea..0ce903a7 100755 --- a/openclaw/install.sh +++ b/openclaw/install.sh @@ -3,7 +3,13 @@ set -euo pipefail # claude-mem OpenClaw Plugin Installer # Installs the claude-mem persistent memory plugin for OpenClaw gateways. -# Usage: bash install.sh [--non-interactive] +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/thedotmack/claude-mem/main/openclaw/install.sh | bash +# # Or with options: +# curl -fsSL https://raw.githubusercontent.com/thedotmack/claude-mem/main/openclaw/install.sh | bash -s -- --provider=gemini --api-key=YOUR_KEY +# # Direct execution: +# bash install.sh [--non-interactive] [--provider=claude|gemini|openrouter] [--api-key=KEY] ############################################################################### # Constants @@ -11,7 +17,68 @@ set -euo pipefail readonly MIN_BUN_VERSION="1.1.14" readonly INSTALLER_VERSION="1.0.0" -readonly NON_INTERACTIVE="${1:-}" + +############################################################################### +# Argument parsing +############################################################################### + +NON_INTERACTIVE="" +CLI_PROVIDER="" +CLI_API_KEY="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --non-interactive) + NON_INTERACTIVE="true" + shift + ;; + --provider=*) + CLI_PROVIDER="${1#--provider=}" + shift + ;; + --provider) + CLI_PROVIDER="${2:-}" + shift 2 + ;; + --api-key=*) + CLI_API_KEY="${1#--api-key=}" + shift + ;; + --api-key) + CLI_API_KEY="${2:-}" + shift 2 + ;; + *) + shift + ;; + esac +done + +############################################################################### +# TTY detection — ensure interactive prompts work under curl | bash +# When piped, stdin reads from curl's output, not the terminal. +# We open /dev/tty on fd 3 and read interactive input from there. +############################################################################### + +TTY_FD=0 + +setup_tty() { + if [[ -t 0 ]]; then + # stdin IS a terminal — use it directly + TTY_FD=0 + elif [[ -e /dev/tty ]]; then + # stdin is piped (curl | bash) but /dev/tty is available + exec 3&2 + echo "Use --non-interactive or run directly: bash install.sh" >&2 + exit 1 + fi + fi +} ############################################################################### # Color utilities — auto-detect terminal color support @@ -43,17 +110,20 @@ warn() { echo -e "${COLOR_YELLOW}⚠${COLOR_RESET} $*"; } error() { echo -e "${COLOR_RED}✗${COLOR_RESET} $*" >&2; } prompt_user() { - if [[ "$NON_INTERACTIVE" == "--non-interactive" ]]; then + if [[ "$NON_INTERACTIVE" == "true" ]]; then error "Cannot prompt in non-interactive mode: $*" return 1 fi - if [[ ! -t 0 ]]; then - error "Cannot prompt when stdin is not a terminal: $*" - return 1 - fi echo -en "${COLOR_CYAN}?${COLOR_RESET} $* " } +# Read a line from the terminal (works even when stdin is piped from curl) +# Callers always pass -r via $@; shellcheck can't see through the delegation +read_tty() { + # shellcheck disable=SC2162 + read "$@" <&"$TTY_FD" +} + ############################################################################### # Banner ############################################################################### @@ -500,7 +570,42 @@ setup_ai_provider() { info "AI Provider Configuration" echo "" - if [[ "$NON_INTERACTIVE" == "--non-interactive" ]] || [[ ! -t 0 ]]; then + # Handle --provider flag (pre-selected via CLI) + if [[ -n "$CLI_PROVIDER" ]]; then + case "$CLI_PROVIDER" in + claude) + AI_PROVIDER="claude" + success "Selected via --provider: Claude Max Plan (CLI authentication)" + ;; + gemini) + AI_PROVIDER="gemini" + AI_PROVIDER_API_KEY="${CLI_API_KEY}" + if [[ -n "$AI_PROVIDER_API_KEY" ]]; then + success "Selected via --provider: Gemini (API key set via --api-key)" + else + warn "Selected via --provider: Gemini (no API key — add later in ~/.claude-mem/settings.json)" + fi + ;; + openrouter) + AI_PROVIDER="openrouter" + AI_PROVIDER_API_KEY="${CLI_API_KEY}" + if [[ -n "$AI_PROVIDER_API_KEY" ]]; then + success "Selected via --provider: OpenRouter (API key set via --api-key)" + else + warn "Selected via --provider: OpenRouter (no API key — add later in ~/.claude-mem/settings.json)" + fi + ;; + *) + error "Unknown provider: ${CLI_PROVIDER}" + error "Valid providers: claude, gemini, openrouter" + exit 1 + ;; + esac + return 0 + fi + + # Handle non-interactive mode (no --provider flag) + if [[ "$NON_INTERACTIVE" == "true" ]]; then info "Non-interactive mode: defaulting to Claude Max Plan (no API key needed)" AI_PROVIDER="claude" return 0 @@ -521,7 +626,7 @@ setup_ai_provider() { local choice while true; do prompt_user "Enter choice [1/2/3] (default: 1):" - read -r choice + read_tty -r choice choice="${choice:-1}" case "$choice" in @@ -534,7 +639,7 @@ setup_ai_provider() { AI_PROVIDER="gemini" echo "" prompt_user "Enter your Gemini API key (from https://ai.google.dev):" - read -rs AI_PROVIDER_API_KEY + read_tty -rs AI_PROVIDER_API_KEY echo "" if [[ -z "$AI_PROVIDER_API_KEY" ]]; then warn "No API key provided — you can add it later in ~/.claude-mem/settings.json" @@ -547,7 +652,7 @@ setup_ai_provider() { AI_PROVIDER="openrouter" echo "" prompt_user "Enter your OpenRouter API key (from https://openrouter.ai):" - read -rs AI_PROVIDER_API_KEY + read_tty -rs AI_PROVIDER_API_KEY echo "" if [[ -z "$AI_PROVIDER_API_KEY" ]]; then warn "No API key provided — you can add it later in ~/.claude-mem/settings.json" @@ -822,7 +927,7 @@ setup_observation_feed() { echo " you'll see it in your chat." echo "" - if [[ "$NON_INTERACTIVE" == "--non-interactive" ]] || [[ ! -t 0 ]]; then + if [[ "$NON_INTERACTIVE" == "true" ]]; then info "Non-interactive mode: skipping observation feed setup" info "Configure later in ~/.openclaw/openclaw.json under" info " plugins.entries.claude-mem.config.observationFeed" @@ -831,7 +936,7 @@ setup_observation_feed() { prompt_user "Would you like to set up real-time observation streaming to a messaging channel? (y/n)" local answer - read -r answer + read_tty -r answer answer="${answer:-n}" if [[ "${answer,,}" != "y" && "${answer,,}" != "yes" ]]; then @@ -857,7 +962,7 @@ setup_observation_feed() { local channel_choice while true; do prompt_user "Enter choice [1-6]:" - read -r channel_choice + read_tty -r channel_choice case "$channel_choice" in 1) @@ -917,7 +1022,7 @@ setup_observation_feed() { echo "" prompt_user "Enter your ${FEED_CHANNEL} target ID:" - read -r FEED_TARGET_ID + read_tty -r FEED_TARGET_ID if [[ -z "$FEED_TARGET_ID" ]]; then warn "No target ID provided — skipping observation feed setup." @@ -1098,6 +1203,7 @@ print_completion_summary() { ############################################################################### main() { + setup_tty print_banner detect_platform diff --git a/openclaw/test-install.sh b/openclaw/test-install.sh index d61e69a0..b1aeb0bf 100755 --- a/openclaw/test-install.sh +++ b/openclaw/test-install.sh @@ -1324,6 +1324,275 @@ assert_contains "$(grep -A2 'userinfobot' "$INSTALL_SCRIPT" 2>/dev/null || echo assert_contains "$(grep -A2 'Developer Mode' "$INSTALL_SCRIPT" 2>/dev/null || echo '')" "Developer Mode" "Discord instructions include Developer Mode" assert_contains "$(grep -A2 'C01ABC2DEFG' "$INSTALL_SCRIPT" 2>/dev/null || echo '')" "C01ABC2DEFG" "Slack instructions include sample channel ID" +############################################################################### +# Test: TTY detection — setup_tty() and read_tty() exist +############################################################################### + +echo "" +echo "=== TTY detection ===" + +for fn in setup_tty read_tty; do + if declare -f "$fn" &>/dev/null; then + test_pass "Function ${fn}() is defined" + else + test_fail "Function ${fn}() should be defined" + fi +done + +# Verify TTY_FD is initialized (defaults to 0) +if declare -p TTY_FD &>/dev/null; then + test_pass "TTY_FD variable is defined" +else + test_fail "TTY_FD variable should be defined" +fi + +# Verify setup_tty is called from main() +if grep -q 'setup_tty' "$INSTALL_SCRIPT"; then + test_pass "main() calls setup_tty" +else + test_fail "main() should call setup_tty" +fi + +############################################################################### +# Test: Argument parsing — --provider flag +############################################################################### + +echo "" +echo "=== Argument parsing — --provider flag ===" + +test_provider_flag_claude() { + local result + result="$(bash -c ' + set -euo pipefail + TERM=dumb + tmp=$(mktemp) + sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp" + echo "main() { :; }" >> "$tmp" + set -- "--provider=claude" + source "$tmp" + rm -f "$tmp" + setup_ai_provider >/dev/null 2>&1 + echo "$AI_PROVIDER" + ' 2>/dev/null)" || true + + assert_eq "claude" "$result" "--provider=claude sets AI_PROVIDER to claude" +} + +test_provider_flag_claude + +test_provider_flag_gemini_with_api_key() { + local result + result="$(bash -c ' + set -euo pipefail + TERM=dumb + tmp=$(mktemp) + sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp" + echo "main() { :; }" >> "$tmp" + set -- "--provider=gemini" "--api-key=test-key-123" + source "$tmp" + rm -f "$tmp" + setup_ai_provider >/dev/null 2>&1 + echo "PROVIDER=$AI_PROVIDER" + echo "KEY=$AI_PROVIDER_API_KEY" + ' 2>/dev/null)" || true + + assert_contains "$result" "PROVIDER=gemini" "--provider=gemini sets AI_PROVIDER to gemini" + assert_contains "$result" "KEY=test-key-123" "--api-key=test-key-123 sets API key" +} + +test_provider_flag_gemini_with_api_key + +test_provider_flag_openrouter() { + local result + result="$(bash -c ' + set -euo pipefail + TERM=dumb + tmp=$(mktemp) + sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp" + echo "main() { :; }" >> "$tmp" + set -- "--provider=openrouter" "--api-key=sk-or-test" + source "$tmp" + rm -f "$tmp" + setup_ai_provider >/dev/null 2>&1 + echo "PROVIDER=$AI_PROVIDER" + echo "KEY=$AI_PROVIDER_API_KEY" + ' 2>/dev/null)" || true + + assert_contains "$result" "PROVIDER=openrouter" "--provider=openrouter sets AI_PROVIDER" + assert_contains "$result" "KEY=sk-or-test" "--api-key sets API key for openrouter" +} + +test_provider_flag_openrouter + +test_provider_flag_invalid() { + local exit_code=0 + bash -c ' + set -euo pipefail + TERM=dumb + tmp=$(mktemp) + sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp" + echo "main() { :; }" >> "$tmp" + set -- "--provider=invalid" + source "$tmp" + rm -f "$tmp" + setup_ai_provider + ' >/dev/null 2>&1 || exit_code=$? + + if [[ "$exit_code" -ne 0 ]]; then + test_pass "--provider=invalid exits with error" + else + test_fail "--provider=invalid should exit with error" + fi +} + +test_provider_flag_invalid + +############################################################################### +# Test: Argument parsing — --non-interactive flag (new format) +############################################################################### + +echo "" +echo "=== Argument parsing — --non-interactive ===" + +test_non_interactive_flag() { + local result + result="$(bash -c ' + set -euo pipefail + TERM=dumb + tmp=$(mktemp) + sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp" + echo "main() { :; }" >> "$tmp" + set -- "--non-interactive" + source "$tmp" + rm -f "$tmp" + echo "NON_INTERACTIVE=$NON_INTERACTIVE" + ' 2>/dev/null)" || true + + assert_contains "$result" "NON_INTERACTIVE=true" "--non-interactive sets NON_INTERACTIVE=true" +} + +test_non_interactive_flag + +test_non_interactive_with_provider() { + local result + result="$(bash -c ' + set -euo pipefail + TERM=dumb + tmp=$(mktemp) + sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp" + echo "main() { :; }" >> "$tmp" + set -- "--non-interactive" "--provider=gemini" "--api-key=my-key" + source "$tmp" + rm -f "$tmp" + setup_ai_provider >/dev/null 2>&1 + echo "PROVIDER=$AI_PROVIDER" + echo "KEY=$AI_PROVIDER_API_KEY" + echo "NON_INTERACTIVE=$NON_INTERACTIVE" + ' 2>/dev/null)" || true + + assert_contains "$result" "PROVIDER=gemini" "--non-interactive + --provider: provider set correctly" + assert_contains "$result" "KEY=my-key" "--non-interactive + --api-key: key set correctly" + assert_contains "$result" "NON_INTERACTIVE=true" "--non-interactive flag parsed alongside --provider" +} + +test_non_interactive_with_provider + +############################################################################### +# Test: --non-interactive mode completes without hanging +############################################################################### + +echo "" +echo "=== --non-interactive full flow ===" + +test_non_interactive_completes() { + # Run the full setup_ai_provider + setup_observation_feed in non-interactive mode + # This should complete without any prompts or hangs + local result + result="$(bash -c ' + set -euo pipefail + TERM=dumb + tmp=$(mktemp) + sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp" + echo "main() { :; }" >> "$tmp" + set -- "--non-interactive" + source "$tmp" + rm -f "$tmp" + setup_ai_provider 2>/dev/null + setup_observation_feed 2>/dev/null + echo "AI=$AI_PROVIDER" + echo "FEED=$FEED_CONFIGURED" + ' 2>/dev/null)" || true + + assert_contains "$result" "AI=claude" "--non-interactive: AI provider defaults to claude" + assert_contains "$result" "FEED=false" "--non-interactive: observation feed skipped" +} + +test_non_interactive_completes + +############################################################################### +# Test: Script structure — curl | bash usage comment +############################################################################### + +echo "" +echo "=== curl | bash usage comment ===" + +if grep -q 'curl -fsSL.*raw.githubusercontent.com.*install.sh | bash' "$INSTALL_SCRIPT"; then + test_pass "install.sh contains curl | bash usage comment" +else + test_fail "install.sh should contain curl | bash usage comment" +fi + +if grep -q 'bash -s -- --provider=' "$INSTALL_SCRIPT"; then + test_pass "install.sh documents --provider flag in usage comment" +else + test_fail "install.sh should document --provider flag in usage comment" +fi + +############################################################################### +# Test: write_settings with --provider flag end-to-end +############################################################################### + +echo "" +echo "=== write_settings with --provider flag ===" + +test_write_settings_via_provider_flag() { + local fake_home + fake_home="$(mktemp -d)" + + local result + result="$(bash -c ' + set -euo pipefail + TERM=dumb + export HOME="'"$fake_home"'" + tmp=$(mktemp) + sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp" + echo "main() { :; }" >> "$tmp" + set -- "--provider=gemini" "--api-key=test-end-to-end-key" + source "$tmp" + rm -f "$tmp" + setup_ai_provider >/dev/null 2>&1 + write_settings >/dev/null 2>&1 + echo "DONE" + ' 2>/dev/null)" || true + + if [[ "$result" == *"DONE"* ]]; then + local settings_file="${fake_home}/.claude-mem/settings.json" + local provider + provider="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);")" + assert_eq "gemini" "$provider" "--provider flag: settings.json has provider=gemini" + + local api_key + api_key="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_GEMINI_API_KEY);")" + assert_eq "test-end-to-end-key" "$api_key" "--provider flag: settings.json has correct API key" + else + test_fail "--provider flag: write_settings failed" + fi + + rm -rf "$fake_home" +} + +test_write_settings_via_provider_flag + ############################################################################### # Summary ###############################################################################