This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 962d5871 [tools]Import Wizard for Installation Setup (#599)
962d5871 is described below

commit 962d58713ac5237269f895fdc5e9815edf4e4102
Author: Jinkun Liu <[email protected]>
AuthorDate: Fri May 22 22:48:20 2026 +0800

    [tools]Import Wizard for Installation Setup (#599)
    
    Co-authored-by: WenjinXie <[email protected]>
---
 .github/workflows/ci.yml                           |   15 +
 tools/.rat-excludes                                |    4 +
 tools/install.sh                                   | 1886 ++++++++++++++++++++
 tools/test/.gitignore                              |    1 +
 tools/test/helpers/load.bash                       |   50 +
 tools/test/helpers/shim.bash                       |   88 +
 tools/test/integration/.gitkeep                    |    0
 tools/test/integration/bootstrap_gum_temp.bats     |  165 ++
 tools/test/integration/download_file.bats          |   88 +
 tools/test/integration/dry_run.bats                |   54 +
 tools/test/integration/dry_run_extra.bats          |   42 +
 tools/test/integration/err_trap.bats               |   39 +
 tools/test/integration/help.bats                   |   16 +
 .../test/integration/install_flink_agents_jar.bats |   98 +
 .../test/integration/install_flink_if_needed.bats  |  132 ++
 tools/test/integration/venv_dir_validation.bats    |   61 +
 tools/test/run.sh                                  |   53 +
 .../test/unit/detect_flink_version_from_home.bats  |  114 ++
 tools/test/unit/edit_plan_back.bats                |   69 +
 tools/test/unit/edit_plan_quote.bats               |   88 +
 tools/test/unit/is_snapshot_version.bats           |   43 +
 tools/test/unit/is_valid_tgz.bats                  |   44 +
 tools/test/unit/mark_explicit.bats                 |   31 +
 tools/test/unit/normalize_path.bats                |   64 +
 tools/test/unit/parse_args.bats                    |   82 +
 tools/test/unit/platform_detect.bats               |   98 +
 tools/test/unit/shim_self_test.bats                |   50 +
 tools/test/unit/show_install_plan.bats             |   74 +
 tools/test/unit/smoke.bats                         |   18 +
 tools/test/unit/ui_helpers.bats                    |   54 +
 tools/test/unit/validate_python_bin.bats           |   73 +
 tools/test/unit/validate_venv_dir.bats             |   83 +
 32 files changed, 3777 insertions(+)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 484f0e48..198c1452 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,6 +48,21 @@ jobs:
       - name: Check code style
         run: ./tools/lint.sh -c
 
+  install_sh_tests:
+    name: install.sh tests (${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, macos-latest]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Install bash 4+ (macOS)
+        if: runner.os == 'macOS'
+        run: brew install bash
+      - name: Run install.sh tests
+        run: bash tools/test/run.sh
+
   build_backend_tests:
     name: ut-build-backend
     runs-on: ubuntu-latest
diff --git a/tools/.rat-excludes b/tools/.rat-excludes
index dceb40d3..11e47ca7 100644
--- a/tools/.rat-excludes
+++ b/tools/.rat-excludes
@@ -4,6 +4,10 @@ docs
 .git
 .gitignore
 .gitmodules
+\.bats-cache
+.*\.bats$
+.*\.bash$
+\.gitkeep
 .*\.lock$
 .venv/*
 .idea/*
diff --git a/tools/install.sh b/tools/install.sh
new file mode 100755
index 00000000..c71e3fa3
--- /dev/null
+++ b/tools/install.sh
@@ -0,0 +1,1886 @@
+#!/usr/bin/env bash
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+# -E (errtrace) propagates the ERR trap into functions and command
+# substitutions so the failure banner fires from anywhere in the script,
+# not only at the top level.
+set -Eeuo pipefail
+
+BOLD='\033[1m'
+ACCENT='\033[38;2;255;77;77m'       # coral-bright
+INFO='\033[38;2;136;146;176m'       # text-secondary
+SUCCESS='\033[38;2;0;229;204m'      # cyan-bright
+WARN='\033[38;2;255;176;32m'        # amber
+ERROR='\033[38;2;230;57;70m'        # coral-mid
+MUTED='\033[38;2;90;100;128m'       # text-muted
+NC='\033[0m' # No Color
+
+TMPFILES=()
+cleanup_tmpfiles() {
+    local f
+    for f in "${TMPFILES[@]:-}"; do
+        rm -rf "$f" 2>/dev/null || true
+    done
+}
+trap cleanup_tmpfiles EXIT
+# Some interactive read combinations (notably `read -e` under stdin 
redirection)
+# can swallow SIGINT, leaving the user pressing Ctrl+C with no effect. Install
+# an explicit INT trap so Ctrl+C always lands.
+trap 'die_cancelled' INT
+
+mktempfile() {
+    local f
+    f="$(mktemp)"
+    TMPFILES+=("$f")
+    echo "$f"
+}
+
+DOWNLOADER=""
+detect_downloader() {
+    if command -v curl &> /dev/null; then
+        DOWNLOADER="curl"
+        return 0
+    fi
+    if command -v wget &> /dev/null; then
+        DOWNLOADER="wget"
+        return 0
+    fi
+    ui_error "Missing downloader (curl or wget required)"
+    exit 1
+}
+
+download_file() {
+    local url="$1"
+    local output="$2"
+    if [[ -z "$DOWNLOADER" ]]; then
+        detect_downloader
+    fi
+    if [[ "$DOWNLOADER" == "curl" ]]; then
+        curl -fL --progress-bar --proto '=https' --tlsv1.2 --retry 3 
--max-time 900 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --show-progress --https-only --secure-protocol=TLSv1_2 --tries=3 
--timeout=900 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+# Persistent cache for the auto-downloaded gum binary so reruns don't
+# re-download it. Honors XDG_CACHE_HOME, falls back to ~/.cache.
+GUM_CACHE_ROOT="${FLINK_AGENTS_GUM_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/flink-agents/gum}"
+
+is_non_interactive_shell() {
+    if [[ "${NO_PROMPT:-0}" == "1" ]]; then
+        return 0
+    fi
+    if [[ ! -t 0 || ! -t 1 ]]; then
+        return 0
+    fi
+    return 1
+}
+
+gum_is_tty() {
+    if [[ -n "${NO_COLOR:-}" ]]; then
+        return 1
+    fi
+    if [[ "${TERM:-dumb}" == "dumb" ]]; then
+        return 1
+    fi
+    if [[ -t 2 || -t 1 ]]; then
+        return 0
+    fi
+    if [[ -r /dev/tty && -w /dev/tty ]]; then
+        return 0
+    fi
+    return 1
+}
+
+gum_detect_os() {
+    case "$(uname -s 2>/dev/null || true)" in
+        Darwin) echo "Darwin" ;;
+        Linux) echo "Linux" ;;
+        *) echo "unsupported" ;;
+    esac
+}
+
+gum_detect_arch() {
+    case "$(uname -m 2>/dev/null || true)" in
+        x86_64|amd64) echo "x86_64" ;;
+        arm64|aarch64) echo "arm64" ;;
+        i386|i686) echo "i386" ;;
+        armv7l|armv7) echo "armv7" ;;
+        armv6l|armv6) echo "armv6" ;;
+        *) echo "unknown" ;;
+    esac
+}
+
+verify_sha256sum_file() {
+    local checksums="$1"
+    if command -v sha256sum >/dev/null 2>&1; then
+        sha256sum --ignore-missing -c "$checksums" >/dev/null 2>&1
+        return $?
+    fi
+    if command -v shasum >/dev/null 2>&1; then
+        shasum -a 256 --ignore-missing -c "$checksums" >/dev/null 2>&1
+        return $?
+    fi
+    return 1
+}
+
+bootstrap_gum_temp() {
+    GUM=""
+    GUM_STATUS="skipped"
+    GUM_REASON=""
+
+    if is_non_interactive_shell; then
+        GUM_REASON="non-interactive shell (auto-disabled)"
+        return 1
+    fi
+
+    if ! gum_is_tty; then
+        GUM_REASON="terminal does not support gum UI"
+        return 1
+    fi
+
+    if command -v gum >/dev/null 2>&1; then
+        GUM="gum"
+        GUM_STATUS="found"
+        GUM_REASON="already installed"
+        return 0
+    fi
+
+    local cache_dir="${GUM_CACHE_ROOT}/${GUM_VERSION}"
+    local cached_gum="${cache_dir}/gum"
+    if [[ -x "$cached_gum" ]]; then
+        GUM="$cached_gum"
+        GUM_STATUS="cached"
+        GUM_REASON="reused from ${cache_dir}"
+        return 0
+    fi
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir extracted_path
+    os="$(gum_detect_os)"
+    arch="$(gum_detect_arch)"
+    if [[ "$os" == "unsupported" || "$arch" == "unknown" ]]; then
+        GUM_REASON="unsupported os/arch ($os/$arch)"
+        return 1
+    fi
+
+    asset="gum_${GUM_VERSION}_${os}_${arch}.tar.gz"
+    
base="https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}";
+
+    gum_tmpdir="$(mktemp -d)"
+    TMPFILES+=("$gum_tmpdir")
+
+    ui_info "Installing gum v${GUM_VERSION}, please wait..."
+
+    if ! download_file "${base}/${asset}" "$gum_tmpdir/$asset"; then
+        GUM_REASON="download failed"
+        return 1
+    fi
+
+    if ! download_file "${base}/checksums.txt" "$gum_tmpdir/checksums.txt"; 
then
+        GUM_REASON="checksum unavailable or failed"
+        return 1
+    fi
+
+    if ! (cd "$gum_tmpdir" && verify_sha256sum_file "checksums.txt"); then
+        GUM_REASON="checksum unavailable or failed"
+        return 1
+    fi
+
+    if ! tar -xzf "$gum_tmpdir/$asset" -C "$gum_tmpdir" >/dev/null 2>&1; then
+        GUM_REASON="extract failed"
+        return 1
+    fi
+
+    extracted_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head 
-n1 || true)"
+    if [[ -z "$extracted_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$extracted_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$extracted_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    # Promote into the persistent cache so subsequent runs skip the download.
+    if mkdir -p "$cache_dir" 2>/dev/null && mv "$extracted_path" "$cached_gum" 
2>/dev/null; then
+        GUM="$cached_gum"
+        GUM_REASON="cached at ${cache_dir}"
+    else
+        # Cache write failed (read-only HOME etc.) — fall back to using the
+        # extracted binary directly. It'll get cleaned up at EXIT, costing a
+        # re-download next run, but the current run still works.
+        GUM="$extracted_path"
+        GUM_REASON="temp, verified (cache unavailable)"
+    fi
+    GUM_STATUS="installed"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        cached)
+            ui_success "gum loaded from cache (v${GUM_VERSION})"
+            ;;
+        installed)
+            ui_success "gum bootstrapped (${GUM_REASON}, v${GUM_VERSION})"
+            ;;
+        *)
+            if [[ -n "$GUM_REASON" && "$GUM_REASON" != "non-interactive shell 
(auto-disabled)" ]]; then
+                ui_info "gum skipped (${GUM_REASON})"
+            fi
+            ;;
+    esac
+}
+
+print_installer_banner() {
+    if [[ -n "$GUM" ]]; then
+        local title
+        title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink 
Agents Installer")"
+        "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding 
"1 2" "$title"
+        echo ""
+        return
+    fi
+
+    echo -e "${ACCENT}${BOLD}"
+    echo "  Apache Flink Agents Installer"
+    echo ""
+}
+
+detect_os_or_die() {
+    OS="unknown"
+    if [[ "$OSTYPE" == "darwin"* ]]; then
+        OS="macos"
+    elif [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; 
then
+        OS="linux"
+    fi
+
+    if [[ "$OS" == "unknown" ]]; then
+        ui_error "Unsupported operating system"
+        echo "This installer supports macOS and Linux (including WSL)."
+        exit 1
+    fi
+
+    ui_success "Detected: $OS"
+}
+
+ui_info() {
+    local msg="$*"
+    if [[ -n "$GUM" ]]; then
+        "$GUM" log --level info "$msg"
+    else
+        echo -e "${MUTED}·${NC} ${msg}"
+    fi
+}
+
+ui_warn() {
+    local msg="$*"
+    if [[ -n "$GUM" ]]; then
+        "$GUM" log --level warn "$msg"
+    else
+        echo -e "${WARN}!${NC} ${msg}"
+    fi
+}
+
+ui_success() {
+    local msg="$*"
+    if [[ -n "$GUM" ]]; then
+        local mark
+        mark="$("$GUM" style --foreground "#00e5cc" --bold "✓")"
+        echo "${mark} ${msg}"
+    else
+        echo -e "${SUCCESS}✓${NC} ${msg}"
+    fi
+}
+
+ui_error() {
+    local msg="$*"
+    if [[ -n "$GUM" ]]; then
+        "$GUM" log --level error "$msg"
+    else
+        echo -e "${ERROR}x${NC} ${msg}"
+    fi
+}
+
+die() {
+    ui_error "$*"
+    exit 1
+}
+
+die_cancelled() {
+    # Write to stderr so the message survives `$(...)` command substitution
+    # — otherwise the cancellation note would be silently captured by the
+    # caller's variable and the user would see nothing on Ctrl+C.
+    ui_info "Cancelled by user" >&2
+    exit 130
+}
+
+# Fires on any command that exits non-zero under set -e — the cases that
+# would otherwise dump a tail of pip / curl / tar noise and silently exit.
+# `die`/`die_cancelled` use plain `exit` (not a non-zero command), so they
+# do NOT trip this trap; they print their own friendly message and exit
+# straight away. That keeps the banner reserved for genuinely unexpected
+# failures.
+on_error() {
+    local rc=$1
+    local line=$2
+    local cmd=$3
+    # Suppress ourselves if we re-enter (e.g. echo failing under set -e
+    # inside the handler itself).
+    trap - ERR
+    {
+        echo ""
+        echo 
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+        if (( INSTALL_STAGE_CURRENT > 0 )); then
+            printf '%b Installation failed at stage %d/%d (%s).\n' \
+                "${ERROR}✗${NC}" \
+                "$INSTALL_STAGE_CURRENT" "$INSTALL_STAGE_TOTAL" \
+                "$INSTALL_STAGE_TITLE"
+        else
+            printf '%b Installation failed.\n' "${ERROR}✗${NC}"
+        fi
+        echo ""
+        echo "  Command:   ${cmd}"
+        echo "  Source:    install.sh:${line}"
+        echo "  Exit code: ${rc}"
+        echo ""
+        echo "  Re-run with --verbose for full output, or report at:"
+        echo "    https://github.com/apache/flink-agents/issues";
+        echo 
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+    } >&2
+    exit "$rc"
+}
+trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR
+
+INSTALL_STAGE_TOTAL=5
+INSTALL_STAGE_CURRENT=0
+INSTALL_STAGE_TITLE=""
+
+ui_section() {
+    local title="$1"
+    if [[ -n "$GUM" ]]; then
+        "$GUM" style --bold --foreground "#ff4d4d" --padding "1 0" "$title"
+    else
+        echo ""
+        echo -e "${ACCENT}${BOLD}${title}${NC}"
+    fi
+}
+
+ui_stage() {
+    local title="$1"
+    INSTALL_STAGE_CURRENT=$((INSTALL_STAGE_CURRENT + 1))
+    # Remember the stage title so on_error can include it in the
+    # failure banner ("Installation failed at stage 3/5 (Installing
+    # Apache Flink)").
+    INSTALL_STAGE_TITLE="$title"
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+UI_KV_KEY_WIDTH=24
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    local labeled="${key}:"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 
"$UI_KV_KEY_WIDTH" "$labeled")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        printf "${MUTED}%-${UI_KV_KEY_WIDTH}s${NC} %s\n" "$labeled" "$value"
+    fi
+}
+
+ui_celebrate() {
+    local msg="$1"
+    if [[ -n "$GUM" ]]; then
+        "$GUM" style --bold --foreground "#00e5cc" "$msg"
+    else
+        echo -e "${SUCCESS}${BOLD}${msg}${NC}"
+    fi
+}
+
+mark_explicit() {
+    local var="$1"
+    if [[ -n "${!var:-}" ]]; then
+        printf -v "${var}_EXPLICIT" '%s' '1'
+    else
+        printf -v "${var}_EXPLICIT" '%s' '0'
+    fi
+}
+
+mark_explicit FLINK_VERSION
+mark_explicit FLINK_AGENTS_VERSION
+mark_explicit INSTALL_DIR
+mark_explicit VENV_DIR
+
+FLINK_VERSION="${FLINK_VERSION:-2.2.0}"
+FLINK_AGENTS_VERSION="${FLINK_AGENTS_VERSION:-0.2.1}"
+FLINK_SCALA_VERSION="${FLINK_SCALA_VERSION:-2.12}"
+FLINK_BASE_URL="${FLINK_BASE_URL:-https://dlcdn.apache.org/flink}";
+# Flink Agents JARs live next to Flink on the ASF mirror network. Override
+# this to point at archive.apache.org if you need a non-current release.
+FLINK_AGENTS_BASE_URL="${FLINK_AGENTS_BASE_URL:-https://dlcdn.apache.org/flink}";
+# Direct (non-mirrored) ASF download host. We hit it for the SHA512 sidecar
+# because the mirror network does not redistribute checksum files.
+FLINK_AGENTS_CHECKSUM_BASE_URL="${FLINK_AGENTS_CHECKSUM_BASE_URL:-https://downloads.apache.org/flink}";
+
+FLINK_SUPPORTED_VERSIONS=("2.2.0" "2.1.1" "2.0.1" "1.20.3")
+FLINK_RECOMMENDED_VERSION="2.2.0"
+
+# Mirrors https://flink.apache.org/downloads/#apache-flink-agents
+# (latest first). Note: 0.1.x only ships JARs for Flink 1.20, while 0.2.x
+# ships JARs for Flink 1.20 / 2.0 / 2.1 / 2.2 — see the download page for
+# the exact compatibility matrix.
+FLINK_AGENTS_SUPPORTED_VERSIONS=("0.2.1" "0.2.0" "0.1.1" "0.1.0")
+FLINK_AGENTS_RECOMMENDED_VERSION="0.2.1"
+
+INSTALL_FLINK="${INSTALL_FLINK:-Ask}"
+ENABLE_PYFLINK="${ENABLE_PYFLINK:-Ask}"
+INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/flink}"
+VENV_DIR="${VENV_DIR:-.flink-agents-env}"
+PYTHON_BIN="${PYTHON_BIN:-}"
+NO_PROMPT="${NO_PROMPT:-0}"
+VERBOSE="${FLINK_AGENTS_VERBOSE:-0}"
+DRY_RUN="${FLINK_AGENTS_DRY_RUN:-0}"
+VERIFY_INSTALL="${FLINK_AGENTS_VERIFY_INSTALL:-0}"
+HELP=0
+PYFLINK_ACTUALLY_ENABLED=0
+
+print_usage() {
+    cat <<EOF
+Apache Flink Agents Installer
+
+Usage:
+  bash install.sh [options]
+  curl -fsSL <url>/install.sh | bash -s -- [options]
+
+Options:
+  --non-interactive       Non-interactive mode (accept all defaults)
+  --install-flink         Download and install Apache Flink
+  --enable-pyflink        Enable PyFlink and install Python packages
+  --verbose               Print debug output (set -x)
+  --dry-run               Print install plan without making changes
+  --verify                Run post-install verification checks
+  --python <path>         Path to a Python3 interpreter (overrides PATH lookup)
+  --flink-version <ver>   Apache Flink version (e.g. 2.2.0); overrides the 
interactive picker
+  --flink-agents-version <ver>
+                          Flink Agents version (default: 
${FLINK_AGENTS_RECOMMENDED_VERSION}); overrides the picker
+  --help, -h              Show this help
+
+Environment variables:
+  FLINK_VERSION             Flink version
+  FLINK_AGENTS_VERSION      Flink Agents version to install
+  FLINK_SCALA_VERSION       Scala version suffix (default: 2.12)
+  FLINK_BASE_URL            Mirror base URL for Flink (default: 
https://dlcdn.apache.org/flink)
+  FLINK_AGENTS_BASE_URL     Mirror base URL for Flink Agents JARs (default: 
https://dlcdn.apache.org/flink)
+  FLINK_AGENTS_CHECKSUM_BASE_URL  Direct ASF URL for SHA512 sidecars (default: 
https://downloads.apache.org/flink)
+  INSTALL_FLINK             Ask|Yes|No (default: Ask)
+  ENABLE_PYFLINK            Ask|Yes|No (default: Ask)
+  INSTALL_DIR               Flink install directory (default: 
\$HOME/.local/flink)
+  VENV_DIR                  Python venv directory (default: .flink-agents-env)
+  PYTHON_BIN                Path to Python3 interpreter (default: auto-detect 
python3 on PATH)
+  NO_PROMPT                 1 to disable all prompts
+  FLINK_AGENTS_VERBOSE      1 to enable verbose output
+  FLINK_AGENTS_DRY_RUN      1 to enable dry-run mode
+  FLINK_AGENTS_VERIFY_INSTALL  1 to enable post-install verification
+
+Examples:
+  bash install.sh --install-flink --enable-pyflink --non-interactive
+  FLINK_VERSION=2.2.0 bash install.sh --verbose
+  bash install.sh --dry-run
+EOF
+}
+
+require_cmd() {
+    command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
+}
+
+# Normalize a filesystem path so downstream string interpolation can rely on
+# a consistent shape. Empty input is passed through (callers handle "user
+# entered nothing"). Otherwise:
+#   - "~" prefix expands to $HOME
+#   - relative paths are anchored at $PWD
+#   - "/./" segments and trailing "/." are folded away
+#   - trailing slashes are stripped, except for the root "/"
+#   - runs of '/' collapse to a single '/'
+#
+# Implementation note: the folding steps use sed instead of bash
+# `${var//pattern/replacement}`. Bash 3.2 (macOS /bin/bash) does not
+# strip the backslash from an escaped slash in the replacement, so
+# `${p//\/.\//\/}` leaves a literal `\/` in the result (concretely:
+# normalize_path "./" under PWD=/tmp/x produced /tmp/x\ on macOS).
+# sed sidesteps the parameter-expansion quirk entirely.
+normalize_path() {
+    local p="$1"
+    if [[ -z "$p" ]]; then
+        printf ''
+        return 0
+    fi
+    p="${p/#\~/$HOME}"
+    if [[ "$p" != /* ]]; then
+        p="$PWD/$p"
+    fi
+    p="$(printf '%s' "$p" | sed -E 's|/+|/|g; s|/\./|/|g; s|/\.$||; s|/+$||')"
+    [[ -z "$p" ]] && p='/'
+    printf '%s' "$p"
+}
+
+is_valid_tgz() {
+    local archive="$1"
+    [[ -f "$archive" ]] || return 1
+    tar -tzf "$archive" >/dev/null 2>&1
+}
+
+is_promptable() {
+    if [[ "$NO_PROMPT" == "1" ]]; then
+        return 1
+    fi
+    if [[ -r /dev/tty && -w /dev/tty ]]; then
+        return 0
+    fi
+    return 1
+}
+
+choose_install_method_interactive() {
+    local prompt="$1"
+
+    if ! is_promptable; then
+        return 1
+    fi
+
+    if [[ -n "$GUM" ]] && gum_is_tty; then
+        local selection _rc=0
+        selection="$("$GUM" choose \
+            --header "$prompt" \
+            --cursor-prefix "❯ " \
+            "Yes" "No" < /dev/tty)" || _rc=$?
+        (( _rc == 0 )) || die_cancelled
+        [[ "$selection" == "Yes" ]]
+        return
+    fi
+
+    local answer=""
+    printf '%s [y/n]: ' "$prompt" > /dev/tty
+    read -r answer < /dev/tty || die_cancelled
+
+    [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+prompt_flink_version_interactive() {
+    if ! is_promptable; then
+        return 1
+    fi
+
+    local labels=()
+    local v
+    for v in "${FLINK_SUPPORTED_VERSIONS[@]}"; do
+        if [[ "$v" == "$FLINK_RECOMMENDED_VERSION" ]]; then
+            labels+=("$v (recommended)")
+        else
+            labels+=("$v")
+        fi
+    done
+
+    local selection=""
+    if [[ -n "$GUM" ]] && gum_is_tty; then
+        local _rc=0
+        selection="$("$GUM" choose \
+            --header "Select Flink version" \
+            --cursor-prefix "❯ " \
+            "${labels[@]}" < /dev/tty)" || _rc=$?
+        (( _rc == 0 )) || die_cancelled
+        selection="${selection%% *}"
+    else
+        printf 'Select Flink version:\n' > /dev/tty
+        local i=1
+        for v in "${FLINK_SUPPORTED_VERSIONS[@]}"; do
+            local suffix=""
+            [[ "$v" == "$FLINK_RECOMMENDED_VERSION" ]] && suffix=" 
(recommended)"
+            printf '  %d) %s%s\n' "$i" "$v" "$suffix" > /dev/tty
+            i=$((i+1))
+        done
+        local answer=""
+        printf 'Enter choice [1-%d, default %s]: ' \
+            "${#FLINK_SUPPORTED_VERSIONS[@]}" "$FLINK_RECOMMENDED_VERSION" > 
/dev/tty
+        read -r answer < /dev/tty || die_cancelled
+        if [[ "$answer" =~ ^[0-9]+$ ]] \
+           && (( answer >= 1 && answer <= ${#FLINK_SUPPORTED_VERSIONS[@]} )); 
then
+            selection="${FLINK_SUPPORTED_VERSIONS[$((answer-1))]}"
+        fi
+    fi
+
+    if [[ -z "$selection" ]]; then
+        selection="$FLINK_RECOMMENDED_VERSION"
+    fi
+    FLINK_VERSION="$selection"
+    return 0
+}
+
+prompt_flink_agents_version_interactive() {
+    if ! is_promptable; then
+        return 1
+    fi
+
+    local labels=()
+    local v
+    for v in "${FLINK_AGENTS_SUPPORTED_VERSIONS[@]}"; do
+        if [[ "$v" == "$FLINK_AGENTS_RECOMMENDED_VERSION" ]]; then
+            labels+=("$v (recommended)")
+        else
+            labels+=("$v")
+        fi
+    done
+
+    local selection=""
+    if [[ -n "$GUM" ]] && gum_is_tty; then
+        local _rc=0
+        selection="$("$GUM" choose \
+            --header "Select Flink Agents version" \
+            --cursor-prefix "❯ " \
+            "${labels[@]}" < /dev/tty)" || _rc=$?
+        (( _rc == 0 )) || die_cancelled
+        selection="${selection%% *}"
+    else
+        printf 'Select Flink Agents version:\n' > /dev/tty
+        local i=1
+        for v in "${FLINK_AGENTS_SUPPORTED_VERSIONS[@]}"; do
+            local suffix=""
+            [[ "$v" == "$FLINK_AGENTS_RECOMMENDED_VERSION" ]] && suffix=" 
(recommended)"
+            printf '  %d) %s%s\n' "$i" "$v" "$suffix" > /dev/tty
+            i=$((i+1))
+        done
+        local answer=""
+        printf 'Enter choice [1-%d, default %s]: ' \
+            "${#FLINK_AGENTS_SUPPORTED_VERSIONS[@]}" 
"$FLINK_AGENTS_RECOMMENDED_VERSION" > /dev/tty
+        read -r answer < /dev/tty || die_cancelled
+        if [[ "$answer" =~ ^[0-9]+$ ]] \
+           && (( answer >= 1 && answer <= 
${#FLINK_AGENTS_SUPPORTED_VERSIONS[@]} )); then
+            selection="${FLINK_AGENTS_SUPPORTED_VERSIONS[$((answer-1))]}"
+        fi
+    fi
+
+    if [[ -z "$selection" ]]; then
+        selection="$FLINK_AGENTS_RECOMMENDED_VERSION"
+    fi
+    FLINK_AGENTS_VERSION="$selection"
+    return 0
+}
+
+# Populate FLINK_VERSION from an existing FLINK_HOME. Tries to parse
+# `lib/flink-dist-<ver>.jar` first because it's instant; falls back to
+# Extract the major.minor portion of a Flink version string.
+#   2.2.0           -> 2.2
+#   2.2.0-SNAPSHOT  -> 2.2
+#   2.2-SNAPSHOT    -> 2.2     (local source builds, no patch number)
+#   2.1-rc1         -> 2.1
+# Returns empty string on no match. Note: replaces the older
+# `${FLINK_VERSION%.*}` trick, which silently produced "2" for "2.2-SNAPSHOT".
+flink_major_minor() {
+    printf '%s' "$1" | sed -E -n 's/^([0-9]+\.[0-9]+).*/\1/p'
+}
+
+# True (rc=0) when the version string carries a pre-release suffix
+# (-SNAPSHOT, -rc1, -dev, -beta-2, ...). PyPI only carries finished
+# release wheels for apache-flink, so we need to know when to fall
+# back from `==exact` to `~=X.Y.0` (compatible release).
+is_snapshot_version() {
+    local v="${1:-}"
+    [[ -n "$v" && "$v" == *-* ]]
+}
+
+# `bin/flink --version`, which is authoritative but spins up a JVM and can
+# take 3-10s on cold start. Returns 0 on success.
+detect_flink_version_from_home() {
+    [[ -n "${FLINK_HOME:-}" && -d "$FLINK_HOME" ]] || return 1
+
+    # A Flink version on the wire is roughly:
+    #   <num>.<num>(.<num>)?(-<suffix>)?
+    # Suffix examples: SNAPSHOT, rc1, beta-2, 20251115. Anchor it loose
+    # enough to handle local source builds (flink-dist-2.2-SNAPSHOT.jar).
+    local ver_re='[0-9]+\.[0-9]+(\.[0-9]+)?(-[A-Za-z0-9._]+)?'
+    local version=""
+
+    # Fast path: filename inspection. Avoids JVM startup entirely.
+    local jar
+    for jar in "$FLINK_HOME/lib"/flink-dist-[0-9]*.jar; do
+        [[ -f "$jar" ]] || continue
+        version="$(basename "$jar" | sed -E -n 
"s/^flink-dist-(${ver_re})\.jar$/\1/p")"
+        [[ -n "$version" ]] && break
+    done
+
+    # Slow path: ask the Flink CLI. JVM startup means a few seconds of
+    # silence, so signal what's happening.
+    if [[ -z "$version" && -x "$FLINK_HOME/bin/flink" ]]; then
+        ui_info "Detecting Flink version (running '${FLINK_HOME}/bin/flink 
--version', this may take a few seconds)..."
+        local out
+        out="$("$FLINK_HOME/bin/flink" --version 2>/dev/null || true)"
+        version="$(printf '%s\n' "$out" | sed -E -n 
"s/.*Version:[[:space:]]*(${ver_re}).*/\1/p" | head -n1)"
+    fi
+
+    if [[ -z "$version" ]]; then
+        return 1
+    fi
+
+    FLINK_VERSION="$version"
+    return 0
+}
+
+plan_flink() {
+    case "$INSTALL_FLINK" in
+        Yes|No)
+            ;;
+        Ask)
+            if choose_install_method_interactive "Do you want this script to 
download and install Apache Flink?"; then
+                INSTALL_FLINK=Yes
+            else
+                INSTALL_FLINK=No
+            fi
+            ;;
+        *)
+            die "Unsupported INSTALL_FLINK value: ${INSTALL_FLINK}. Use: 
Ask|Yes|No"
+            ;;
+    esac
+
+    if [[ "$INSTALL_FLINK" == "No" ]]; then
+        if [[ -n "${FLINK_HOME:-}" ]]; then
+            FLINK_HOME="$(normalize_path "$FLINK_HOME")"
+        fi
+        if is_promptable; then
+            while [[ -z "${FLINK_HOME:-}" || ! -d "${FLINK_HOME}" || ! -d 
"${FLINK_HOME}/lib" ]]; do
+                if [[ -n "${FLINK_HOME:-}" ]]; then
+                    if [[ ! -d "${FLINK_HOME}" ]]; then
+                        ui_warn "Path does not exist: ${FLINK_HOME}"
+                    else
+                        ui_warn "Not a valid Flink home (missing 'lib' 
directory): ${FLINK_HOME}"
+                    fi
+                fi
+                FLINK_HOME="$(prompt_path_input "Enter the path to your 
existing FLINK_HOME (version >= 1.20)" "/path/to/flink-${FLINK_VERSION}")"
+            done
+            export FLINK_HOME
+        fi
+        [[ -n "${FLINK_HOME:-}" ]] || die "FLINK_HOME is not set."
+        [[ -d "$FLINK_HOME" ]] || die "FLINK_HOME does not exist: $FLINK_HOME"
+        [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib 
directory): $FLINK_HOME"
+        ui_success "FLINK_HOME accepted: $FLINK_HOME"
+        if detect_flink_version_from_home; then
+            ui_success "Detected Flink version: $FLINK_VERSION"
+        else
+            ui_warn "Could not auto-detect Flink version from $FLINK_HOME; 
assuming ${FLINK_VERSION}."
+            ui_warn "If JAR copy fails, set FLINK_VERSION explicitly (e.g. 
FLINK_VERSION=2.1.1 bash install.sh)."
+        fi
+        FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")"
+        return
+    fi
+
+    if [[ "$FLINK_VERSION_EXPLICIT" -eq 0 ]]; then
+        prompt_flink_version_interactive || true
+    fi
+
+    if [[ "$INSTALL_DIR_EXPLICIT" -eq 0 ]] && is_promptable; then
+        INSTALL_DIR="$(prompt_path_choice_interactive \
+            "Choose Flink install directory" \
+            "$INSTALL_DIR" \
+            "/path/to/flink-install-dir")"
+    fi
+
+    INSTALL_DIR="$(normalize_path "$INSTALL_DIR")"
+    FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+    FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")"
+}
+
+plan_flink_agents() {
+    if [[ "$FLINK_AGENTS_VERSION_EXPLICIT" -eq 0 ]]; then
+        prompt_flink_agents_version_interactive || true
+    fi
+}
+
+# Classify a candidate VENV_DIR path. Echoes one of:
+#   new       — path doesn't exist; caller should mkdir + python -m venv
+#   empty     — empty directory; safe to use as venv root
+#   venv      — already a real Python venv (pyvenv.cfg marker present)
+#   nonempty  — directory contains foreign files; caller MUST re-prompt
+#   file      — path is a regular file or invalid; caller MUST re-prompt
+# Exit code: 0 for the safe-to-use cases, 1 for the must-re-prompt cases.
+#
+# We only treat pyvenv.cfg as the venv marker — `bin/activate` alone is
+# not enough since arbitrary projects sometimes ship a file by that name.
+validate_venv_dir() {
+    local p="$1"
+    if [[ -z "$p" ]]; then
+        printf 'file'
+        return 1
+    fi
+    if [[ ! -e "$p" ]]; then
+        printf 'new'
+        return 0
+    fi
+    if [[ ! -d "$p" ]]; then
+        printf 'file'
+        return 1
+    fi
+    if [[ -f "$p/pyvenv.cfg" ]]; then
+        printf 'venv'
+        return 0
+    fi
+    # Directory exists. Empty iff no entries including dotfiles.
+    local entries
+    entries="$(find "$p" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null || 
true)"
+    if [[ -z "$entries" ]]; then
+        printf 'empty'
+        return 0
+    fi
+    printf 'nonempty'
+    return 1
+}
+
+# Drive the "Choose Python venv directory" picker, then loop until the
+# user picks a path that's either non-existent, empty, or an actual
+# Python venv. Sets the global VENV_DIR. Caller must already have
+# ensured we're in an interactive shell (is_promptable).
+prompt_and_validate_venv_dir() {
+    local seed_default="$1"
+    VENV_DIR="$(prompt_path_choice_interactive \
+        "Choose Python venv directory" \
+        "$seed_default" \
+        "/path/to/venv")"
+    VENV_DIR="$(normalize_path "$VENV_DIR")"
+    local kind
+    while true; do
+        kind="$(validate_venv_dir "$VENV_DIR")" || true
+        case "$kind" in
+            new|empty|venv) return 0 ;;
+            nonempty)
+                ui_warn "${VENV_DIR} already exists and is not a Python venv. 
Pick a different path, or remove its contents and try again."
+                ;;
+            file|*)
+                ui_warn "${VENV_DIR} is not a directory. Pick a different 
path."
+                ;;
+        esac
+        VENV_DIR="$(prompt_path_input \
+            "Enter Python venv directory" \
+            "/path/to/venv")"
+        VENV_DIR="$(normalize_path "$VENV_DIR")"
+    done
+}
+
+plan_pyflink() {
+    case "$ENABLE_PYFLINK" in
+        Yes|No)
+            ;;
+        Ask)
+            if choose_install_method_interactive "Create a Python venv with 
PyFlink and flink-agents? (Only needed for Python API users; Java users can 
select No)"; then
+                ENABLE_PYFLINK=Yes
+            else
+                ENABLE_PYFLINK=No
+            fi
+            ;;
+        *)
+            die "Unsupported ENABLE_PYFLINK value: ${ENABLE_PYFLINK}. Use: 
Ask|Yes|No"
+            ;;
+    esac
+
+    if [[ "$ENABLE_PYFLINK" == "No" ]]; then
+        return
+    fi
+
+    PYFLINK_ACTUALLY_ENABLED=1
+
+    # Validate VENV_DIR before doing anything Python-related — if the
+    # user picked a bad path we want to bail before we make them resolve
+    # a Python interpreter, and certainly before stage 3 downloads Flink.
+    if [[ "$VENV_DIR_EXPLICIT" -eq 0 ]] && is_promptable; then
+        prompt_and_validate_venv_dir "$VENV_DIR"
+    else
+        VENV_DIR="$(normalize_path "$VENV_DIR")"
+        # No prompt available (--non-interactive / NO_PROMPT / explicit
+        # VENV_DIR). Refuse to scribble into a foreign directory; tell
+        # the caller exactly what's wrong instead of silently mixing the
+        # venv into their codebase.
+        local kind
+        kind="$(validate_venv_dir "$VENV_DIR")" || true
+        case "$kind" in
+            new|empty|venv) ;;
+            nonempty)
+                die "VENV_DIR=${VENV_DIR} already exists and is not a Python 
venv. Set VENV_DIR to a new or empty path, or to an existing venv."
+                ;;
+            file|*)
+                die "VENV_DIR=${VENV_DIR} is not a directory."
+                ;;
+        esac
+    fi
+
+    resolve_python
+}
+
+# Echo one of: "confirm" | "edit" | "cancel"
+confirm_plan_action_interactive() {
+    if [[ -n "$GUM" ]] && gum_is_tty; then
+        local selection _rc=0
+        selection="$("$GUM" choose \
+            --header "Proceed with installation?" \
+            --cursor-prefix "❯ " \
+            "Proceed" "Edit a setting" "Cancel" < /dev/tty)" || _rc=$?
+        (( _rc == 0 )) || die_cancelled
+        case "$selection" in
+            Proceed)          echo confirm ;;
+            "Edit a setting") echo edit ;;
+            *)                echo cancel ;;
+        esac
+        return
+    fi
+
+    printf 'Proceed with installation?\n' > /dev/tty
+    printf '  1) Proceed\n' > /dev/tty
+    printf '  2) Edit a setting\n' > /dev/tty
+    printf '  3) Cancel\n' > /dev/tty
+    local answer=""
+    printf 'Enter choice [1-3, default 1]: ' > /dev/tty
+    read -r answer < /dev/tty || die_cancelled
+    case "$answer" in
+        2) echo edit ;;
+        3) echo cancel ;;
+        *) echo confirm ;;
+    esac
+}
+
+# Prompt the user to pick a Flink home interactively. Loops until a valid
+# path (exists and has a lib/ subdir) is provided. Mirrors the loop in
+# plan_flink so the two stay in sync.
+edit_prompt_flink_home() {
+    FLINK_HOME=""
+    while [[ -z "${FLINK_HOME:-}" || ! -d "${FLINK_HOME}" || ! -d 
"${FLINK_HOME}/lib" ]]; do
+        if [[ -n "${FLINK_HOME:-}" ]]; then
+            if [[ ! -d "${FLINK_HOME}" ]]; then
+                ui_warn "Path does not exist: ${FLINK_HOME}"
+            else
+                ui_warn "Not a valid Flink home (missing 'lib' directory): 
${FLINK_HOME}"
+            fi
+        fi
+        FLINK_HOME="$(prompt_path_input "Enter the path to your existing 
FLINK_HOME (version >= 1.20)" "/path/to/flink-${FLINK_VERSION}")"
+    done
+    export FLINK_HOME
+}
+
+# Show a menu of currently-applicable plan fields and re-prompt for the
+# selected one. After the edit, recomputes FLINK_HOME / FLINK_MAJOR_MINOR
+# so the next show_install_plan reflects the change.
+# Quote a value for safe re-sourcing by the parent shell via single-quoted
+# assignment. Any embedded single quotes are escaped by closing/reopening
+# the quoted string.
+edit_plan_quote() {
+    local v="$1"
+    printf "'%s'" "${v//\'/\'\\\'\'}"
+}
+
+# Write the subset of plan variables we may have modified to a sourceable
+# state file. Called from inside the edit subshell on a successful action.
+edit_plan_dump_state() {
+    local out="$1"
+    {
+        printf 'INSTALL_FLINK=%s\n'             "$(edit_plan_quote 
"$INSTALL_FLINK")"
+        printf 'FLINK_VERSION=%s\n'             "$(edit_plan_quote 
"$FLINK_VERSION")"
+        printf 'INSTALL_DIR=%s\n'               "$(edit_plan_quote 
"$INSTALL_DIR")"
+        printf 'FLINK_HOME=%s\n'                "$(edit_plan_quote 
"${FLINK_HOME:-}")"
+        printf 'FLINK_MAJOR_MINOR=%s\n'         "$(edit_plan_quote 
"${FLINK_MAJOR_MINOR:-}")"
+        printf 'ENABLE_PYFLINK=%s\n'            "$(edit_plan_quote 
"$ENABLE_PYFLINK")"
+        printf 'PYFLINK_ACTUALLY_ENABLED=%s\n'  "$(edit_plan_quote 
"$PYFLINK_ACTUALLY_ENABLED")"
+        printf 'VENV_DIR=%s\n'                  "$(edit_plan_quote 
"$VENV_DIR")"
+        printf 'PYTHON_BIN=%s\n'                "$(edit_plan_quote 
"${PYTHON_BIN:-}")"
+        printf 'FLINK_AGENTS_VERSION=%s\n'      "$(edit_plan_quote 
"$FLINK_AGENTS_VERSION")"
+    } > "$out"
+}
+
+# Show a menu of currently-applicable plan fields and re-prompt for the
+# selected one. The entire body runs inside a `()` subshell so that an
+# ESC anywhere — top-level menu or any nested sub-prompt — only terminates
+# this menu (subshell exits 130, caller treats it as "back"). Successful
+# edits dump their state to a file that the caller sources back.
+# Ctrl+C is delivered to the whole process group; the parent's INT trap
+# fires die_cancelled and exits the installer before we ever inspect $rc.
+edit_plan_interactive() {
+    local state_file
+    state_file="$(mktempfile)"
+
+    # `set -e` is active, so we MUST catch any non-zero exit from the
+    # subshell with `|| rc=$?` — otherwise the script would terminate the
+    # moment a sub-prompt's ESC bubbles `exit 130` up through the subshell,
+    # and the parent would never get to interpret it as "back".
+    local rc=0
+    (
+        local labels=()
+        local actions=()
+
+        labels+=("Flink Agents version: $FLINK_AGENTS_VERSION")
+        actions+=("flink_agents_version")
+
+        labels+=("Install Flink: $INSTALL_FLINK")
+        actions+=("install_flink")
+
+        if [[ "$INSTALL_FLINK" == "Yes" ]]; then
+            labels+=("Flink version: $FLINK_VERSION")
+            actions+=("flink_version")
+            labels+=("Install directory: $INSTALL_DIR")
+            actions+=("install_dir")
+        else
+            labels+=("FLINK_HOME: ${FLINK_HOME:-<unset>}")
+            actions+=("flink_home")
+        fi
+
+        labels+=("Enable PyFlink: $ENABLE_PYFLINK")
+        actions+=("enable_pyflink")
+
+        if [[ "$ENABLE_PYFLINK" == "Yes" ]]; then
+            labels+=("Venv directory: $VENV_DIR")
+            actions+=("venv_dir")
+        fi
+
+        local picked_index=-1
+        if [[ -n "$GUM" ]] && gum_is_tty; then
+            local _rc=0
+            local selected=""
+            selected="$("$GUM" choose \
+                --header "Edit a setting  (↑/↓ navigate · Enter select · Esc 
to go back)" \
+                --cursor-prefix "❯ " \
+                "${labels[@]}" < /dev/tty)" || _rc=$?
+            # ESC inside this menu — exit the subshell so the caller treats
+            # it as "back to confirm". The exit code we use here (130) is
+            # what `gum` returns on Esc; we just propagate it.
+            if (( _rc != 0 )); then
+                exit "$_rc"
+            fi
+            local i=0
+            for l in "${labels[@]}"; do
+                if [[ "$l" == "$selected" ]]; then
+                    picked_index=$i
+                    break
+                fi
+                i=$((i+1))
+            done
+        else
+            printf 'Edit a setting (blank or "b" to go back):\n' > /dev/tty
+            local i=1
+            for l in "${labels[@]}"; do
+                printf '  %d) %s\n' "$i" "$l" > /dev/tty
+                i=$((i+1))
+            done
+            local answer=""
+            printf 'Enter choice [1-%d]: ' "${#labels[@]}" > /dev/tty
+            read -r answer < /dev/tty || die_cancelled
+            case "$answer" in
+                ""|b|B|back|0) exit 130 ;;
+            esac
+            if [[ "$answer" =~ ^[0-9]+$ ]] && (( answer >= 1 && answer <= 
${#labels[@]} )); then
+                picked_index=$((answer - 1))
+            fi
+        fi
+
+        if (( picked_index < 0 )); then
+            # No valid selection — same as back.
+            exit 130
+        fi
+
+        local action="${actions[$picked_index]}"
+        case "$action" in
+            flink_agents_version)
+                prompt_flink_agents_version_interactive || true
+                ;;
+            install_flink)
+                if choose_install_method_interactive "Install Flink?"; then
+                    if [[ "$INSTALL_FLINK" != "Yes" ]]; then
+                        INSTALL_FLINK=Yes
+                        # Coming from No → Yes: caller had no version/dir, so
+                        # walk them through both immediately instead of
+                        # forcing two more menu trips.
+                        prompt_flink_version_interactive || true
+                        INSTALL_DIR="$(prompt_path_choice_interactive \
+                            "Choose Flink install directory" \
+                            "$INSTALL_DIR" \
+                            "/path/to/flink-install-dir")"
+                        INSTALL_DIR="$(normalize_path "$INSTALL_DIR")"
+                    fi
+                    FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+                else
+                    INSTALL_FLINK=No
+                    edit_prompt_flink_home
+                    detect_flink_version_from_home || ui_warn "Could not 
auto-detect Flink version; keeping ${FLINK_VERSION}"
+                fi
+                FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")"
+                ;;
+            flink_version)
+                prompt_flink_version_interactive || true
+                FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+                FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")"
+                ;;
+            install_dir)
+                INSTALL_DIR="$(prompt_path_choice_interactive \
+                    "Choose Flink install directory" \
+                    "$INSTALL_DIR" \
+                    "/path/to/flink-install-dir")"
+                INSTALL_DIR="$(normalize_path "$INSTALL_DIR")"
+                FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+                ;;
+            flink_home)
+                edit_prompt_flink_home
+                ;;
+            enable_pyflink)
+                if choose_install_method_interactive "Enable PyFlink?"; then
+                    if [[ "$ENABLE_PYFLINK" != "Yes" ]]; then
+                        ENABLE_PYFLINK=Yes
+                        PYFLINK_ACTUALLY_ENABLED=1
+                        resolve_python
+                        prompt_and_validate_venv_dir "$VENV_DIR"
+                    fi
+                else
+                    ENABLE_PYFLINK=No
+                fi
+                ;;
+            venv_dir)
+                prompt_and_validate_venv_dir "$VENV_DIR"
+                ;;
+        esac
+
+        edit_plan_dump_state "$state_file"
+    ) || rc=$?
+
+    if (( rc != 0 )); then
+        # Any non-zero exit from the subshell means we never reached the
+        # state dump — treat it as "back to confirm". (Ctrl+C would have
+        # killed the parent before this point via the INT trap.)
+        return 0
+    fi
+
+    # shellcheck disable=SC1090
+    source "$state_file"
+}
+
+confirm_install_plan() {
+    if [[ "$NO_PROMPT" == "1" ]] || ! is_promptable; then
+        return 0
+    fi
+    while true; do
+        local action
+        # Capture stdout AND honor the child's exit code. The child runs in a
+        # command-substitution subshell, which does NOT inherit the parent's
+        # `trap die_cancelled INT`. When Ctrl+C lands inside `gum choose`,
+        # the subshell exits 130 but its stdout is empty — without this `||`,
+        # the case-statement falls through to the default arm and we'd loop
+        # forever re-prompting instead of exiting.
+        action="$(confirm_plan_action_interactive)" || exit 130
+        case "$action" in
+            confirm) return 0 ;;
+            edit)
+                edit_plan_interactive
+                # Re-display the updated plan so the user sees their change
+                # in context before re-prompting.
+                show_install_plan
+                ;;
+            cancel)
+                ui_info "Installation cancelled by user."
+                exit 0
+                ;;
+        esac
+    done
+}
+
+install_flink_if_needed() {
+    if [[ "$INSTALL_FLINK" == "No" ]]; then
+        ui_info "Skipping Flink download/install (using existing FLINK_HOME)."
+        ui_success "FLINK_HOME resolved: $FLINK_HOME"
+        return
+    fi
+
+    local 
ARCHIVE_NAME="flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+    local 
ARCHIVE_URL="${FLINK_BASE_URL}/flink-${FLINK_VERSION}/${ARCHIVE_NAME}"
+
+    detect_downloader
+    require_cmd tar
+
+    if ! mkdir -p "$INSTALL_DIR"; then
+        die "Failed to create INSTALL_DIR=$INSTALL_DIR. Please run with proper 
permissions or set INSTALL_DIR to a writable path."
+    fi
+    if [[ -f "${INSTALL_DIR}/${ARCHIVE_NAME}" ]] && ! is_valid_tgz 
"${INSTALL_DIR}/${ARCHIVE_NAME}"; then
+        ui_warn "Existing archive is corrupted; re-downloading: 
${INSTALL_DIR}/${ARCHIVE_NAME}"
+        rm -f "${INSTALL_DIR}/${ARCHIVE_NAME}"
+    fi
+
+    if [[ ! -f "${INSTALL_DIR}/${ARCHIVE_NAME}" ]]; then
+        ui_info "Downloading ${ARCHIVE_URL}"
+        download_file "$ARCHIVE_URL" "${INSTALL_DIR}/${ARCHIVE_NAME}"
+    else
+        ui_info "Reusing existing archive: ${INSTALL_DIR}/${ARCHIVE_NAME}"
+    fi
+
+    if ! is_valid_tgz "${INSTALL_DIR}/${ARCHIVE_NAME}"; then
+        die "Downloaded archive is invalid or truncated: 
${INSTALL_DIR}/${ARCHIVE_NAME}"
+    fi
+
+    if [[ -d "${INSTALL_DIR}/flink-${FLINK_VERSION}" ]] && [[ ! -d 
"${INSTALL_DIR}/flink-${FLINK_VERSION}/lib" ]]; then
+        ui_warn "Existing Flink home is incomplete; re-extracting: 
${INSTALL_DIR}/flink-${FLINK_VERSION}"
+        rm -rf "${INSTALL_DIR}/flink-${FLINK_VERSION}"
+    fi
+
+    if [[ ! -d "${INSTALL_DIR}/flink-${FLINK_VERSION}" ]]; then
+        ui_info "Extracting Flink to ${INSTALL_DIR}"
+        tar -xzf "${INSTALL_DIR}/${ARCHIVE_NAME}" -C "$INSTALL_DIR"
+    else
+        ui_info "Reusing existing Flink home: 
${INSTALL_DIR}/flink-${FLINK_VERSION}"
+    fi
+
+    export FLINK_HOME
+    ui_success "FLINK_HOME resolved: $FLINK_HOME"
+}
+
+prompt_path_input() {
+    local header="$1"
+    # Placeholder kept for backward compatibility with the previous signature;
+    # readline does not surface placeholders the way `gum input` did, so we
+    # weave the hint into the prompt label instead.
+    local placeholder="${2:-}"
+    local input=""
+    printf '%s\n' "$header" > /dev/tty
+    if [[ -n "$placeholder" ]]; then
+        printf '  (example: %s)\n' "$placeholder" > /dev/tty
+    fi
+    IFS= read -e -r -p "  path> " input < /dev/tty || die_cancelled
+    printf '%s' "$(normalize_path "$input")"
+}
+
+prompt_path_choice_interactive() {
+    local header="$1"
+    local default_path="$2"
+    local placeholder="$3"
+
+    if ! is_promptable; then
+        printf '%s' "$default_path"
+        return 0
+    fi
+
+    local default_label="Default: ${default_path}"
+    local custom_label="Custom..."
+    local selection=""
+
+    if [[ -n "$GUM" ]] && gum_is_tty; then
+        local _rc=0
+        selection="$("$GUM" choose \
+            --header "$header" \
+            --cursor-prefix "❯ " \
+            "$default_label" "$custom_label" < /dev/tty)" || _rc=$?
+        (( _rc == 0 )) || die_cancelled
+    else
+        printf '%s\n' "$header" > /dev/tty
+        printf '  1) %s\n' "$default_label" > /dev/tty
+        printf '  2) %s\n' "$custom_label" > /dev/tty
+        local answer=""
+        printf 'Enter choice [1-2, default 1]: ' > /dev/tty
+        read -r answer < /dev/tty || die_cancelled
+        case "$answer" in
+            2) selection="$custom_label" ;;
+            *) selection="$default_label" ;;
+        esac
+    fi
+
+    if [[ "$selection" != "$custom_label" ]]; then
+        printf '%s' "$(normalize_path "$default_path")"
+        return 0
+    fi
+
+    local input=""
+    printf '%s\n' "$header" > /dev/tty
+    IFS= read -e -r -p "  path> " input < /dev/tty || die_cancelled
+
+    if [[ -z "$input" ]]; then
+        ui_warn "Empty path; falling back to default: $default_path"
+        printf '%s' "$(normalize_path "$default_path")"
+        return 0
+    fi
+
+    printf '%s' "$(normalize_path "$input")"
+}
+
+copy_pyflink_jar() {
+    local pyflink_jar="$FLINK_HOME/opt/flink-python-${FLINK_VERSION}.jar"
+    [[ -f "$pyflink_jar" ]] || die "Missing required PyFlink jar: $pyflink_jar"
+
+    ui_info "Copying PyFlink jar into Flink lib"
+    cp "$pyflink_jar" "$FLINK_HOME/lib/"
+}
+
+create_venv() {
+    local venv_err
+    venv_err="$(mktempfile)"
+    if "$PYTHON_BIN" -m venv "$VENV_DIR" 2>"$venv_err"; then
+        return 0
+    fi
+
+    ui_error "Failed to create virtual environment at $VENV_DIR"
+    if [[ -s "$venv_err" ]]; then
+        sed 's/^/  /' "$venv_err" >&2 || true
+    fi
+    die "Virtual environment creation failed"
+}
+
+# Run `python -m pip install <args>` with reduced terminal noise.
+# Strategy depends on environment:
+#   VERBOSE=1                : full output, pass-through
+#   gum available + tty      : `gum spin --show-error` (spinner; pip output
+#                              is hidden unless pip exits non-zero)
+#   plain tty (no gum)       : single rolling status line via \r\033[K;
+#                              full output goes to a temp log and is shown
+#                              only on failure
+#   non-tty (CI / piped)     : pip --quiet (errors still surface)
+pip_install_quiet() {
+    local pkgs=("$@")
+
+    if [[ "$VERBOSE" == "1" ]]; then
+        python -m pip install "${pkgs[@]}"
+        return
+    fi
+
+    if [[ -n "$GUM" ]] && gum_is_tty; then
+        "$GUM" spin --show-error \
+            --title "pip install ${pkgs[*]}" \
+            -- python -m pip install "${pkgs[@]}"
+        return
+    fi
+
+    if [[ ! -t 1 ]]; then
+        python -m pip install -q "${pkgs[@]}"
+        return
+    fi
+
+    local log
+    log="$(mktempfile)"
+    local cols max
+    cols="$(tput cols 2>/dev/null || echo 80)"
+    local prefix="  · "
+    max=$((cols - ${#prefix} - 4))
+    (( max < 20 )) && max=20
+
+    local rc=0
+    {
+        python -m pip install "${pkgs[@]}" 2>&1 \
+            | while IFS= read -r line; do
+                printf '%s\n' "$line" >> "$log"
+                printf '\r\033[K%s%s' "$prefix" "${line:0:$max}"
+              done
+    } || rc=$?
+
+    # Clear the rolling status line.
+    printf '\r\033[K'
+
+    if (( rc != 0 )); then
+        ui_error "pip install failed; full output:"
+        sed 's/^/  /' "$log" >&2
+        return $rc
+    fi
+}
+
+setup_python_env() {
+    if [[ ! -d "$VENV_DIR" ]]; then
+        ui_info "Creating virtual environment: $VENV_DIR"
+        create_venv
+    else
+        ui_info "Reusing existing virtual environment: $VENV_DIR"
+    fi
+
+    source "$VENV_DIR/bin/activate"
+
+    export PIP_PROGRESS_BAR=off
+    export PIP_NO_COLOR=1
+    export PIP_NO_INPUT=1
+
+    # PyPI only ships finished release wheels for apache-flink, so a
+    # source-built FLINK_VERSION like "2.1-SNAPSHOT" / "2.0.0-rc1" has
+    # no matching distribution. Fall back to the compatible-release
+    # operator (~= X.Y.0 ↔ >=X.Y.0,<X.(Y+1)) for these cases — the
+    # user's Java JARs are still the source build, but PyFlink will
+    # come from the nearest released minor on PyPI.
+    local apache_flink_spec="apache-flink==${FLINK_VERSION}"
+    if is_snapshot_version "$FLINK_VERSION"; then
+        apache_flink_spec="apache-flink~=${FLINK_MAJOR_MINOR}.0"
+        ui_warn "FLINK_VERSION=${FLINK_VERSION} is a pre-release build; 
installing ${apache_flink_spec} from PyPI instead."
+    fi
+
+    ui_info "Installing Python packages (may take a few minutes)..."
+    pip_install_quiet \
+        "flink-agents==${FLINK_AGENTS_VERSION}" \
+        "$apache_flink_spec"
+}
+
+# Compute the relative path of the flink-agents JAR under the ASF mirror,
+# e.g. flink-agents-0.2.1/flink-agents-dist-flink-2.2-0.2.1.jar
+flink_agents_jar_relpath() {
+    printf '%s' 
"flink-agents-${FLINK_AGENTS_VERSION}/flink-agents-dist-flink-${FLINK_MAJOR_MINOR}-${FLINK_AGENTS_VERSION}.jar"
+}
+
+# Compare a downloaded artifact against its .sha512 sidecar. The sidecar
+# format from ASF is "<hex>  <filename>" (single line). Best-effort: a
+# missing sidecar or missing sha512 tool warns but does not fail (mirrors
+# may not always carry the checksum on time).
+verify_flink_agents_jar_sha512() {
+    local jar="$1"
+    local sha_url="$2"
+    local sha_file
+    sha_file="$(mktempfile)"
+
+    if ! download_file "$sha_url" "$sha_file" >/dev/null 2>&1; then
+        ui_warn "SHA512 sidecar unavailable (${sha_url}); skipping 
verification"
+        return 0
+    fi
+
+    local expected actual
+    expected="$(awk 'NR==1 {print $1}' "$sha_file" 2>/dev/null || true)"
+    if [[ -z "$expected" ]]; then
+        ui_warn "SHA512 sidecar is empty; skipping verification"
+        return 0
+    fi
+    # SHA-512 hex is exactly 128 lowercase/upper hex chars. If the sidecar
+    # didn't look like a real ASF .sha512 file, warn instead of failing.
+    if [[ ! "$expected" =~ ^[A-Fa-f0-9]{128}$ ]]; then
+        ui_warn "SHA512 sidecar at ${sha_url} is not a valid hash; skipping 
verification"
+        return 0
+    fi
+
+    if command -v sha512sum >/dev/null 2>&1; then
+        actual="$(sha512sum "$jar" | awk '{print $1}')"
+    elif command -v shasum >/dev/null 2>&1; then
+        actual="$(shasum -a 512 "$jar" | awk '{print $1}')"
+    else
+        ui_warn "Neither sha512sum nor shasum available; skipping SHA512 
verification"
+        return 0
+    fi
+
+    # Use `tr` for case-folding instead of `${var,,}` — the latter is
+    # bash 4+ only, and macOS still ships /bin/bash 3.2.
+    local expected_lc actual_lc
+    expected_lc="$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')"
+    actual_lc="$(printf '%s' "$actual"   | tr '[:upper:]' '[:lower:]')"
+    if [[ "$expected_lc" != "$actual_lc" ]]; then
+        die "SHA512 mismatch for $(basename "$jar") (expected 
${expected:0:16}…, got ${actual:0:16}…)"
+    fi
+    ui_success "SHA512 verified"
+}
+
+# Download flink-agents-dist-flink-<flink_major.minor>-<agents_ver>.jar
+# directly from the ASF mirror into FLINK_HOME/lib. This replaces the older
+# wheel-extraction path, so Java-only users no longer need Python at all.
+install_flink_agents_jar() {
+    [[ -n "${FLINK_HOME:-}" ]] || die "FLINK_HOME is not set"
+    [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib): 
$FLINK_HOME"
+
+    local relpath jar_name target url sha_url
+    relpath="$(flink_agents_jar_relpath)"
+    jar_name="$(basename "$relpath")"
+    target="$FLINK_HOME/lib/$jar_name"
+    url="${FLINK_AGENTS_BASE_URL}/${relpath}"
+    sha_url="${FLINK_AGENTS_CHECKSUM_BASE_URL}/${relpath}.sha512"
+
+    detect_downloader
+
+    if [[ -f "$target" ]]; then
+        ui_info "Reusing existing JAR: ${target}"
+        verify_flink_agents_jar_sha512 "$target" "$sha_url" || true
+        return 0
+    fi
+
+    ui_info "Downloading ${url}"
+    if ! download_file "$url" "$target"; then
+        rm -f "$target" 2>/dev/null || true
+        die "Failed to download flink-agents JAR from ${url}"
+    fi
+
+    if [[ ! -s "$target" ]]; then
+        rm -f "$target" 2>/dev/null || true
+        die "Downloaded an empty file: ${target}"
+    fi
+
+    verify_flink_agents_jar_sha512 "$target" "$sha_url"
+    ui_success "Installed: ${jar_name} → ${FLINK_HOME}/lib"
+}
+
+check_java() {
+    if ! command -v java &>/dev/null; then
+        ui_warn "Java not found on PATH"
+        if [[ "$OS" == "macos" ]]; then
+            ui_info "Install Java: brew install openjdk@17"
+        elif [[ "$OS" == "linux" ]]; then
+            ui_info "Install Java: sudo apt install openjdk-17-jdk 
(Debian/Ubuntu)"
+            ui_info "              sudo yum install java-17-openjdk-devel 
(RHEL/CentOS)"
+        fi
+        ui_info "Flink requires Java 11+ to run"
+        return 1
+    fi
+
+    local java_version_output
+    java_version_output="$(java -version 2>&1 | head -n1)"
+    local java_major=""
+    java_major="$(echo "$java_version_output" | sed -E -n 's/.*version 
"?([0-9]+).*/\1/p')"
+
+    if [[ -z "$java_major" ]]; then
+        ui_warn "Could not parse Java version from: $java_version_output"
+        return 1
+    fi
+
+    if [[ "$java_major" -lt 11 ]]; then
+        ui_error "Java $java_major detected, but Flink requires Java 11+"
+        ui_info "Please upgrade your Java installation"
+        return 1
+    fi
+
+    ui_success "Java $java_major found"
+
+    if [[ -z "${JAVA_HOME:-}" ]]; then
+        ui_info "JAVA_HOME is not set (Flink will try to detect it 
automatically)"
+    fi
+    return 0
+}
+
+validate_python_bin() {
+    local bin="$1"
+    [[ -n "$bin" ]] || return 1
+    command -v "$bin" >/dev/null 2>&1 || return 1
+
+    local py_version_output
+    py_version_output="$("$bin" -c 'import sys; 
print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || 
true)"
+    [[ -n "$py_version_output" ]] || return 1
+
+    local py_major py_minor
+    py_major="${py_version_output%%.*}"
+    py_minor="${py_version_output##*.}"
+
+    if [[ "$py_major" -ne 3 ]] || [[ "$py_minor" -lt 10 ]] || [[ "$py_minor" 
-ge 12 ]]; then
+        return 1
+    fi
+    return 0
+}
+
+resolve_python() {
+    if [[ -n "$PYTHON_BIN" ]]; then
+        if validate_python_bin "$PYTHON_BIN"; then
+            ui_success "Using Python: $PYTHON_BIN"
+            return 0
+        fi
+        die "PYTHON_BIN is invalid or unsupported (need >=3.10 and <3.12): 
$PYTHON_BIN"
+    fi
+
+    if validate_python_bin python3; then
+        PYTHON_BIN="python3"
+        local v
+        v="$(python3 -c 'import sys; 
print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)"
+        ui_success "Python $v found on PATH"
+        return 0
+    fi
+
+    if command -v python3 >/dev/null 2>&1; then
+        ui_warn "python3 on PATH is incompatible (Flink Agents requires Python 
>=3.10 and <3.12)"
+    else
+        ui_warn "python3 not found on PATH"
+    fi
+
+    if ! is_promptable; then
+        die "No compatible Python found. Set PYTHON_BIN or pass --python 
<path>."
+    fi
+
+    local input
+    input="$(prompt_path_input "Enter path to a Python interpreter" 
"/path/to/python3")"
+    if [[ -z "$input" ]]; then
+        die "No Python interpreter provided."
+    fi
+    if ! validate_python_bin "$input"; then
+        die "Provided Python is invalid or unsupported (need >=3.10 and 
<3.12): $input"
+    fi
+    PYTHON_BIN="$input"
+    ui_success "Using Python: $PYTHON_BIN"
+    return 0
+}
+
+show_install_plan() {
+    ui_section "Environment (read-only)"
+    ui_kv "OS" "$OS"
+    local java_summary="not found"
+    if command -v java >/dev/null 2>&1; then
+        local jv
+        jv="$(java -version 2>&1 | head -n1 | sed -E 's/^[^ ]+ version 
"?([^"]+)"?.*/\1/')"
+        java_summary="${jv:-detected}"
+    fi
+    ui_kv "Java" "$java_summary"
+    if [[ -n "${JAVA_HOME:-}" ]]; then
+        ui_kv "JAVA_HOME" "$JAVA_HOME"
+    else
+        ui_kv "JAVA_HOME" "<not set, Flink will auto-detect>"
+    fi
+    local python_summary="<none>"
+    if [[ -n "$PYTHON_BIN" ]] && command -v "$PYTHON_BIN" >/dev/null 2>&1; then
+        local pv
+        pv="$("$PYTHON_BIN" -c 'import sys; 
print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")'
 2>/dev/null || true)"
+        python_summary="${PYTHON_BIN}${pv:+ ($pv)}"
+    elif command -v python3 >/dev/null 2>&1; then
+        local pv
+        pv="$(python3 -c 'import sys; 
print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")'
 2>/dev/null || true)"
+        python_summary="python3${pv:+ ($pv)}"
+    fi
+    ui_kv "Python" "$python_summary"
+
+    ui_section "Installation plan"
+    ui_kv "Flink Agents version" "$FLINK_AGENTS_VERSION"
+    ui_kv "Install Flink" "$INSTALL_FLINK"
+    if [[ "$INSTALL_FLINK" == "Yes" ]]; then
+        ui_kv "Flink version" "$FLINK_VERSION"
+        ui_kv "Install directory" "$INSTALL_DIR"
+    else
+        if [[ -n "${FLINK_HOME:-}" ]]; then
+            ui_kv "FLINK_HOME" "$FLINK_HOME (v$FLINK_VERSION)"
+        fi
+    fi
+    ui_kv "Enable PyFlink" "$ENABLE_PYFLINK"
+    if [[ "$ENABLE_PYFLINK" == "Yes" ]] || [[ "$PYFLINK_ACTUALLY_ENABLED" -eq 
1 ]]; then
+        ui_kv "Venv directory" "$VENV_DIR"
+    fi
+    if [[ "$DRY_RUN" == "1" ]]; then
+        ui_kv "Dry run" "yes"
+    fi
+    if [[ "$VERIFY_INSTALL" == "1" ]]; then
+        ui_kv "Verify" "yes"
+    fi
+}
+
+verify_installation() {
+    if [[ "${VERIFY_INSTALL}" != "1" ]]; then
+        return 0
+    fi
+
+    ui_stage "Verifying installation"
+
+    if [[ -x "$FLINK_HOME/bin/flink" ]]; then
+        local flink_ver
+        flink_ver="$("$FLINK_HOME/bin/flink" --version 2>/dev/null || true)"
+        if [[ -n "$flink_ver" ]]; then
+            ui_success "Flink binary: $flink_ver"
+        else
+            ui_warn "Flink binary exists but --version failed"
+        fi
+    else
+        ui_warn "Flink binary not found at $FLINK_HOME/bin/flink"
+    fi
+
+    local 
expected_jar="$FLINK_HOME/lib/flink-agents-dist-flink-${FLINK_MAJOR_MINOR}-${FLINK_AGENTS_VERSION}.jar"
+    if [[ -f "$expected_jar" ]]; then
+        ui_success "flink-agents JAR found: $(basename "$expected_jar")"
+    else
+        ui_error "Expected flink-agents JAR missing: $expected_jar"
+        return 1
+    fi
+
+    if [[ "$PYFLINK_ACTUALLY_ENABLED" -eq 1 ]]; then
+        if python -c "import flink_agents; print('flink-agents', 
flink_agents.__version__)" 2>/dev/null; then
+            ui_success "flink-agents Python package verified"
+        else
+            ui_error "flink-agents Python package import failed"
+            return 1
+        fi
+
+        if [[ -f "$FLINK_HOME/lib/flink-python-${FLINK_VERSION}.jar" ]]; then
+            ui_success "flink-python JAR found in FLINK_HOME/lib"
+        else
+            ui_warn "flink-python-${FLINK_VERSION}.jar not found in 
$FLINK_HOME/lib"
+        fi
+    fi
+
+    ui_success "Verification complete"
+}
+
+show_footer_links() {
+    local 
docs_url="https://nightlies.apache.org/flink/flink-agents-docs-latest/";
+    local issues_url="https://github.com/apache/flink-agents/issues";
+    echo ""
+    ui_info "Documentation: ${docs_url}"
+    ui_info "Report issues: ${issues_url}"
+}
+
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --non-interactive)
+                NO_PROMPT=1
+                shift
+                ;;
+            --install-flink)
+                INSTALL_FLINK=Yes
+                shift
+                ;;
+            --enable-pyflink|--enable-pyFlink)
+                ENABLE_PYFLINK=Yes
+                shift
+                ;;
+            --verbose)
+                VERBOSE=1
+                shift
+                ;;
+            --dry-run)
+                DRY_RUN=1
+                shift
+                ;;
+            --verify)
+                VERIFY_INSTALL=1
+                shift
+                ;;
+            --python)
+                if [[ $# -lt 2 ]]; then
+                    die "--python requires a path argument"
+                fi
+                PYTHON_BIN="$2"
+                shift 2
+                ;;
+            --python=*)
+                PYTHON_BIN="${1#*=}"
+                shift
+                ;;
+            --flink-agents-version)
+                if [[ $# -lt 2 ]]; then
+                    die "--flink-agents-version requires a version argument"
+                fi
+                FLINK_AGENTS_VERSION="$2"
+                FLINK_AGENTS_VERSION_EXPLICIT=1
+                shift 2
+                ;;
+            --flink-agents-version=*)
+                FLINK_AGENTS_VERSION="${1#*=}"
+                FLINK_AGENTS_VERSION_EXPLICIT=1
+                shift
+                ;;
+            --flink-version)
+                if [[ $# -lt 2 ]]; then
+                    die "--flink-version requires a version argument"
+                fi
+                FLINK_VERSION="$2"
+                FLINK_VERSION_EXPLICIT=1
+                shift 2
+                ;;
+            --flink-version=*)
+                FLINK_VERSION="${1#*=}"
+                FLINK_VERSION_EXPLICIT=1
+                shift
+                ;;
+            --help|-h)
+                HELP=1
+                shift
+                ;;
+            *)
+                ui_warn "Unknown option: $1"
+                shift
+                ;;
+        esac
+    done
+}
+
+configure_verbose() {
+    if [[ "$VERBOSE" != "1" ]]; then
+        return 0
+    fi
+    if [[ "$NPM_LOGLEVEL" == "error" ]]; then
+        NPM_LOGLEVEL="notice"
+    fi
+    NPM_SILENT_FLAG=""
+    set -x
+}
+
+main() {
+    if [[ "$HELP" == "1" ]]; then
+        print_usage
+        return 0
+    fi
+
+    bootstrap_gum_temp || true
+    print_installer_banner
+    print_gum_status
+    detect_os_or_die
+    check_java || die "Java environment check failed. Please install Java 11 
or newer."
+
+    ui_stage "Planning Flink installation"
+    plan_flink
+    plan_flink_agents
+
+    ui_stage "Planning Python environment"
+    plan_pyflink
+
+    show_install_plan
+
+    if [[ "$DRY_RUN" == "1" ]]; then
+        ui_success "Dry run complete (no changes made)"
+        return 0
+    fi
+
+    confirm_install_plan
+
+    ui_stage "Installing Apache Flink"
+    install_flink_if_needed
+
+    ui_stage "Installing Flink Agents"
+    install_flink_agents_jar
+    if [[ "$ENABLE_PYFLINK" == "Yes" ]]; then
+        copy_pyflink_jar
+        setup_python_env
+    else
+        ui_info "Skipping Python venv (ENABLE_PYFLINK=No)."
+    fi
+
+    ui_stage "Finalizing"
+
+    verify_installation
+
+    echo ""
+    ui_celebrate "Apache Flink Agents installation finished!"
+    echo ""
+    ui_section "Next steps"
+    ui_success "1) Point FLINK_HOME at this install (Flink CLI and clients 
read it):"
+    ui_info  "       export FLINK_HOME=${FLINK_HOME}"
+    if [[ "$PYFLINK_ACTUALLY_ENABLED" -eq 1 ]]; then
+        ui_success "2) Activate the Python venv (PyFlink + flink-agents are 
installed there):"
+        ui_info  "       source ${VENV_DIR}/bin/activate"
+        ui_success "3) To make both permanent, append the two lines above to 
your shell rc"
+        ui_info  "   (~/.zshrc or ~/.bashrc)."
+    else
+        ui_success "2) To make it permanent, append the line above to your 
shell rc"
+        ui_info  "   (~/.zshrc or ~/.bashrc)."
+    fi
+
+    show_footer_links
+}
+
+if [[ "${FLINK_AGENTS_INSTALL_SH_NO_RUN:-0}" != "1" ]]; then
+    parse_args "$@"
+    configure_verbose
+    main
+fi
+
diff --git a/tools/test/.gitignore b/tools/test/.gitignore
new file mode 100644
index 00000000..74b45037
--- /dev/null
+++ b/tools/test/.gitignore
@@ -0,0 +1 @@
+.bats-cache/
diff --git a/tools/test/helpers/load.bash b/tools/test/helpers/load.bash
new file mode 100644
index 00000000..308d450d
--- /dev/null
+++ b/tools/test/helpers/load.bash
@@ -0,0 +1,50 @@
+# Helpers used by every bats file. Loaded via `load 'helpers/load'`.
+
+# Sources install.sh with the no-run hook so main() is skipped.
+load_install_sh() {
+    export FLINK_AGENTS_INSTALL_SH_NO_RUN=1
+    # shellcheck disable=SC1090
+    source "${BATS_TEST_DIRNAME}/../../install.sh"
+    # Note: install.sh sets `set -euo pipefail`, which matches the
+    # strict mode bats itself enables. Leave it on so bats's ERR trap
+    # can detect failed assertions. Use `run <cmd>` (which captures
+    # $status in a subshell) for tests that exercise failure paths.
+}
+
+# Resets every module-level variable install.sh defines, so tests don't
+# leak state into one another.
+reset_install_sh_state() {
+    TMPFILES=()
+    INSTALL_STAGE_CURRENT=0
+    GUM=""
+    GUM_STATUS="skipped"
+    GUM_REASON=""
+    DOWNLOADER=""
+    HELP=0
+    DRY_RUN=0
+    NO_PROMPT=0
+    VERBOSE=0
+    VERIFY_INSTALL=0
+    INSTALL_FLINK="Ask"
+    ENABLE_PYFLINK="Ask"
+    PYTHON_BIN=""
+    PYFLINK_ACTUALLY_ENABLED=0
+    FLINK_VERSION="2.2.0"
+    FLINK_AGENTS_VERSION="0.2.1"
+    FLINK_SCALA_VERSION="2.12"
+    FLINK_BASE_URL="https://dlcdn.apache.org/flink";
+    FLINK_SUPPORTED_VERSIONS=("2.2.0" "2.1.1" "2.0.1" "1.20.3")
+    FLINK_RECOMMENDED_VERSION="2.2.0"
+    INSTALL_DIR="$HOME/.local/flink"
+    VENV_DIR=".flink-agents-env"
+    GUM_VERSION="0.17.0"
+    # Default the bootstrap cache to a per-test directory so we never touch
+    # the developer's real $HOME/.cache when running locally.
+    GUM_CACHE_ROOT="${BATS_TEST_TMPDIR:-/tmp}/gum-cache"
+    FLINK_VERSION_EXPLICIT=0
+    FLINK_AGENTS_VERSION_EXPLICIT=0
+    INSTALL_DIR_EXPLICIT=0
+    VENV_DIR_EXPLICIT=0
+    FLINK_AGENTS_SUPPORTED_VERSIONS=("0.2.1" "0.2.0" "0.1.1" "0.1.0")
+    FLINK_AGENTS_RECOMMENDED_VERSION="0.2.1"
+}
diff --git a/tools/test/helpers/shim.bash b/tools/test/helpers/shim.bash
new file mode 100644
index 00000000..1d60a5b1
--- /dev/null
+++ b/tools/test/helpers/shim.bash
@@ -0,0 +1,88 @@
+# PATH-shim helpers for integration tests. Loaded via `load 'helpers/shim'`.
+#
+# After `shim_setup` runs, $BATS_TEST_TMPDIR/bin is prepended to PATH and
+# every shimmed binary appends one tab-separated line per invocation to
+# $BATS_TEST_TMPDIR/calls/<name>.log.
+#
+# `shim_bin_missing` additionally registers a name in SHIM_MISSING_NAMES;
+# the `command` function override below intercepts `command -v <name>` for
+# those names and returns 1, so install.sh's `command -v curl &>/dev/null`
+# checks see them as unavailable.
+
+shim_setup() {
+    SHIM_DIR="$BATS_TEST_TMPDIR/bin"
+    SHIM_CALLS="$BATS_TEST_TMPDIR/calls"
+    SHIM_MISSING_NAMES=()
+    mkdir -p "$SHIM_DIR" "$SHIM_CALLS"
+    export PATH="$SHIM_DIR:$PATH"
+}
+
+# Override `command` as a shell function. When SHIM_MISSING_NAMES is empty
+# (i.e. shim_setup hasn't been called or nothing has been marked missing),
+# this is fully transparent — every call falls through to `builtin command`.
+command() {
+    if [[ "$1" == "-v" ]]; then
+        local q="$2"
+        local m
+        for m in "${SHIM_MISSING_NAMES[@]:-}"; do
+            if [[ "$q" == "$m" ]]; then
+                return 1
+            fi
+        done
+    fi
+    builtin command "$@"
+}
+
+# Replace `name` with a stub that records argv and exits with `exit_code` 
(default 0).
+shim_bin() {
+    local name="$1" exit_code="${2:-0}"
+    cat >"$SHIM_DIR/$name" <<EOF
+#!/usr/bin/env bash
+( IFS=\$'\t'; printf '%s\n' "\$*" ) >> "$SHIM_CALLS/$name.log"
+exit $exit_code
+EOF
+    chmod +x "$SHIM_DIR/$name"
+}
+
+# Replace `name` with a stub that records argv then runs an arbitrary shell 
body.
+# Note: `body` must not contain a line that is exactly `EOF`, or it will
+# terminate the heredoc prematurely.
+shim_bin_script() {
+    local name="$1" body="$2"
+    cat >"$SHIM_DIR/$name" <<EOF
+#!/usr/bin/env bash
+( IFS=\$'\t'; printf '%s\n' "\$*" ) >> "$SHIM_CALLS/$name.log"
+$body
+EOF
+    chmod +x "$SHIM_DIR/$name"
+}
+
+# Make `name` resolve to "missing" for both `command -v` checks and actual
+# invocation: register it in SHIM_MISSING_NAMES (the `command` override
+# returns 1 for these) and drop an exit-127 stub on PATH (so direct
+# invocation doesn't accidentally hit the real system binary).
+shim_bin_missing() {
+    local name="$1"
+    SHIM_MISSING_NAMES+=("$name")
+    cat >"$SHIM_DIR/$name" <<'EOF'
+#!/usr/bin/env bash
+exit 127
+EOF
+    chmod +x "$SHIM_DIR/$name"
+}
+
+# Print all recorded calls for `name`, one per line, tab-separated argv.
+shim_calls() {
+    local name="$1"
+    cat "$SHIM_CALLS/$name.log" 2>/dev/null || true
+}
+
+# Print the number of times `name` was invoked.
+shim_call_count() {
+    local name="$1"
+    if [[ ! -f "$SHIM_CALLS/$name.log" ]]; then
+        echo 0
+        return
+    fi
+    wc -l < "$SHIM_CALLS/$name.log" | tr -d ' '
+}
diff --git a/tools/test/integration/.gitkeep b/tools/test/integration/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/tools/test/integration/bootstrap_gum_temp.bats 
b/tools/test/integration/bootstrap_gum_temp.bats
new file mode 100644
index 00000000..c35ab9ff
--- /dev/null
+++ b/tools/test/integration/bootstrap_gum_temp.bats
@@ -0,0 +1,165 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load '../helpers/shim'
+    load_install_sh
+    reset_install_sh_state
+    shim_setup
+    # Force interactive-shell + tty path so bootstrap doesn't auto-skip.
+    NO_PROMPT=0
+    # gum_is_tty falls back to checking /dev/tty readability; bats provides 
one.
+    # If the test env happens not to, override via TERM/NO_COLOR being unset.
+    unset NO_COLOR
+    TERM=xterm
+    # Make sure `gum` isn't already on PATH so we exercise the install branch.
+    shim_bin_missing gum
+    # Provide tar so the early "tar not found" branch is skipped.
+    shim_bin tar
+    # Real uname is fine here — we just need a supported os/arch.
+
+    # Override is_non_interactive_shell to report "interactive" by default so
+    # tests that exercise the download path are not short-circuited by stdin/
+    # stdout not being ttys in the bats process.
+    is_non_interactive_shell() { return 1; }
+
+    # Override gum_is_tty to report "has tty" by default so the second guard
+    # in bootstrap_gum_temp does not short-circuit the download path either.
+    gum_is_tty() { return 0; }
+}
+
+@test "bootstrap_gum_temp: non-interactive shell auto-skips with no downloads" 
{
+    # Restore the real check and set NO_PROMPT to force non-interactive.
+    is_non_interactive_shell() {
+        [[ "${NO_PROMPT:-0}" == "1" ]]
+    }
+    NO_PROMPT=1
+    run bootstrap_gum_temp
+    [ "$status" -ne 0 ]
+    [ "$GUM" = "" ]
+    [ "$GUM_STATUS" = "skipped" ]
+    [ "$(shim_call_count curl)" = "0" ]
+    [ "$(shim_call_count wget)" = "0" ]
+}
+
+@test "bootstrap_gum_temp: download failure sets reason='download failed'" {
+    DOWNLOADER=curl
+    shim_bin curl 22
+    bootstrap_gum_temp || true
+    [ "$GUM" = "" ]
+    [ "$GUM_STATUS" = "skipped" ]
+    [ "$GUM_REASON" = "download failed" ]
+}
+
+@test "bootstrap_gum_temp: checksum failure sets reason='checksum unavailable 
or failed'" {
+    DOWNLOADER=curl
+    # curl always succeeds (both asset and checksums.txt downloads succeed)
+    shim_bin curl
+    # sha256sum / shasum both fail
+    shim_bin sha256sum 1
+    shim_bin shasum 1
+    bootstrap_gum_temp || true
+    [ "$GUM_REASON" = "checksum unavailable or failed" ]
+}
+
+@test "bootstrap_gum_temp: extract failure sets reason='extract failed'" {
+    DOWNLOADER=curl
+    shim_bin curl
+    shim_bin sha256sum
+    shim_bin shasum
+    shim_bin tar 1
+    bootstrap_gum_temp || true
+    [ "$GUM_REASON" = "extract failed" ]
+}
+
+@test "bootstrap_gum_temp: missing gum binary after extract sets reason" {
+    DOWNLOADER=curl
+    shim_bin curl
+    shim_bin sha256sum
+    shim_bin shasum
+    # tar 'succeeds' but produces nothing
+    shim_bin tar
+    bootstrap_gum_temp || true
+    [ "$GUM_REASON" = "gum binary missing after extract" ]
+}
+
+@test "bootstrap_gum_temp: gum already on PATH is reported as found" {
+    # Clear the missing-names list so command -v gum sees the shim on PATH.
+    SHIM_MISSING_NAMES=()
+    shim_bin gum
+    bootstrap_gum_temp
+    [ "$GUM" = "gum" ]
+    [ "$GUM_STATUS" = "found" ]
+}
+
+@test "bootstrap_gum_temp: successful download+extract sets 
GUM_STATUS=installed" {
+    DOWNLOADER=curl
+    # curl writes a dummy archive to the -o path
+    shim_bin_script curl '
+out=""; prev=""
+for a in "$@"; do
+    [[ "$prev" == "-o" ]] && out="$a"
+    prev="$a"
+done
+[[ -n "$out" ]] && printf "fake" > "$out"
+'
+    shim_bin sha256sum
+    shim_bin shasum
+    # tar extracts a fake gum binary into the temp dir
+    shim_bin_script tar '
+if [[ "$1" == "-xzf" ]]; then
+    dest="$4"
+    mkdir -p "$dest"
+    printf "#!/bin/bash\n" > "$dest/gum"
+    chmod +x "$dest/gum"
+fi
+'
+    bootstrap_gum_temp
+    [ "$GUM_STATUS" = "installed" ]
+    [ -n "$GUM" ]
+    [ -x "$GUM" ]
+}
+
+@test "bootstrap_gum_temp: cached binary on disk is reused without 
downloading" {
+    DOWNLOADER=curl
+    # Pre-seed the persistent cache with an executable gum.
+    mkdir -p "${GUM_CACHE_ROOT}/${GUM_VERSION}"
+    printf '#!/bin/bash\n' > "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum"
+    chmod +x "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum"
+    # Any downloader call would be a regression — fail loudly if it happens.
+    shim_bin curl 22
+    shim_bin wget 22
+
+    bootstrap_gum_temp
+    [ "$GUM_STATUS" = "cached" ]
+    [ "$GUM" = "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" ]
+    [ -x "$GUM" ]
+    [ "$(shim_call_count curl)" = "0" ]
+    [ "$(shim_call_count wget)" = "0" ]
+}
+
+@test "bootstrap_gum_temp: successful install promotes binary into the cache" {
+    DOWNLOADER=curl
+    shim_bin_script curl '
+out=""; prev=""
+for a in "$@"; do
+    [[ "$prev" == "-o" ]] && out="$a"
+    prev="$a"
+done
+[[ -n "$out" ]] && printf "fake" > "$out"
+'
+    shim_bin sha256sum
+    shim_bin shasum
+    shim_bin_script tar '
+if [[ "$1" == "-xzf" ]]; then
+    dest="$4"
+    mkdir -p "$dest"
+    printf "#!/bin/bash\n" > "$dest/gum"
+    chmod +x "$dest/gum"
+fi
+'
+    bootstrap_gum_temp
+    [ "$GUM_STATUS" = "installed" ]
+    [ -x "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" ]
+    [ "$GUM" = "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" ]
+}
diff --git a/tools/test/integration/download_file.bats 
b/tools/test/integration/download_file.bats
new file mode 100644
index 00000000..2b02d075
--- /dev/null
+++ b/tools/test/integration/download_file.bats
@@ -0,0 +1,88 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load '../helpers/shim'
+    load_install_sh
+    reset_install_sh_state
+    shim_setup
+}
+
+@test "download_file: curl is invoked with the exact expected argv" {
+    DOWNLOADER=curl
+    shim_bin curl
+
+    download_file "https://example.test/x.tgz"; "$BATS_TEST_TMPDIR/out"
+
+    [ "$(shim_call_count curl)" = "1" ]
+    local got
+    got="$(shim_calls curl)"
+    local expected
+    
expected=$'-fL\t--progress-bar\t--proto\t=https\t--tlsv1.2\t--retry\t3\t--max-time\t900\t--retry-delay\t1\t--retry-connrefused\t-o\t'"$BATS_TEST_TMPDIR/out"$'\thttps://example.test/x.tgz'
+    [ "$got" = "$expected" ]
+}
+
+@test "download_file: curl argv contains no stray '--' token (PR #599 
regression guard)" {
+    DOWNLOADER=curl
+    shim_bin curl
+
+    download_file "https://example.test/x.tgz"; "$BATS_TEST_TMPDIR/out"
+
+    local got
+    got="$(shim_calls curl)"
+    # A bare '--' token would appear as tab-bracketed or at line edges.
+    # Use POSIX [ ] with string-prefix stripping: if the pattern is absent,
+    # stripping it leaves got unchanged (strings are equal → [ ] succeeds).
+    # bash 3.2 does not trigger set -e for [[ ]] failures, so [ ] is required.
+    [ "${got#*$'\t--\t'}" = "$got" ]
+    [ "${got%$'\t--'}" = "$got" ]
+    [ "${got#'--'}" = "$got" ]
+}
+
+@test "download_file: curl failure propagates" {
+    DOWNLOADER=curl
+    shim_bin curl 22
+
+    run download_file "https://example.test/x.tgz"; "$BATS_TEST_TMPDIR/out"
+    [ "$status" -ne 0 ]
+}
+
+@test "download_file: wget is invoked with the exact expected argv" {
+    DOWNLOADER=wget
+    shim_bin wget
+
+    download_file "https://example.test/x.tgz"; "$BATS_TEST_TMPDIR/out"
+
+    [ "$(shim_call_count wget)" = "1" ]
+    local got
+    got="$(shim_calls wget)"
+    local expected
+    
expected=$'-q\t--show-progress\t--https-only\t--secure-protocol=TLSv1_2\t--tries=3\t--timeout=900\t-O\t'"$BATS_TEST_TMPDIR/out"$'\thttps://example.test/x.tgz'
+    [ "$got" = "$expected" ]
+}
+
+@test "detect_downloader: picks curl when available" {
+    DOWNLOADER=""
+    shim_bin curl
+    shim_bin wget
+    detect_downloader
+    [ "$DOWNLOADER" = "curl" ]
+}
+
+@test "detect_downloader: falls back to wget when curl is missing" {
+    DOWNLOADER=""
+    shim_bin_missing curl
+    shim_bin wget
+    detect_downloader
+    [ "$DOWNLOADER" = "wget" ]
+}
+
+@test "detect_downloader: dies when neither curl nor wget is available" {
+    DOWNLOADER=""
+    shim_bin_missing curl
+    shim_bin_missing wget
+    run detect_downloader
+    [ "$status" -ne 0 ]
+    # Use POSIX [ ] for set -e compatibility on bash 3.2 (see also Test 2).
+    [ "${output#*Missing downloader}" != "$output" ]
+}
diff --git a/tools/test/integration/dry_run.bats 
b/tools/test/integration/dry_run.bats
new file mode 100644
index 00000000..f1698214
--- /dev/null
+++ b/tools/test/integration/dry_run.bats
@@ -0,0 +1,54 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load '../helpers/shim'
+    shim_setup
+    # Fake FLINK_HOME so plan_flink (with INSTALL_FLINK=No) passes validation.
+    export FLINK_HOME="$BATS_TEST_TMPDIR/flink-home"
+    mkdir -p "$FLINK_HOME/lib"
+    # Fake `java` so check_java passes.
+    shim_bin_script java "
+case \"\$1\" in
+    -version) echo 'openjdk version \"17.0.2\"' >&2 ;;
+esac
+"
+}
+
+@test "--dry-run --non-interactive prints plan and makes no external calls" {
+    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run 
--non-interactive
+    [ "$status" -eq 0 ]
+    case "$output" in *"Installation plan"*) ;; *) false ;; esac
+    case "$output" in *"Dry run complete"*) ;; *) false ;; esac
+    # No downloader should have been invoked.
+    [ "$(shim_call_count curl)" = "0" ]
+    [ "$(shim_call_count wget)" = "0" ]
+}
+
+@test "--dry-run --install-flink --non-interactive shows Install Flink: Yes" {
+    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink 
--non-interactive
+    [ "$status" -eq 0 ]
+    case "$output" in *"Install Flink"*) ;; *) false ;; esac
+    case "$output" in *"Yes"*) ;; *) false ;; esac
+    case "$output" in *"Dry run complete"*) ;; *) false ;; esac
+}
+
+@test "INSTALL_DIR=. does not produce a double-slash FLINK_HOME (review 
feedback guard)" {
+    cd "$BATS_TEST_TMPDIR"
+    run env INSTALL_DIR="." bash "${BATS_TEST_DIRNAME}/../../install.sh" 
--dry-run --install-flink --non-interactive
+    [ "$status" -eq 0 ]
+    case "$output" in *".//flink-"*) false ;; *) ;; esac
+}
+
+@test "INSTALL_DIR with trailing slash does not produce double-slash (review 
#7b)" {
+    run env INSTALL_DIR="/tmp/flink-test/" bash 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink 
--non-interactive
+    [ "$status" -eq 0 ]
+    case "$output" in *"//"*) false ;; *) ;; esac
+    case "$output" in *"/tmp/flink-test"*) ;; *) false ;; esac
+}
+
+@test "INSTALL_DIR with consecutive slashes is collapsed (review #7b)" {
+    run env INSTALL_DIR="/tmp//flink-test" bash 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink 
--non-interactive
+    [ "$status" -eq 0 ]
+    case "$output" in *"//flink-"*) false ;; *) ;; esac
+}
diff --git a/tools/test/integration/dry_run_extra.bats 
b/tools/test/integration/dry_run_extra.bats
new file mode 100644
index 00000000..1e7efac0
--- /dev/null
+++ b/tools/test/integration/dry_run_extra.bats
@@ -0,0 +1,42 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load '../helpers/shim'
+    shim_setup
+    export FLINK_HOME="$BATS_TEST_TMPDIR/flink-home"
+    mkdir -p "$FLINK_HOME/lib" "$FLINK_HOME/bin"
+    # Seed a fake flink-dist jar so detect_flink_version_from_home succeeds
+    # and the plan reflects the on-disk version (review feedback #2 + #3).
+    : > "$FLINK_HOME/lib/flink-dist-2.1.1.jar"
+    shim_bin_script java "
+case \"\$1\" in
+    -version) echo 'openjdk version \"17.0.2\"' >&2 ;;
+esac
+"
+}
+
+@test "dry-run: plan shows Flink Agents version (review #1 — JARs are 
implicit)" {
+    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run 
--non-interactive
+    [ "$status" -eq 0 ]
+    case "$output" in *"Flink Agents version"*"0.2.1"*) ;; *) false ;; esac
+}
+
+@test "dry-run: existing FLINK_HOME — plan shows detected version, not default 
(review #2)" {
+    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run 
--non-interactive
+    [ "$status" -eq 0 ]
+    case "$output" in *"v2.1.1"*) ;; *) false ;; esac
+}
+
+@test "dry-run: INSTALL_FLINK=No suppresses Install directory line (review 
#3)" {
+    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run 
--non-interactive
+    [ "$status" -eq 0 ]
+    case "$output" in *"Install directory"*) false ;; *) ;; esac
+}
+
+@test "dry-run: Environment section is shown separately from Plan (review #5)" 
{
+    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run 
--non-interactive
+    [ "$status" -eq 0 ]
+    case "$output" in *"Environment (read-only)"*) ;; *) false ;; esac
+    case "$output" in *"Installation plan"*) ;; *) false ;; esac
+}
diff --git a/tools/test/integration/err_trap.bats 
b/tools/test/integration/err_trap.bats
new file mode 100644
index 00000000..bc553c53
--- /dev/null
+++ b/tools/test/integration/err_trap.bats
@@ -0,0 +1,39 @@
+#!/usr/bin/env bats
+
+# When a command fails under `set -e` (i.e. NOT through die()/die_cancelled),
+# the ERR trap should print a banner that names the stage, the command,
+# the install.sh line, and the exit code. die() must keep its existing
+# single-line message and not trip the banner.
+
+@test "on_error: set -e failure triggers a stage-aware banner" {
+    # Use a real failure mode: pretend a Flink download 404'd. The script
+    # walks through plan_flink with INSTALL_FLINK=Yes (default INSTALL_DIR,
+    # but we override it to /tmp) and then dies on curl in stage 3.
+    local tmp="$BATS_TEST_TMPDIR/demo"
+    mkdir -p "$tmp"
+    run env INSTALL_DIR="$tmp" FLINK_BASE_URL="https://dlcdn.apache.org/flink"; 
\
+        FLINK_VERSION=99.99.0 \
+        bash "${BATS_TEST_DIRNAME}/../../install.sh" \
+            --install-flink --non-interactive
+    [ "$status" -ne 0 ]
+    # Banner must name the stage by title.
+    case "$output" in *"Installation failed at stage 3/5"*) ;; *) false ;; esac
+    case "$output" in *"Installing Apache Flink"*) ;; *) false ;; esac
+    # Banner must include source line + exit code keywords.
+    case "$output" in *"Source:"*"install.sh:"*) ;; *) false ;; esac
+    case "$output" in *"Exit code:"*) ;; *) false ;; esac
+    # Banner must not point at the trap line itself, which would be a
+    # regression — we want the line where the failing command lives.
+    case "$output" in *"install.sh:0"*) false ;; *) ;; esac
+}
+
+@test "die(): single-line message, NO banner duplication" {
+    # die() uses `exit`, which doesn't trigger ERR. Reach it by giving
+    # plan_flink a non-existent FLINK_HOME under --non-interactive.
+    run env FLINK_HOME="" \
+        bash "${BATS_TEST_DIRNAME}/../../install.sh" --non-interactive
+    [ "$status" -ne 0 ]
+    case "$output" in *"FLINK_HOME is not set"*) ;; *) false ;; esac
+    # Banner box characters must NOT appear in a die() path.
+    case "$output" in *"Installation failed at stage"*) false ;; *) ;; esac
+}
diff --git a/tools/test/integration/help.bats b/tools/test/integration/help.bats
new file mode 100644
index 00000000..d77335e3
--- /dev/null
+++ b/tools/test/integration/help.bats
@@ -0,0 +1,16 @@
+#!/usr/bin/env bats
+
+@test "--help prints usage and exits 0" {
+    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --help
+    [ "$status" -eq 0 ]
+    case "$output" in *"Apache Flink Agents Installer"*) ;; *) false ;; esac
+    case "$output" in *"Options:"*) ;; *) false ;; esac
+    case "$output" in *"--install-flink"*) ;; *) false ;; esac
+    case "$output" in *"--dry-run"*) ;; *) false ;; esac
+}
+
+@test "-h prints usage and exits 0" {
+    run bash "${BATS_TEST_DIRNAME}/../../install.sh" -h
+    [ "$status" -eq 0 ]
+    case "$output" in *"Apache Flink Agents Installer"*) ;; *) false ;; esac
+}
diff --git a/tools/test/integration/install_flink_agents_jar.bats 
b/tools/test/integration/install_flink_agents_jar.bats
new file mode 100644
index 00000000..146967cc
--- /dev/null
+++ b/tools/test/integration/install_flink_agents_jar.bats
@@ -0,0 +1,98 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load '../helpers/shim'
+    load_install_sh
+    reset_install_sh_state
+    shim_setup
+    FLINK_HOME="$BATS_TEST_TMPDIR/flink-home"
+    mkdir -p "$FLINK_HOME/lib"
+    FLINK_VERSION="2.2.0"
+    FLINK_MAJOR_MINOR="2.2"
+    FLINK_AGENTS_VERSION="0.2.1"
+    FLINK_AGENTS_BASE_URL="https://mirror.test/flink";
+    FLINK_AGENTS_CHECKSUM_BASE_URL="https://downloads.test/flink";
+}
+
+# A curl shim that writes whatever was requested at -o into a fake file
+# whose body is "fake-jar-<basename>" — enough to satisfy the "non-empty"
+# check, and lets us verify which URLs were requested.
+configure_curl_shim() {
+    shim_bin_script curl '
+out=""; url=""; prev=""
+for a in "$@"; do
+    [[ "$prev" == "-o" ]] && out="$a"
+    case "$a" in -*|"") ;; *) url="$a" ;; esac
+    prev="$a"
+done
+[[ -n "$out" ]] && printf "fake-content-%s" "$(basename "$out")" > "$out"
+'
+}
+
+@test "flink_agents_jar_relpath: builds expected ASF mirror path" {
+    FLINK_MAJOR_MINOR="2.1"
+    FLINK_AGENTS_VERSION="0.2.0"
+    run flink_agents_jar_relpath
+    [ "$status" -eq 0 ]
+    [ "$output" = "flink-agents-0.2.0/flink-agents-dist-flink-2.1-0.2.0.jar" ]
+}
+
+@test "install_flink_agents_jar: downloads from mirror, verifies, lands in 
FLINK_HOME/lib" {
+    DOWNLOADER=curl
+    configure_curl_shim
+
+    run install_flink_agents_jar
+    [ "$status" -eq 0 ]
+    [ -f "$FLINK_HOME/lib/flink-agents-dist-flink-2.2-0.2.1.jar" ]
+
+    # Two curl calls expected: jar from mirror, sha512 sidecar from 
downloads.apache.org.
+    case "$(shim_calls curl)" in
+        
*"https://mirror.test/flink/flink-agents-0.2.1/flink-agents-dist-flink-2.2-0.2.1.jar"*)
 ;;
+        *) false ;;
+    esac
+    case "$(shim_calls curl)" in
+        
*"https://downloads.test/flink/flink-agents-0.2.1/flink-agents-dist-flink-2.2-0.2.1.jar.sha512"*)
 ;;
+        *) false ;;
+    esac
+}
+
+@test "install_flink_agents_jar: reuses existing JAR, skips re-download" {
+    DOWNLOADER=curl
+    configure_curl_shim
+    # Pre-seed the target JAR.
+    : > "$FLINK_HOME/lib/flink-agents-dist-flink-2.2-0.2.1.jar"
+
+    run install_flink_agents_jar
+    [ "$status" -eq 0 ]
+    # Only the sha512 sidecar should have been fetched, never the JAR itself.
+    case "$(shim_calls curl)" in
+        
*"flink-agents-dist-flink-2.2-0.2.1.jar"*"flink-agents-dist-flink-2.2-0.2.1.jar"*)
 false ;;
+        *) ;;
+    esac
+}
+
+@test "install_flink_agents_jar: empty downloaded file is a hard error" {
+    DOWNLOADER=curl
+    # Shim that "succeeds" but writes nothing.
+    shim_bin_script curl '
+out=""; prev=""
+for a in "$@"; do
+    [[ "$prev" == "-o" ]] && out="$a"
+    prev="$a"
+done
+[[ -n "$out" ]] && : > "$out"
+'
+    run install_flink_agents_jar
+    [ "$status" -ne 0 ]
+    case "$output" in *"empty file"*) ;; *) false ;; esac
+    [ ! -f "$FLINK_HOME/lib/flink-agents-dist-flink-2.2-0.2.1.jar" ]
+}
+
+@test "install_flink_agents_jar: download failure surfaces a clean error" {
+    DOWNLOADER=curl
+    shim_bin curl 22  # curl exit 22 = HTTP error
+    run install_flink_agents_jar
+    [ "$status" -ne 0 ]
+    case "$output" in *"Failed to download flink-agents JAR"*) ;; *) false ;; 
esac
+}
diff --git a/tools/test/integration/install_flink_if_needed.bats 
b/tools/test/integration/install_flink_if_needed.bats
new file mode 100644
index 00000000..7bbdceee
--- /dev/null
+++ b/tools/test/integration/install_flink_if_needed.bats
@@ -0,0 +1,132 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load '../helpers/shim'
+    load_install_sh
+    reset_install_sh_state
+    shim_setup
+    INSTALL_DIR="$BATS_TEST_TMPDIR/install"
+    FLINK_VERSION="2.2.0"
+    FLINK_SCALA_VERSION="2.12"
+    FLINK_BASE_URL="https://example.test/flink";
+}
+
+# A tar shim that succeeds for both `-tzf` (validity check) and `-xzf` 
(extract).
+# On extract it materializes a minimal Flink home with a lib/ dir.
+configure_tar_shim() {
+    shim_bin_script tar "
+case \"\$1\" in
+    -tzf) exit 0 ;;
+    -xzf)
+        # arg order: -xzf <archive> -C <dest>  (\$4 = dest)
+        mkdir -p \"\$4/flink-${FLINK_VERSION}/lib\"
+        ;;
+    *) exit 0 ;;
+esac
+"
+}
+
+@test "install_flink_if_needed: INSTALL_FLINK=No uses pre-existing FLINK_HOME" 
{
+    INSTALL_FLINK=No
+    FLINK_HOME="$BATS_TEST_TMPDIR/preexisting"
+    mkdir -p "$FLINK_HOME/lib"
+    run install_flink_if_needed
+    [ "$status" -eq 0 ]
+    [ "$(shim_call_count curl)" = "0" ]
+    [ "$(shim_call_count wget)" = "0" ]
+}
+
+@test "install_flink_if_needed: fresh install downloads then extracts" {
+    INSTALL_FLINK=Yes
+    DOWNLOADER=curl
+    FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION"
+    # curl shim writes a placeholder archive at the requested -o path.
+    shim_bin_script curl "
+# argv pattern: ... -o <output> <url>
+out=\"\"
+prev=\"\"
+for a in \"\$@\"; do
+    if [[ \"\$prev\" == \"-o\" ]]; then out=\"\$a\"; fi
+    prev=\"\$a\"
+done
+[[ -n \"\$out\" ]] && printf 'fake tgz' > \"\$out\"
+"
+    configure_tar_shim
+
+    run install_flink_if_needed
+    [ "$status" -eq 0 ]
+    [ "$(shim_call_count curl)" = "1" ]
+    case "$(shim_calls curl)" in
+        
*"https://example.test/flink/flink-2.2.0/flink-2.2.0-bin-scala_2.12.tgz"*) ;;
+        *) false ;;
+    esac
+    [ -d "$INSTALL_DIR/flink-$FLINK_VERSION/lib" ]
+}
+
+@test "install_flink_if_needed: existing valid archive is reused (no 
download)" {
+    INSTALL_FLINK=Yes
+    DOWNLOADER=curl
+    mkdir -p "$INSTALL_DIR"
+    # Pre-seed a "valid" archive — tar shim will report it as valid via -tzf.
+    printf 'existing' > 
"$INSTALL_DIR/flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+    shim_bin curl
+    configure_tar_shim
+    FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION"
+
+    run install_flink_if_needed
+    [ "$status" -eq 0 ]
+    [ "$(shim_call_count curl)" = "0" ]
+}
+
+@test "install_flink_if_needed: corrupt existing archive triggers re-download" 
{
+    INSTALL_FLINK=Yes
+    DOWNLOADER=curl
+    mkdir -p "$INSTALL_DIR"
+    local 
archive="$INSTALL_DIR/flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+    printf 'existing' > "$archive"
+    # tar reports the existing archive as INVALID, then valid on the 
re-download.
+    local state="$BATS_TEST_TMPDIR/tar_state"
+    : > "$state"
+    shim_bin_script tar "
+case \"\$1\" in
+    -tzf)
+        if [[ ! -s '$state' ]]; then
+            echo bad > '$state'
+            exit 1
+        fi
+        exit 0
+        ;;
+    -xzf)
+        mkdir -p \"\$4/flink-${FLINK_VERSION}/lib\"
+        ;;
+esac
+"
+    shim_bin_script curl "
+out=\"\"; prev=\"\"
+for a in \"\$@\"; do
+    [[ \"\$prev\" == \"-o\" ]] && out=\"\$a\"
+    prev=\"\$a\"
+done
+[[ -n \"\$out\" ]] && printf 'freshly downloaded' > \"\$out\"
+"
+    FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION"
+
+    run install_flink_if_needed
+    [ "$status" -eq 0 ]
+    [ "$(shim_call_count curl)" = "1" ]
+}
+
+@test "install_flink_if_needed: incomplete extracted dir triggers re-extract" {
+    INSTALL_FLINK=Yes
+    DOWNLOADER=curl
+    mkdir -p "$INSTALL_DIR/flink-${FLINK_VERSION}"  # missing lib/
+    printf 'archive' > 
"$INSTALL_DIR/flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+    shim_bin curl
+    configure_tar_shim
+    FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION"
+
+    run install_flink_if_needed
+    [ "$status" -eq 0 ]
+    [ -d "$INSTALL_DIR/flink-$FLINK_VERSION/lib" ]
+}
diff --git a/tools/test/integration/venv_dir_validation.bats 
b/tools/test/integration/venv_dir_validation.bats
new file mode 100644
index 00000000..55fc2315
--- /dev/null
+++ b/tools/test/integration/venv_dir_validation.bats
@@ -0,0 +1,61 @@
+#!/usr/bin/env bats
+
+# End-to-end behavior of VENV_DIR validation. In non-interactive mode
+# (the only mode the integration runner can drive), passing a foreign
+# non-empty directory as VENV_DIR must abort BEFORE downloading Flink.
+
+setup() {
+    load '../helpers/load'
+    load '../helpers/shim'
+    shim_setup
+    # Fake an existing Flink so plan_flink (INSTALL_FLINK=No) passes.
+    export FLINK_HOME="$BATS_TEST_TMPDIR/flink-home"
+    mkdir -p "$FLINK_HOME/lib"
+    : > "$FLINK_HOME/lib/flink-dist-2.2.0.jar"
+    # Fake java so check_java passes.
+    shim_bin_script java "
+case \"\$1\" in
+    -version) echo 'openjdk version \"17.0.2\"' >&2 ;;
+esac
+"
+}
+
+@test "VENV_DIR=non-empty foreign dir + --non-interactive → die before 
download" {
+    local foreign="$BATS_TEST_TMPDIR/foreign"
+    mkdir -p "$foreign"
+    : > "$foreign/unrelated.txt"
+
+    run env VENV_DIR="$foreign" PYTHON_BIN=/no/such/python3 \
+        bash "${BATS_TEST_DIRNAME}/../../install.sh" \
+            --non-interactive --enable-pyflink
+
+    [ "$status" -ne 0 ]
+    case "$output" in
+        *"already exists and is not a Python venv"*) ;;
+        *) false ;;
+    esac
+    # Critical: must NOT have invoked any downloader before failing.
+    [ "$(shim_call_count curl)" = "0" ]
+    [ "$(shim_call_count wget)" = "0" ]
+}
+
+@test "VENV_DIR=existing real venv (has pyvenv.cfg) is accepted in 
non-interactive mode" {
+    local venv="$BATS_TEST_TMPDIR/real-venv"
+    mkdir -p "$venv/bin"
+    : > "$venv/pyvenv.cfg"
+    : > "$venv/bin/activate"
+
+    # We don't actually want it to *install* — just to get past the
+    # plan_pyflink validation. Use --dry-run so plan succeeds and we
+    # bail before stage 3.
+    run env VENV_DIR="$venv" PYTHON_BIN=/no/such/python3 \
+        bash "${BATS_TEST_DIRNAME}/../../install.sh" \
+            --non-interactive --enable-pyflink --dry-run
+    # Either the dry-run printout completes (status 0) or fails for an
+    # unrelated reason (e.g. PYTHON_BIN missing in non-interactive 
resolve_python).
+    # The thing we explicitly want NOT to see: the VENV_DIR rejection.
+    case "$output" in
+        *"is not a Python venv"*) false ;;
+        *) ;;
+    esac
+}
diff --git a/tools/test/run.sh b/tools/test/run.sh
new file mode 100755
index 00000000..c5d303d7
--- /dev/null
+++ b/tools/test/run.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+# Bash 4+ is required: bash 3.2 (macOS default) does not trigger `set -e`
+# on `[[ ]]` failures or fire the ERR trap on them, which means many
+# substring assertions in this suite would silently pass on bash 3.2.
+# Force a clean failure here rather than mislead developers.
+if [ -z "${BASH_VERSION:-}" ] || [ "${BASH_VERSION%%.*}" -lt 4 ]; then
+    echo "ERROR: bash >= 4 required (detected: ${BASH_VERSION:-unknown})." >&2
+    echo "macOS ships bash 3.2 at /bin/bash; install bash 4+ via Homebrew:" >&2
+    echo "    brew install bash" >&2
+    echo "Then run with the new bash, e.g.:" >&2
+    echo "    /opt/homebrew/bin/bash $0" >&2
+    exit 1
+fi
+
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CACHE="$HERE/.bats-cache"
+
+clone_pinned() {
+    local name="$1" url="$2" tag="$3"
+    if [[ ! -d "$CACHE/$name" ]]; then
+        echo "Fetching $name@$tag" >&2
+        git clone --quiet --depth 1 --branch "$tag" "$url" "$CACHE/$name"
+    fi
+}
+
+mkdir -p "$CACHE"
+clone_pinned bats-core    https://github.com/bats-core/bats-core.git    v1.11.0
+clone_pinned bats-support https://github.com/bats-core/bats-support.git v0.3.0
+clone_pinned bats-assert  https://github.com/bats-core/bats-assert.git  v2.1.0
+
+export BATS_LIB_PATH="$CACHE"
+
+exec "$CACHE/bats-core/bin/bats" --recursive "$HERE/unit" "$HERE/integration"
diff --git a/tools/test/unit/detect_flink_version_from_home.bats 
b/tools/test/unit/detect_flink_version_from_home.bats
new file mode 100644
index 00000000..546bb51a
--- /dev/null
+++ b/tools/test/unit/detect_flink_version_from_home.bats
@@ -0,0 +1,114 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+    FLINK_HOME="$BATS_TEST_TMPDIR/flink-home"
+    mkdir -p "$FLINK_HOME/bin" "$FLINK_HOME/lib"
+}
+
+@test "detect_flink_version_from_home: prefers bin/flink --version when 
runnable" {
+    cat > "$FLINK_HOME/bin/flink" <<'EOF'
+#!/usr/bin/env bash
+echo "Version: 2.1.1, Commit ID: abc"
+EOF
+    chmod +x "$FLINK_HOME/bin/flink"
+    FLINK_VERSION=""
+    run detect_flink_version_from_home
+    [ "$status" -eq 0 ]
+    [ "$FLINK_VERSION" = "2.1.1" ] || {
+        # `run` executes in subshell — re-run inline to read the assignment.
+        FLINK_VERSION=""
+        detect_flink_version_from_home
+        [ "$FLINK_VERSION" = "2.1.1" ]
+    }
+}
+
+@test "detect_flink_version_from_home: falls back to lib/flink-dist-*.jar" {
+    : > "$FLINK_HOME/lib/flink-dist-2.0.1.jar"
+    FLINK_VERSION=""
+    detect_flink_version_from_home
+    [ "$FLINK_VERSION" = "2.0.1" ]
+}
+
+@test "detect_flink_version_from_home: returns non-zero when no source 
available" {
+    FLINK_VERSION=""
+    run detect_flink_version_from_home
+    [ "$status" -ne 0 ]
+}
+
+@test "detect_flink_version_from_home: ignores stderr noise from flink 
--version" {
+    cat > "$FLINK_HOME/bin/flink" <<'EOF'
+#!/usr/bin/env bash
+echo "WARNING: some noise" >&2
+echo "Version: 1.20.3, Commit ID: deadbeef"
+EOF
+    chmod +x "$FLINK_HOME/bin/flink"
+    FLINK_VERSION=""
+    detect_flink_version_from_home
+    [ "$FLINK_VERSION" = "1.20.3" ]
+}
+
+@test "detect_flink_version_from_home: jar fallback picks first match 
deterministically" {
+    : > "$FLINK_HOME/lib/flink-dist-2.2.0.jar"
+    : > "$FLINK_HOME/lib/flink-dist-other-1.0.jar"
+    FLINK_VERSION=""
+    detect_flink_version_from_home
+    [ "$FLINK_VERSION" = "2.2.0" ]
+}
+
+@test "detect_flink_version_from_home: announces progress before invoking 
bin/flink" {
+    # No dist jar in lib/ -> forces the slow path that runs bin/flink 
--version.
+    cat > "$FLINK_HOME/bin/flink" <<'EOF'
+#!/usr/bin/env bash
+echo "Version: 2.1.1, Commit ID: abc"
+EOF
+    chmod +x "$FLINK_HOME/bin/flink"
+    FLINK_VERSION=""
+    run detect_flink_version_from_home
+    [ "$status" -eq 0 ]
+    case "$output" in *"Detecting Flink version"*) ;; *) false ;; esac
+}
+
+@test "detect_flink_version_from_home: fast jar path does NOT announce 
progress" {
+    : > "$FLINK_HOME/lib/flink-dist-2.0.1.jar"
+    FLINK_VERSION=""
+    run detect_flink_version_from_home
+    [ "$status" -eq 0 ]
+    case "$output" in *"Detecting Flink version"*) false ;; *) ;; esac
+}
+
+@test "detect_flink_version_from_home: accepts X.Y-SNAPSHOT from local source 
builds" {
+    : > "$FLINK_HOME/lib/flink-dist-2.2-SNAPSHOT.jar"
+    FLINK_VERSION=""
+    detect_flink_version_from_home
+    [ "$FLINK_VERSION" = "2.2-SNAPSHOT" ]
+}
+
+@test "detect_flink_version_from_home: accepts X.Y.Z-SNAPSHOT" {
+    : > "$FLINK_HOME/lib/flink-dist-2.1.0-SNAPSHOT.jar"
+    FLINK_VERSION=""
+    detect_flink_version_from_home
+    [ "$FLINK_VERSION" = "2.1.0-SNAPSHOT" ]
+}
+
+@test "detect_flink_version_from_home: accepts -rc suffix from CLI output" {
+    cat > "$FLINK_HOME/bin/flink" <<'EOF'
+#!/usr/bin/env bash
+echo "Version: 2.0.0-rc1, Commit ID: abc"
+EOF
+    chmod +x "$FLINK_HOME/bin/flink"
+    FLINK_VERSION=""
+    detect_flink_version_from_home
+    [ "$FLINK_VERSION" = "2.0.0-rc1" ]
+}
+
+@test "flink_major_minor: derives X.Y from various version strings" {
+    [ "$(flink_major_minor "2.2.0")"          = "2.2" ]
+    [ "$(flink_major_minor "2.2.0-SNAPSHOT")" = "2.2" ]
+    [ "$(flink_major_minor "2.2-SNAPSHOT")"   = "2.2" ]
+    [ "$(flink_major_minor "1.20.3")"         = "1.20" ]
+    [ "$(flink_major_minor "2.1-rc1")"        = "2.1" ]
+    [ "$(flink_major_minor "garbage")"        = "" ]
+}
diff --git a/tools/test/unit/edit_plan_back.bats 
b/tools/test/unit/edit_plan_back.bats
new file mode 100644
index 00000000..40c4796b
--- /dev/null
+++ b/tools/test/unit/edit_plan_back.bats
@@ -0,0 +1,69 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+}
+
+# When a sub-prompt's ESC propagates `exit 130` out of edit_plan_interactive's
+# subshell, the surrounding `set -e` MUST NOT kill the installer. The wrapper
+# should swallow the non-zero exit and return 0 ("back to confirm").
+
+@test "edit_plan_interactive: subshell exit 130 is treated as back, not as 
installer kill" {
+    # Replace edit_plan_interactive with a stand-in that runs a subshell
+    # which exits 130 the same way the real ESC path does, and uses the
+    # exact `|| rc=$?` pattern we ship.
+    fake_edit() {
+        local rc=0
+        (
+            exit 130
+        ) || rc=$?
+        if (( rc != 0 )); then
+            return 0
+        fi
+        return 99   # we should never get here
+    }
+
+    # If `set -e` were still propagating the 130, the test process would
+    # die here. Reaching the assertion means the pattern works.
+    run fake_edit
+    [ "$status" -eq 0 ]
+}
+
+@test "edit_plan_interactive: subshell exit 1 (gum ESC) also becomes back" {
+    fake_edit() {
+        local rc=0
+        ( exit 1 ) || rc=$?
+        if (( rc != 0 )); then
+            return 0
+        fi
+        return 99
+    }
+    run fake_edit
+    [ "$status" -eq 0 ]
+}
+
+@test "edit_plan_interactive: subshell exit 0 sources state and propagates 
changes" {
+    local state_file="$BATS_TEST_TMPDIR/state"
+    cat > "$state_file" <<EOF
+INSTALL_FLINK='Yes'
+FLINK_VERSION='9.9.9'
+EOF
+
+    fake_edit() {
+        local rc=0
+        ( : ) || rc=$?   # subshell that succeeds
+        if (( rc != 0 )); then
+            return 0
+        fi
+        # shellcheck disable=SC1090
+        source "$state_file"
+    }
+
+    INSTALL_FLINK="No"
+    FLINK_VERSION="0.0.0"
+    fake_edit
+    [ "$INSTALL_FLINK" = "Yes" ]
+    [ "$FLINK_VERSION" = "9.9.9" ]
+}
diff --git a/tools/test/unit/edit_plan_quote.bats 
b/tools/test/unit/edit_plan_quote.bats
new file mode 100644
index 00000000..426b1d59
--- /dev/null
+++ b/tools/test/unit/edit_plan_quote.bats
@@ -0,0 +1,88 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+}
+
+# edit_plan_quote single-quotes its argument so the parent shell can
+# safely `source` the dumped state file. Round-tripping the output via
+# eval should reproduce the original string exactly.
+
+@test "edit_plan_quote: empty string round-trips" {
+    local quoted
+    quoted="$(edit_plan_quote "")"
+    local out
+    eval "out=$quoted"
+    [ "$out" = "" ]
+}
+
+@test "edit_plan_quote: plain path round-trips" {
+    local quoted
+    quoted="$(edit_plan_quote "/opt/flink")"
+    local out
+    eval "out=$quoted"
+    [ "$out" = "/opt/flink" ]
+}
+
+@test "edit_plan_quote: path with spaces round-trips" {
+    local quoted
+    quoted="$(edit_plan_quote "/home/jin doe/flink")"
+    local out
+    eval "out=$quoted"
+    [ "$out" = "/home/jin doe/flink" ]
+}
+
+@test "edit_plan_quote: path with single quote round-trips" {
+    local input="/tmp/it's-fine"
+    local quoted
+    quoted="$(edit_plan_quote "$input")"
+    local out
+    eval "out=$quoted"
+    [ "$out" = "$input" ]
+}
+
+@test "edit_plan_quote: shell metacharacters are not expanded on source-back" {
+    local input='/tmp/$HOME-or-$(rm -rf /)'
+    local quoted
+    quoted="$(edit_plan_quote "$input")"
+    local out
+    eval "out=$quoted"
+    [ "$out" = "$input" ]
+}
+
+@test "edit_plan_dump_state: writes a sourceable file that restores values" {
+    INSTALL_FLINK="No"
+    FLINK_VERSION="2.1.1"
+    INSTALL_DIR="/tmp/old"
+    FLINK_HOME="/usr/local/flink"
+    FLINK_MAJOR_MINOR="2.1"
+    ENABLE_PYFLINK="Yes"
+    PYFLINK_ACTUALLY_ENABLED=1
+    VENV_DIR="/tmp/venv with space"
+    PYTHON_BIN="/usr/bin/python3"
+    FLINK_AGENTS_VERSION="0.2.0"
+
+    local f="$BATS_TEST_TMPDIR/state"
+    edit_plan_dump_state "$f"
+
+    # Clobber the live values, then re-source.
+    INSTALL_FLINK=""; FLINK_VERSION=""; INSTALL_DIR=""; FLINK_HOME=""
+    FLINK_MAJOR_MINOR=""; ENABLE_PYFLINK=""; PYFLINK_ACTUALLY_ENABLED=0
+    VENV_DIR=""; PYTHON_BIN=""; FLINK_AGENTS_VERSION=""
+
+    # shellcheck disable=SC1090
+    source "$f"
+
+    [ "$INSTALL_FLINK" = "No" ]
+    [ "$FLINK_VERSION" = "2.1.1" ]
+    [ "$INSTALL_DIR" = "/tmp/old" ]
+    [ "$FLINK_HOME" = "/usr/local/flink" ]
+    [ "$FLINK_MAJOR_MINOR" = "2.1" ]
+    [ "$ENABLE_PYFLINK" = "Yes" ]
+    [ "$PYFLINK_ACTUALLY_ENABLED" = "1" ]
+    [ "$VENV_DIR" = "/tmp/venv with space" ]
+    [ "$PYTHON_BIN" = "/usr/bin/python3" ]
+    [ "$FLINK_AGENTS_VERSION" = "0.2.0" ]
+}
diff --git a/tools/test/unit/is_snapshot_version.bats 
b/tools/test/unit/is_snapshot_version.bats
new file mode 100644
index 00000000..8f387886
--- /dev/null
+++ b/tools/test/unit/is_snapshot_version.bats
@@ -0,0 +1,43 @@
+#!/usr/bin/env bats
+
+# is_snapshot_version returns 0 (true) for any non-release Flink version
+# string — anything bearing a "-SNAPSHOT", "-rc", "-dev", etc. suffix.
+# Pure release versions like 2.2.0 return 1 (false).
+#
+# Used by setup_python_env to decide whether to ask PyPI for an exact
+# match (apache-flink==X.Y.Z) or a compatible release (apache-flink~=X.Y.0)
+# when the user is running against a source-built Flink.
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+}
+
+@test "is_snapshot_version: SNAPSHOT suffix → true" {
+    run is_snapshot_version "2.2-SNAPSHOT"
+    [ "$status" -eq 0 ]
+    run is_snapshot_version "2.1.0-SNAPSHOT"
+    [ "$status" -eq 0 ]
+}
+
+@test "is_snapshot_version: rc / beta / dev suffix → true" {
+    run is_snapshot_version "2.0.0-rc1"
+    [ "$status" -eq 0 ]
+    run is_snapshot_version "1.20.3-beta-2"
+    [ "$status" -eq 0 ]
+    run is_snapshot_version "2.1.0-dev"
+    [ "$status" -eq 0 ]
+}
+
+@test "is_snapshot_version: plain release versions → false" {
+    run is_snapshot_version "2.2.0"
+    [ "$status" -eq 1 ]
+    run is_snapshot_version "1.20.3"
+    [ "$status" -eq 1 ]
+}
+
+@test "is_snapshot_version: empty string → false (defensive)" {
+    run is_snapshot_version ""
+    [ "$status" -eq 1 ]
+}
diff --git a/tools/test/unit/is_valid_tgz.bats 
b/tools/test/unit/is_valid_tgz.bats
new file mode 100644
index 00000000..784022b8
--- /dev/null
+++ b/tools/test/unit/is_valid_tgz.bats
@@ -0,0 +1,44 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+    WORK="$BATS_TEST_TMPDIR/tgz"
+    mkdir -p "$WORK"
+}
+
+@test "is_valid_tgz: missing file is invalid" {
+    run is_valid_tgz "$WORK/nope.tgz"
+    [ "$status" -ne 0 ]
+}
+
+@test "is_valid_tgz: empty file is invalid" {
+    printf '\x00' > "$WORK/empty.tgz"
+    run is_valid_tgz "$WORK/empty.tgz"
+    [ "$status" -ne 0 ]
+}
+
+@test "is_valid_tgz: random bytes are invalid" {
+    printf 'not a tarball at all\n' > "$WORK/junk.tgz"
+    run is_valid_tgz "$WORK/junk.tgz"
+    [ "$status" -ne 0 ]
+}
+
+@test "is_valid_tgz: a real tgz is valid" {
+    mkdir -p "$WORK/src"
+    echo hello > "$WORK/src/file.txt"
+    tar -czf "$WORK/good.tgz" -C "$WORK/src" .
+    run is_valid_tgz "$WORK/good.tgz"
+    [ "$status" -eq 0 ]
+}
+
+@test "is_valid_tgz: truncated tgz is invalid" {
+    mkdir -p "$WORK/src"
+    head -c 4096 /dev/urandom > "$WORK/src/blob"
+    tar -czf "$WORK/full.tgz" -C "$WORK/src" .
+    # truncate to first 32 bytes (gzip header only)
+    head -c 32 "$WORK/full.tgz" > "$WORK/trunc.tgz"
+    run is_valid_tgz "$WORK/trunc.tgz"
+    [ "$status" -ne 0 ]
+}
diff --git a/tools/test/unit/mark_explicit.bats 
b/tools/test/unit/mark_explicit.bats
new file mode 100644
index 00000000..68a1111f
--- /dev/null
+++ b/tools/test/unit/mark_explicit.bats
@@ -0,0 +1,31 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+}
+
+@test "mark_explicit: unset variable yields _EXPLICIT=0" {
+    unset MY_VAR MY_VAR_EXPLICIT
+    mark_explicit MY_VAR
+    [ "$MY_VAR_EXPLICIT" = "0" ]
+}
+
+@test "mark_explicit: empty variable yields _EXPLICIT=0" {
+    MY_VAR=""
+    mark_explicit MY_VAR
+    [ "$MY_VAR_EXPLICIT" = "0" ]
+}
+
+@test "mark_explicit: non-empty variable yields _EXPLICIT=1" {
+    MY_VAR="something"
+    mark_explicit MY_VAR
+    [ "$MY_VAR_EXPLICIT" = "1" ]
+}
+
+@test "mark_explicit: zero-string non-empty is _EXPLICIT=1" {
+    MY_VAR="0"
+    mark_explicit MY_VAR
+    [ "$MY_VAR_EXPLICIT" = "1" ]
+}
diff --git a/tools/test/unit/normalize_path.bats 
b/tools/test/unit/normalize_path.bats
new file mode 100644
index 00000000..96ecd578
--- /dev/null
+++ b/tools/test/unit/normalize_path.bats
@@ -0,0 +1,64 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+}
+
+@test "normalize_path: empty input yields empty string" {
+    run normalize_path ""
+    [ "$status" -eq 0 ]
+    [ "$output" = "" ]
+}
+
+@test "normalize_path: tilde expands to \$HOME" {
+    HOME="/home/u" run normalize_path "~/foo"
+    [ "$status" -eq 0 ]
+    [ "$output" = "/home/u/foo" ]
+}
+
+@test "normalize_path: relative path resolves against PWD" {
+    cd "$BATS_TEST_TMPDIR"
+    run normalize_path "bar"
+    [ "$status" -eq 0 ]
+    [ "$output" = "$BATS_TEST_TMPDIR/bar" ]
+}
+
+@test "normalize_path: dot relative is collapsed to current dir" {
+    cd "$BATS_TEST_TMPDIR"
+    run normalize_path "."
+    [ "$status" -eq 0 ]
+    [ "$output" = "$BATS_TEST_TMPDIR" ]
+}
+
+@test "normalize_path: trailing slash is stripped" {
+    run normalize_path "/x/y/"
+    [ "$status" -eq 0 ]
+    [ "$output" = "/x/y" ]
+}
+
+@test "normalize_path: multiple trailing slashes are stripped" {
+    run normalize_path "/x/y///"
+    [ "$status" -eq 0 ]
+    [ "$output" = "/x/y" ]
+}
+
+@test "normalize_path: consecutive slashes are collapsed" {
+    run normalize_path "/x//y///z"
+    [ "$status" -eq 0 ]
+    [ "$output" = "/x/y/z" ]
+}
+
+@test "normalize_path: root '/' is preserved" {
+    run normalize_path "/"
+    [ "$status" -eq 0 ]
+    [ "$output" = "/" ]
+}
+
+@test "normalize_path: combined tilde + dot + trailing slash" {
+    HOME=/h
+    run normalize_path "~/a/./b/"
+    [ "$status" -eq 0 ]
+    [ "$output" = "/h/a/b" ]
+}
diff --git a/tools/test/unit/parse_args.bats b/tools/test/unit/parse_args.bats
new file mode 100644
index 00000000..76084472
--- /dev/null
+++ b/tools/test/unit/parse_args.bats
@@ -0,0 +1,82 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+}
+
+@test "parse_args: --non-interactive sets NO_PROMPT=1" {
+    parse_args --non-interactive
+    [ "$NO_PROMPT" = "1" ]
+}
+
+@test "parse_args: --install-flink sets INSTALL_FLINK=Yes" {
+    parse_args --install-flink
+    [ "$INSTALL_FLINK" = "Yes" ]
+}
+
+@test "parse_args: --enable-pyflink sets ENABLE_PYFLINK=Yes" {
+    parse_args --enable-pyflink
+    [ "$ENABLE_PYFLINK" = "Yes" ]
+}
+
+@test "parse_args: --enable-pyFlink alias is honored" {
+    parse_args --enable-pyFlink
+    [ "$ENABLE_PYFLINK" = "Yes" ]
+}
+
+@test "parse_args: --verbose sets VERBOSE=1" {
+    parse_args --verbose
+    [ "$VERBOSE" = "1" ]
+}
+
+@test "parse_args: --dry-run sets DRY_RUN=1" {
+    parse_args --dry-run
+    [ "$DRY_RUN" = "1" ]
+}
+
+@test "parse_args: --verify sets VERIFY_INSTALL=1" {
+    parse_args --verify
+    [ "$VERIFY_INSTALL" = "1" ]
+}
+
+@test "parse_args: --python <path> sets PYTHON_BIN" {
+    parse_args --python /opt/py/bin/python3
+    [ "$PYTHON_BIN" = "/opt/py/bin/python3" ]
+}
+
+@test "parse_args: --python=<path> sets PYTHON_BIN" {
+    parse_args --python=/usr/local/bin/python3.11
+    [ "$PYTHON_BIN" = "/usr/local/bin/python3.11" ]
+}
+
+@test "parse_args: --python without arg dies" {
+    run parse_args --python
+    [ "$status" -ne 0 ]
+    [[ "$output" == *"--python requires a path argument"* ]]
+}
+
+@test "parse_args: --help sets HELP=1" {
+    parse_args --help
+    [ "$HELP" = "1" ]
+}
+
+@test "parse_args: -h sets HELP=1" {
+    parse_args -h
+    [ "$HELP" = "1" ]
+}
+
+@test "parse_args: unknown flag warns but does not die" {
+    run parse_args --no-such-flag
+    [ "$status" -eq 0 ]
+    [[ "$output" == *"Unknown option: --no-such-flag"* ]]
+}
+
+@test "parse_args: combined flags all apply" {
+    parse_args --non-interactive --install-flink --enable-pyflink --verify
+    [ "$NO_PROMPT" = "1" ]
+    [ "$INSTALL_FLINK" = "Yes" ]
+    [ "$ENABLE_PYFLINK" = "Yes" ]
+    [ "$VERIFY_INSTALL" = "1" ]
+}
diff --git a/tools/test/unit/platform_detect.bats 
b/tools/test/unit/platform_detect.bats
new file mode 100644
index 00000000..c1340080
--- /dev/null
+++ b/tools/test/unit/platform_detect.bats
@@ -0,0 +1,98 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load '../helpers/shim'
+    load_install_sh
+    reset_install_sh_state
+    shim_setup
+}
+
+# Helper: install a fake `uname` that emits a chosen string for -s and -m.
+fake_uname() {
+    local sysname="$1" machine="$2"
+    shim_bin_script uname "
+case \"\$1\" in
+    -s) echo '$sysname' ;;
+    -m) echo '$machine' ;;
+    *)  echo 'fake' ;;
+esac
+"
+}
+
+@test "gum_detect_os: Darwin" {
+    fake_uname Darwin x86_64
+    [ "$(gum_detect_os)" = "Darwin" ]
+}
+
+@test "gum_detect_os: Linux" {
+    fake_uname Linux x86_64
+    [ "$(gum_detect_os)" = "Linux" ]
+}
+
+@test "gum_detect_os: unknown kernel" {
+    fake_uname FreeBSD x86_64
+    [ "$(gum_detect_os)" = "unsupported" ]
+}
+
+@test "gum_detect_arch: x86_64 stays x86_64" {
+    fake_uname Linux x86_64
+    [ "$(gum_detect_arch)" = "x86_64" ]
+}
+
+@test "gum_detect_arch: amd64 maps to x86_64" {
+    fake_uname Linux amd64
+    [ "$(gum_detect_arch)" = "x86_64" ]
+}
+
+@test "gum_detect_arch: arm64 stays arm64" {
+    fake_uname Darwin arm64
+    [ "$(gum_detect_arch)" = "arm64" ]
+}
+
+@test "gum_detect_arch: aarch64 maps to arm64" {
+    fake_uname Linux aarch64
+    [ "$(gum_detect_arch)" = "arm64" ]
+}
+
+@test "gum_detect_arch: i686 maps to i386" {
+    fake_uname Linux i686
+    [ "$(gum_detect_arch)" = "i386" ]
+}
+
+@test "gum_detect_arch: armv7l maps to armv7" {
+    fake_uname Linux armv7l
+    [ "$(gum_detect_arch)" = "armv7" ]
+}
+
+@test "gum_detect_arch: unknown machine is unknown" {
+    fake_uname Linux riscv64
+    [ "$(gum_detect_arch)" = "unknown" ]
+}
+
+@test "detect_os_or_die: macOS via OSTYPE" {
+    OSTYPE="darwin23"
+    detect_os_or_die
+    [ "$OS" = "macos" ]
+}
+
+@test "detect_os_or_die: linux via OSTYPE" {
+    OSTYPE="linux-gnu"
+    detect_os_or_die
+    [ "$OS" = "linux" ]
+}
+
+@test "detect_os_or_die: WSL via WSL_DISTRO_NAME" {
+    OSTYPE="something-unknown"
+    WSL_DISTRO_NAME="Ubuntu"
+    detect_os_or_die
+    [ "$OS" = "linux" ]
+}
+
+@test "detect_os_or_die: unsupported OSTYPE dies" {
+    OSTYPE="cygwin"
+    unset WSL_DISTRO_NAME
+    run detect_os_or_die
+    [ "$status" -ne 0 ]
+    [[ "$output" == *"Unsupported operating system"* ]]
+}
diff --git a/tools/test/unit/shim_self_test.bats 
b/tools/test/unit/shim_self_test.bats
new file mode 100644
index 00000000..04def76a
--- /dev/null
+++ b/tools/test/unit/shim_self_test.bats
@@ -0,0 +1,50 @@
+#!/usr/bin/env bats
+
+bats_require_minimum_version 1.5.0
+
+setup() {
+    load '../helpers/load'
+    load '../helpers/shim'
+    load_install_sh
+    reset_install_sh_state
+    shim_setup
+}
+
+@test "shim_bin records argv for a single call" {
+    shim_bin fake_tool
+    fake_tool --foo bar baz
+    [ "$(shim_call_count fake_tool)" = "1" ]
+    run shim_calls fake_tool
+    [ "$output" = "--foo       bar     baz" ]
+}
+
+@test "shim_bin can simulate non-zero exit" {
+    shim_bin fake_tool 7
+    run fake_tool
+    [ "$status" -eq 7 ]
+}
+
+@test "shim_bin_script can write output files" {
+    shim_bin_script fake_tar 'touch "$2/marker"'
+    mkdir -p "$BATS_TEST_TMPDIR/out"
+    fake_tar -x "$BATS_TEST_TMPDIR/out"
+    [ -f "$BATS_TEST_TMPDIR/out/marker" ]
+}
+
+@test "PATH shim is preferred over real binary" {
+    shim_bin curl
+    run command -v curl
+    [[ "$output" == "$BATS_TEST_TMPDIR/bin/curl" ]]
+}
+
+@test "shim_bin_missing makes command -v report missing" {
+    shim_bin_missing tool_x
+    run command -v tool_x
+    [ "$status" -ne 0 ]
+}
+
+@test "shim_bin_missing makes direct invocation exit 127" {
+    shim_bin_missing tool_y
+    run -127 tool_y --some arg
+    [ "$status" -eq 127 ]
+}
diff --git a/tools/test/unit/show_install_plan.bats 
b/tools/test/unit/show_install_plan.bats
new file mode 100644
index 00000000..0a304a2f
--- /dev/null
+++ b/tools/test/unit/show_install_plan.bats
@@ -0,0 +1,74 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+    OS="linux"
+}
+
+@test "show_install_plan: Environment section always present" {
+    INSTALL_FLINK=Yes
+    INSTALL_DIR="/opt/flink"
+    FLINK_HOME="/opt/flink/flink-2.2.0"
+    run show_install_plan
+    [ "$status" -eq 0 ]
+    case "$output" in *"Environment (read-only)"*) ;; *) false ;; esac
+    case "$output" in *"OS:"*) ;; *) false ;; esac
+    case "$output" in *"Java:"*) ;; *) false ;; esac
+    case "$output" in *"JAVA_HOME:"*) ;; *) false ;; esac
+    case "$output" in *"Python:"*) ;; *) false ;; esac
+}
+
+@test "show_install_plan: INSTALL_FLINK=Yes shows version + install directory, 
not FLINK_HOME explicit" {
+    INSTALL_FLINK=Yes
+    INSTALL_DIR="/opt/flink"
+    FLINK_VERSION="2.2.0"
+    FLINK_HOME="/opt/flink/flink-2.2.0"
+    run show_install_plan
+    [ "$status" -eq 0 ]
+    case "$output" in *"Flink version"*) ;; *) false ;; esac
+    case "$output" in *"Install directory"*) ;; *) false ;; esac
+}
+
+@test "show_install_plan: INSTALL_FLINK=No hides Install directory, shows 
FLINK_HOME with version" {
+    INSTALL_FLINK=No
+    INSTALL_DIR="/should/not/show"
+    FLINK_VERSION="2.1.1"
+    FLINK_HOME="/usr/local/flink"
+    run show_install_plan
+    [ "$status" -eq 0 ]
+    case "$output" in *"FLINK_HOME"*) ;; *) false ;; esac
+    case "$output" in *"v2.1.1"*) ;; *) false ;; esac
+    case "$output" in *"Install directory"*) false ;; *) ;; esac
+    case "$output" in *"/should/not/show"*) false ;; *) ;; esac
+}
+
+@test "show_install_plan: JAVA_HOME unset shows hint" {
+    INSTALL_FLINK=Yes
+    INSTALL_DIR="/opt/flink"
+    FLINK_HOME="/opt/flink/flink-2.2.0"
+    unset JAVA_HOME
+    run show_install_plan
+    [ "$status" -eq 0 ]
+    case "$output" in *"<not set, Flink will auto-detect>"*) ;; *) false ;; 
esac
+}
+
+@test "show_install_plan: Flink Agents version line is present" {
+    INSTALL_FLINK=Yes
+    INSTALL_DIR="/opt/flink"
+    FLINK_HOME="/opt/flink/flink-2.2.0"
+    FLINK_AGENTS_VERSION="0.2.1"
+    run show_install_plan
+    [ "$status" -eq 0 ]
+    case "$output" in *"Flink Agents version"*"0.2.1"*) ;; *) false ;; esac
+}
+
+@test "show_install_plan: no redundant 'Install flink-agents JARs' row" {
+    INSTALL_FLINK=Yes
+    INSTALL_DIR="/opt/flink"
+    FLINK_HOME="/opt/flink/flink-2.2.0"
+    run show_install_plan
+    [ "$status" -eq 0 ]
+    case "$output" in *"Install flink-agents JARs"*) false ;; *) ;; esac
+}
diff --git a/tools/test/unit/smoke.bats b/tools/test/unit/smoke.bats
new file mode 100644
index 00000000..08ced7ee
--- /dev/null
+++ b/tools/test/unit/smoke.bats
@@ -0,0 +1,18 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+}
+
+@test "install.sh sources without executing main" {
+    [ "$(type -t parse_args)" = "function" ]
+    [ "$(type -t download_file)" = "function" ]
+}
+
+@test "reset_install_sh_state restores defaults" {
+    NO_PROMPT=1
+    reset_install_sh_state
+    [ "$NO_PROMPT" = "0" ]
+}
diff --git a/tools/test/unit/ui_helpers.bats b/tools/test/unit/ui_helpers.bats
new file mode 100644
index 00000000..61ad31d0
--- /dev/null
+++ b/tools/test/unit/ui_helpers.bats
@@ -0,0 +1,54 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+    # Force the non-gum fallback branch.
+    GUM=""
+}
+
+@test "ui_info: prints the message in fallback branch" {
+    run ui_info "hello world"
+    [ "$status" -eq 0 ]
+    [[ "$output" == *"hello world"* ]]
+}
+
+@test "ui_warn: prints the message in fallback branch" {
+    run ui_warn "be careful"
+    [ "$status" -eq 0 ]
+    [[ "$output" == *"be careful"* ]]
+}
+
+@test "ui_success: prints the message in fallback branch" {
+    run ui_success "all good"
+    [ "$status" -eq 0 ]
+    [[ "$output" == *"all good"* ]]
+}
+
+@test "ui_error: prints the message in fallback branch" {
+    run ui_error "uh oh"
+    [ "$status" -eq 0 ]
+    [[ "$output" == *"uh oh"* ]]
+}
+
+@test "ui_kv: prints key and value" {
+    run ui_kv "Flink version" "2.2.0"
+    [ "$status" -eq 0 ]
+    [[ "$output" == *"Flink version"* ]]
+    [[ "$output" == *"2.2.0"* ]]
+}
+
+@test "ui_stage: increments stage counter and prints title" {
+    INSTALL_STAGE_CURRENT=0
+    run ui_stage "First thing"
+    [ "$status" -eq 0 ]
+    [[ "$output" == *"First thing"* ]]
+    [[ "$output" == *"[1/${INSTALL_STAGE_TOTAL}]"* ]]
+}
+
+@test "die: prints message and exits non-zero" {
+    run die "fatal boom"
+    [ "$status" -ne 0 ]
+    [[ "$output" == *"fatal boom"* ]]
+}
diff --git a/tools/test/unit/validate_python_bin.bats 
b/tools/test/unit/validate_python_bin.bats
new file mode 100644
index 00000000..87998bd1
--- /dev/null
+++ b/tools/test/unit/validate_python_bin.bats
@@ -0,0 +1,73 @@
+#!/usr/bin/env bats
+
+setup() {
+    load '../helpers/load'
+    load '../helpers/shim'
+    load_install_sh
+    reset_install_sh_state
+    shim_setup
+}
+
+# Helper: write a fake python that reports a chosen version.
+fake_python() {
+    local ver="$1"
+    shim_bin_script fake_py "
+case \"\$*\" in
+    *'sys.version_info.major'*)
+        echo '$ver'
+        ;;
+esac
+"
+}
+
+@test "validate_python_bin: empty path is rejected" {
+    run validate_python_bin ""
+    [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: missing binary is rejected" {
+    run validate_python_bin /no/such/bin
+    [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: Python 3.10 is accepted" {
+    fake_python "3.10"
+    run validate_python_bin fake_py
+    [ "$status" -eq 0 ]
+}
+
+@test "validate_python_bin: Python 3.11 is accepted" {
+    fake_python "3.11"
+    run validate_python_bin fake_py
+    [ "$status" -eq 0 ]
+}
+
+@test "validate_python_bin: Python 3.9 is rejected" {
+    fake_python "3.9"
+    run validate_python_bin fake_py
+    [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: Python 3.12 is rejected" {
+    fake_python "3.12"
+    run validate_python_bin fake_py
+    [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: Python 3.13 is rejected" {
+    fake_python "3.13"
+    run validate_python_bin fake_py
+    [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: Python 2.7 is rejected" {
+    fake_python "2.7"
+    run validate_python_bin fake_py
+    [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: binary that prints nothing is rejected" {
+    shim_bin_script fake_py 'exit 0'
+    run validate_python_bin fake_py
+    [ "$status" -ne 0 ]
+}
diff --git a/tools/test/unit/validate_venv_dir.bats 
b/tools/test/unit/validate_venv_dir.bats
new file mode 100644
index 00000000..b41dfd15
--- /dev/null
+++ b/tools/test/unit/validate_venv_dir.bats
@@ -0,0 +1,83 @@
+#!/usr/bin/env bats
+
+# validate_venv_dir classifies a candidate VENV_DIR path so the caller
+# can either accept it (new / empty / real venv) or re-prompt the user
+# (non-empty foreign directory or file).
+#
+# Echoes one of: new | empty | venv | nonempty | file
+# Exit code 0 for new/empty/venv (caller proceeds), 1 for nonempty/file
+# (caller re-prompts).
+
+setup() {
+    load '../helpers/load'
+    load_install_sh
+    reset_install_sh_state
+}
+
+@test "validate_venv_dir: path that does not exist → 'new'" {
+    local p="$BATS_TEST_TMPDIR/will-be-created"
+    run validate_venv_dir "$p"
+    [ "$status" -eq 0 ]
+    [ "$output" = "new" ]
+}
+
+@test "validate_venv_dir: empty directory → 'empty'" {
+    local p="$BATS_TEST_TMPDIR/empty"
+    mkdir -p "$p"
+    run validate_venv_dir "$p"
+    [ "$status" -eq 0 ]
+    [ "$output" = "empty" ]
+}
+
+@test "validate_venv_dir: directory with pyvenv.cfg → 'venv'" {
+    local p="$BATS_TEST_TMPDIR/avenv"
+    mkdir -p "$p/bin"
+    : > "$p/pyvenv.cfg"
+    : > "$p/bin/activate"
+    run validate_venv_dir "$p"
+    [ "$status" -eq 0 ]
+    [ "$output" = "venv" ]
+}
+
+@test "validate_venv_dir: non-empty foreign directory → 'nonempty' (rc=1)" {
+    local p="$BATS_TEST_TMPDIR/foreign"
+    mkdir -p "$p"
+    : > "$p/some-unrelated-file"
+    : > "$p/README.md"
+    run validate_venv_dir "$p"
+    [ "$status" -eq 1 ]
+    [ "$output" = "nonempty" ]
+}
+
+@test "validate_venv_dir: path is a regular file → 'file' (rc=1)" {
+    local p="$BATS_TEST_TMPDIR/just-a-file"
+    : > "$p"
+    run validate_venv_dir "$p"
+    [ "$status" -eq 1 ]
+    [ "$output" = "file" ]
+}
+
+@test "validate_venv_dir: directory with only a hidden file is still 
'nonempty'" {
+    local p="$BATS_TEST_TMPDIR/dot"
+    mkdir -p "$p"
+    : > "$p/.hiddenfile"
+    run validate_venv_dir "$p"
+    [ "$status" -eq 1 ]
+    [ "$output" = "nonempty" ]
+}
+
+@test "validate_venv_dir: bin/activate without pyvenv.cfg is still 'nonempty'" 
{
+    # A user could have a random project with a bin/activate file. Treat
+    # only the strict pyvenv.cfg marker as a real venv.
+    local p="$BATS_TEST_TMPDIR/halfbaked"
+    mkdir -p "$p/bin"
+    : > "$p/bin/activate"
+    run validate_venv_dir "$p"
+    [ "$status" -eq 1 ]
+    [ "$output" = "nonempty" ]
+}
+
+@test "validate_venv_dir: empty argument → 'file' (rc=1) (treated as invalid)" 
{
+    run validate_venv_dir ""
+    [ "$status" -eq 1 ]
+}

Reply via email to