MAESTRO: add TTY detection, --provider/--api-key flags, and curl | bash support to install.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-02-11 22:06:20 -05:00
parent 9bdd00ea5a
commit 81ebb8b6c0
2 changed files with 390 additions and 15 deletions
+121 -15
View File
@@ -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</dev/tty
TTY_FD=3
else
# No terminal available at all
if [[ "$NON_INTERACTIVE" != "true" ]]; then
echo "Error: No terminal available for interactive prompts." >&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
+269
View File
@@ -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
###############################################################################