Convert all setup prompts to whiptail, fix VRAM estimates, add model expectations

Setup script changes:
- All prompts now use whiptail dialogs with text fallback
- Q1b (SSH), Q2 (storage), Q3 (Kiwix), Q4b (firewall), Q5 (models),
  Q6 (download), final confirm all converted
- Model tier selection uses radiolist with recommended tier pre-selected
- Custom model entry uses inputbox with current defaults pre-filled
- Fix speed_label: now shows actual VRAM needed (file size + 2GB overhead)
  instead of misleading "fully in VRAM" for models that don't fit
- qwen3.5-35b-a3b MoE already in tier list (was there, now with accurate
  VRAM estimate shown)

README changes:
- Add "Realistic expectations by model size" table
- 35B MoE highlighted as sweet spot for small GPUs

https://claude.ai/code/session_01PtYTPherSJaxDEVPgF6Nxu
This commit is contained in:
Claude
2026-03-22 19:39:39 +00:00
parent b3985ea053
commit 3bd714960f
2 changed files with 246 additions and 103 deletions
+223 -103
View File
@@ -217,15 +217,21 @@ fi
SSH_IMPORT_IDS=() # list of "gh:username" or "lp:username" entries
if ! $IS_UPDATE || [[ ! -f "$HOME/.ssh/authorized_keys" ]]; then
echo ""
echo -e " ${BOLD}[SSH] Import SSH public keys? (for passwordless SSH into this machine)${NC}"
echo " Pulls your public keys from GitHub or Launchpad and adds them to"
echo " ~/.ssh/authorized_keys using ssh-import-id."
echo ""
echo " Examples: gh:yourusername lp:yourlaunchpadid"
echo " Multiple: gh:alice lp:alice"
echo ""
read -rp " Usernames (or Enter to skip): " SSH_INPUT
if command -v whiptail &>/dev/null; then
SSH_INPUT=$(whiptail --title "SSH Key Import" \
--inputbox "Import SSH public keys for passwordless SSH into this machine.\n\nExamples: gh:yourusername lp:yourlaunchpadid\nMultiple: gh:alice lp:alice\n\nLeave blank to skip." \
14 68 "" 3>&1 1>&2 2>&3) || SSH_INPUT=""
else
echo ""
echo -e " ${BOLD}[SSH] Import SSH public keys? (for passwordless SSH into this machine)${NC}"
echo " Pulls your public keys from GitHub or Launchpad and adds them to"
echo " ~/.ssh/authorized_keys using ssh-import-id."
echo ""
echo " Examples: gh:yourusername lp:yourlaunchpadid"
echo " Multiple: gh:alice lp:alice"
echo ""
read -rp " Usernames (or Enter to skip): " SSH_INPUT
fi
if [[ -n "$SSH_INPUT" ]]; then
read -ra SSH_IMPORT_IDS <<< "$SSH_INPUT"
fi
@@ -236,10 +242,6 @@ OLLAMA_STORAGE="volume" # "volume" = Docker named volume, else a host path
OLLAMA_HOST_PATH=""
if $INSTALL_AI; then
echo ""
echo -e " ${BOLD}[2/6] Where should Ollama models be stored?${NC}"
echo " (Models are large — 5-50GB each. A fast SSD or large HDD is ideal.)"
echo ""
mapfile -t _MPTS < <(
df -h --output=target,avail,fstype 2>/dev/null \
| awk 'NR>1 && $2~/[0-9]/ {
@@ -248,37 +250,58 @@ if $INSTALL_AI; then
if ((unit=="G" && num>=20) || unit=="T") print $0
}' | head -10
)
echo " 0) Docker volume (default — /var/lib/docker/volumes/)"
for _i in "${!_MPTS[@]}"; do
printf " %d) %s\n" "$((_i+1))" "${_MPTS[$_i]}"
done
echo ""
read -rp " Choice [0]: " STORAGE_CHOICE
STORAGE_CHOICE="${STORAGE_CHOICE:-0}"
if [[ "$STORAGE_CHOICE" == "0" ]]; then
OLLAMA_STORAGE="volume"
elif [[ "$STORAGE_CHOICE" =~ ^[0-9]+$ ]] && (( STORAGE_CHOICE >= 1 && STORAGE_CHOICE <= ${#_MPTS[@]} )); then
_MP=$(awk '{print $1}' <<< "${_MPTS[$(( STORAGE_CHOICE-1 ))]}")
OLLAMA_HOST_PATH="$_MP/ollama-models"
OLLAMA_STORAGE="bind"
ok "Ollama models → $OLLAMA_HOST_PATH"
if command -v whiptail &>/dev/null; then
WHIP_STORAGE=("volume" "Docker volume (default — /var/lib/docker/volumes/)" ON)
for _i in "${!_MPTS[@]}"; do
_mp_path=$(awk '{print $1}' <<< "${_MPTS[$_i]}")
_mp_avail=$(awk '{print $2}' <<< "${_MPTS[$_i]}")
WHIP_STORAGE+=("$_mp_path" "${_mp_path} (${_mp_avail} free)" OFF)
done
STORAGE_CHOICE=$(whiptail --title "Ollama Model Storage" \
--radiolist "Models are large (5-50GB each). Choose a location.\nSPACE = select ENTER = confirm" \
$((10 + ${#_MPTS[@]})) 72 $((1 + ${#_MPTS[@]})) \
"${WHIP_STORAGE[@]}" 3>&1 1>&2 2>&3) || STORAGE_CHOICE="volume"
STORAGE_CHOICE="${STORAGE_CHOICE//\"/}"
if [[ "$STORAGE_CHOICE" == "volume" ]]; then
OLLAMA_STORAGE="volume"
else
OLLAMA_HOST_PATH="${STORAGE_CHOICE}/ollama-models"
OLLAMA_STORAGE="bind"
ok "Ollama models → $OLLAMA_HOST_PATH"
fi
else
# Fallback: treat input as a literal path
OLLAMA_HOST_PATH="${STORAGE_CHOICE%/}"
[[ -z "$OLLAMA_HOST_PATH" ]] && die "No path entered."
OLLAMA_STORAGE="bind"
echo ""
echo -e " ${BOLD}[2/6] Where should Ollama models be stored?${NC}"
echo " (Models are large — 5-50GB each. A fast SSD or large HDD is ideal.)"
echo ""
echo " 0) Docker volume (default — /var/lib/docker/volumes/)"
for _i in "${!_MPTS[@]}"; do
printf " %d) %s\n" "$((_i+1))" "${_MPTS[$_i]}"
done
echo ""
read -rp " Choice [0]: " STORAGE_CHOICE
STORAGE_CHOICE="${STORAGE_CHOICE:-0}"
if [[ "$STORAGE_CHOICE" == "0" ]]; then
OLLAMA_STORAGE="volume"
elif [[ "$STORAGE_CHOICE" =~ ^[0-9]+$ ]] && (( STORAGE_CHOICE >= 1 && STORAGE_CHOICE <= ${#_MPTS[@]} )); then
_MP=$(awk '{print $1}' <<< "${_MPTS[$(( STORAGE_CHOICE-1 ))]}")
OLLAMA_HOST_PATH="$_MP/ollama-models"
OLLAMA_STORAGE="bind"
ok "Ollama models → $OLLAMA_HOST_PATH"
else
OLLAMA_HOST_PATH="${STORAGE_CHOICE%/}"
[[ -z "$OLLAMA_HOST_PATH" ]] && die "No path entered."
OLLAMA_STORAGE="bind"
fi
fi
unset _MPTS _MP _i
unset _MPTS _MP _i WHIP_STORAGE
fi
# ── Q3: Storage for Kiwix ZIMs ────────────────────────────────────────────────
KIWIX_DIR="$BASE/kiwix" # default
if $SVC_KIWIX; then
echo ""
echo -e " ${BOLD}[3/6] Where should Kiwix ZIM files be stored?${NC}"
echo " (ZIMs are large — Wikipedia alone is ~46GB. Total collection ~130GB.)"
echo ""
mapfile -t _MPTS < <(
df -h --output=target,avail,fstype 2>/dev/null \
| awk 'NR>1 && $2~/[0-9]/ {
@@ -287,22 +310,44 @@ if $SVC_KIWIX; then
if ((unit=="G" && num>=50) || unit=="T") print $0
}' | head -10
)
echo " 0) Default: $KIWIX_DIR"
for _i in "${!_MPTS[@]}"; do
printf " %d) %s\n" "$((_i+1))" "${_MPTS[$_i]}"
done
echo ""
read -rp " Choice [0]: " KIWIX_CHOICE
KIWIX_CHOICE="${KIWIX_CHOICE:-0}"
if [[ "$KIWIX_CHOICE" != "0" ]] && [[ "$KIWIX_CHOICE" =~ ^[0-9]+$ ]] && (( KIWIX_CHOICE >= 1 && KIWIX_CHOICE <= ${#_MPTS[@]} )); then
_MP=$(awk '{print $1}' <<< "${_MPTS[$(( KIWIX_CHOICE-1 ))]}")
KIWIX_DIR="$_MP/kiwix"
ok "Kiwix ZIMs → $KIWIX_DIR"
elif [[ "$KIWIX_CHOICE" != "0" ]] && [[ -n "$KIWIX_CHOICE" ]]; then
# Fallback: treat as a literal path
KIWIX_DIR="${KIWIX_CHOICE%/}"
if command -v whiptail &>/dev/null; then
WHIP_KIWIX=("default" "Default: $KIWIX_DIR" ON)
for _i in "${!_MPTS[@]}"; do
_mp_path=$(awk '{print $1}' <<< "${_MPTS[$_i]}")
_mp_avail=$(awk '{print $2}' <<< "${_MPTS[$_i]}")
WHIP_KIWIX+=("$_mp_path" "${_mp_path} (${_mp_avail} free)" OFF)
done
_KIWIX_SEL=$(whiptail --title "Kiwix ZIM Storage" \
--radiolist "ZIMs are large — Wikipedia alone ~46GB, total ~130GB.\nSPACE = select ENTER = confirm" \
$((10 + ${#_MPTS[@]})) 72 $((1 + ${#_MPTS[@]})) \
"${WHIP_KIWIX[@]}" 3>&1 1>&2 2>&3) || _KIWIX_SEL="default"
_KIWIX_SEL="${_KIWIX_SEL//\"/}"
if [[ "$_KIWIX_SEL" != "default" ]]; then
KIWIX_DIR="${_KIWIX_SEL}/kiwix"
ok "Kiwix ZIMs → $KIWIX_DIR"
fi
else
echo ""
echo -e " ${BOLD}[3/6] Where should Kiwix ZIM files be stored?${NC}"
echo " (ZIMs are large — Wikipedia alone is ~46GB. Total collection ~130GB.)"
echo ""
echo " 0) Default: $KIWIX_DIR"
for _i in "${!_MPTS[@]}"; do
printf " %d) %s\n" "$((_i+1))" "${_MPTS[$_i]}"
done
echo ""
read -rp " Choice [0]: " KIWIX_CHOICE
KIWIX_CHOICE="${KIWIX_CHOICE:-0}"
if [[ "$KIWIX_CHOICE" != "0" ]] && [[ "$KIWIX_CHOICE" =~ ^[0-9]+$ ]] && (( KIWIX_CHOICE >= 1 && KIWIX_CHOICE <= ${#_MPTS[@]} )); then
_MP=$(awk '{print $1}' <<< "${_MPTS[$(( KIWIX_CHOICE-1 ))]}")
KIWIX_DIR="$_MP/kiwix"
ok "Kiwix ZIMs → $KIWIX_DIR"
elif [[ "$KIWIX_CHOICE" != "0" ]] && [[ -n "$KIWIX_CHOICE" ]]; then
KIWIX_DIR="${KIWIX_CHOICE%/}"
fi
fi
unset _MPTS _MP _i _KIWIX_CHOICE
unset _MPTS _MP _i _KIWIX_CHOICE WHIP_KIWIX
# ── Q4: Download ZIMs now? ─────────────────────────────────────────────────
# ON if any matching ZIM file already exists in KIWIX_DIR
@@ -447,11 +492,16 @@ fi
# ── Q4b: Firewall (LAN subnet) ────────────────────────────────────────────────
LAN_SUBNET="192.168.1.0/24"
if command -v ufw &>/dev/null && { [[ ! -f "$BASE/.ufw-done" ]] || $FORCE; }; then
echo ""
# Auto-detect likely subnet from current IP
AUTO_SUBNET=$(echo "$LOCAL_IP" | awk -F. '{print $1"."$2"."$3".0/24"}')
echo -e " ${BOLD}[4b/6] Firewall — allow LAN access to services${NC}"
read -rp " LAN subnet [${AUTO_SUBNET}]: " LAN_INPUT
if command -v whiptail &>/dev/null; then
LAN_INPUT=$(whiptail --title "Firewall — LAN Access" \
--inputbox "Allow LAN access to all services.\n\nYour detected subnet:" \
10 60 "$AUTO_SUBNET" 3>&1 1>&2 2>&3) || LAN_INPUT=""
else
echo ""
echo -e " ${BOLD}[4b/6] Firewall — allow LAN access to services${NC}"
read -rp " LAN subnet [${AUTO_SUBNET}]: " LAN_INPUT
fi
LAN_SUBNET="${LAN_INPUT:-$AUTO_SUBNET}"
[[ "$LAN_SUBNET" =~ /[0-9]+$ ]] || LAN_SUBNET="${LAN_SUBNET}/24"
fi
@@ -462,35 +512,48 @@ REASON_MODEL=""
if $INSTALL_AI; then
# speed estimate based on Q4 model size vs available VRAM
# no hard limits — just honest labels so the user can choose
# speed estimate: model_file_size + ~1.5GB overhead for KV cache/CUDA vs VRAM
# Ollama has no hard limits — silently offloads to CPU when over VRAM
speed_label() {
local mgb="$1" # approximate Q4 size in GB
local mgb="$1" # approximate Q4 file size in GB
local needed=$(( mgb + 2 )) # +2GB for KV cache, attention, CUDA overhead
if [[ "$VRAM_GB" -eq 0 ]]; then
printf "CPU only — very slow"
elif (( mgb <= VRAM_GB )); then
printf "✓ fast — fully in VRAM"
elif (( mgb <= VRAM_GB + 2 )); then
printf "~ good — fits with small overhang (~reading speed)"
elif (( mgb <= VRAM_GB + 8 )); then
printf "✗ slow — partial CPU offload"
elif (( needed <= VRAM_GB )); then
printf "✓ fast — fits in VRAM (~%dGB needed)" "$needed"
elif (( needed <= VRAM_GB + 2 )); then
printf "~ tight — ~%dGB needed, may spill to CPU" "$needed"
elif (( needed <= VRAM_GB + 8 )); then
printf "✗ slow — ~%dGB needed, partial CPU offload" "$needed"
else
printf "✗ very slow — heavy CPU offload"
printf "✗ very slow — ~%dGB needed, heavy CPU offload" "$needed"
fi
}
echo ""
echo -e " ${BOLD}[5/6] Model selection${NC}"
echo " GPU: ${GPU_NAME:-None} (${VRAM_GB}GB VRAM)"
echo ""
echo " Origin preference:"
echo " 1) Western-only — Codestral (Mistral 🇫🇷) · Phi4 (Microsoft 🇺🇸) · Mistral 7B"
echo " 2) Performance-first Qwen 3.5 (Feb 2026, top benchmarks, vision+code)"
echo " 3) Mixed — Western for chat, Qwen 3.5 for coding"
echo " 4) Custom — enter model names manually"
echo ""
read -rp " Choice [2]: " MODEL_PREF
MODEL_PREF="${MODEL_PREF:-2}"
if command -v whiptail &>/dev/null; then
MODEL_PREF=$(whiptail --title "Model Selection${GPU_NAME:-No GPU} (${VRAM_GB}GB VRAM)" \
--radiolist "Choose model origin preference.\nSPACE = select ENTER = confirm" \
14 78 4 \
"2" "Performance-first — Qwen 3.5 (top benchmarks, vision+code)" ON \
"1" "Western-only — Codestral · Phi4 · Mistral 7B" OFF \
"3" "Mixed — Western chat + Qwen 3.5 coding" OFF \
"4" "Custom — enter model names manually" OFF \
3>&1 1>&2 2>&3) || MODEL_PREF="2"
MODEL_PREF="${MODEL_PREF//\"/}"
else
echo ""
echo -e " ${BOLD}[5/6] Model selection${NC}"
echo " GPU: ${GPU_NAME:-None} (${VRAM_GB}GB VRAM)"
echo ""
echo " Origin preference:"
echo " 1) Western-only — Codestral (Mistral) · Phi4 (Microsoft) · Mistral 7B"
echo " 2) Performance-first — Qwen 3.5 (Feb 2026, top benchmarks, vision+code)"
echo " 3) Mixed — Western for chat, Qwen 3.5 for coding"
echo " 4) Custom — enter model names manually"
echo ""
read -rp " Choice [2]: " MODEL_PREF
MODEL_PREF="${MODEL_PREF:-2}"
fi
# Q4_K_M approximate weights in GB: 4B=2.5, 7B=4, 9B=5.5, 14B=9, 22B=13, 27B=17, 35B-MoE=12, 70B=41
@@ -554,21 +617,59 @@ if $INSTALL_AI; then
if [[ "$MODEL_PREF" == "4" ]]; then
# Custom — free-form entry
echo " Current defaults: fast=$FAST_MODEL chat=$CHAT_MODEL code=$CODE_MODEL"
echo " Press Enter on any line to keep the default shown."
echo ""
read -rp " Fast/chat model [$FAST_MODEL]: " _in; FAST_MODEL="${_in:-$FAST_MODEL}"
read -rp " Smart chat model [$CHAT_MODEL]: " _in; CHAT_MODEL="${_in:-$CHAT_MODEL}"
read -rp " Code model [$CODE_MODEL]: " _in; CODE_MODEL="${_in:-$CODE_MODEL}"
read -rp " Reasoning model (Enter to skip): " REASON_MODEL
else
read -rp " Choose tier [$REC_NUM]: " _TIER_INPUT
_TIER_INPUT="${_TIER_INPUT:-$REC_NUM}"
# Accept either a number (1-4) or the tier name directly
if [[ "$_TIER_INPUT" =~ ^[1-4]$ ]]; then
TIER_PICK="${_TIER_NAMES[$((_TIER_INPUT-1))]}"
if command -v whiptail &>/dev/null; then
_in=$(whiptail --title "Custom Models — Fast/Chat" \
--inputbox "Fast/chat model (small, quick responses):" \
9 60 "$FAST_MODEL" 3>&1 1>&2 2>&3) && FAST_MODEL="${_in:-$FAST_MODEL}"
_in=$(whiptail --title "Custom Models — Smart Chat" \
--inputbox "Smart chat model (main model for complex tasks):" \
9 60 "$CHAT_MODEL" 3>&1 1>&2 2>&3) && CHAT_MODEL="${_in:-$CHAT_MODEL}"
_in=$(whiptail --title "Custom Models — Code" \
--inputbox "Code model:" \
9 60 "$CODE_MODEL" 3>&1 1>&2 2>&3) && CODE_MODEL="${_in:-$CODE_MODEL}"
REASON_MODEL=$(whiptail --title "Custom Models — Reasoning" \
--inputbox "Reasoning model (leave blank to skip):" \
9 60 "" 3>&1 1>&2 2>&3) || REASON_MODEL=""
else
TIER_PICK="$_TIER_INPUT"
echo " Current defaults: fast=$FAST_MODEL chat=$CHAT_MODEL code=$CODE_MODEL"
echo " Press Enter on any line to keep the default shown."
echo ""
read -rp " Fast/chat model [$FAST_MODEL]: " _in; FAST_MODEL="${_in:-$FAST_MODEL}"
read -rp " Smart chat model [$CHAT_MODEL]: " _in; CHAT_MODEL="${_in:-$CHAT_MODEL}"
read -rp " Code model [$CODE_MODEL]: " _in; CODE_MODEL="${_in:-$CODE_MODEL}"
read -rp " Reasoning model (Enter to skip): " REASON_MODEL
fi
else
if command -v whiptail &>/dev/null; then
# Build whiptail radiolist with recommended tier pre-selected
WHIP_TIERS=()
declare -A _TIER_LABELS
case "$MODEL_PREF" in
1) _TIER_LABELS=([7B]="mistral:7b + codellama:7b" [14B]="phi4:14b + starcoder2:15b" [22B]="phi4:14b + codestral:22b" [70B]="llama3.3:70b + codestral:22b")
_TIER_SPEEDS=([7B]="$(speed_label 4)" [14B]="$(speed_label 9)" [22B]="$(speed_label 13)" [70B]="$(speed_label 41)") ;;
2) _TIER_LABELS=([4B]="qwen3.5:4b (chat+code)" [9B]="qwen3.5:9b (chat+code)" [35B]="qwen3.5-35b-a3b (MoE, 3B active)" [27B]="qwen3.5:27b (dense)")
_TIER_SPEEDS=([4B]="$(speed_label 2)" [9B]="$(speed_label 5)" [35B]="$(speed_label 12)" [27B]="$(speed_label 17)") ;;
3) _TIER_LABELS=([7B]="mistral:7b + qwen3.5:4b" [14B]="phi4:14b + qwen3.5:9b" [35B]="phi4:14b + qwen3.5-35b-a3b" [70B]="llama3.3:70b + qwen3.5-35b-a3b")
_TIER_SPEEDS=([7B]="$(speed_label 4)" [14B]="$(speed_label 9)" [35B]="$(speed_label 19)" [70B]="$(speed_label 41)") ;;
esac
for _tn in "${_TIER_NAMES[@]}"; do
_onoff="OFF"; [[ "$_tn" == "$REC_TIER" ]] && _onoff="ON"
WHIP_TIERS+=("$_tn" "${_TIER_LABELS[$_tn]} | ${_TIER_SPEEDS[$_tn]}" "$_onoff")
done
TIER_PICK=$(whiptail --title "Model Size — ${VRAM_GB}GB GPU" \
--radiolist "Recommended tier pre-selected based on your GPU.\nSPACE = select ENTER = confirm" \
14 90 4 \
"${WHIP_TIERS[@]}" 3>&1 1>&2 2>&3) || TIER_PICK="$REC_TIER"
TIER_PICK="${TIER_PICK//\"/}"
unset WHIP_TIERS _TIER_LABELS _TIER_SPEEDS
else
read -rp " Choose tier [$REC_NUM]: " _TIER_INPUT
_TIER_INPUT="${_TIER_INPUT:-$REC_NUM}"
if [[ "$_TIER_INPUT" =~ ^[1-4]$ ]]; then
TIER_PICK="${_TIER_NAMES[$((_TIER_INPUT-1))]}"
else
TIER_PICK="$_TIER_INPUT"
fi
fi
unset _TIER_NAMES _TIER_INPUT REC_NUM
@@ -594,16 +695,29 @@ if $INSTALL_AI; then
esac
fi
echo ""
echo " Models selected:"
printf " %-16s %s\n" "Fast chat:" "$FAST_MODEL"
printf " %-16s %s\n" "Smart chat:" "$CHAT_MODEL"
printf " %-16s %s\n" "Code:" "$CODE_MODEL"
[[ -n "$REASON_MODEL" ]] && printf " %-16s %s\n" "Reasoning:" "$REASON_MODEL"
printf " %-16s %s\n" "Embed (RAG):" "$EMBED_MODEL"
echo ""
read -rp " Download these models now? [Y/n]: " DO_PULL
[[ "${DO_PULL,,}" != "n" ]] && PULL_MODELS=true
# Show selected models and ask about download
_MODEL_SUMMARY="Fast chat: $FAST_MODEL\nSmart chat: $CHAT_MODEL\nCode: $CODE_MODEL"
[[ -n "$REASON_MODEL" ]] && _MODEL_SUMMARY+="\nReasoning: $REASON_MODEL"
_MODEL_SUMMARY+="\nEmbed (RAG): $EMBED_MODEL"
if command -v whiptail &>/dev/null; then
if whiptail --title "Download Models Now?" \
--yesno "Models selected:\n\n$_MODEL_SUMMARY\n\nDownload these models now? (can take 10-40 min)" \
14 60 3>&1 1>&2 2>&3; then
PULL_MODELS=true
fi
else
echo ""
echo " Models selected:"
printf " %-16s %s\n" "Fast chat:" "$FAST_MODEL"
printf " %-16s %s\n" "Smart chat:" "$CHAT_MODEL"
printf " %-16s %s\n" "Code:" "$CODE_MODEL"
[[ -n "$REASON_MODEL" ]] && printf " %-16s %s\n" "Reasoning:" "$REASON_MODEL"
printf " %-16s %s\n" "Embed (RAG):" "$EMBED_MODEL"
echo ""
read -rp " Download these models now? [Y/n]: " DO_PULL
[[ "${DO_PULL,,}" != "n" ]] && PULL_MODELS=true
fi
fi
@@ -642,8 +756,14 @@ if [[ "$ZIM_CHOICE" == "2" ]]; then
echo " ✓ Download ${_nzim} ZIM(s): $ZIM_PICKS"
fi
echo ""
read -rp " Proceed? [Y/n]: " CONFIRM
[[ "${CONFIRM,,}" == "n" ]] && echo "Aborted." && exit 0
if command -v whiptail &>/dev/null; then
whiptail --title "Ready to Install" \
--yesno "Everything above will be installed and configured.\n\nProceed?" \
9 50 3>&1 1>&2 2>&3 || { echo "Aborted."; exit 0; }
else
read -rp " Proceed? [Y/n]: " CONFIRM
[[ "${CONFIRM,,}" == "n" ]] && echo "Aborted." && exit 0
fi
echo ""
# ── helper: write only if missing (or --force) ────────────────────────────────