#!/usr/bin/env bash # ────────────────────────────────────────────────────────────────────────────── # Nullion — one-command installer (macOS + Linux) # Usage: curl -fsSL "https://raw.githubusercontent.com/shumanhi/nullion/main/install.sh?$(date +%s)" | bash # or: bash install.sh # ────────────────────────────────────────────────────────────────────────────── set -euo pipefail NULLION_VERSION="${NULLION_VERSION:-latest}" NULLION_INSTALL_DIR="$HOME/.nullion" NULLION_ENV_FILE="$NULLION_INSTALL_DIR/.env" NULLION_LOG_DIR="$NULLION_INSTALL_DIR/logs" REPO_URL="https://github.com/shumanhi/nullion.git" # ── OS detection ────────────────────────────────────────────────────────────── OS="$(uname -s)" case "$OS" in Darwin) PLATFORM="macos" ;; Linux) PLATFORM="linux" ;; *) echo "Unsupported platform: $OS" echo "For Windows, use install.ps1 instead." exit 1 ;; esac # macOS-specific paths LAUNCHD_LABEL="com.nullion.web" LAUNCHD_PLIST="$HOME/Library/LaunchAgents/${LAUNCHD_LABEL}.plist" TRAY_LAUNCHD_LABEL="com.nullion.tray" TRAY_LAUNCHD_PLIST="$HOME/Library/LaunchAgents/${TRAY_LAUNCHD_LABEL}.plist" TELEGRAM_LAUNCHD_LABEL="ai.nullion.telegram" TELEGRAM_LAUNCHD_PLIST="$HOME/Library/LaunchAgents/${TELEGRAM_LAUNCHD_LABEL}.plist" SLACK_LAUNCHD_LABEL="ai.nullion.slack" SLACK_LAUNCHD_PLIST="$HOME/Library/LaunchAgents/${SLACK_LAUNCHD_LABEL}.plist" DISCORD_LAUNCHD_LABEL="ai.nullion.discord" DISCORD_LAUNCHD_PLIST="$HOME/Library/LaunchAgents/${DISCORD_LAUNCHD_LABEL}.plist" # Linux-specific paths SYSTEMD_USER_DIR="$HOME/.config/systemd/user" SYSTEMD_SERVICE="nullion.service" TELEGRAM_SYSTEMD_SERVICE="nullion-telegram.service" SLACK_SYSTEMD_SERVICE="nullion-slack.service" DISCORD_SYSTEMD_SERVICE="nullion-discord.service" # Web UI NULLION_WEB_PORT=8742 # Recommended local speech-to-text runtime. base.en is small enough for CPU # voice notes but much more reliable than tiny.en for short commands. WHISPER_CPP_MODEL_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin" WHISPER_CPP_MODEL_PATH="$NULLION_INSTALL_DIR/models/ggml-base.en.bin" # ── colours ─────────────────────────────────────────────────────────────────── BOLD="\033[1m" DIM="\033[2m" GREEN="\033[32m" YELLOW="\033[33m" RED="\033[31m" CYAN="\033[36m" MAGENTA="\033[35m" BLUE="\033[34m" RESET="\033[0m" print_header() { echo echo -e " ${DIM}╭────────────────────────────────────────────────────────────╮${RESET}" echo -e " ${DIM}│${RESET} ${BOLD}${CYAN}$*${RESET}" echo -e " ${DIM}╰────────────────────────────────────────────────────────────╯${RESET}" } print_ok() { echo -e " ${GREEN}✓${RESET} $*"; } print_info() { echo -e " ${YELLOW}→${RESET} $*"; } print_err() { echo -e " ${RED}✗${RESET} $*" >&2; } print_bold() { echo -e "\n ${BOLD}${CYAN}◆${RESET} ${BOLD}$*${RESET}"; } print_chip() { echo -e " ${DIM}[$1]${RESET} $2"; } prompt_read() { if [[ -r /dev/tty ]]; then read "$@" /dev/null; } xml_escape() { local value="$1" value="${value//&/&}" value="${value///>}" value="${value//\"/"}" printf '%s' "$value" } launchd_agent_is_running() { local target="$1" local state state="$(launchctl print "$target" 2>/dev/null | awk -F'= ' '/state = / {print $2; exit}' || true)" [[ "$state" == "running" ]] } launchd_register_agent() { local label="$1" local plist="$2" local display="$3" local domain="gui/$(id -u)" local target="${domain}/${label}" local output if command_exists plutil; then if ! output="$(plutil -lint "$plist" 2>&1)"; then print_err "${display} LaunchAgent plist is invalid: ${output}" print_info "Plist: ${plist}" return 1 fi fi launchctl bootout "$target" >/dev/null 2>&1 || true sleep 1 local bootstrap_output="" local bootstrapped=false local attempt for attempt in 1 2 3; do if output="$(launchctl bootstrap "$domain" "$plist" 2>&1)"; then bootstrapped=true break fi bootstrap_output="$output" sleep "$attempt" if launchctl print "$target" >/dev/null 2>&1; then bootstrapped=true print_info "${display} launchd bootstrap reported a warning, but the service is registered." break fi launchctl bootout "$target" >/dev/null 2>&1 || true done if [[ "$bootstrapped" != "true" ]]; then print_err "${display} launchd bootstrap failed: ${bootstrap_output:-unknown launchctl error}" print_info "Plist: ${plist}" print_info "For deeper macOS diagnostics, run: launchctl print ${target}" return 1 fi if ! output="$(launchctl kickstart -k "$target" 2>&1)"; then if launchd_agent_is_running "$target"; then print_info "${display} launchd kickstart reported a warning, but the service is running." return 0 fi print_err "${display} launchd kickstart failed: ${output:-unknown launchctl error}" print_info "Plist: ${plist}" print_info "For deeper macOS diagnostics, run: launchctl print ${target}" return 1 fi return 0 } write_chat_launchd_plist() { local plist="$1" local label="$2" local command_name="$3" local log_prefix="$4" local throttle="${5:-5}" cat > "$plist" << PLIST Label $(xml_escape "$label") ProgramArguments $(xml_escape "${VENV_DIR}/bin/${command_name}") --checkpoint $(xml_escape "${NULLION_INSTALL_DIR}/runtime-store.json") --env-file $(xml_escape "$NULLION_ENV_FILE") EnvironmentVariables NULLION_ENV_FILE $(xml_escape "$NULLION_ENV_FILE") PATH $(xml_escape "${VENV_DIR}/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin") WorkingDirectory $(xml_escape "$NULLION_INSTALL_DIR") StandardOutPath $(xml_escape "${NULLION_LOG_DIR}/${log_prefix}.log") StandardErrorPath $(xml_escape "${NULLION_LOG_DIR}/${log_prefix}-error.log") RunAtLoad KeepAlive SuccessfulExit ThrottleInterval ${throttle} PLIST } write_chat_systemd_unit() { local unit_path="$1" local description="$2" local command_name="$3" local log_prefix="$4" local restart_sec="${5:-5}" cat > "$unit_path" << UNIT [Unit] Description=${description} After=network-online.target Wants=network-online.target [Service] Type=simple ExecStart=${VENV_DIR}/bin/${command_name} --checkpoint ${NULLION_INSTALL_DIR}/runtime-store.json --env-file ${NULLION_ENV_FILE} EnvironmentFile=${NULLION_ENV_FILE} WorkingDirectory=${NULLION_INSTALL_DIR} StandardOutput=append:${NULLION_LOG_DIR}/${log_prefix}.log StandardError=append:${NULLION_LOG_DIR}/${log_prefix}-error.log Restart=on-failure RestartSec=${restart_sec} StartLimitIntervalSec=120 StartLimitBurst=5 [Install] WantedBy=default.target UNIT } wait_for_web_ui() { local health_url="http://127.0.0.1:${NULLION_WEB_PORT}/api/health" local attempt if ! command_exists curl; then sleep 2 return 0 fi for attempt in {1..20}; do if curl -fsS "$health_url" >/dev/null 2>&1; then return 0 fi sleep 0.5 done return 1 } open_native_webview_now() { print_info "Opening Nullion in the desktop app..." wait_for_web_ui || print_info "Nullion is still warming up; the window will finish loading shortly." if [[ -x "$VENV_DIR/bin/nullion-webview" ]]; then nohup "$VENV_DIR/bin/nullion-webview" \ --port "${NULLION_WEB_PORT}" \ --env-file "${NULLION_ENV_FILE}" \ --browser-fallback \ >> "$NULLION_LOG_DIR/webview.log" \ 2>> "$NULLION_LOG_DIR/webview-error.log" & print_ok "Opened Nullion desktop app." else print_info "Desktop app launcher is missing; opening the browser instead." open "http://localhost:${NULLION_WEB_PORT}" 2>/dev/null || true fi } confirm() { local prompt="${1:-Continue?}" local yn prompt_read -rp " $prompt [y/N] " yn [[ "$(echo "$yn" | tr '[:upper:]' '[:lower:]')" =~ ^(y|yes)$ ]] } confirm_yes() { local prompt="${1:-Continue?}" local yn prompt_read -rp " $prompt [Y/n] " yn [[ -z "$yn" || "$(echo "$yn" | tr '[:upper:]' '[:lower:]')" =~ ^(y|yes)$ ]] } choose_key_storage() { local existing="${1:-}" NULLION_KEY_STORAGE="${existing:-}" if [[ -n "$NULLION_KEY_STORAGE" ]]; then print_info "Found existing local data key storage: ${NULLION_KEY_STORAGE}" if confirm_yes "Keep existing encryption key storage setting?"; then return 0 fi fi echo print_bold " Local data encryption" if [[ "$PLATFORM" == "macos" ]]; then echo " Nullion encrypts local chat history. You can protect the encryption key" echo " with macOS Keychain, or store it locally beside your Nullion data." echo if confirm_yes "Protect local data encryption key with macOS Keychain?"; then NULLION_KEY_STORAGE="keychain" else NULLION_KEY_STORAGE="local" fi else echo " Nullion will store the local data encryption key at ~/.nullion/chat_history.key." NULLION_KEY_STORAGE="local" fi } initialize_key_storage() { local requested="${NULLION_KEY_STORAGE:-local}" if NULLION_KEY_STORAGE="$requested" "$VENV_DIR/bin/python" -m nullion.secure_storage --init --storage "$requested" >/tmp/nullion_key_storage.out 2>/tmp/nullion_key_storage.err; then if [[ "$requested" == "keychain" ]]; then print_ok "Local data key protected with macOS Keychain." else print_ok "Local data key stored at $NULLION_INSTALL_DIR/chat_history.key." fi return 0 fi local err err="$(cat /tmp/nullion_key_storage.err 2>/dev/null || true)" if [[ "$requested" == "keychain" ]]; then print_err "Could not initialize macOS Keychain storage: ${err:-unknown error}" print_info "Falling back to local key storage for this install." NULLION_KEY_STORAGE="local" NULLION_KEY_STORAGE="local" "$VENV_DIR/bin/python" -m nullion.secure_storage --init --storage local >/dev/null print_ok "Local data key stored at $NULLION_INSTALL_DIR/chat_history.key." return 0 fi print_err "Could not initialize local key storage: ${err:-unknown error}" exit 1 } env_value_from_file() { local file="$1" local key="$2" [[ -f "$file" ]] || return 0 grep -E "^${key}=" "$file" 2>/dev/null | tail -n 1 | cut -d= -f2- | sed -e 's/\r$//' -e 's/^"//' -e 's/"$//' || true } env_candidate_files() { local seen="" local candidate for candidate in \ "${NULLION_ENV_FILE:-}" \ "${SOURCE_DIR:-}/.env"; do [[ -n "$candidate" ]] || continue [[ -f "$candidate" ]] || continue case ":$seen:" in *":$candidate:"*) continue ;; esac seen="${seen:+$seen:}$candidate" printf '%s\n' "$candidate" done } env_value() { local key="$1" local candidate local value while IFS= read -r candidate; do value="$(env_value_from_file "$candidate" "$key")" if [[ -n "$value" ]]; then printf '%s' "$value" return 0 fi done < <(env_candidate_files) return 0 } env_value_any() { local key local value for key in "$@"; do value="$(env_value "$key")" if [[ -n "$value" ]]; then printf '%s' "$value" return 0 fi done return 0 } provider_key_env_names() { local provider provider="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]')" case "$provider" in anthropic) printf '%s\n' "NULLION_ANTHROPIC_API_KEY" "ANTHROPIC_API_KEY" ;; openrouter) printf '%s\n' "NULLION_OPENROUTER_API_KEY" "OPENROUTER_API_KEY" "NULLION_OPENAI_API_KEY" "OPENAI_API_KEY" ;; gemini) printf '%s\n' "NULLION_GEMINI_API_KEY" "GEMINI_API_KEY" "GOOGLE_API_KEY" "NULLION_OPENAI_API_KEY" "OPENAI_API_KEY" ;; groq) printf '%s\n' "NULLION_GROQ_API_KEY" "GROQ_API_KEY" "NULLION_OPENAI_API_KEY" "OPENAI_API_KEY" ;; mistral) printf '%s\n' "NULLION_MISTRAL_API_KEY" "MISTRAL_API_KEY" "NULLION_OPENAI_API_KEY" "OPENAI_API_KEY" ;; deepseek) printf '%s\n' "NULLION_DEEPSEEK_API_KEY" "DEEPSEEK_API_KEY" "NULLION_OPENAI_API_KEY" "OPENAI_API_KEY" ;; xai) printf '%s\n' "NULLION_XAI_API_KEY" "XAI_API_KEY" "NULLION_OPENAI_API_KEY" "OPENAI_API_KEY" ;; together) printf '%s\n' "NULLION_TOGETHER_API_KEY" "TOGETHER_API_KEY" "NULLION_OPENAI_API_KEY" "OPENAI_API_KEY" ;; ollama) printf '%s\n' "NULLION_OLLAMA_API_KEY" "OLLAMA_API_KEY" "NULLION_OPENAI_API_KEY" "OPENAI_API_KEY" ;; *) printf '%s\n' "NULLION_OPENAI_API_KEY" "OPENAI_API_KEY" ;; esac } provider_key_value() { local provider="$1" local names=() local name while IFS= read -r name; do names+=("$name") done < <(provider_key_env_names "$provider") env_value_any "${names[@]}" } first_existing_provider_key_value() { local provider local value for provider in openai openrouter gemini groq mistral deepseek xai together ollama; do value="$(provider_key_value "$provider")" if [[ -n "$value" ]]; then printf '%s' "$value" return 0 fi done return 0 } mask_secret() { local value="$1" local visible="${2:-8}" if [[ -z "$value" ]]; then printf 'not set' else local suffix_len=4 if (( ${#value} <= suffix_len )); then printf '••••' else printf '••••%s' "${value: -suffix_len}" fi fi } join_summary_parts() { local joined="" local part for part in "$@"; do [[ -z "$part" ]] && continue if [[ -n "$joined" ]]; then joined="${joined}, ${part}" else joined="$part" fi done printf '%s' "$joined" } existing_messaging_summary() { local telegram="" local slack="" local discord="" if [[ "$EXISTING_TELEGRAM_ENABLED" == "true" || -n "$EXISTING_TELEGRAM_TOKEN$EXISTING_TELEGRAM_CHAT_ID" ]]; then telegram="Telegram" [[ -n "$EXISTING_TELEGRAM_TOKEN" ]] && telegram+=" token $(mask_secret "$EXISTING_TELEGRAM_TOKEN" 12)" [[ -n "$EXISTING_TELEGRAM_CHAT_ID" ]] && telegram+=", chat $EXISTING_TELEGRAM_CHAT_ID" fi if [[ "$EXISTING_SLACK_ENABLED" == "true" || -n "$EXISTING_SLACK_BOT_TOKEN$EXISTING_SLACK_APP_TOKEN" ]]; then slack="Slack" [[ -n "$EXISTING_SLACK_BOT_TOKEN" ]] && slack+=" bot $(mask_secret "$EXISTING_SLACK_BOT_TOKEN" 10)" [[ -n "$EXISTING_SLACK_APP_TOKEN" ]] && slack+=", app $(mask_secret "$EXISTING_SLACK_APP_TOKEN" 10)" [[ -n "$EXISTING_SLACK_OPERATOR_USER_ID" ]] && slack+=", operator $EXISTING_SLACK_OPERATOR_USER_ID" fi if [[ "$EXISTING_DISCORD_ENABLED" == "true" || -n "$EXISTING_DISCORD_BOT_TOKEN" ]]; then discord="Discord" [[ -n "$EXISTING_DISCORD_BOT_TOKEN" ]] && discord+=" token $(mask_secret "$EXISTING_DISCORD_BOT_TOKEN" 10)" fi join_summary_parts "$telegram" "$slack" "$discord" } existing_ai_provider_summary() { local provider="$EXISTING_MODEL_PROVIDER" local parts=() if [[ -z "$provider" ]]; then if [[ -n "$EXISTING_ANTHROPIC_KEY" ]]; then provider="anthropic" elif [[ -n "$EXISTING_MODEL_BASE_URL" ]]; then provider="OpenAI-compatible" elif [[ -n "$EXISTING_OPENAI_KEY" ]]; then provider="openai" else provider="configured" fi fi parts+=("provider $provider") [[ -n "$EXISTING_MODEL_NAME" ]] && parts+=("model $EXISTING_MODEL_NAME") [[ -n "$EXISTING_MODEL_BASE_URL" ]] && parts+=("base URL $EXISTING_MODEL_BASE_URL") [[ -n "$EXISTING_OPENAI_KEY" ]] && parts+=("OpenAI-compatible key $(mask_secret "$EXISTING_OPENAI_KEY" 8)") [[ -n "$EXISTING_ANTHROPIC_KEY" ]] && parts+=("Anthropic key $(mask_secret "$EXISTING_ANTHROPIC_KEY" 10)") join_summary_parts "${parts[@]}" } env_escape_value() { local value="$1" value="${value//\\/\\\\}" value="${value//\"/\\\"}" printf '%s' "$value" } checkpoint_env_raw() { local key="$1" local raw_value="$2" local tmp_file mkdir -p "$NULLION_INSTALL_DIR" touch "$NULLION_ENV_FILE" chmod 600 "$NULLION_ENV_FILE" tmp_file="$(mktemp)" awk -v key="$key" -v line="${key}=${raw_value}" ' BEGIN { written = 0 } $0 ~ "^" key "=" { if (!written) { print line written = 1 } next } { print } END { if (!written) { print line } } ' "$NULLION_ENV_FILE" > "$tmp_file" mv "$tmp_file" "$NULLION_ENV_FILE" chmod 600 "$NULLION_ENV_FILE" } checkpoint_env_value() { local key="$1" local value="$2" checkpoint_env_raw "$key" "\"$(env_escape_value "$value")\"" } checkpoint_env_value_if_set() { local key="$1" local value="${2:-}" if [[ -n "$value" ]]; then checkpoint_env_value "$key" "$value" fi return 0 } checkpoint_plugin_setup() { local enabled_plugins="search_plugin,browser_plugin,workspace_plugin" local provider_bindings="search_plugin=${SEARCH_PROVIDER:-builtin_search_provider}" if [[ "${EMAIL_CALENDAR_ENABLED:-false}" == "true" ]]; then enabled_plugins="${enabled_plugins},email_plugin,calendar_plugin" provider_bindings="${provider_bindings},email_plugin=google_workspace_provider,calendar_plugin=google_workspace_provider" elif [[ "${CUSTOM_EMAIL_API_ENABLED:-false}" == "true" ]]; then enabled_plugins="${enabled_plugins},email_plugin" provider_bindings="${provider_bindings},email_plugin=custom_api_provider" fi if [[ "${MEDIA_ENABLED:-false}" == "true" ]]; then enabled_plugins="${enabled_plugins},media_plugin" provider_bindings="${provider_bindings},media_plugin=local_media_provider" fi checkpoint_env_value "NULLION_ENABLED_PLUGINS" "$enabled_plugins" checkpoint_env_value "NULLION_PROVIDER_BINDINGS" "$provider_bindings" } checkpoint_provider_setup() { checkpoint_env_raw "NULLION_SETUP_PROVIDER_DONE" true checkpoint_env_value_if_set "ANTHROPIC_API_KEY" "${ANTHROPIC_KEY:-}" checkpoint_env_value_if_set "OPENAI_API_KEY" "${OPENAI_KEY:-}" checkpoint_env_value_if_set "NULLION_MODEL_PROVIDER" "${MODEL_PROVIDER:-}" checkpoint_env_value_if_set "NULLION_OPENAI_BASE_URL" "${MODEL_BASE_URL:-}" checkpoint_env_value_if_set "NULLION_MODEL" "${MODEL_NAME:-}" } checkpoint_browser_setup() { checkpoint_env_raw "NULLION_SETUP_BROWSER_DONE" true checkpoint_env_value_if_set "NULLION_BROWSER_BACKEND" "${BROWSER_BACKEND:-}" checkpoint_env_value_if_set "NULLION_BROWSER_CDP_URL" "${BROWSER_CDP_URL:-}" checkpoint_env_value_if_set "NULLION_BROWSER_PREFERRED" "${BROWSER_PREFERRED:-}" } checkpoint_search_setup() { checkpoint_env_raw "NULLION_SETUP_SEARCH_DONE" true checkpoint_plugin_setup checkpoint_env_value_if_set "NULLION_BRAVE_SEARCH_API_KEY" "${BRAVE_SEARCH_KEY:-}" checkpoint_env_value_if_set "NULLION_GOOGLE_SEARCH_API_KEY" "${GOOGLE_SEARCH_KEY:-}" checkpoint_env_value_if_set "NULLION_GOOGLE_SEARCH_CX" "${GOOGLE_SEARCH_CX:-}" checkpoint_env_value_if_set "NULLION_PERPLEXITY_API_KEY" "${PERPLEXITY_SEARCH_KEY:-}" } checkpoint_account_setup() { checkpoint_env_raw "NULLION_SETUP_ACCOUNT_DONE" true checkpoint_plugin_setup checkpoint_env_value_if_set "MATON_API_KEY" "${MATON_API_KEY:-}" checkpoint_env_value_if_set "COMPOSIO_API_KEY" "${COMPOSIO_API_KEY:-}" checkpoint_env_value_if_set "NANGO_SECRET_KEY" "${NANGO_SECRET_KEY:-}" checkpoint_env_value_if_set "ACTIVEPIECES_API_KEY" "${ACTIVEPIECES_API_KEY:-}" checkpoint_env_value_if_set "N8N_BASE_URL" "${N8N_BASE_URL:-}" checkpoint_env_value_if_set "N8N_API_KEY" "${N8N_API_KEY:-}" [[ "${MATON_CONNECTOR_ENABLED:-false}" == "true" ]] && checkpoint_env_value "NULLION_CONNECTOR_GATEWAY" "maton" checkpoint_env_value_if_set "NULLION_CUSTOM_API_BASE_URL" "${CUSTOM_API_BASE_URL:-}" checkpoint_env_value_if_set "NULLION_CUSTOM_API_TOKEN" "${CUSTOM_API_TOKEN:-}" } checkpoint_media_setup() { checkpoint_env_raw "NULLION_SETUP_MEDIA_DONE" true checkpoint_plugin_setup checkpoint_env_value_if_set "NULLION_MEDIA_OPENAI_API_KEY" "${MEDIA_OPENAI_KEY:-}" checkpoint_env_value_if_set "NULLION_MEDIA_ANTHROPIC_API_KEY" "${MEDIA_ANTHROPIC_KEY:-}" checkpoint_env_value_if_set "NULLION_MEDIA_OPENROUTER_API_KEY" "${MEDIA_OPENROUTER_KEY:-}" checkpoint_env_value_if_set "NULLION_MEDIA_GEMINI_API_KEY" "${MEDIA_GEMINI_KEY:-}" checkpoint_env_value_if_set "NULLION_MEDIA_GROQ_API_KEY" "${MEDIA_GROQ_KEY:-}" checkpoint_env_value_if_set "NULLION_MEDIA_MISTRAL_API_KEY" "${MEDIA_MISTRAL_KEY:-}" checkpoint_env_value_if_set "NULLION_MEDIA_DEEPSEEK_API_KEY" "${MEDIA_DEEPSEEK_KEY:-}" checkpoint_env_value_if_set "NULLION_MEDIA_XAI_API_KEY" "${MEDIA_XAI_KEY:-}" checkpoint_env_value_if_set "NULLION_MEDIA_TOGETHER_API_KEY" "${MEDIA_TOGETHER_KEY:-}" checkpoint_env_value_if_set "NULLION_MEDIA_CUSTOM_API_KEY" "${MEDIA_CUSTOM_KEY:-}" checkpoint_env_value_if_set "NULLION_MEDIA_CUSTOM_BASE_URL" "${MEDIA_CUSTOM_BASE_URL:-}" checkpoint_env_value_if_set "NULLION_IMAGE_OCR_COMMAND" "${IMAGE_OCR_COMMAND:-}" checkpoint_env_value_if_set "NULLION_AUDIO_TRANSCRIBE_COMMAND" "${AUDIO_TRANSCRIBE_COMMAND:-}" checkpoint_env_value_if_set "NULLION_IMAGE_GENERATE_COMMAND" "${IMAGE_GENERATE_COMMAND:-}" [[ "${AUDIO_TRANSCRIBE_ENABLED:-false}" == "true" ]] && checkpoint_env_raw "NULLION_AUDIO_TRANSCRIBE_ENABLED" true checkpoint_env_value_if_set "NULLION_AUDIO_TRANSCRIBE_PROVIDER" "${AUDIO_TRANSCRIBE_PROVIDER:-}" checkpoint_env_value_if_set "NULLION_AUDIO_TRANSCRIBE_MODEL" "${AUDIO_TRANSCRIBE_MODEL:-}" [[ "${IMAGE_OCR_ENABLED:-false}" == "true" ]] && checkpoint_env_raw "NULLION_IMAGE_OCR_ENABLED" true checkpoint_env_value_if_set "NULLION_IMAGE_OCR_PROVIDER" "${IMAGE_OCR_PROVIDER:-}" checkpoint_env_value_if_set "NULLION_IMAGE_OCR_MODEL" "${IMAGE_OCR_MODEL:-}" [[ "${IMAGE_GENERATE_ENABLED:-false}" == "true" ]] && checkpoint_env_raw "NULLION_IMAGE_GENERATE_ENABLED" true checkpoint_env_value_if_set "NULLION_IMAGE_GENERATE_PROVIDER" "${IMAGE_GENERATE_PROVIDER:-}" checkpoint_env_value_if_set "NULLION_IMAGE_GENERATE_MODEL" "${IMAGE_GENERATE_MODEL:-}" [[ "${VIDEO_INPUT_ENABLED:-false}" == "true" ]] && checkpoint_env_raw "NULLION_VIDEO_INPUT_ENABLED" true checkpoint_env_value_if_set "NULLION_VIDEO_INPUT_PROVIDER" "${VIDEO_INPUT_PROVIDER:-}" checkpoint_env_value_if_set "NULLION_VIDEO_INPUT_MODEL" "${VIDEO_INPUT_MODEL:-}" } checkpoint_skill_setup() { checkpoint_env_raw "NULLION_SETUP_SKILLS_DONE" true checkpoint_env_value_if_set "NULLION_ENABLED_SKILL_PACKS" "${ENABLED_SKILL_PACKS:-}" if [[ -n "${ENABLED_SKILL_PACKS:-}" ]]; then checkpoint_env_raw "NULLION_SKILL_PACK_ACCESS_ENABLED" true fi if [[ ",${ENABLED_SKILL_PACKS:-}," == *",nullion/connector-skills,"* || "${ENABLED_SKILL_PACKS:-}" == *"api-gateway"* ]]; then checkpoint_env_raw "NULLION_CONNECTOR_ACCESS_ENABLED" true fi } download_whisper_cpp_model() { if [[ -f "$WHISPER_CPP_MODEL_PATH" ]]; then print_ok "Found whisper.cpp base.en model." return 0 fi if ! command_exists curl; then print_info "curl not found. Download this model later:" print_info "$WHISPER_CPP_MODEL_URL" return 1 fi mkdir -p "$(dirname "$WHISPER_CPP_MODEL_PATH")" print_info "Downloading whisper.cpp base.en model (~148 MB)..." if curl -fL --progress-bar "$WHISPER_CPP_MODEL_URL" -o "${WHISPER_CPP_MODEL_PATH}.tmp"; then mv "${WHISPER_CPP_MODEL_PATH}.tmp" "$WHISPER_CPP_MODEL_PATH" print_ok "Downloaded whisper.cpp base.en model." return 0 fi rm -f "${WHISPER_CPP_MODEL_PATH}.tmp" print_err "Could not download the whisper.cpp model." return 1 } browser_installed() { local browser="$1" if [[ "$browser" == "brave" ]]; then if [[ "$PLATFORM" == "macos" ]]; then open -Ra "Brave Browser" 2>/dev/null return $? fi command_exists brave-browser || command_exists brave || command_exists brave-browser-stable return $? fi if [[ "$browser" == "chrome" ]]; then if [[ "$PLATFORM" == "macos" ]]; then open -Ra "Google Chrome" 2>/dev/null return $? fi command_exists google-chrome || command_exists google-chrome-stable || command_exists chromium || command_exists chromium-browser return $? fi return 1 } browser_status_label() { local browser="$1" if browser_installed "$browser"; then echo "installed" else echo "not detected" fi } ensure_whisper_cpp_runtime() { install_default_local_media_runtime local missing_packages=() if ! command_exists whisper-cli; then missing_packages+=("whisper-cpp") fi if ! command_exists ffmpeg; then missing_packages+=("ffmpeg") fi if ((${#missing_packages[@]} > 0)); then if [[ "$PLATFORM" == "macos" ]] && command_exists brew; then print_info "Installing ${missing_packages[*]} via Homebrew..." brew install "${missing_packages[@]}" else print_info "Install ${missing_packages[*]} for default audio transcription." fi fi if ! command_exists whisper-cli; then print_info "whisper-cli was not found. Add NULLION_AUDIO_TRANSCRIBE_COMMAND later." return 1 fi if ! command_exists ffmpeg; then print_info "ffmpeg was not found. Telegram OGG/Opus voice note conversion will not be available." return 1 fi if ! download_whisper_cpp_model; then print_info "Download ggml-base.en.bin later or add NULLION_AUDIO_TRANSCRIBE_COMMAND." return 1 fi WHISPER_CPP_READY=true AUDIO_TRANSCRIBE_COMMAND="whisper-cli -m \"$WHISPER_CPP_MODEL_PATH\" -f {input} -nt" AUDIO_TRANSCRIBE_ENABLED=true print_ok "Audio transcription will use whisper.cpp defaults." return 0 } install_default_local_media_runtime() { local brew_packages=() local apt_packages=() local installed_any=false if ! command_exists whisper-cli; then if [[ "$PLATFORM" == "macos" ]] && command_exists brew; then brew_packages+=("whisper-cpp") else print_info "whisper.cpp is not installed; install whisper-cli later to switch audio transcription to local." fi fi if ! command_exists ffmpeg; then if [[ "$PLATFORM" == "macos" ]] && command_exists brew; then brew_packages+=("ffmpeg") elif command_exists apt-get; then apt_packages+=("ffmpeg") else print_info "ffmpeg is not installed; install it later for audio conversion." fi fi if ! command_exists tesseract; then if [[ "$PLATFORM" == "macos" ]] && command_exists brew; then brew_packages+=("tesseract") elif command_exists apt-get; then apt_packages+=("tesseract-ocr") else print_info "Tesseract is not installed; install it later to switch image OCR to local." fi fi if ((${#brew_packages[@]} > 0)); then print_info "Installing local media packages via Homebrew: ${brew_packages[*]}" if brew install "${brew_packages[@]}"; then installed_any=true fi fi if ((${#apt_packages[@]} > 0)); then print_info "Installing local media packages via apt: ${apt_packages[*]}" if sudo apt-get update -qq && sudo apt-get install -y "${apt_packages[@]}"; then installed_any=true fi fi if command_exists whisper-cli; then download_whisper_cpp_model || true fi if [[ "$installed_any" == "true" || "$(command_exists whisper-cli && echo yes)$(command_exists ffmpeg && echo yes)$(command_exists tesseract && echo yes)" == *yes* ]]; then print_ok "Local media runtime checked. You can switch audio/OCR to local later in Settings." fi } ensure_git() { if command_exists git; then print_ok "Found git." return 0 fi print_info "git not found. Attempting to install..." if [[ "$PLATFORM" == "macos" ]]; then if command_exists brew; then brew install git else print_err "git is required to clone Nullion." print_info "Install Git or Homebrew, then re-run this script." exit 1 fi elif [[ "$PLATFORM" == "linux" ]]; then if command_exists apt-get; then sudo apt-get update -qq sudo apt-get install -y git elif command_exists dnf; then sudo dnf install -y git elif command_exists pacman; then sudo pacman -Sy --noconfirm git elif command_exists zypper; then sudo zypper install -y git else print_err "git is required to clone Nullion." print_info "Install Git with your package manager, then re-run this script." exit 1 fi fi if ! command_exists git; then print_err "git installation did not finish successfully." exit 1 fi print_ok "git installed." } # ── banner ──────────────────────────────────────────────────────────────────── clear 2>/dev/null || true echo print_logo echo print_chip "platform" "$OS" print_setup_overview echo if ! confirm_yes "Ready to start?"; then echo " Cancelled." exit 0 fi # ── Step 1: Python ───────────────────────────────────────────────────────── print_header "Step 1 of 4 — Python" PYTHON="" for candidate in python3.13 python3.12 python3.11 python3; do if command_exists "$candidate"; then version=$("$candidate" --version 2>&1 | awk '{print $2}') major=$(echo "$version" | cut -d. -f1) minor=$(echo "$version" | cut -d. -f2) if [[ "$major" -ge 3 && "$minor" -ge 11 ]]; then PYTHON="$candidate" print_ok "Found $candidate ($version)" break fi fi done if [[ -z "$PYTHON" ]]; then print_info "Python 3.11+ not found. Attempting to install..." if [[ "$PLATFORM" == "macos" ]]; then if command_exists brew; then print_info "Installing Python 3.12 via Homebrew..." brew install python@3.12 PYTHON="python3.12" print_ok "Python 3.12 installed." else print_err "Homebrew not found." print_info "Install Homebrew from https://brew.sh, then re-run this script." print_info "Or download Python 3.11+ directly from https://python.org" exit 1 fi elif [[ "$PLATFORM" == "linux" ]]; then # Detect package manager and install Python if command_exists apt-get; then print_info "Installing python3.12 via apt..." sudo apt-get update -qq sudo apt-get install -y python3.12 python3.12-venv python3.12-dev PYTHON="python3.12" elif command_exists dnf; then print_info "Installing python3.12 via dnf..." sudo dnf install -y python3.12 PYTHON="python3.12" elif command_exists pacman; then print_info "Installing python via pacman..." sudo pacman -Sy --noconfirm python PYTHON="python3" elif command_exists zypper; then print_info "Installing python312 via zypper..." sudo zypper install -y python312 PYTHON="python3.12" else print_err "No supported package manager found (apt, dnf, pacman, zypper)." print_info "Please install Python 3.11+ manually from https://python.org" exit 1 fi # Verify install succeeded if ! command_exists "$PYTHON"; then print_err "Python install failed. Please install Python 3.11+ manually." exit 1 fi print_ok "Python installed: $($PYTHON --version)" fi fi # Ensure we have venv module available (some Linux distros package it separately) if [[ "$PLATFORM" == "linux" ]]; then if ! "$PYTHON" -m venv --help &>/dev/null; then print_info "Installing python3-venv..." if command_exists apt-get; then sudo apt-get install -y python3-venv python3-pip fi fi fi # ── Step 2: Install Nullion ──────────────────────────────────────────────── print_header "Step 2 of 4 — Installing Nullion" mkdir -p "$NULLION_INSTALL_DIR" "$NULLION_LOG_DIR" # If we're running from inside a cloned repo, install from there. # Under `curl | bash`, Bash may not expose BASH_SOURCE[0], so treat stdin as remote install. SCRIPT_SOURCE="${BASH_SOURCE[0]-}" SCRIPT_DIR="" if [[ -n "$SCRIPT_SOURCE" && -f "$SCRIPT_SOURCE" ]]; then SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)" fi if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/pyproject.toml" ]]; then SOURCE_DIR="$SCRIPT_DIR" print_info "Installing from local source at $SOURCE_DIR" else print_info "Cloning Nullion from GitHub..." ensure_git SOURCE_DIR="$NULLION_INSTALL_DIR/src" if [[ -d "$SOURCE_DIR/.git" ]]; then git -C "$SOURCE_DIR" remote set-url origin "$REPO_URL" >/dev/null 2>&1 || true git -C "$SOURCE_DIR" fetch --quiet --depth 1 origin main git -C "$SOURCE_DIR" reset --quiet --hard FETCH_HEAD git -C "$SOURCE_DIR" clean --quiet -ffd print_ok "Updated to latest." else git clone --depth 1 "$REPO_URL" "$SOURCE_DIR" print_ok "Cloned." fi fi VENV_DIR="$NULLION_INSTALL_DIR/venv" if [[ ! -d "$VENV_DIR" ]]; then print_info "Creating virtual environment..." "$PYTHON" -m venv "$VENV_DIR" print_ok "Virtual environment created." fi print_info "Installing dependencies (this may take a minute)..." "$VENV_DIR/bin/pip" install --quiet --upgrade pip "$VENV_DIR/bin/pip" install --quiet -e "$SOURCE_DIR" print_ok "Nullion installed." EXISTING_KEY_STORAGE="$(env_value NULLION_KEY_STORAGE)" choose_key_storage "$EXISTING_KEY_STORAGE" initialize_key_storage # ── Step 3: Capabilities ───────────────────────────────────────────────── print_header "Step 3 of 4 — Capabilities (optional)" echo echo " Nullion's web dashboard runs at http://localhost:${NULLION_WEB_PORT} — no setup needed." echo echo " Next you can enable optional capabilities: messaging apps, AI provider," echo " browser/search access, account/API tools, media tools, and skill packs." echo echo " First, choose any messaging apps you want to connect." echo BOT_TOKEN="" CHAT_ID="" TELEGRAM_ENABLED=false SLACK_ENABLED=false SLACK_BOT_TOKEN="" SLACK_APP_TOKEN="" SLACK_SIGNING_SECRET="" SLACK_OPERATOR_USER_ID="" DISCORD_ENABLED=false DISCORD_BOT_TOKEN="" SKIP_MESSAGING_SETUP=false EXISTING_TELEGRAM_TOKEN="$(env_value NULLION_TELEGRAM_BOT_TOKEN)" EXISTING_TELEGRAM_CHAT_ID="$(env_value NULLION_TELEGRAM_OPERATOR_CHAT_ID)" EXISTING_TELEGRAM_ENABLED="$(env_value NULLION_TELEGRAM_CHAT_ENABLED)" EXISTING_SLACK_ENABLED="$(env_value NULLION_SLACK_ENABLED)" EXISTING_SLACK_BOT_TOKEN="$(env_value NULLION_SLACK_BOT_TOKEN)" EXISTING_SLACK_APP_TOKEN="$(env_value NULLION_SLACK_APP_TOKEN)" EXISTING_SLACK_SIGNING_SECRET="$(env_value NULLION_SLACK_SIGNING_SECRET)" EXISTING_SLACK_OPERATOR_USER_ID="$(env_value NULLION_SLACK_OPERATOR_USER_ID)" EXISTING_DISCORD_ENABLED="$(env_value NULLION_DISCORD_ENABLED)" EXISTING_DISCORD_BOT_TOKEN="$(env_value NULLION_DISCORD_BOT_TOKEN)" EXISTING_MESSAGING_DONE="$(env_value NULLION_SETUP_MESSAGING_DONE)" if [[ "$EXISTING_MESSAGING_DONE" == "true" || -n "$EXISTING_TELEGRAM_TOKEN$EXISTING_SLACK_BOT_TOKEN$EXISTING_DISCORD_BOT_TOKEN" ]]; then EXISTING_MESSAGING_SUMMARY="$(existing_messaging_summary)" print_info "Found existing messaging settings in $NULLION_ENV_FILE: ${EXISTING_MESSAGING_SUMMARY:-configured}." if confirm_yes "Use existing messaging setup instead of setting it up again?"; then SKIP_MESSAGING_SETUP=true if [[ "$EXISTING_TELEGRAM_ENABLED" == "true" || -n "$EXISTING_TELEGRAM_TOKEN$EXISTING_TELEGRAM_CHAT_ID" ]]; then TELEGRAM_ENABLED=true BOT_TOKEN="$EXISTING_TELEGRAM_TOKEN" CHAT_ID="$EXISTING_TELEGRAM_CHAT_ID" fi if [[ "$EXISTING_SLACK_ENABLED" == "true" || -n "$EXISTING_SLACK_BOT_TOKEN$EXISTING_SLACK_APP_TOKEN" ]]; then SLACK_ENABLED=true SLACK_BOT_TOKEN="$EXISTING_SLACK_BOT_TOKEN" SLACK_APP_TOKEN="$EXISTING_SLACK_APP_TOKEN" SLACK_SIGNING_SECRET="$EXISTING_SLACK_SIGNING_SECRET" SLACK_OPERATOR_USER_ID="$EXISTING_SLACK_OPERATOR_USER_ID" fi if [[ "$EXISTING_DISCORD_ENABLED" == "true" || -n "$EXISTING_DISCORD_BOT_TOKEN" ]]; then DISCORD_ENABLED=true DISCORD_BOT_TOKEN="$EXISTING_DISCORD_BOT_TOKEN" fi print_ok "Using existing messaging setup." fi fi if [[ "$SKIP_MESSAGING_SETUP" == "false" ]]; then print_bold " Choose messaging apps to configure:" print_menu_item "1" "Telegram" "Best mobile setup, voice notes, and direct chats" "[recommended]" print_menu_item "2" "Slack" "Team workspace messaging through Slack Socket Mode" print_menu_item "3" "Discord" "Server/community bot with Message Content intent" print_menu_item "4" "Skip" "Set up messaging later from the web dashboard" echo prompt_read -rp " Select one or more [1]: " MESSAGING_CHOICES MESSAGING_CHOICES="${MESSAGING_CHOICES:-1}" MESSAGING_CHOICES="$(echo "$MESSAGING_CHOICES" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" if [[ "$MESSAGING_CHOICES" == *"4"* || "$MESSAGING_CHOICES" == "skip" || "$MESSAGING_CHOICES" == "none" ]]; then MESSAGING_CHOICES="" print_ok "Skipped messaging apps. You can set them up later from the web dashboard at http://localhost:${NULLION_WEB_PORT}" fi if [[ "$MESSAGING_CHOICES" == *"1"* || "$MESSAGING_CHOICES" == *"telegram"* ]]; then TELEGRAM_ENABLED=true # Load existing config if present EXISTING_TOKEN="" EXISTING_CHAT_ID="" EXISTING_TOKEN="$(env_value NULLION_TELEGRAM_BOT_TOKEN)" EXISTING_CHAT_ID="$(env_value NULLION_TELEGRAM_OPERATOR_CHAT_ID)" echo echo -e " You need a Telegram bot token. Here's how to get one in ~2 minutes:" echo echo -e " ${BOLD}1.${RESET} Open Telegram and search for ${CYAN}@BotFather${RESET}" echo -e " ${BOLD}2.${RESET} Send: ${CYAN}/newbot${RESET}" echo -e " ${BOLD}3.${RESET} Give your bot a name (e.g. \"My Nullion\")" echo -e " ${BOLD}4.${RESET} Give it a username ending in 'bot' (e.g. \"my_nullion_bot\")" echo -e " ${BOLD}5.${RESET} BotFather will send you a token that looks like:" echo -e " ${YELLOW}1234567890:ABCdef...${RESET}" echo if [[ -n "$EXISTING_TOKEN" ]]; then print_info "Existing token found: $(mask_secret "$EXISTING_TOKEN")" if confirm "Keep this token?"; then BOT_TOKEN="$EXISTING_TOKEN" else EXISTING_TOKEN="" fi fi if [[ -z "$EXISTING_TOKEN" ]]; then while true; do echo -n " Paste your bot token here (hidden): " prompt_read -rs BOT_TOKEN echo BOT_TOKEN="$(echo "$BOT_TOKEN" | tr -d ' ')" if [[ "$BOT_TOKEN" =~ ^[0-9]{6,}:[A-Za-z0-9_-]{20,}$ ]]; then print_ok "Token format looks good." break else print_err "That doesn't look like a valid bot token. It should match: 123456789:ABCdef..." fi done fi echo echo -e " Now we need your Telegram chat ID so the bot knows who to talk to." echo echo -e " ${BOLD}1.${RESET} Send any message to your new bot in Telegram" echo -e " ${BOLD}2.${RESET} Then open this URL in a browser:" echo -e " ${CYAN}https://api.telegram.org/bot/getUpdates${RESET}" echo -e " ${BOLD}3.${RESET} Look for: ${YELLOW}\"id\": 123456789${RESET} inside ${YELLOW}\"chat\"${RESET}" echo -e " That number is your chat ID." echo if [[ -n "$EXISTING_CHAT_ID" ]]; then print_info "Existing chat ID found: $EXISTING_CHAT_ID" if confirm "Keep this chat ID?"; then CHAT_ID="$EXISTING_CHAT_ID" else EXISTING_CHAT_ID="" fi fi if [[ -z "$EXISTING_CHAT_ID" ]]; then while true; do prompt_read -rp " Enter your Telegram chat ID (numbers only): " CHAT_ID CHAT_ID="$(echo "$CHAT_ID" | tr -d ' -')" if [[ "$CHAT_ID" =~ ^-?[0-9]+$ ]]; then print_ok "Chat ID: $CHAT_ID" break else print_err "That doesn't look right — it should be a number like 123456789." fi done fi fi if [[ "$MESSAGING_CHOICES" == *"2"* || "$MESSAGING_CHOICES" == *"slack"* ]]; then SLACK_ENABLED=true echo print_bold " Slack setup" echo " Create a Slack app with Socket Mode enabled, then add bot and app-level tokens." echo " Required: bot token (xoxb-...) and app-level token (xapp-...)." echo while true; do prompt_read -rsp " Slack bot token (xoxb-...): " SLACK_BOT_TOKEN echo SLACK_BOT_TOKEN="$(echo "$SLACK_BOT_TOKEN" | tr -d ' ')" if [[ "$SLACK_BOT_TOKEN" == xoxb-* ]]; then break fi print_err "That should start with xoxb-." done while true; do prompt_read -rsp " Slack app-level token (xapp-...): " SLACK_APP_TOKEN echo SLACK_APP_TOKEN="$(echo "$SLACK_APP_TOKEN" | tr -d ' ')" if [[ "$SLACK_APP_TOKEN" == xapp-* ]]; then break fi print_err "That should start with xapp-." done prompt_read -rsp " Slack signing secret (optional): " SLACK_SIGNING_SECRET echo prompt_read -rp " Operator Slack user ID (optional, e.g. U012ABCDEF): " SLACK_OPERATOR_USER_ID print_ok "Slack messaging configured." fi if [[ "$MESSAGING_CHOICES" == *"3"* || "$MESSAGING_CHOICES" == *"discord"* ]]; then DISCORD_ENABLED=true echo print_bold " Discord setup" echo " Create a Discord application bot, enable Message Content intent, and paste its token." echo while true; do prompt_read -rsp " Discord bot token: " DISCORD_BOT_TOKEN echo DISCORD_BOT_TOKEN="$(echo "$DISCORD_BOT_TOKEN" | tr -d ' ')" if [[ -n "$DISCORD_BOT_TOKEN" ]]; then break fi print_err "Discord needs a bot token." done print_ok "Discord messaging configured." fi if [[ "$TELEGRAM_ENABLED" == "true" || "$SLACK_ENABLED" == "true" || "$DISCORD_ENABLED" == "true" || "$SKIP_MESSAGING_SETUP" == "true" ]]; then checkpoint_env_raw "NULLION_SETUP_MESSAGING_DONE" true checkpoint_env_raw "NULLION_WEB_PORT" "$NULLION_WEB_PORT" checkpoint_env_raw "NULLION_TELEGRAM_CHAT_ENABLED" "$TELEGRAM_ENABLED" if [[ "$TELEGRAM_ENABLED" == "true" ]]; then checkpoint_env_value "NULLION_TELEGRAM_BOT_TOKEN" "$BOT_TOKEN" checkpoint_env_value "NULLION_TELEGRAM_OPERATOR_CHAT_ID" "$CHAT_ID" fi checkpoint_env_raw "NULLION_SLACK_ENABLED" "$SLACK_ENABLED" if [[ "$SLACK_ENABLED" == "true" ]]; then checkpoint_env_value "NULLION_SLACK_BOT_TOKEN" "$SLACK_BOT_TOKEN" checkpoint_env_value "NULLION_SLACK_APP_TOKEN" "$SLACK_APP_TOKEN" [[ -n "$SLACK_SIGNING_SECRET" ]] && checkpoint_env_value "NULLION_SLACK_SIGNING_SECRET" "$SLACK_SIGNING_SECRET" [[ -n "$SLACK_OPERATOR_USER_ID" ]] && checkpoint_env_value "NULLION_SLACK_OPERATOR_USER_ID" "$SLACK_OPERATOR_USER_ID" fi checkpoint_env_raw "NULLION_DISCORD_ENABLED" "$DISCORD_ENABLED" if [[ "$DISCORD_ENABLED" == "true" ]]; then checkpoint_env_value "NULLION_DISCORD_BOT_TOKEN" "$DISCORD_BOT_TOKEN" fi print_ok "Messaging setup checkpoint saved to $NULLION_ENV_FILE" fi fi # ── Check for existing credentials ─────────────────────────────────────────── CREDENTIALS_FILE="$NULLION_INSTALL_DIR/credentials.json" ANTHROPIC_KEY="" OPENAI_KEY="" MODEL_PROVIDER="" MODEL_BASE_URL="" MODEL_NAME="" SKIP_PROVIDER=false EXISTING_MODEL_PROVIDER="$(env_value NULLION_MODEL_PROVIDER)" EXISTING_MODEL_BASE_URL="$(env_value_any NULLION_OPENAI_BASE_URL OPENAI_BASE_URL)" EXISTING_MODEL_NAME="$(env_value_any NULLION_MODEL NULLION_OPENAI_MODEL OPENAI_MODEL)" EXISTING_OPENAI_KEY="$(provider_key_value "$EXISTING_MODEL_PROVIDER")" if [[ -z "$EXISTING_OPENAI_KEY" ]]; then EXISTING_OPENAI_KEY="$(first_existing_provider_key_value)" fi EXISTING_ANTHROPIC_KEY="$(env_value_any NULLION_ANTHROPIC_API_KEY ANTHROPIC_API_KEY)" EXISTING_PROVIDER_DONE="$(env_value NULLION_SETUP_PROVIDER_DONE)" if [[ "$EXISTING_PROVIDER_DONE" == "true" || -n "$EXISTING_MODEL_PROVIDER$EXISTING_MODEL_NAME$EXISTING_OPENAI_KEY$EXISTING_ANTHROPIC_KEY" ]]; then echo print_ok "Found existing AI provider settings: $(existing_ai_provider_summary)" if confirm_yes "Use existing AI provider setup instead of setting it up again?"; then SKIP_PROVIDER=true MODEL_PROVIDER="$EXISTING_MODEL_PROVIDER" MODEL_BASE_URL="$EXISTING_MODEL_BASE_URL" MODEL_NAME="$EXISTING_MODEL_NAME" OPENAI_KEY="$EXISTING_OPENAI_KEY" ANTHROPIC_KEY="$EXISTING_ANTHROPIC_KEY" print_ok "Using existing AI provider setup." fi fi if [[ -f "$CREDENTIALS_FILE" ]]; then _EXISTING_PROVIDER=$(python3 -c "import json,sys; d=json.load(open('$CREDENTIALS_FILE')); print(d.get('provider',''))" 2>/dev/null || true) _EXISTING_KEY=$(python3 -c "import json,sys; d=json.load(open('$CREDENTIALS_FILE')); print(d.get('api_key','')[:8])" 2>/dev/null || true) if [[ "$SKIP_PROVIDER" == "false" && -n "$_EXISTING_PROVIDER" && -n "$_EXISTING_KEY" ]]; then echo print_ok "Found existing credentials for: $_EXISTING_PROVIDER" if confirm "Keep existing credentials and skip provider setup?"; then SKIP_PROVIDER=true MODEL_PROVIDER="$_EXISTING_PROVIDER" print_ok "Using existing credentials." fi fi fi if [[ "$SKIP_PROVIDER" == "false" ]]; then # Ask for model provider echo print_bold " Choose your AI provider:" print_menu_item "1" "OpenAI" "GPT-5.5, GPT-4.5, GPT-4o, o4-mini..." "[recommended]" print_menu_item "2" "Anthropic" "Claude Opus 4.6, Sonnet 4.6..." print_menu_item "3" "OpenRouter" "GPT, Gemini, Llama, Claude, DeepSeek, and many more" "[broadest]" print_menu_item "4" "Google Gemini" "Gemini models through the OpenAI-compatible API" print_menu_item "5" "Ollama local" "OpenAI-compatible localhost endpoint; private and low-cost" print_menu_item "6" "Groq" "Fast hosted inference" print_menu_item "7" "Mistral" "Mistral and Pixtral models" print_menu_item "8" "DeepSeek" "DeepSeek chat and reasoning models" print_menu_item "9" "xAI" "Grok models" print_menu_item "10" "Together AI" "Open-source model hosting" print_menu_item "11" "Local / custom endpoint" "vLLM, LM Studio, LiteLLM, or any compatible URL" echo prompt_read -rp " Enter 1-11: " PROVIDER_CHOICE case "$PROVIDER_CHOICE" in 1) MODEL_PROVIDER="openai" MODEL_NAME="gpt-5.5" echo print_bold " How would you like to authenticate with OpenAI?" print_menu_item "1" "API key" "Paste a key from platform.openai.com" print_menu_item "2" "OAuth" "Sign in with your OpenAI account in the browser" echo prompt_read -rp " Enter 1 or 2: " OPENAI_AUTH_CHOICE echo if [[ "$OPENAI_AUTH_CHOICE" == "2" ]]; then print_info "Opening your browser for OpenAI sign-in…" OAUTH_TOKEN="" OAUTH_ERR="" OAUTH_TOKEN_FILE="$(mktemp)" if PYTHONPATH="$SOURCE_DIR/src" "$VENV_DIR/bin/python" -m nullion.auth --write-codex-access-token "$OAUTH_TOKEN_FILE" 2> >(tee /tmp/nullion_oauth_err.txt >&2); then OAUTH_TOKEN="$(cat "$OAUTH_TOKEN_FILE" 2>/dev/null || true)" fi rm -f "$OAUTH_TOKEN_FILE" OAUTH_ERR=$(cat /tmp/nullion_oauth_err.txt 2>/dev/null || true) if [[ -n "$OAUTH_TOKEN" ]]; then MODEL_PROVIDER="codex" OPENAI_KEY="$OAUTH_TOKEN" print_ok "Authenticated via OAuth." else print_err "OAuth failed: ${OAUTH_ERR:-unknown error}. Falling back to API key." OPENAI_AUTH_CHOICE="1" MODEL_PROVIDER="openai" fi fi if [[ "$OPENAI_AUTH_CHOICE" != "2" || -z "$OPENAI_KEY" ]]; then echo -e " Get an API key at ${CYAN}https://platform.openai.com/api-keys${RESET}" while true; do echo -n " Paste your OpenAI API key (hidden): " prompt_read -rs OPENAI_KEY echo if [[ "$OPENAI_KEY" =~ ^sk- ]]; then print_ok "Key accepted." break else print_err "Key should start with 'sk-'. Try again." fi done fi prompt_model_name MODEL_NAME ;; 2) MODEL_PROVIDER="anthropic" MODEL_NAME="claude-opus-4-6" echo echo -e " Get an API key at ${CYAN}https://console.anthropic.com/settings/keys${RESET}" while true; do echo -n " Paste your Anthropic API key (hidden): " prompt_read -rs ANTHROPIC_KEY echo if [[ "$ANTHROPIC_KEY" =~ ^sk-ant- ]]; then print_ok "Key accepted." break else print_err "Key should start with 'sk-ant-'. Try again." fi done prompt_model_name MODEL_NAME ;; 3) MODEL_PROVIDER="openrouter" MODEL_BASE_URL="https://openrouter.ai/api/v1" MODEL_NAME="openai/gpt-4o" echo echo -e " Get an API key at ${CYAN}https://openrouter.ai/keys${RESET}" while true; do echo -n " Paste your OpenRouter API key (hidden): " prompt_read -rs OPENAI_KEY echo if [[ "$OPENAI_KEY" =~ ^sk-or- ]]; then print_ok "Key accepted." break else print_err "Key should start with 'sk-or-'. Try again." fi done prompt_model_name MODEL_NAME ;; 4) MODEL_PROVIDER="gemini" MODEL_BASE_URL="https://generativelanguage.googleapis.com/v1beta/openai/" MODEL_NAME="models/gemini-2.5-flash" echo echo -e " Get an API key at ${CYAN}https://aistudio.google.com/app/apikey${RESET}" echo -n " Paste your Gemini API key (hidden): " prompt_read -rs OPENAI_KEY echo prompt_model_name MODEL_NAME print_ok "Gemini selected." ;; 5) MODEL_PROVIDER="ollama" MODEL_BASE_URL="http://127.0.0.1:11434/v1" MODEL_NAME="llama3.3" OPENAI_KEY="ollama-local" echo print_info "Using Ollama's OpenAI-compatible endpoint at ${MODEL_BASE_URL}." print_info "Run 'ollama serve' and 'ollama pull ${MODEL_NAME}' if you have not already." prompt_model_name MODEL_NAME ;; 6) MODEL_PROVIDER="groq" MODEL_BASE_URL="https://api.groq.com/openai/v1" MODEL_NAME="llama-3.3-70b-versatile" echo -e " Get an API key at ${CYAN}https://console.groq.com/keys${RESET}" echo -n " Paste your Groq API key (hidden): " prompt_read -rs OPENAI_KEY echo prompt_model_name MODEL_NAME ;; 7) MODEL_PROVIDER="mistral" MODEL_BASE_URL="https://api.mistral.ai/v1" MODEL_NAME="mistral-large-latest" echo -e " Get an API key at ${CYAN}https://console.mistral.ai/api-keys/${RESET}" echo -n " Paste your Mistral API key (hidden): " prompt_read -rs OPENAI_KEY echo prompt_model_name MODEL_NAME ;; 8) MODEL_PROVIDER="deepseek" MODEL_BASE_URL="https://api.deepseek.com/v1" MODEL_NAME="deepseek-chat" echo -e " Get an API key at ${CYAN}https://platform.deepseek.com/api_keys${RESET}" echo -n " Paste your DeepSeek API key (hidden): " prompt_read -rs OPENAI_KEY echo prompt_model_name MODEL_NAME ;; 9) MODEL_PROVIDER="xai" MODEL_BASE_URL="https://api.x.ai/v1" MODEL_NAME="grok-4" echo -e " Get an API key at ${CYAN}https://console.x.ai/${RESET}" echo -n " Paste your xAI API key (hidden): " prompt_read -rs OPENAI_KEY echo prompt_model_name MODEL_NAME ;; 10) MODEL_PROVIDER="together" MODEL_BASE_URL="https://api.together.xyz/v1" MODEL_NAME="meta-llama/Llama-3.3-70B-Instruct-Turbo" echo -e " Get an API key at ${CYAN}https://api.together.xyz/settings/api-keys${RESET}" echo -n " Paste your Together API key (hidden): " prompt_read -rs OPENAI_KEY echo prompt_model_name MODEL_NAME ;; 11) MODEL_PROVIDER="custom" echo prompt_read -rp " OpenAI-compatible base URL (e.g. http://localhost:1234/v1): " MODEL_BASE_URL prompt_read -rp " Model name: " MODEL_NAME if confirm "Does this endpoint require an API key?"; then echo -n " Paste API key (hidden): " prompt_read -rs OPENAI_KEY echo else OPENAI_KEY="local" fi ;; esac fi # end SKIP_PROVIDER checkpoint_provider_setup print_ok "AI provider setup checkpoint saved to $NULLION_ENV_FILE" # ── Browser setup ────────────────────────────────────────────────────────── echo print_bold " Would you like Nullion to control a browser?" echo " This lets it browse the web, fill forms, and take screenshots on your behalf." echo BROWSER_BACKEND="" BROWSER_CDP_URL="" BROWSER_PREFERRED="" BROWSER_EXTRA_NOTE="" SKIP_BROWSER_SETUP=false EXISTING_BROWSER_BACKEND="$(env_value NULLION_BROWSER_BACKEND)" EXISTING_BROWSER_CDP_URL="$(env_value NULLION_BROWSER_CDP_URL)" EXISTING_BROWSER_PREFERRED="$(env_value NULLION_BROWSER_PREFERRED)" EXISTING_BROWSER_DONE="$(env_value NULLION_SETUP_BROWSER_DONE)" if [[ "$EXISTING_BROWSER_DONE" == "true" || -n "$EXISTING_BROWSER_BACKEND$EXISTING_BROWSER_CDP_URL$EXISTING_BROWSER_PREFERRED" ]]; then print_info "Found existing browser setup: ${EXISTING_BROWSER_PREFERRED:-${EXISTING_BROWSER_BACKEND}}" if confirm_yes "Use existing browser setup instead of setting it up again?"; then SKIP_BROWSER_SETUP=true BROWSER_BACKEND="$EXISTING_BROWSER_BACKEND" BROWSER_CDP_URL="$EXISTING_BROWSER_CDP_URL" BROWSER_PREFERRED="$EXISTING_BROWSER_PREFERRED" print_ok "Using existing browser setup." fi fi if [[ "$SKIP_BROWSER_SETUP" == "false" ]]; then BRAVE_STATUS="$(browser_status_label brave)" CHROME_STATUS="$(browser_status_label chrome)" print_menu_item "1" "Attach to Brave" "Uses your existing Brave window (${BRAVE_STATUS})" "[recommended]" print_menu_item "2" "Attach to Chrome" "Uses your existing Chrome window (${CHROME_STATUS})" print_menu_item "3" "Headless" "Invisible Chromium running in the background" print_menu_item "4" "None" "No browser access" echo prompt_read -rp " Enter 1, 2, 3, or 4: " BROWSER_CHOICE case "$BROWSER_CHOICE" in 1) BROWSER_BACKEND="auto" BROWSER_CDP_URL="http://localhost:9222" BROWSER_PREFERRED="brave" print_ok "Brave selected." if ! browser_installed brave; then print_info "Brave was not detected. Install Brave or choose another browser if attach fails." fi BROWSER_EXTRA_NOTE=" Browser automation will attach to Brave on port 9222 if available, otherwise Nullion will open a visible automation window." ;; 2) BROWSER_BACKEND="auto" BROWSER_CDP_URL="http://localhost:9222" BROWSER_PREFERRED="chrome" print_ok "Chrome selected." if ! browser_installed chrome; then print_info "Chrome was not detected. Install Chrome or choose another browser if attach fails." fi BROWSER_EXTRA_NOTE=" Browser automation will attach to Chrome on port 9222 if available, otherwise Nullion will open a visible automation window." ;; 3) BROWSER_BACKEND="playwright" print_info "Installing headless browser (Playwright + Chromium)..." "$VENV_DIR/bin/pip" install --quiet playwright "$VENV_DIR/bin/playwright" install chromium --with-deps 2>/dev/null || \ "$VENV_DIR/bin/playwright" install chromium print_ok "Headless browser ready." ;; *) print_info "No browser — skipped." ;; esac fi checkpoint_browser_setup print_ok "Browser setup checkpoint saved to $NULLION_ENV_FILE" # ── Search provider setup ───────────────────────────────────────────────── echo print_bold " Choose your search provider:" SEARCH_PROVIDER="builtin_search_provider" BRAVE_SEARCH_KEY="" GOOGLE_SEARCH_KEY="" GOOGLE_SEARCH_CX="" PERPLEXITY_SEARCH_KEY="" SKIP_SEARCH_SETUP=false EXISTING_PROVIDER_BINDINGS="$(env_value NULLION_PROVIDER_BINDINGS)" EXISTING_BRAVE_SEARCH_KEY="$(env_value NULLION_BRAVE_SEARCH_API_KEY)" EXISTING_GOOGLE_SEARCH_KEY="$(env_value NULLION_GOOGLE_SEARCH_API_KEY)" EXISTING_GOOGLE_SEARCH_CX="$(env_value NULLION_GOOGLE_SEARCH_CX)" EXISTING_PERPLEXITY_SEARCH_KEY="$(env_value NULLION_PERPLEXITY_API_KEY)" EXISTING_SEARCH_DONE="$(env_value NULLION_SETUP_SEARCH_DONE)" if [[ "$EXISTING_PROVIDER_BINDINGS" =~ search_plugin=([^,]+) ]]; then EXISTING_SEARCH_PROVIDER="${BASH_REMATCH[1]}" elif [[ "$EXISTING_SEARCH_DONE" == "true" ]]; then EXISTING_SEARCH_PROVIDER="builtin_search_provider" fi if [[ -n "${EXISTING_SEARCH_PROVIDER:-}" ]]; then print_info "Found existing search provider: $EXISTING_SEARCH_PROVIDER" if confirm_yes "Use existing search setup instead of setting it up again?"; then SKIP_SEARCH_SETUP=true SEARCH_PROVIDER="$EXISTING_SEARCH_PROVIDER" BRAVE_SEARCH_KEY="$EXISTING_BRAVE_SEARCH_KEY" GOOGLE_SEARCH_KEY="$EXISTING_GOOGLE_SEARCH_KEY" GOOGLE_SEARCH_CX="$EXISTING_GOOGLE_SEARCH_CX" PERPLEXITY_SEARCH_KEY="$EXISTING_PERPLEXITY_SEARCH_KEY" print_ok "Using existing search setup." fi fi if [[ "$SKIP_SEARCH_SETUP" == "false" ]]; then print_menu_item "1" "Built-in local adapter" "Default search/fetch behavior; no extra key" "[default]" print_menu_item "2" "Brave Search API" "Independent web index" print_menu_item "3" "Google Custom Search API" "Requires API key plus search engine ID" print_menu_item "4" "Perplexity Search API" "Ranked AI-oriented web results" print_menu_item "5" "DuckDuckGo Instant Answers" "Keyless, but not full web search" echo prompt_read -rp " Enter 1, 2, 3, 4, or 5: " SEARCH_CHOICE case "$SEARCH_CHOICE" in 2) SEARCH_PROVIDER="brave_search_provider" echo -e " Get a key at ${CYAN}https://api-dashboard.search.brave.com/${RESET}" echo -n " Paste your Brave Search API key (hidden): " prompt_read -rs BRAVE_SEARCH_KEY echo print_ok "Brave Search selected." ;; 3) SEARCH_PROVIDER="google_custom_search_provider" echo -e " Custom Search docs: ${CYAN}https://developers.google.com/custom-search/v1/overview${RESET}" echo -n " Paste your Google Search API key (hidden): " prompt_read -rs GOOGLE_SEARCH_KEY echo prompt_read -rp " Paste your Programmable Search Engine ID (cx): " GOOGLE_SEARCH_CX print_ok "Google Custom Search selected." ;; 4) SEARCH_PROVIDER="perplexity_search_provider" echo -e " Get a key at ${CYAN}https://www.perplexity.ai/settings/api${RESET}" echo -n " Paste your Perplexity API key (hidden): " prompt_read -rs PERPLEXITY_SEARCH_KEY echo print_ok "Perplexity Search selected." ;; 5) SEARCH_PROVIDER="duckduckgo_instant_answer_provider" print_ok "DuckDuckGo Instant Answers selected." ;; *) print_ok "Built-in search selected." ;; esac fi checkpoint_search_setup print_ok "Search setup checkpoint saved to $NULLION_ENV_FILE" # ── Account / API tools setup ────────────────────────────────────────────── echo print_bold " Choose account/API tools to enable:" echo " These add account-aware tools. Native support is available for Gmail/Google" echo " Calendar; connector gateways can bridge other apps when they expose a" echo " compatible HTTP API." echo EMAIL_CALENDAR_ENABLED=false MATON_CONNECTOR_ENABLED=false CONNECTOR_SKILLS_ENABLED=false CUSTOM_EMAIL_API_ENABLED=false MATON_API_KEY="$(env_value MATON_API_KEY)" COMPOSIO_API_KEY="$(env_value COMPOSIO_API_KEY)" NANGO_SECRET_KEY="$(env_value NANGO_SECRET_KEY)" ACTIVEPIECES_API_KEY="$(env_value ACTIVEPIECES_API_KEY)" N8N_API_KEY="$(env_value N8N_API_KEY)" N8N_BASE_URL="$(env_value N8N_BASE_URL)" CUSTOM_API_BASE_URL="$(env_value NULLION_CUSTOM_API_BASE_URL)" CUSTOM_API_TOKEN="$(env_value NULLION_CUSTOM_API_TOKEN)" EXISTING_ENABLED_PLUGINS="$(env_value NULLION_ENABLED_PLUGINS)" EXISTING_ACCOUNT_DONE="$(env_value NULLION_SETUP_ACCOUNT_DONE)" EXISTING_CONNECTOR_GATEWAY="$(env_value NULLION_CONNECTOR_GATEWAY)" if [[ -n "$MATON_API_KEY$COMPOSIO_API_KEY$NANGO_SECRET_KEY$ACTIVEPIECES_API_KEY$N8N_API_KEY$EXISTING_CONNECTOR_GATEWAY" ]]; then MATON_CONNECTOR_ENABLED=true CONNECTOR_SKILLS_ENABLED=true fi if [[ "$EXISTING_ACCOUNT_DONE" == "true" || ",$EXISTING_ENABLED_PLUGINS," == *",email_plugin,"* || ",$EXISTING_ENABLED_PLUGINS," == *",calendar_plugin,"* || "$CONNECTOR_SKILLS_ENABLED" == "true" ]]; then print_info "Found existing account/API tools setup." if confirm_yes "Use existing account/API setup instead of setting it up again?"; then if [[ "$(env_value NULLION_PROVIDER_BINDINGS)" == *"email_plugin=custom_api_provider"* ]]; then CUSTOM_EMAIL_API_ENABLED=true elif [[ ",$EXISTING_ENABLED_PLUGINS," == *",email_plugin,"* || ",$EXISTING_ENABLED_PLUGINS," == *",calendar_plugin,"* ]]; then EMAIL_CALENDAR_ENABLED=true fi if [[ -n "$MATON_API_KEY$COMPOSIO_API_KEY$NANGO_SECRET_KEY$ACTIVEPIECES_API_KEY$N8N_API_KEY$EXISTING_CONNECTOR_GATEWAY" ]]; then MATON_CONNECTOR_ENABLED=true CONNECTOR_SKILLS_ENABLED=true fi print_ok "Using existing account/API setup." fi fi if [[ "$EMAIL_CALENDAR_ENABLED" == "false" && "$CUSTOM_EMAIL_API_ENABLED" == "false" && "$MATON_CONNECTOR_ENABLED" == "false" ]]; then print_menu_item "1" "Gmail / Google Calendar" "Local setup with Himalaya plus the Google API wrapper" "[recommended]" print_menu_item "2" "Connector skill credentials" "Maton, Composio, Nango, Activepieces, n8n, or custom gateway" print_menu_item "3" "Custom email API bridge" "Bind Nullion email tools to your own HTTP bridge" print_menu_item "4" "Skip" "Set up account/API tools later in the web UI" echo prompt_read -rp " Enter 1, 2, 3, or 4 [4]: " ACCOUNT_TOOLS_CHOICE ACCOUNT_TOOLS_CHOICE="${ACCOUNT_TOOLS_CHOICE:-4}" case "$ACCOUNT_TOOLS_CHOICE" in 1) EMAIL_CALENDAR_ENABLED=true if command_exists himalaya; then print_ok "Found Himalaya: $(himalaya --version 2>/dev/null | head -1)" else print_info "Himalaya is not installed on this machine." if [[ "$PLATFORM" == "macos" ]] && command_exists brew; then if confirm "Install Himalaya now with Homebrew?"; then brew install himalaya print_ok "Himalaya installed." else print_info "Skipped Himalaya install. Install it later with: brew install himalaya" fi else print_info "Install Himalaya later from https://github.com/pimalaya/himalaya" print_info "Then configure a Gmail account profile and add it in Settings → Users → Workspace connections." fi fi echo echo " After Himalaya has a Gmail account profile, open:" echo " Settings → Users → Workspace connections" echo " Then add a Gmail / Google Workspace connection using that profile name." print_ok "Email/calendar plugins will be enabled." ;; 2) MATON_CONNECTOR_ENABLED=true CONNECTOR_SKILLS_ENABLED=true echo echo " Connector skills are broad workflow guidance for SaaS/API gateways." echo " They do not grant access by themselves; setup saves credentials for the" echo " connector or MCP tools you choose to use." print_menu_item "1" "Maton" "API gateway and MCP toolkit for many SaaS apps" "[recommended]" print_menu_item "2" "Composio" "MCP/direct API toolkits for connected apps" print_menu_item "3" "Nango" "Open-source OAuth and integration platform" print_menu_item "4" "Activepieces" "Open-source automation pieces" print_menu_item "5" "n8n" "Self-hostable workflow automation" print_menu_item "6" "Skip credentials" "Enable the connector skills only" echo prompt_read -rp " Select one or more [1]: " CONNECTOR_CHOICES CONNECTOR_CHOICES="${CONNECTOR_CHOICES:-1}" CONNECTOR_CHOICES_NORMALIZED="$(echo "$CONNECTOR_CHOICES" | tr -cs '0-9' ',')" CONNECTOR_CHOICES_NORMALIZED=",${CONNECTOR_CHOICES_NORMALIZED#,}" if [[ "$CONNECTOR_CHOICES_NORMALIZED" != *, ]]; then CONNECTOR_CHOICES_NORMALIZED="${CONNECTOR_CHOICES_NORMALIZED}," fi if [[ "$CONNECTOR_CHOICES_NORMALIZED" == *",1,"* ]]; then echo -n " Maton API key (hidden): " prompt_read -rs MATON_API_KEY echo fi if [[ "$CONNECTOR_CHOICES_NORMALIZED" == *",2,"* ]]; then echo -n " Composio API key (hidden): " prompt_read -rs COMPOSIO_API_KEY echo fi if [[ "$CONNECTOR_CHOICES_NORMALIZED" == *",3,"* ]]; then echo -n " Nango secret key (hidden): " prompt_read -rs NANGO_SECRET_KEY echo fi if [[ "$CONNECTOR_CHOICES_NORMALIZED" == *",4,"* ]]; then echo -n " Activepieces API key (hidden): " prompt_read -rs ACTIVEPIECES_API_KEY echo fi if [[ "$CONNECTOR_CHOICES_NORMALIZED" == *",5,"* ]]; then prompt_read -rp " n8n base URL (e.g. http://localhost:5678): " N8N_BASE_URL echo -n " n8n API key (hidden): " prompt_read -rs N8N_API_KEY echo fi print_ok "Connector/API skill pack will be enabled." ;; 3) CUSTOM_EMAIL_API_ENABLED=true echo echo " Nullion's custom email provider expects:" echo " GET /email/search?q=...&limit=..." echo " GET /email/read/{id}" echo " A bridge can call Maton, Composio, n8n, Activepieces, Nango, or any API behind those endpoints." prompt_read -rp " Custom API base URL: " CUSTOM_API_BASE_URL echo -n " Custom API bearer token (hidden): " prompt_read -rs CUSTOM_API_TOKEN echo print_ok "Custom email API tools will be enabled." ;; *) print_info "Skipped account/API tools. You can easily enable them later in the web UI." ;; esac fi checkpoint_account_setup print_ok "Account/API setup checkpoint saved to $NULLION_ENV_FILE" media_model_supports() { local capability="$1" local provider="$(echo "${2:-}" | tr '[:upper:]' '[:lower:]')" local model="$(echo "${3:-}" | tr '[:upper:]' '[:lower:]')" [[ -z "$provider" || -z "$model" ]] && return 1 case "$capability" in audio) [[ "$provider" =~ ^(openai|groq|custom)$ && "$model" =~ (transcribe|whisper|audio) ]] ;; image_ocr) [[ "$provider" =~ ^(anthropic|codex)$ || "$model" =~ (gpt-4o|gpt-4\.1|gpt-5|vision|vl|llava|pixtral|gemini|claude|sonnet|opus|haiku) ]] ;; image_generate) if [[ "$provider" == "openai" ]]; then [[ "$model" =~ (gpt-image|dall-e|image) ]] else [[ "$model" =~ (image|imagen|flux|stable-diffusion|sdxl) || "$provider" == "custom" ]] fi ;; video) if [[ "$provider" == "openai" ]]; then [[ "$model" =~ (gpt-4o|gpt-4\.1|gpt-5|video|sora) ]] else [[ "$model" =~ (video|veo|gemini|vision|vl) ]] fi ;; *) return 1 ;; esac } current_media_model_usable() { local provider="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]')" local key key="$(get_media_provider_key "$provider")" if [[ "$provider" == "openai" ]]; then [[ "$key" == sk-* ]] elif [[ "$provider" == "codex" ]]; then return 1 else [[ -n "$key" ]] fi } media_provider_default_model() { local capability="$1" local provider="$2" case "$capability:$provider" in audio:openai) echo "gpt-4o-transcribe" ;; audio:groq) echo "whisper-large-v3-turbo" ;; image_ocr:openai) echo "gpt-4o" ;; image_ocr:anthropic) echo "claude-sonnet-4-6" ;; image_ocr:openrouter) echo "openai/gpt-4o" ;; image_ocr:gemini) echo "models/gemini-2.5-flash" ;; image_ocr:mistral) echo "pixtral-large-latest" ;; image_generate:openai) echo "gpt-image-1" ;; image_generate:openrouter) echo "google/gemini-3.1-flash-image-preview" ;; image_generate:gemini) echo "gemini-3.1-flash-image-preview" ;; image_generate:xai) echo "grok-2-image" ;; image_generate:together) echo "black-forest-labs/FLUX.1-schnell-Free" ;; video:openai) echo "gpt-4o" ;; video:openrouter) echo "openai/gpt-4o" ;; video:gemini) echo "models/gemini-2.5-flash" ;; *) echo "" ;; esac } set_media_provider_key() { local provider="$1" local key="$2" case "$provider" in anthropic) MEDIA_ANTHROPIC_KEY="$key" ;; openai) MEDIA_OPENAI_KEY="$key" ;; openrouter) MEDIA_OPENROUTER_KEY="$key" ;; gemini) MEDIA_GEMINI_KEY="$key" ;; groq) MEDIA_GROQ_KEY="$key" ;; mistral) MEDIA_MISTRAL_KEY="$key" ;; deepseek) MEDIA_DEEPSEEK_KEY="$key" ;; xai) MEDIA_XAI_KEY="$key" ;; together) MEDIA_TOGETHER_KEY="$key" ;; custom) MEDIA_CUSTOM_KEY="$key" ;; esac } get_media_provider_key() { case "$1" in anthropic) echo "${MEDIA_ANTHROPIC_KEY:-$ANTHROPIC_KEY}" ;; openai) echo "${MEDIA_OPENAI_KEY:-$OPENAI_KEY}" ;; openrouter) echo "${MEDIA_OPENROUTER_KEY:-}" ;; gemini) echo "${MEDIA_GEMINI_KEY:-}" ;; groq) echo "${MEDIA_GROQ_KEY:-}" ;; mistral) echo "${MEDIA_MISTRAL_KEY:-}" ;; deepseek) echo "${MEDIA_DEEPSEEK_KEY:-}" ;; xai) echo "${MEDIA_XAI_KEY:-}" ;; together) echo "${MEDIA_TOGETHER_KEY:-}" ;; custom) echo "${MEDIA_CUSTOM_KEY:-}" ;; *) echo "" ;; esac } prompt_media_api_key() { local provider="$1" local key_url="$2" local key key="$(get_media_provider_key "$provider")" if [[ "$provider" == "openai" && "$key" != sk-* ]]; then key="" print_info "OpenAI OAuth sign-in cannot be reused for media API calls; paste an API key for this media model." fi if [[ -z "$key" ]]; then echo -e " Get an API key at ${CYAN}${key_url}${RESET}" echo -n " Paste $(media_provider_label "$provider") media API key (hidden): " prompt_read -rs key echo set_media_provider_key "$provider" "$key" fi } prompt_media_api_provider() { local capability="$1" local title="$2" local result_prefix="$3" local default_provider="$4" local default_model="$5" local allow_openai_only="${6:-false}" local choice provider model key_url echo print_bold " ${title} API provider" print_menu_item "1" "OpenAI" "OpenAI platform API key" if [[ "$capability" == "audio" && "$allow_openai_only" != "true" ]]; then print_menu_item "2" "Groq" "OpenAI-compatible transcription API" print_menu_item "3" "Custom endpoint" "Any OpenAI-compatible audio transcription endpoint" prompt_read -rp " Enter 1-3 [1]: " choice elif [[ "$capability" == "image_generate" && "$allow_openai_only" != "true" ]]; then print_menu_item "2" "OpenRouter" "OpenAI-compatible image model routing" print_menu_item "3" "Google Gemini" "Imagen through the Gemini API" print_menu_item "4" "xAI" "Image generation models" print_menu_item "5" "Together AI" "FLUX and other image models" print_menu_item "6" "Custom endpoint" "OpenAI-compatible base URL and model" prompt_read -rp " Enter 1-6 [1]: " choice elif [[ "$allow_openai_only" != "true" ]]; then print_menu_item "2" "Anthropic" "Claude models" print_menu_item "3" "OpenRouter" "OpenAI-compatible model routing" print_menu_item "4" "Google Gemini" "OpenAI-compatible Gemini API" print_menu_item "5" "Mistral" "Mistral and Pixtral models" print_menu_item "6" "Custom endpoint" "OpenAI-compatible base URL and model" prompt_read -rp " Enter 1-6 [1]: " choice else prompt_read -rp " Enter 1 [1]: " choice fi choice="${choice:-1}" case "$choice" in 2) if [[ "$capability" == "audio" ]]; then provider="groq"; key_url="https://console.groq.com/keys" elif [[ "$capability" == "image_generate" ]]; then provider="openrouter"; key_url="https://openrouter.ai/keys" else provider="anthropic"; key_url="https://console.anthropic.com/settings/keys" fi ;; 3) if [[ "$capability" == "audio" ]]; then provider="custom" prompt_read -rp " OpenAI-compatible base URL (e.g. http://localhost:1234/v1): " MEDIA_CUSTOM_BASE_URL prompt_read -rp " API key setup URL (optional): " key_url elif [[ "$capability" == "image_generate" ]]; then provider="gemini"; key_url="https://aistudio.google.com/app/apikey" else provider="openrouter"; key_url="https://openrouter.ai/keys" fi ;; 4) if [[ "$capability" == "image_generate" ]]; then provider="xai"; key_url="https://console.x.ai/" else provider="gemini"; key_url="https://aistudio.google.com/app/apikey" fi ;; 5) if [[ "$capability" == "image_generate" ]]; then provider="together"; key_url="https://api.together.xyz/settings/api-keys" else provider="mistral"; key_url="https://console.mistral.ai/api-keys/" fi ;; 6) provider="custom" prompt_read -rp " OpenAI-compatible base URL (e.g. http://localhost:1234/v1): " MEDIA_CUSTOM_BASE_URL prompt_read -rp " API key setup URL (optional): " key_url ;; *) provider="$default_provider"; key_url="https://platform.openai.com/api-keys" ;; esac model="$(media_provider_default_model "$capability" "$provider")" model="${model:-$default_model}" print_info "Press Enter to use the default (${model}), or type a different model name." prompt_read -rp " Model [${model}]: " _MEDIA_MODEL_INPUT model="${_MEDIA_MODEL_INPUT:-$model}" if media_model_supports "$capability" "$provider" "$model"; then print_ok "$(media_provider_label "$provider") · ${model} supports ${title}." elif [[ "$provider" == "custom" ]]; then print_info "Custom provider selected. Nullion will use this if its OpenAI-compatible endpoint supports ${title}." else print_info "$(media_provider_label "$provider") · ${model} is not a known default for ${title}; make sure this model supports the tool." fi prompt_media_api_key "$provider" "${key_url:-your provider dashboard}" eval "${result_prefix}_PROVIDER=\"\$provider\"" eval "${result_prefix}_MODEL=\"\$model\"" eval "${result_prefix}_ENABLED=true" } media_provider_label() { case "$1" in anthropic) echo "Anthropic" ;; codex) echo "Codex" ;; openai) echo "OpenAI" ;; openrouter) echo "OpenRouter" ;; gemini) echo "Gemini" ;; ollama) echo "Ollama" ;; groq) echo "Groq" ;; mistral) echo "Mistral" ;; deepseek) echo "DeepSeek" ;; xai) echo "xAI" ;; together) echo "Together AI" ;; *) echo "${1:-provider}" ;; esac } prompt_media_model_provider() { local capability="$1" local title="$2" local recommended_provider="$3" local recommended_model="$4" local key_url="$5" local result_prefix="$6" local current_supported=false if media_model_supports "$capability" "$MODEL_PROVIDER" "$MODEL_NAME" && current_media_model_usable "$MODEL_PROVIDER"; then current_supported=true fi echo print_bold " ${title}" if [[ "$current_supported" == "true" ]]; then print_menu_item "1" "Use current provider" "$(media_provider_label "$MODEL_PROVIDER") · ${MODEL_NAME}" print_menu_item "2" "Add/configure ${recommended_provider} media model" "$recommended_model" print_menu_item "3" "Skip" "Set this up later in the web UI" prompt_read -rp " Enter 1, 2, or 3: " _MEDIA_CHOICE case "$_MEDIA_CHOICE" in 1) eval "${result_prefix}_PROVIDER=\"\$MODEL_PROVIDER\"" eval "${result_prefix}_MODEL=\"\$MODEL_NAME\"" eval "${result_prefix}_ENABLED=true" return ;; 2) ;; *) return ;; esac else print_menu_item "1" "Add/configure ${recommended_provider} media model" "$recommended_model" print_menu_item "2" "Skip" "Set this up later in the web UI" prompt_read -rp " Enter 1 or 2: " _MEDIA_CHOICE [[ "$_MEDIA_CHOICE" != "1" ]] && return fi eval "${result_prefix}_PROVIDER=\"openai\"" eval "${result_prefix}_MODEL=\"${recommended_model}\"" eval "${result_prefix}_ENABLED=true" prompt_media_api_key "openai" "$key_url" } # ── Local media tools setup ──────────────────────────────────────────────── echo print_bold " Configure media tools?" echo " We'll set these up separately so local tools are used where they are cheap" echo " and fast, while image/video AI can use your current provider or a media provider." echo MEDIA_ENABLED=false IMAGE_OCR_COMMAND="" AUDIO_TRANSCRIBE_COMMAND="" IMAGE_GENERATE_COMMAND="" MEDIA_OPENAI_KEY="" MEDIA_ANTHROPIC_KEY="" MEDIA_OPENROUTER_KEY="" MEDIA_GEMINI_KEY="" MEDIA_GROQ_KEY="" MEDIA_MISTRAL_KEY="" MEDIA_DEEPSEEK_KEY="" MEDIA_XAI_KEY="" MEDIA_TOGETHER_KEY="" MEDIA_CUSTOM_KEY="" MEDIA_CUSTOM_BASE_URL="" AUDIO_TRANSCRIBE_PROVIDER="" AUDIO_TRANSCRIBE_MODEL="" AUDIO_TRANSCRIBE_ENABLED=false IMAGE_OCR_PROVIDER="" IMAGE_OCR_MODEL="" IMAGE_OCR_ENABLED=false IMAGE_GENERATE_PROVIDER="" IMAGE_GENERATE_MODEL="" IMAGE_GENERATE_ENABLED=false VIDEO_INPUT_PROVIDER="" VIDEO_INPUT_MODEL="" VIDEO_INPUT_ENABLED=false WHISPER_CPP_READY=false print_info "Installing default local media runtime so you can switch to local audio/OCR later." install_default_local_media_runtime EXISTING_MEDIA_DONE="$(env_value NULLION_SETUP_MEDIA_DONE)" if [[ "$EXISTING_MEDIA_DONE" == "true" || ",$EXISTING_ENABLED_PLUGINS," == *",media_plugin,"* ]]; then print_info "Found existing media tools setup." if confirm_yes "Use existing media setup instead of setting it up again?"; then MEDIA_ENABLED=true MEDIA_OPENAI_KEY="$(env_value NULLION_MEDIA_OPENAI_API_KEY)" MEDIA_ANTHROPIC_KEY="$(env_value NULLION_MEDIA_ANTHROPIC_API_KEY)" MEDIA_OPENROUTER_KEY="$(env_value NULLION_MEDIA_OPENROUTER_API_KEY)" MEDIA_GEMINI_KEY="$(env_value NULLION_MEDIA_GEMINI_API_KEY)" MEDIA_GROQ_KEY="$(env_value NULLION_MEDIA_GROQ_API_KEY)" MEDIA_MISTRAL_KEY="$(env_value NULLION_MEDIA_MISTRAL_API_KEY)" MEDIA_DEEPSEEK_KEY="$(env_value NULLION_MEDIA_DEEPSEEK_API_KEY)" MEDIA_XAI_KEY="$(env_value NULLION_MEDIA_XAI_API_KEY)" MEDIA_TOGETHER_KEY="$(env_value NULLION_MEDIA_TOGETHER_API_KEY)" MEDIA_CUSTOM_KEY="$(env_value NULLION_MEDIA_CUSTOM_API_KEY)" MEDIA_CUSTOM_BASE_URL="$(env_value NULLION_MEDIA_CUSTOM_BASE_URL)" IMAGE_OCR_COMMAND="$(env_value NULLION_IMAGE_OCR_COMMAND)" AUDIO_TRANSCRIBE_COMMAND="$(env_value NULLION_AUDIO_TRANSCRIBE_COMMAND)" IMAGE_GENERATE_COMMAND="$(env_value NULLION_IMAGE_GENERATE_COMMAND)" AUDIO_TRANSCRIBE_ENABLED="$(env_value NULLION_AUDIO_TRANSCRIBE_ENABLED)" AUDIO_TRANSCRIBE_PROVIDER="$(env_value NULLION_AUDIO_TRANSCRIBE_PROVIDER)" AUDIO_TRANSCRIBE_MODEL="$(env_value NULLION_AUDIO_TRANSCRIBE_MODEL)" IMAGE_OCR_ENABLED="$(env_value NULLION_IMAGE_OCR_ENABLED)" IMAGE_OCR_PROVIDER="$(env_value NULLION_IMAGE_OCR_PROVIDER)" IMAGE_OCR_MODEL="$(env_value NULLION_IMAGE_OCR_MODEL)" IMAGE_GENERATE_ENABLED="$(env_value NULLION_IMAGE_GENERATE_ENABLED)" IMAGE_GENERATE_PROVIDER="$(env_value NULLION_IMAGE_GENERATE_PROVIDER)" IMAGE_GENERATE_MODEL="$(env_value NULLION_IMAGE_GENERATE_MODEL)" VIDEO_INPUT_ENABLED="$(env_value NULLION_VIDEO_INPUT_ENABLED)" VIDEO_INPUT_PROVIDER="$(env_value NULLION_VIDEO_INPUT_PROVIDER)" VIDEO_INPUT_MODEL="$(env_value NULLION_VIDEO_INPUT_MODEL)" print_ok "Using existing media setup." fi fi if [[ "$MEDIA_ENABLED" == "false" ]] && confirm "Configure media tools now?"; then if [[ "$MODEL_PROVIDER" == "codex" || ( "$MODEL_PROVIDER" == "openai" && "${OPENAI_KEY:-}" != sk-* ) ]]; then print_info "Codex/OpenAI OAuth works for chat sign-in, but audio transcription APIs need a provider API key or custom endpoint." fi echo print_bold " Audio transcription" print_menu_item "1" "Local whisper.cpp" "Fast, private, no per-minute API cost" "[recommended]" if media_model_supports audio "$MODEL_PROVIDER" "$MODEL_NAME" && current_media_model_usable "$MODEL_PROVIDER"; then print_menu_item "2" "Use connected provider/model" "$(media_provider_label "$MODEL_PROVIDER") · ${MODEL_NAME} supports audio transcription" print_menu_item "3" "Add/configure API transcription provider" "OpenAI, Groq, or any OpenAI-compatible endpoint" print_menu_item "4" "Skip" "Set up audio transcription later in the web UI" prompt_read -rp " Enter 1, 2, 3, or 4: " AUDIO_CHOICE else print_menu_item "2" "Add/configure API transcription provider" "OpenAI, Groq, or any OpenAI-compatible endpoint" print_menu_item "3" "Skip" "Set up audio transcription later in the web UI" prompt_read -rp " Enter 1, 2, or 3: " AUDIO_CHOICE fi case "$AUDIO_CHOICE" in 1) MEDIA_ENABLED=true if ensure_whisper_cpp_runtime; then : else print_info "Default audio transcription is not fully installed." if confirm "Configure a custom audio transcription command now?"; then echo " Example: whisper-cli -m \"$WHISPER_CPP_MODEL_PATH\" -f {input} -nt" prompt_read -rp " Audio command template: " AUDIO_TRANSCRIBE_COMMAND [[ -n "$AUDIO_TRANSCRIBE_COMMAND" ]] && AUDIO_TRANSCRIBE_ENABLED=true fi fi ;; 2) MEDIA_ENABLED=true if media_model_supports audio "$MODEL_PROVIDER" "$MODEL_NAME" && current_media_model_usable "$MODEL_PROVIDER"; then AUDIO_TRANSCRIBE_PROVIDER="$MODEL_PROVIDER" AUDIO_TRANSCRIBE_MODEL="$MODEL_NAME" AUDIO_TRANSCRIBE_ENABLED=true print_ok "$(media_provider_label "$MODEL_PROVIDER") · ${MODEL_NAME} will be used for audio transcription." else prompt_media_api_provider "audio" "Audio transcription" "AUDIO_TRANSCRIBE" "openai" "gpt-4o-transcribe" "false" fi ;; 3) if media_model_supports audio "$MODEL_PROVIDER" "$MODEL_NAME" && current_media_model_usable "$MODEL_PROVIDER"; then MEDIA_ENABLED=true prompt_media_api_provider "audio" "Audio transcription" "AUDIO_TRANSCRIBE" "openai" "gpt-4o-transcribe" "false" fi ;; esac echo print_bold " Image text extraction / OCR" print_menu_item "1" "Local Tesseract" "Fast, private, no image API cost" "[recommended]" if media_model_supports image_ocr "$MODEL_PROVIDER" "$MODEL_NAME" && current_media_model_usable "$MODEL_PROVIDER"; then print_menu_item "2" "Use current provider" "$(media_provider_label "$MODEL_PROVIDER") · ${MODEL_NAME}" print_menu_item "3" "Add/configure API vision provider" "OpenAI, Anthropic, OpenRouter, Gemini, Mistral, or custom" print_menu_item "4" "Skip" "Set up image text extraction later in the web UI" prompt_read -rp " Enter 1, 2, 3, or 4: " OCR_CHOICE else print_menu_item "2" "Add/configure API vision provider" "OpenAI, Anthropic, OpenRouter, Gemini, Mistral, or custom" print_menu_item "3" "Skip" "Set up image text extraction later in the web UI" prompt_read -rp " Enter 1, 2, or 3: " OCR_CHOICE fi case "$OCR_CHOICE" in 1) MEDIA_ENABLED=true if command_exists tesseract; then IMAGE_OCR_COMMAND="tesseract {input} stdout" print_ok "Found Tesseract for image text extraction." else print_info "Tesseract not found. Install it later or configure NULLION_IMAGE_OCR_COMMAND in Settings." fi ;; 2) MEDIA_ENABLED=true if media_model_supports image_ocr "$MODEL_PROVIDER" "$MODEL_NAME" && current_media_model_usable "$MODEL_PROVIDER"; then IMAGE_OCR_PROVIDER="$MODEL_PROVIDER" IMAGE_OCR_MODEL="$MODEL_NAME" else prompt_media_api_provider "image_ocr" "Image text extraction" "IMAGE_OCR" "openai" "gpt-4o" "false" fi IMAGE_OCR_ENABLED=true ;; 3) if media_model_supports image_ocr "$MODEL_PROVIDER" "$MODEL_NAME" && current_media_model_usable "$MODEL_PROVIDER"; then MEDIA_ENABLED=true prompt_media_api_provider "image_ocr" "Image text extraction" "IMAGE_OCR" "openai" "gpt-4o" "false" fi ;; esac echo print_bold " Image generation" if media_model_supports image_generate "$MODEL_PROVIDER" "$MODEL_NAME" && current_media_model_usable "$MODEL_PROVIDER"; then print_menu_item "1" "Use current provider" "$(media_provider_label "$MODEL_PROVIDER") · ${MODEL_NAME}" print_menu_item "2" "Add/configure API image generation provider" "OpenAI, OpenRouter, Gemini, xAI, Together, or custom" print_menu_item "3" "Skip" "Set up image generation later in the web UI" prompt_read -rp " Enter 1, 2, or 3: " IMAGE_GEN_CHOICE case "$IMAGE_GEN_CHOICE" in 1) MEDIA_ENABLED=true IMAGE_GENERATE_PROVIDER="$MODEL_PROVIDER" IMAGE_GENERATE_MODEL="$MODEL_NAME" IMAGE_GENERATE_ENABLED=true ;; 2) MEDIA_ENABLED=true prompt_media_api_provider "image_generate" "Image generation" "IMAGE_GENERATE" "openai" "gpt-image-1" "false" ;; esac else print_menu_item "1" "Add/configure API image generation provider" "OpenAI, OpenRouter, Gemini, xAI, Together, or custom" print_menu_item "2" "Skip" "Set up image generation later in the web UI" prompt_read -rp " Enter 1 or 2: " IMAGE_GEN_CHOICE if [[ "$IMAGE_GEN_CHOICE" == "1" ]]; then MEDIA_ENABLED=true prompt_media_api_provider "image_generate" "Image generation" "IMAGE_GENERATE" "openai" "gpt-image-1" "false" fi fi echo print_bold " Video / rich image understanding" if media_model_supports video "$MODEL_PROVIDER" "$MODEL_NAME" && current_media_model_usable "$MODEL_PROVIDER"; then print_menu_item "1" "Use current provider" "$(media_provider_label "$MODEL_PROVIDER") · ${MODEL_NAME}" print_menu_item "2" "Add/configure API vision/video provider" "OpenAI, OpenRouter, Gemini, or custom" print_menu_item "3" "Skip" "Set up video understanding later in the web UI" prompt_read -rp " Enter 1, 2, or 3: " VIDEO_CHOICE case "$VIDEO_CHOICE" in 1) MEDIA_ENABLED=true VIDEO_INPUT_PROVIDER="$MODEL_PROVIDER" VIDEO_INPUT_MODEL="$MODEL_NAME" VIDEO_INPUT_ENABLED=true ;; 2) MEDIA_ENABLED=true prompt_media_api_provider "video" "Video understanding" "VIDEO_INPUT" "openai" "gpt-4o" "false" ;; esac else print_menu_item "1" "Add/configure API vision/video provider" "OpenAI, OpenRouter, Gemini, or custom" print_menu_item "2" "Skip" "Set up video understanding later in the web UI" prompt_read -rp " Enter 1 or 2: " VIDEO_CHOICE if [[ "$VIDEO_CHOICE" == "1" ]]; then MEDIA_ENABLED=true prompt_media_api_provider "video" "Video understanding" "VIDEO_INPUT" "openai" "gpt-4o" "false" fi fi elif [[ "$MEDIA_ENABLED" == "false" ]]; then print_info "Skipped media tools. You can easily set them up later in the web UI." fi checkpoint_media_setup print_ok "Media setup checkpoint saved to $NULLION_ENV_FILE" # ── Skill pack setup ─────────────────────────────────────────────────────── echo print_bold " Choose skill packs to enable:" echo " All built-in skill packs ship with Nullion and are selected by default." echo " Skill packs add workflow guidance only; account access still requires" echo " workspace-scoped provider connections and enabled tools." echo ENABLED_SKILL_PACKS="" trim_skill_pack_id() { local value="$1" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s' "$value" } add_skill_pack() { local pack_id="$1" pack_id="$(trim_skill_pack_id "$pack_id")" [[ -z "$pack_id" ]] && return 0 if [[ -z "$ENABLED_SKILL_PACKS" ]]; then ENABLED_SKILL_PACKS="$pack_id" elif [[ ",$ENABLED_SKILL_PACKS," != *",$pack_id,"* ]]; then ENABLED_SKILL_PACKS="${ENABLED_SKILL_PACKS},${pack_id}" fi } add_skill_pack_list() { local raw="$1" local part local old_ifs="$IFS" IFS=',' for part in $raw; do add_skill_pack "$part" done IFS="$old_ifs" } normalize_skill_pack_list() { ENABLED_SKILL_PACKS="" add_skill_pack_list "$1" printf '%s' "$ENABLED_SKILL_PACKS" ENABLED_SKILL_PACKS="" } print_skill_pack_list() { local label="$1" local raw="$2" local normalized local part normalized="$(normalize_skill_pack_list "$raw")" if [[ -z "$normalized" ]]; then print_info "${label}: none" return 0 fi print_info "${label}:" local old_ifs="$IFS" IFS=',' for part in $normalized; do echo " - $part" done IFS="$old_ifs" } install_custom_skill_pack_now() { local source="$1" local pack_id="$2" local force_flag="${3:-false}" "$VENV_DIR/bin/python" -c ' import sys from nullion.skill_pack_installer import install_skill_pack source = sys.argv[1] pack_id = sys.argv[2] or None force = sys.argv[3].lower() == "true" pack = install_skill_pack(source, pack_id=pack_id, force=force) print(pack.pack_id) ' "$source" "$pack_id" "$force_flag" } request_skill_pack_choices() { SKILL_CHOICES="" if [[ ! -t 0 ]]; then SKILL_CHOICES="1,2,3,4,5,6,7,8" print_info "No interactive terminal detected; using all default skill packs." return 0 fi local titles=( "Web research" "Browser automation" "Files and documents" "Email and calendar" "GitHub and code review" "Local media" "Productivity and memory" "Connector/API skills" "Install custom skill pack" "No default skill packs" ) local details=( "Search, fetch, source-backed answers" "Web navigation, forms, screenshots" "Local files, docs, sheets, decks" "Inbox triage, replies, scheduling" "Repos, PRs, issues, release notes" "Audio transcription, OCR, image workflows" "Tasks, routines, preferences, reminders" "Maton, Composio, Nango, Activepieces, n8n, custom APIs" "Git URL, GitHub folder, or local folder with SKILL.md" "Start with no enabled reference packs" ) local choice_values=(1 2 3 4 5 6 7 8 10 11) local badges=("" "" "" "" "" "" "" "" "" "") local selected=(true true true true true true true true false false) local current=0 local total=${#titles[@]} local key local old_stty="" local alt_screen=false toggle_current_item() { if [[ "${choice_values[$current]}" == "11" ]]; then for ((i = 0; i < total - 1; i++)); do selected[$i]=false; done selected[$current]=true else if [[ "${selected[$current]}" == "true" ]]; then selected[$current]=false else selected[$current]=true fi selected[$((total - 1))]=false fi } draw_menu() { printf '\033[H\033[J' echo " Use ↑/↓ to move, Space to select/deselect, Enter to continue." echo " You can also press the visible number for single-digit items." echo for ((i = 0; i < total; i++)); do print_check_item "${selected[$i]}" "$([[ $i -eq $current ]] && echo true || echo false)" "$((i + 1)). ${titles[$i]}" "${details[$i]}" "${badges[$i]}" done echo echo -e " ${DIM}Enter confirms the checked items.${RESET}" } old_stty="$(stty -g 2>/dev/null || true)" [[ -n "$old_stty" ]] && stty -echo -icanon min 1 time 0 2>/dev/null || true tput civis 2>/dev/null || true if [[ -n "${TERM:-}" && "${TERM:-}" != "dumb" ]] && tput smcup 2>/dev/null; then alt_screen=true fi while true; do draw_menu IFS= prompt_read -rsn1 key || key="" if [[ "$key" == $'\x1b' ]]; then local seq="" IFS= prompt_read -rsn2 -t 1 seq || true case "$seq" in "[A" | "OA") current=$(((current - 1 + total) % total)) ;; "[B" | "OB") current=$(((current + 1) % total)) ;; esac elif [[ "$key" =~ ^[1-9]$ ]]; then current=$((key - 1)) toggle_current_item elif [[ "$key" == " " ]]; then toggle_current_item elif [[ "$key" == "" || "$key" == $'\n' || "$key" == $'\r' ]]; then break fi done [[ "$alt_screen" == "true" ]] && tput rmcup 2>/dev/null || true [[ -n "$old_stty" ]] && stty "$old_stty" 2>/dev/null || true tput cnorm 2>/dev/null || true local choices=() for ((i = 0; i < total; i++)); do [[ "${selected[$i]}" == "true" ]] && choices+=("${choice_values[$i]}") done if ((${#choices[@]} == 0)); then choices=(11) fi SKILL_CHOICES="$(IFS=,; echo "${choices[*]}")" } EXISTING_SKILL_PACKS="$(normalize_skill_pack_list "$(env_value NULLION_ENABLED_SKILL_PACKS)")" EXISTING_SKILLS_DONE="$(env_value NULLION_SETUP_SKILLS_DONE)" SKIP_SKILL_SETUP=false if [[ "$EXISTING_SKILLS_DONE" == "true" || -n "$EXISTING_SKILL_PACKS" ]]; then print_skill_pack_list "Found existing skill packs" "$EXISTING_SKILL_PACKS" if confirm_yes "Use existing skill packs instead of choosing them again?"; then add_skill_pack_list "$EXISTING_SKILL_PACKS" SKIP_SKILL_SETUP=true print_ok "Using existing skill packs." fi fi if [[ "$SKIP_SKILL_SETUP" == "false" ]]; then request_skill_pack_choices if [[ ",${SKILL_CHOICES// /}," == *",11,"* ]]; then print_info "Skipped default skill packs. You can enable them later in Settings." else IFS=',' read -ra _SKILL_PARTS <<< "$SKILL_CHOICES" for choice in "${_SKILL_PARTS[@]}"; do choice="$(echo "$choice" | tr -d '[:space:]')" case "$choice" in 1) add_skill_pack "nullion/web-research" ;; 2) add_skill_pack "nullion/browser-automation" ;; 3) add_skill_pack "nullion/files-and-docs" ;; 4) add_skill_pack "nullion/email-calendar" ;; 5) add_skill_pack "nullion/github-code" ;; 6) add_skill_pack "nullion/media-local" ;; 7) add_skill_pack "nullion/productivity-memory" ;; 8) add_skill_pack "nullion/connector-skills" ;; 10) prompt_read -rp " Skill pack source URL/path: " CUSTOM_SKILL_PACK_SOURCE prompt_read -rp " Pack id [auto]: " CUSTOM_SKILL_PACK_ID if [[ -n "$CUSTOM_SKILL_PACK_SOURCE" ]]; then if CUSTOM_INSTALLED_PACK_ID="$(install_custom_skill_pack_now "$CUSTOM_SKILL_PACK_SOURCE" "$CUSTOM_SKILL_PACK_ID" "true")"; then add_skill_pack "$CUSTOM_INSTALLED_PACK_ID" print_ok "Installed skill pack: $CUSTOM_INSTALLED_PACK_ID" else print_err "Could not install custom skill pack. You can add it later in Settings." fi fi ;; "") ;; *) print_info "Ignoring unknown skill choice: $choice" ;; esac done if [[ -n "$ENABLED_SKILL_PACKS" ]]; then print_skill_pack_list "Skill packs enabled" "$ENABLED_SKILL_PACKS" else print_info "No skill packs selected." fi fi fi ENABLED_SKILL_PACKS="$(normalize_skill_pack_list "$ENABLED_SKILL_PACKS")" checkpoint_skill_setup print_ok "Skill setup checkpoint saved to $NULLION_ENV_FILE" # Write .env BOT_TOKEN="${BOT_TOKEN:-$(env_value NULLION_TELEGRAM_BOT_TOKEN)}" CHAT_ID="${CHAT_ID:-$(env_value NULLION_TELEGRAM_OPERATOR_CHAT_ID)}" if [[ "$TELEGRAM_ENABLED" != "true" && "$(env_value NULLION_TELEGRAM_CHAT_ENABLED)" != "false" && -n "$BOT_TOKEN$CHAT_ID" ]]; then TELEGRAM_ENABLED=true fi SLACK_BOT_TOKEN="${SLACK_BOT_TOKEN:-$(env_value NULLION_SLACK_BOT_TOKEN)}" SLACK_APP_TOKEN="${SLACK_APP_TOKEN:-$(env_value NULLION_SLACK_APP_TOKEN)}" SLACK_SIGNING_SECRET="${SLACK_SIGNING_SECRET:-$(env_value NULLION_SLACK_SIGNING_SECRET)}" SLACK_OPERATOR_USER_ID="${SLACK_OPERATOR_USER_ID:-$(env_value NULLION_SLACK_OPERATOR_USER_ID)}" if [[ "$SLACK_ENABLED" != "true" && "$(env_value NULLION_SLACK_ENABLED)" != "false" && -n "$SLACK_BOT_TOKEN$SLACK_APP_TOKEN" ]]; then SLACK_ENABLED=true fi DISCORD_BOT_TOKEN="${DISCORD_BOT_TOKEN:-$(env_value NULLION_DISCORD_BOT_TOKEN)}" if [[ "$DISCORD_ENABLED" != "true" && "$(env_value NULLION_DISCORD_ENABLED)" != "false" && -n "$DISCORD_BOT_TOKEN" ]]; then DISCORD_ENABLED=true fi MODEL_PROVIDER="${MODEL_PROVIDER:-$(env_value NULLION_MODEL_PROVIDER)}" MODEL_BASE_URL="${MODEL_BASE_URL:-$(env_value_any NULLION_OPENAI_BASE_URL OPENAI_BASE_URL)}" MODEL_NAME="${MODEL_NAME:-$(env_value_any NULLION_MODEL NULLION_OPENAI_MODEL OPENAI_MODEL)}" if [[ -z "$ANTHROPIC_KEY" ]]; then ANTHROPIC_KEY="$(env_value_any NULLION_ANTHROPIC_API_KEY ANTHROPIC_API_KEY)" fi if [[ -z "$OPENAI_KEY" ]]; then OPENAI_KEY="$(provider_key_value "$MODEL_PROVIDER")" fi if [[ -z "$OPENAI_KEY" ]]; then OPENAI_KEY="$(first_existing_provider_key_value)" fi ENABLED_PLUGINS="search_plugin,browser_plugin,workspace_plugin" PROVIDER_BINDINGS="search_plugin=${SEARCH_PROVIDER}" if [[ "$EMAIL_CALENDAR_ENABLED" == "true" ]]; then ENABLED_PLUGINS="${ENABLED_PLUGINS},email_plugin,calendar_plugin" PROVIDER_BINDINGS="${PROVIDER_BINDINGS},email_plugin=google_workspace_provider,calendar_plugin=google_workspace_provider" elif [[ "$CUSTOM_EMAIL_API_ENABLED" == "true" ]]; then ENABLED_PLUGINS="${ENABLED_PLUGINS},email_plugin" PROVIDER_BINDINGS="${PROVIDER_BINDINGS},email_plugin=custom_api_provider" fi if [[ "$MEDIA_ENABLED" == "true" ]]; then ENABLED_PLUGINS="${ENABLED_PLUGINS},media_plugin" PROVIDER_BINDINGS="${PROVIDER_BINDINGS},media_plugin=local_media_provider" fi { echo "# Nullion configuration — generated by install.sh on $(date)" echo "NULLION_WEB_PORT=${NULLION_WEB_PORT}" echo "NULLION_KEY_STORAGE=\"${NULLION_KEY_STORAGE:-local}\"" echo "NULLION_SETUP_MESSAGING_DONE=true" echo "NULLION_SETUP_PROVIDER_DONE=true" echo "NULLION_SETUP_BROWSER_DONE=true" echo "NULLION_SETUP_SEARCH_DONE=true" echo "NULLION_SETUP_ACCOUNT_DONE=true" echo "NULLION_SETUP_MEDIA_DONE=true" echo "NULLION_SETUP_SKILLS_DONE=true" if [[ "$TELEGRAM_ENABLED" == "true" ]]; then echo "NULLION_TELEGRAM_BOT_TOKEN=\"$BOT_TOKEN\"" echo "NULLION_TELEGRAM_OPERATOR_CHAT_ID=\"$CHAT_ID\"" echo "NULLION_TELEGRAM_CHAT_ENABLED=true" else echo "NULLION_TELEGRAM_CHAT_ENABLED=false" fi if [[ "$SLACK_ENABLED" == "true" ]]; then echo "NULLION_SLACK_ENABLED=true" echo "NULLION_SLACK_BOT_TOKEN=\"$SLACK_BOT_TOKEN\"" echo "NULLION_SLACK_APP_TOKEN=\"$SLACK_APP_TOKEN\"" [[ -n "$SLACK_SIGNING_SECRET" ]] && echo "NULLION_SLACK_SIGNING_SECRET=\"$SLACK_SIGNING_SECRET\"" [[ -n "$SLACK_OPERATOR_USER_ID" ]] && echo "NULLION_SLACK_OPERATOR_USER_ID=\"$SLACK_OPERATOR_USER_ID\"" else echo "NULLION_SLACK_ENABLED=false" fi if [[ "$DISCORD_ENABLED" == "true" ]]; then echo "NULLION_DISCORD_ENABLED=true" echo "NULLION_DISCORD_BOT_TOKEN=\"$DISCORD_BOT_TOKEN\"" else echo "NULLION_DISCORD_ENABLED=false" fi [[ -n "$ANTHROPIC_KEY" ]] && echo "ANTHROPIC_API_KEY=\"$ANTHROPIC_KEY\"" [[ -n "$OPENAI_KEY" ]] && echo "OPENAI_API_KEY=\"$OPENAI_KEY\"" [[ -n "$MODEL_PROVIDER" ]] && echo "NULLION_MODEL_PROVIDER=\"$MODEL_PROVIDER\"" [[ -n "$MODEL_BASE_URL" ]] && echo "NULLION_OPENAI_BASE_URL=\"$MODEL_BASE_URL\"" [[ -n "$MODEL_NAME" ]] && echo "NULLION_MODEL=\"$MODEL_NAME\"" [[ -n "$BROWSER_BACKEND" ]] && echo "NULLION_BROWSER_BACKEND=\"$BROWSER_BACKEND\"" [[ -n "$BROWSER_CDP_URL" ]] && echo "NULLION_BROWSER_CDP_URL=\"$BROWSER_CDP_URL\"" [[ -n "$BROWSER_PREFERRED" ]] && echo "NULLION_BROWSER_PREFERRED=\"$BROWSER_PREFERRED\"" [[ -n "$BRAVE_SEARCH_KEY" ]] && echo "NULLION_BRAVE_SEARCH_API_KEY=\"$BRAVE_SEARCH_KEY\"" [[ -n "$GOOGLE_SEARCH_KEY" ]] && echo "NULLION_GOOGLE_SEARCH_API_KEY=\"$GOOGLE_SEARCH_KEY\"" [[ -n "$GOOGLE_SEARCH_CX" ]] && echo "NULLION_GOOGLE_SEARCH_CX=\"$GOOGLE_SEARCH_CX\"" [[ -n "$PERPLEXITY_SEARCH_KEY" ]] && echo "NULLION_PERPLEXITY_API_KEY=\"$PERPLEXITY_SEARCH_KEY\"" [[ -n "$MATON_API_KEY" ]] && echo "MATON_API_KEY=\"$MATON_API_KEY\"" [[ -n "$COMPOSIO_API_KEY" ]] && echo "COMPOSIO_API_KEY=\"$COMPOSIO_API_KEY\"" [[ -n "$NANGO_SECRET_KEY" ]] && echo "NANGO_SECRET_KEY=\"$NANGO_SECRET_KEY\"" [[ -n "$ACTIVEPIECES_API_KEY" ]] && echo "ACTIVEPIECES_API_KEY=\"$ACTIVEPIECES_API_KEY\"" [[ -n "$N8N_BASE_URL" ]] && echo "N8N_BASE_URL=\"$N8N_BASE_URL\"" [[ -n "$N8N_API_KEY" ]] && echo "N8N_API_KEY=\"$N8N_API_KEY\"" [[ "$MATON_CONNECTOR_ENABLED" == "true" ]] && echo "NULLION_CONNECTOR_GATEWAY=\"maton\"" [[ -n "$CUSTOM_API_BASE_URL" ]] && echo "NULLION_CUSTOM_API_BASE_URL=\"$CUSTOM_API_BASE_URL\"" [[ -n "$CUSTOM_API_TOKEN" ]] && echo "NULLION_CUSTOM_API_TOKEN=\"$CUSTOM_API_TOKEN\"" echo "NULLION_ENABLED_PLUGINS=\"${ENABLED_PLUGINS}\"" echo "NULLION_PROVIDER_BINDINGS=\"${PROVIDER_BINDINGS}\"" if [[ "$MEDIA_ENABLED" == "true" ]]; then [[ -n "$MEDIA_OPENAI_KEY" ]] && echo "NULLION_MEDIA_OPENAI_API_KEY=\"$MEDIA_OPENAI_KEY\"" [[ -n "$MEDIA_ANTHROPIC_KEY" ]] && echo "NULLION_MEDIA_ANTHROPIC_API_KEY=\"$MEDIA_ANTHROPIC_KEY\"" [[ -n "$MEDIA_OPENROUTER_KEY" ]] && echo "NULLION_MEDIA_OPENROUTER_API_KEY=\"$MEDIA_OPENROUTER_KEY\"" [[ -n "$MEDIA_GEMINI_KEY" ]] && echo "NULLION_MEDIA_GEMINI_API_KEY=\"$MEDIA_GEMINI_KEY\"" [[ -n "$MEDIA_GROQ_KEY" ]] && echo "NULLION_MEDIA_GROQ_API_KEY=\"$MEDIA_GROQ_KEY\"" [[ -n "$MEDIA_MISTRAL_KEY" ]] && echo "NULLION_MEDIA_MISTRAL_API_KEY=\"$MEDIA_MISTRAL_KEY\"" [[ -n "$MEDIA_DEEPSEEK_KEY" ]] && echo "NULLION_MEDIA_DEEPSEEK_API_KEY=\"$MEDIA_DEEPSEEK_KEY\"" [[ -n "$MEDIA_XAI_KEY" ]] && echo "NULLION_MEDIA_XAI_API_KEY=\"$MEDIA_XAI_KEY\"" [[ -n "$MEDIA_TOGETHER_KEY" ]] && echo "NULLION_MEDIA_TOGETHER_API_KEY=\"$MEDIA_TOGETHER_KEY\"" [[ -n "$MEDIA_CUSTOM_KEY" ]] && echo "NULLION_MEDIA_CUSTOM_API_KEY=\"$MEDIA_CUSTOM_KEY\"" [[ -n "$MEDIA_CUSTOM_BASE_URL" ]] && echo "NULLION_MEDIA_CUSTOM_BASE_URL=\"$MEDIA_CUSTOM_BASE_URL\"" [[ -n "$IMAGE_OCR_COMMAND" ]] && echo "NULLION_IMAGE_OCR_COMMAND=\"$IMAGE_OCR_COMMAND\"" [[ -n "$AUDIO_TRANSCRIBE_COMMAND" ]] && echo "NULLION_AUDIO_TRANSCRIBE_COMMAND=\"$AUDIO_TRANSCRIBE_COMMAND\"" [[ -n "$IMAGE_GENERATE_COMMAND" ]] && echo "NULLION_IMAGE_GENERATE_COMMAND=\"$IMAGE_GENERATE_COMMAND\"" [[ "$AUDIO_TRANSCRIBE_ENABLED" == "true" ]] && echo "NULLION_AUDIO_TRANSCRIBE_ENABLED=true" [[ -n "$AUDIO_TRANSCRIBE_PROVIDER" ]] && echo "NULLION_AUDIO_TRANSCRIBE_PROVIDER=\"$AUDIO_TRANSCRIBE_PROVIDER\"" [[ -n "$AUDIO_TRANSCRIBE_MODEL" ]] && echo "NULLION_AUDIO_TRANSCRIBE_MODEL=\"$AUDIO_TRANSCRIBE_MODEL\"" [[ "$IMAGE_OCR_ENABLED" == "true" ]] && echo "NULLION_IMAGE_OCR_ENABLED=true" [[ -n "$IMAGE_OCR_PROVIDER" ]] && echo "NULLION_IMAGE_OCR_PROVIDER=\"$IMAGE_OCR_PROVIDER\"" [[ -n "$IMAGE_OCR_MODEL" ]] && echo "NULLION_IMAGE_OCR_MODEL=\"$IMAGE_OCR_MODEL\"" [[ "$IMAGE_GENERATE_ENABLED" == "true" ]] && echo "NULLION_IMAGE_GENERATE_ENABLED=true" [[ -n "$IMAGE_GENERATE_PROVIDER" ]] && echo "NULLION_IMAGE_GENERATE_PROVIDER=\"$IMAGE_GENERATE_PROVIDER\"" [[ -n "$IMAGE_GENERATE_MODEL" ]] && echo "NULLION_IMAGE_GENERATE_MODEL=\"$IMAGE_GENERATE_MODEL\"" [[ "$VIDEO_INPUT_ENABLED" == "true" ]] && echo "NULLION_VIDEO_INPUT_ENABLED=true" [[ -n "$VIDEO_INPUT_PROVIDER" ]] && echo "NULLION_VIDEO_INPUT_PROVIDER=\"$VIDEO_INPUT_PROVIDER\"" [[ -n "$VIDEO_INPUT_MODEL" ]] && echo "NULLION_VIDEO_INPUT_MODEL=\"$VIDEO_INPUT_MODEL\"" fi [[ -n "$ENABLED_SKILL_PACKS" ]] && echo "NULLION_ENABLED_SKILL_PACKS=\"$ENABLED_SKILL_PACKS\"" if [[ -n "$ENABLED_SKILL_PACKS" ]]; then echo "NULLION_SKILL_PACK_ACCESS_ENABLED=true" fi if [[ ",$ENABLED_SKILL_PACKS," == *",nullion/connector-skills,"* || "$ENABLED_SKILL_PACKS" == *"api-gateway"* ]]; then echo "NULLION_CONNECTOR_ACCESS_ENABLED=true" fi echo "NULLION_LOG_LEVEL=INFO" } > "$NULLION_ENV_FILE" chmod 600 "$NULLION_ENV_FILE" print_ok "Configuration saved to $NULLION_ENV_FILE" # ── Step 4: Auto-start ──────────────────────────────────────────────────── print_header "Step 4 of 4 — Auto-start" echo if [[ "$PLATFORM" == "macos" ]]; then echo " Nullion can start automatically when you log in to your Mac." if [[ "$TELEGRAM_ENABLED" == "true" ]]; then echo " This will register the web dashboard, menu bar icon, and your Telegram operator bot." else echo " This will register the web dashboard and menu bar icon." fi echo " This uses launchd — the standard macOS service manager." echo if confirm_yes "Set up auto-start at login?"; then mkdir -p "$(dirname "$LAUNCHD_PLIST")" cat > "$LAUNCHD_PLIST" << PLIST Label $(xml_escape "$LAUNCHD_LABEL") ProgramArguments $(xml_escape "${VENV_DIR}/bin/python") -m nullion.web_app --port $(xml_escape "$NULLION_WEB_PORT") EnvironmentVariables NULLION_ENV_FILE $(xml_escape "$NULLION_ENV_FILE") PATH $(xml_escape "${VENV_DIR}/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin") WorkingDirectory $(xml_escape "$NULLION_INSTALL_DIR") StandardOutPath $(xml_escape "${NULLION_LOG_DIR}/nullion.log") StandardErrorPath $(xml_escape "${NULLION_LOG_DIR}/nullion-error.log") RunAtLoad KeepAlive ThrottleInterval 10 PLIST WEB_LAUNCHD_CONFIGURED=false if launchd_register_agent "$LAUNCHD_LABEL" "$LAUNCHD_PLIST" "Web dashboard"; then print_ok "Web auto-start registered via launchd." WEB_LAUNCHD_CONFIGURED=true fi cat > "$TRAY_LAUNCHD_PLIST" << PLIST Label $(xml_escape "$TRAY_LAUNCHD_LABEL") ProgramArguments $(xml_escape "${VENV_DIR}/bin/nullion-tray") --port $(xml_escape "$NULLION_WEB_PORT") --env-file $(xml_escape "$NULLION_ENV_FILE") EnvironmentVariables NULLION_ENV_FILE $(xml_escape "$NULLION_ENV_FILE") PATH $(xml_escape "${VENV_DIR}/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin") WorkingDirectory $(xml_escape "$NULLION_INSTALL_DIR") StandardOutPath $(xml_escape "${NULLION_LOG_DIR}/tray.log") StandardErrorPath $(xml_escape "${NULLION_LOG_DIR}/tray-error.log") RunAtLoad KeepAlive SuccessfulExit ThrottleInterval 10 PLIST TRAY_LAUNCHD_CONFIGURED=false if launchd_register_agent "$TRAY_LAUNCHD_LABEL" "$TRAY_LAUNCHD_PLIST" "Menu bar icon"; then print_ok "Menu bar icon registered via launchd." MACOS_TRAY_CONFIGURED=true TRAY_LAUNCHD_CONFIGURED=true fi if [[ "$TELEGRAM_ENABLED" == "true" ]]; then cat > "$TELEGRAM_LAUNCHD_PLIST" << PLIST Label $(xml_escape "$TELEGRAM_LAUNCHD_LABEL") ProgramArguments $(xml_escape "${VENV_DIR}/bin/nullion-telegram") --checkpoint $(xml_escape "${NULLION_INSTALL_DIR}/runtime-store.json") --env-file $(xml_escape "$NULLION_ENV_FILE") EnvironmentVariables NULLION_ENV_FILE $(xml_escape "$NULLION_ENV_FILE") PATH $(xml_escape "${VENV_DIR}/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin") WorkingDirectory $(xml_escape "$NULLION_INSTALL_DIR") StandardOutPath $(xml_escape "${NULLION_LOG_DIR}/telegram.log") StandardErrorPath $(xml_escape "${NULLION_LOG_DIR}/telegram.error.log") RunAtLoad KeepAlive SuccessfulExit ThrottleInterval 5 PLIST TELEGRAM_LAUNCHD_CONFIGURED=false if launchd_register_agent "$TELEGRAM_LAUNCHD_LABEL" "$TELEGRAM_LAUNCHD_PLIST" "Telegram operator"; then print_ok "Telegram auto-start registered via launchd." TELEGRAM_LAUNCHD_CONFIGURED=true fi else launchctl bootout "gui/$(id -u)/${TELEGRAM_LAUNCHD_LABEL}" >/dev/null 2>&1 || true rm -f "$TELEGRAM_LAUNCHD_PLIST" print_info "Telegram auto-start disabled." fi if [[ "$SLACK_ENABLED" == "true" ]]; then write_chat_launchd_plist "$SLACK_LAUNCHD_PLIST" "$SLACK_LAUNCHD_LABEL" "nullion-slack" "slack" 5 SLACK_LAUNCHD_CONFIGURED=false if launchd_register_agent "$SLACK_LAUNCHD_LABEL" "$SLACK_LAUNCHD_PLIST" "Slack adapter"; then print_ok "Slack auto-start registered via launchd." SLACK_LAUNCHD_CONFIGURED=true fi else launchctl bootout "gui/$(id -u)/${SLACK_LAUNCHD_LABEL}" >/dev/null 2>&1 || true rm -f "$SLACK_LAUNCHD_PLIST" fi if [[ "$DISCORD_ENABLED" == "true" ]]; then write_chat_launchd_plist "$DISCORD_LAUNCHD_PLIST" "$DISCORD_LAUNCHD_LABEL" "nullion-discord" "discord" 5 DISCORD_LAUNCHD_CONFIGURED=false if launchd_register_agent "$DISCORD_LAUNCHD_LABEL" "$DISCORD_LAUNCHD_PLIST" "Discord adapter"; then print_ok "Discord auto-start registered via launchd." DISCORD_LAUNCHD_CONFIGURED=true fi else launchctl bootout "gui/$(id -u)/${DISCORD_LAUNCHD_LABEL}" >/dev/null 2>&1 || true rm -f "$DISCORD_LAUNCHD_PLIST" fi AUTOSTART_STOP_CMD="launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL} && launchctl bootout gui/$(id -u)/${TRAY_LAUNCHD_LABEL} && launchctl bootout gui/$(id -u)/${TELEGRAM_LAUNCHD_LABEL} && launchctl bootout gui/$(id -u)/${SLACK_LAUNCHD_LABEL} && launchctl bootout gui/$(id -u)/${DISCORD_LAUNCHD_LABEL}" if [[ "$WEB_LAUNCHD_CONFIGURED" == "true" || "$TRAY_LAUNCHD_CONFIGURED" == "true" || "${TELEGRAM_LAUNCHD_CONFIGURED:-false}" == "true" || "${SLACK_LAUNCHD_CONFIGURED:-false}" == "true" || "${DISCORD_LAUNCHD_CONFIGURED:-false}" == "true" ]]; then AUTOSTART_CONFIGURED=true else AUTOSTART_CONFIGURED=false print_info "Auto-start was not registered. You can still start Nullion manually below." fi else AUTOSTART_CONFIGURED=false fi elif [[ "$PLATFORM" == "linux" ]]; then echo " Nullion can start automatically when you log in." if [[ "$TELEGRAM_ENABLED" == "true" ]]; then echo " This will register the web dashboard and your Telegram operator bot." else echo " This will register the web dashboard." fi echo " This uses systemd user services — no root required." echo if confirm_yes "Set up auto-start at login?"; then # Ensure systemd user session is available if ! command_exists systemctl; then print_err "systemctl not found. Is systemd running?" print_info "Skipping auto-start. You can set this up manually later." AUTOSTART_CONFIGURED=false else mkdir -p "$SYSTEMD_USER_DIR" cat > "${SYSTEMD_USER_DIR}/${SYSTEMD_SERVICE}" << UNIT [Unit] Description=Nullion Web Dashboard After=network-online.target Wants=network-online.target [Service] Type=simple ExecStart=${VENV_DIR}/bin/nullion-web --port ${NULLION_WEB_PORT} EnvironmentFile=${NULLION_ENV_FILE} WorkingDirectory=${NULLION_INSTALL_DIR} StandardOutput=append:${NULLION_LOG_DIR}/nullion.log StandardError=append:${NULLION_LOG_DIR}/nullion-error.log Restart=on-failure RestartSec=10 StartLimitIntervalSec=120 StartLimitBurst=5 [Install] WantedBy=default.target UNIT # Enable lingering so service starts at boot even without interactive login if command_exists loginctl; then loginctl enable-linger "$USER" 2>/dev/null || true fi systemctl --user daemon-reload systemctl --user enable "$SYSTEMD_SERVICE" print_ok "Web auto-start registered via systemd user service." if [[ "$TELEGRAM_ENABLED" == "true" ]]; then write_chat_systemd_unit "${SYSTEMD_USER_DIR}/${TELEGRAM_SYSTEMD_SERVICE}" "Nullion Telegram Operator" "nullion-telegram" "telegram" 5 systemctl --user daemon-reload systemctl --user enable "$TELEGRAM_SYSTEMD_SERVICE" systemctl --user restart "$TELEGRAM_SYSTEMD_SERVICE" 2>/dev/null || true print_ok "Telegram auto-start registered via systemd user service." else rm -f "${SYSTEMD_USER_DIR}/${TELEGRAM_SYSTEMD_SERVICE}" fi if [[ "$SLACK_ENABLED" == "true" ]]; then write_chat_systemd_unit "${SYSTEMD_USER_DIR}/${SLACK_SYSTEMD_SERVICE}" "Nullion Slack Adapter" "nullion-slack" "slack" 5 systemctl --user daemon-reload systemctl --user enable "$SLACK_SYSTEMD_SERVICE" systemctl --user restart "$SLACK_SYSTEMD_SERVICE" 2>/dev/null || true print_ok "Slack auto-start registered via systemd user service." else rm -f "${SYSTEMD_USER_DIR}/${SLACK_SYSTEMD_SERVICE}" fi if [[ "$DISCORD_ENABLED" == "true" ]]; then write_chat_systemd_unit "${SYSTEMD_USER_DIR}/${DISCORD_SYSTEMD_SERVICE}" "Nullion Discord Adapter" "nullion-discord" "discord" 5 systemctl --user daemon-reload systemctl --user enable "$DISCORD_SYSTEMD_SERVICE" systemctl --user restart "$DISCORD_SYSTEMD_SERVICE" 2>/dev/null || true print_ok "Discord auto-start registered via systemd user service." else rm -f "${SYSTEMD_USER_DIR}/${DISCORD_SYSTEMD_SERVICE}" fi systemctl --user daemon-reload print_info "Nullion will start when you log in (lingering enabled)." AUTOSTART_STOP_CMD="systemctl --user stop ${SYSTEMD_SERVICE} ${TELEGRAM_SYSTEMD_SERVICE} ${SLACK_SYSTEMD_SERVICE} ${DISCORD_SYSTEMD_SERVICE} && systemctl --user disable ${SYSTEMD_SERVICE} ${TELEGRAM_SYSTEMD_SERVICE} ${SLACK_SYSTEMD_SERVICE} ${DISCORD_SYSTEMD_SERVICE}" AUTOSTART_CONFIGURED=true fi else AUTOSTART_CONFIGURED=false fi fi if [[ "${AUTOSTART_CONFIGURED:-false}" == "false" ]]; then print_info "Skipped auto-start. You can start Nullion manually any time:" echo echo -e " ${CYAN}source ${NULLION_ENV_FILE} && ${VENV_DIR}/bin/nullion-web --port ${NULLION_WEB_PORT}${RESET}" if [[ "$TELEGRAM_ENABLED" == "true" ]]; then echo print_info "Telegram was configured but not registered for auto-start. Start it manually with:" echo echo -e " ${CYAN}${VENV_DIR}/bin/nullion-telegram --checkpoint ${NULLION_INSTALL_DIR}/runtime-store.json --env-file ${NULLION_ENV_FILE}${RESET}" fi if [[ "$SLACK_ENABLED" == "true" ]]; then echo print_info "Slack was configured. Start it manually with:" echo echo -e " ${CYAN}${VENV_DIR}/bin/nullion-slack --checkpoint ${NULLION_INSTALL_DIR}/runtime-store.json --env-file ${NULLION_ENV_FILE}${RESET}" fi if [[ "$DISCORD_ENABLED" == "true" ]]; then echo print_info "Discord was configured. Start it manually with:" echo echo -e " ${CYAN}${VENV_DIR}/bin/nullion-discord --checkpoint ${NULLION_INSTALL_DIR}/runtime-store.json --env-file ${NULLION_ENV_FILE}${RESET}" fi echo fi # ── Start now ───────────────────────────────────────────────────────────── echo print_header "All done!" echo print_ok "Nullion v${NULLION_VERSION} is installed." echo if [[ "${MACOS_TRAY_CONFIGURED:-false}" == "true" && "$PLATFORM" == "macos" ]]; then open_native_webview_now elif confirm "Open Nullion in your browser now?"; then # If autostart is configured on Linux, use systemctl to start if [[ "${AUTOSTART_CONFIGURED:-false}" == "true" && "$PLATFORM" == "linux" ]]; then systemctl --user start "$SYSTEMD_SERVICE" sleep 2 if systemctl --user is-active --quiet "$SYSTEMD_SERVICE"; then print_ok "Nullion is running!" else print_err "Nullion failed to start. Check the log:" echo " journalctl --user -u $SYSTEMD_SERVICE -n 50" echo " tail -50 $NULLION_LOG_DIR/nullion-error.log" fi else print_info "Starting Nullion..." set -a # shellcheck source=/dev/null source "$NULLION_ENV_FILE" set +a nohup "$VENV_DIR/bin/nullion-web" --port "${NULLION_WEB_PORT}" \ >> "$NULLION_LOG_DIR/nullion.log" \ 2>> "$NULLION_LOG_DIR/nullion-error.log" & WEB_PID=$! sleep 2 if kill -0 "$WEB_PID" 2>/dev/null; then print_ok "Nullion is running (PID $WEB_PID)" else print_err "Nullion exited unexpectedly. Check the log:" echo " tail -50 $NULLION_LOG_DIR/nullion-error.log" fi fi # Open browser echo echo -e " ${BOLD}${GREEN}→ http://localhost:${NULLION_WEB_PORT}${RESET}" echo if [[ "$PLATFORM" == "macos" ]]; then open "http://localhost:${NULLION_WEB_PORT}" 2>/dev/null || true else xdg-open "http://localhost:${NULLION_WEB_PORT}" 2>/dev/null || true fi else echo print_info "To start manually:" echo -e " ${CYAN}source ${NULLION_ENV_FILE} && ${VENV_DIR}/bin/nullion-web --port ${NULLION_WEB_PORT}${RESET}" echo echo -e " Then open: ${BOLD}${GREEN}http://localhost:${NULLION_WEB_PORT}${RESET}" echo fi if [[ -n "${BROWSER_EXTRA_NOTE:-}" ]]; then echo echo -e "${YELLOW} Browser note:${RESET}" echo -e " $BROWSER_EXTRA_NOTE" fi # ── Add venv to PATH in shell profile ──────────────────────────────────────── PATH_LINE="export PATH=\"\$HOME/.nullion/venv/bin:\$PATH\"" SHELL_PROFILE="" if [[ "$PLATFORM" == "macos" ]]; then # zsh is the default on macOS Catalina+ if [[ -f "$HOME/.zshrc" ]]; then SHELL_PROFILE="$HOME/.zshrc" elif [[ -f "$HOME/.bash_profile" ]]; then SHELL_PROFILE="$HOME/.bash_profile" fi else if [[ -f "$HOME/.bashrc" ]]; then SHELL_PROFILE="$HOME/.bashrc" elif [[ -f "$HOME/.profile" ]]; then SHELL_PROFILE="$HOME/.profile" fi fi if [[ -n "$SHELL_PROFILE" ]]; then if ! grep -qF ".nullion/venv/bin" "$SHELL_PROFILE" 2>/dev/null; then echo "" >> "$SHELL_PROFILE" echo "# Nullion CLI tools" >> "$SHELL_PROFILE" echo "$PATH_LINE" >> "$SHELL_PROFILE" print_ok "Added nullion to PATH in $SHELL_PROFILE" echo -e " Run ${CYAN}source $SHELL_PROFILE${RESET} or open a new terminal to use ${BOLD}nullion${RESET} directly." else print_ok "nullion already in PATH ($SHELL_PROFILE)" fi fi echo echo -e " Logs: ${CYAN}${NULLION_LOG_DIR}/nullion.log${RESET}" echo -e " Config: ${CYAN}${NULLION_ENV_FILE}${RESET}" if [[ -n "${AUTOSTART_STOP_CMD:-}" ]]; then echo -e " To stop: ${CYAN}${AUTOSTART_STOP_CMD}${RESET}" fi echo