You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@flink.apache.org by "JinkunLiu (via GitHub)" <gi...@apache.org> on 2026/04/01 05:04:22 UTC

[PR] [tools]Import Wizard for Installation Setup [flink-agents]

JinkunLiu opened a new pull request, #599:
URL: https://github.com/apache/flink-agents/pull/599

   <!--
   * Thank you very much for contributing to Flink Agents.
   * Please add the relevant components in the PR title. E.g., [api], [runtime], [java], [python], [hotfix], etc.
   -->
   
   <!-- Please link the PR to the relevant issue(s). Hotfix doesn't need this. -->
   Linked issue: #586 
   
   ### Purpose of change
   
   <!-- What is the purpose of this change? -->
   Import Wizard for Installation Setup 
   
   Install Flink & Install PyFlink Environment
   
   ### Tests
   
   <!-- How is this change verified? -->
   
   ### API
   
   <!-- Does this change touches any public APIs? -->
   
   ### Documentation
   
   <!-- Do not remove this section. Check the proper box only. -->
   
   - [x] `doc-needed` <!-- Your PR changes impact docs -->
   - [ ] `doc-not-needed` <!-- Your PR changes do not impact docs -->
   - [ ] `doc-included` <!-- Your PR already contains the necessary documentation updates -->
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "JinkunLiu (via GitHub)" <gi...@apache.org>.
JinkunLiu commented on PR #599:
URL: https://github.com/apache/flink-agents/pull/599#issuecomment-4473999308

   Thanks for the review! @xintongsong 
   
   1. Fixed the extra `--` issue. On whether to make gum required — I referenced openclaw's design, which treats it as an optional dependency rather than a hard requirement. Since gum is just a UI enhancement and not something the script needs to work, I think keeping it optional makes more sense.
   2. Added a download progress indicator for the Flink binary. Done.
   3. I aware the pip install output is noisy, but haven't found a elegant solution yet. Open to suggestions if you have ideas.
   4. I couldn't reproduce the double-slash issue in `FLINK_HOME`. Could you share more details about  how it was triggered?
   5. Improved path input validation so users get a second chance on wrong Flink path input. As for going back after reviewing the plan — since the number of configuration fields is still relatively small, I considering leaving that out for now. WDYT?
   6. The `Ctrl+C` issue has been fixed.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 commented on code in PR #599:
URL: https://github.com/apache/flink-agents/pull/599#discussion_r3160077951


##########
tools/install.sh:
##########
@@ -0,0 +1,1117 @@
+#!/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --connect-timeout 5 --max-time 30 --retry 2 --retry-delay 1 --retry-connrefused -o "$output" "$url"

Review Comment:
   The `flink-2.2.0-bin-scala_2.12.tgz` file is 544 MB in size, and setting the max-time to 30 seconds imposes high demands on network performance.
   Based on my actual tests, it takes approximately 500 seconds in my office network. I recommend setting it to 600 seconds.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 commented on code in PR #599:
URL: https://github.com/apache/flink-agents/pull/599#discussion_r3144748442


##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+
+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
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir gum_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")
+
+    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
+
+    gum_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)"
+    if [[ -z "$gum_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$gum_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$gum_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    GUM="$gum_path"
+    GUM_STATUS="installed"
+    GUM_REASON="temp, verified"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        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 card
+        title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink Agents Installer")"
+        card="$(printf '%s\n' "$title")"
+        "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding "1 2" "$card"
+        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
+}
+
+INSTALL_STAGE_TOTAL=3
+INSTALL_STAGE_CURRENT=0
+
+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))
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 20 "$key")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        echo -e "${MUTED}${key}:${NC} ${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
+}
+
+is_shell_function() {
+    local name="${1:-}"
+    [[ -n "$name" ]] && declare -F "$name" >/dev/null 2>&1
+}
+
+is_gum_raw_mode_failure() {
+    local err_log="$1"
+    [[ -s "$err_log" ]] || return 1
+    grep -Eiq '(setrawmode|inappropriate ioctl for device)' "$err_log"
+}
+
+configure_verbose() {
+    if [[ "$VERBOSE" != "1" ]]; then
+        return 0
+    fi
+    set -x
+}
+
+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_MAJOR_MINOR="${FLINK_VERSION%.*}"
+FLINK_BASE_URL="${FLINK_BASE_URL:-https://dlcdn.apache.org/flink}"
+
+INSTALL_FLINK="${INSTALL_FLINK:-ask}"
+ENABLE_PYFLINK="${ENABLE_PYFLINK:-ask}"
+INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/flink}"
+VENV_DIR="${VENV_DIR:-.flink-agents-env}"
+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
+
+ARCHIVE_NAME="flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ARCHIVE_URL="${FLINK_BASE_URL}/flink-${FLINK_VERSION}/${ARCHIVE_NAME}"
+
+require_cmd() {
+    command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
+}
+
+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
+        selection="$("$GUM" choose \
+            --header "$prompt" \
+            --cursor-prefix "❯ " \
+            "Yes" "No" < /dev/tty || true)"
+        [[ "$selection" == "Yes" ]]
+        return
+    fi
+
+    local answer=""
+    printf '%s [y/n]: ' "$prompt" > /dev/tty
+    read -r answer < /dev/tty || true
+
+    [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+install_flink_if_needed() {
+    case "$INSTALL_FLINK" in
+        yes|true|1)
+            ;;
+        no|false|0)
+            ui_info "Skipping Flink download/install (INSTALL_FLINK=${INSTALL_FLINK})."
+            return
+            ;;
+        ask)
+            if ! choose_install_method_interactive "Do you want this script to download and install Flink ${FLINK_VERSION}?"; then
+                ui_info "Skipping Flink download/install by user choice."
+                return
+            fi
+            ;;
+        *)
+            die "Unsupported INSTALL_FLINK value: ${INSTALL_FLINK}. Use: ask|yes|no"
+            ;;
+    esac
+
+    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="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+}
+
+resolve_flink_home() {
+    if [[ -z "${FLINK_HOME:-}" ]]; then
+        die "FLINK_HOME is not set. Set FLINK_HOME or run with INSTALL_FLINK=yes."
+    fi
+    [[ -d "$FLINK_HOME" ]] || die "FLINK_HOME does not exist: $FLINK_HOME"
+    [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib directory): $FLINK_HOME"
+}
+
+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/"
+}
+
+setup_python_env() {
+    check_python || die "Python environment check failed. Please install Python >=3.10."
+
+    if [[ ! -d "$VENV_DIR" ]]; then
+        ui_info "Creating virtual environment: $VENV_DIR"
+        python3 -m venv "$VENV_DIR"
+    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
+
+    ui_info "Installing Python packages"
+    python -m pip install --upgrade pip
+    python -m pip install "flink-agents==${FLINK_AGENTS_VERSION}" "apache-flink==${FLINK_VERSION}"
+}
+
+copy_flink_agents_jars() {
+    local pkg_root
+    pkg_root="$(python - <<'PY'
+import pathlib
+import flink_agents
+print(pathlib.Path(flink_agents.__file__).resolve().parent)
+PY
+)"
+
+    local version_lib_dir="${pkg_root}/lib/flink-${FLINK_MAJOR_MINOR}"
+
+    [[ -d "$version_lib_dir" ]] || die "Flink Agents lib directory not found: $version_lib_dir"
+
+    local copied=0
+    local jar
+    for jar in "$version_lib_dir"/flink-agents-dist-*.jar; do
+        [[ -f "$jar" ]] || continue
+        cp "$jar" "$FLINK_HOME/lib/"

Review Comment:
   The thin jar contains only the code for flink-agents itself and does not include any Integrations. We need copy both the corresponding thin jar and common jar to flink lib.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "xintongsong (via GitHub)" <gi...@apache.org>.
xintongsong commented on code in PR #599:
URL: https://github.com/apache/flink-agents/pull/599#discussion_r3200963501


##########
tools/install.sh:
##########
@@ -0,0 +1,1117 @@
+#!/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --max-time 600 --retry-delay 1 -- --retry-connrefused -o "$output" "$url"

Review Comment:
   There's an extra `--` that causes the download failure.



##########
tools/install.sh:
##########
@@ -0,0 +1,1117 @@
+#!/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --max-time 600 --retry-delay 1 -- --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=600 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+
+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
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir gum_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
+
+    gum_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)"
+    if [[ -z "$gum_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$gum_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$gum_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    GUM="$gum_path"
+    GUM_STATUS="installed"
+    GUM_REASON="temp, verified"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        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
+}
+
+INSTALL_STAGE_TOTAL=5
+INSTALL_STAGE_CURRENT=0
+
+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))
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 20 "$key")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        echo -e "${MUTED}${key}:${NC} ${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 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_SUPPORTED_VERSIONS=("2.2.0" "2.1.1" "2.0.1" "1.20.3")
+FLINK_RECOMMENDED_VERSION="2.2.0"
+
+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)
+  --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 (default: https://dlcdn.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"
+}
+
+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
+        selection="$("$GUM" choose \
+            --header "$prompt" \
+            --cursor-prefix "❯ " \
+            "Yes" "No" < /dev/tty || true)"
+        [[ "$selection" == "Yes" ]]
+        return
+    fi
+
+    local answer=""
+    printf '%s [y/n]: ' "$prompt" > /dev/tty
+    read -r answer < /dev/tty || true
+
+    [[ "$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
+        selection="$("$GUM" choose \
+            --header "Select Flink version" \
+            --cursor-prefix "❯ " \
+            "${labels[@]}" < /dev/tty || true)"
+        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 || true
+        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
+}
+
+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 [[ -z "${FLINK_HOME:-}" || ! -d "${FLINK_HOME}" || ! -d "${FLINK_HOME}/lib" ]]; then
+            if is_promptable; then
+                FLINK_HOME="$(prompt_path_input "Enter the path to your existing FLINK_HOME (version >= 1.20)" "/path/to/flink-${FLINK_VERSION}")"
+                export FLINK_HOME
+            fi
+        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"
+        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
+
+    FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+    FLINK_MAJOR_MINOR="${FLINK_VERSION%.*}"
+}
+
+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
+    resolve_python
+
+    if [[ "$VENV_DIR_EXPLICIT" -eq 0 ]] && is_promptable; then
+        VENV_DIR="$(prompt_path_choice_interactive \
+            "Choose Python venv directory" \
+            "$VENV_DIR" \
+            "/path/to/venv")"
+    fi
+
+    case "$VENV_DIR" in
+        /*) ;;
+        *)  VENV_DIR="$PWD/$VENV_DIR" ;;
+    esac
+}
+
+confirm_install_plan() {
+    if [[ "$NO_PROMPT" == "1" ]] || ! is_promptable; then
+        return 0
+    fi
+    if choose_install_method_interactive "Proceed with installation?"; then
+        return 0
+    fi
+    ui_info "Installation cancelled by user."
+    exit 0
+}
+
+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"
+    local placeholder="$2"
+    local input=""
+    if [[ -n "$GUM" ]] && gum_is_tty; then
+        input="$("$GUM" input \
+            --header "$header" \
+            --placeholder "$placeholder" \
+            --width 70 < /dev/tty || true)"
+    else
+        printf '%s: ' "$header" > /dev/tty
+        read -r input < /dev/tty || true
+    fi
+    input="${input/#\~/$HOME}"
+    printf '%s' "$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
+        selection="$("$GUM" choose \
+            --header "$header" \
+            --cursor-prefix "❯ " \
+            "$default_label" "$custom_label" < /dev/tty || true)"
+    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 || true
+        case "$answer" in
+            2) selection="$custom_label" ;;
+            *) selection="$default_label" ;;
+        esac
+    fi
+
+    if [[ "$selection" != "$custom_label" ]]; then
+        printf '%s' "$default_path"
+        return 0
+    fi
+
+    local input=""
+    if [[ -n "$GUM" ]] && gum_is_tty; then
+        input="$("$GUM" input \
+            --header "$header" \
+            --placeholder "$placeholder" \
+            --width 70 < /dev/tty || true)"
+    else
+        printf 'Enter custom path: ' > /dev/tty
+        read -r input < /dev/tty || true
+    fi
+    input="${input/#\~/$HOME}"
+
+    if [[ -z "$input" ]]; then
+        ui_warn "Empty path; falling back to default: $default_path"
+        printf '%s' "$default_path"
+        return 0
+    fi
+
+    printf '%s' "$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"
+}
+
+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
+
+    ui_info "Installing Python packages"
+    python -m pip install "flink-agents==${FLINK_AGENTS_VERSION}" "apache-flink==${FLINK_VERSION}"
+}
+
+copy_flink_agents_jars() {
+    local pkg_root
+    pkg_root="$(python - <<'PY'
+import pathlib
+import flink_agents
+print(pathlib.Path(flink_agents.__file__).resolve().parent)
+PY
+)"
+
+    local version_lib_dir="${pkg_root}/lib/flink-${FLINK_MAJOR_MINOR}"
+    local common_lib_dir="${pkg_root}/lib/common"
+
+    [[ -d "$version_lib_dir" ]] || die "Flink Agents lib directory not found: $version_lib_dir"
+    [[ -d "$common_lib_dir" ]] || die "Flink Agents common lib directory not found: $common_lib_dir"
+
+    local jar
+
+    ui_info "Copying Flink Agents common jar into Flink lib"
+    local common_copied=0
+    for jar in "$common_lib_dir"/flink-agents-dist-common-*.jar; do
+        [[ -f "$jar" ]] || continue
+        cp "$jar" "$FLINK_HOME/lib/"
+        common_copied=1
+    done
+    [[ "$common_copied" -eq 1 ]] || die "No flink-agents-dist-common jar found in: $common_lib_dir"
+
+    ui_info "Copying Flink Agents thin jar into Flink lib"
+    local copied=0
+    for jar in "$version_lib_dir"/flink-agents-dist-*.jar; do
+        [[ -f "$jar" ]] || continue
+        cp "$jar" "$FLINK_HOME/lib/"
+        copied=1
+    done
+    [[ "$copied" -eq 1 ]] || die "No flink-agents-dist jar found in: $version_lib_dir"
+}
+
+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 "Install plan"

Review Comment:
   ```suggestion
       ui_section "Installation plan"
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 commented on code in PR #599:
URL: https://github.com/apache/flink-agents/pull/599#discussion_r3122133667


##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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() {

Review Comment:
   I think we could add a progress bar to the `download_file` to prevent situations where the download takes too long but the user interface provides no feedback. 
   
   Additionally, we can add a log similar to `ui_info "Downloading ${url}"` in the `download_file`. Currently, `bootstrap_gum_temp` provides no bootstrapping messages. After running the script, it is unclear whether an issue has occurred; in reality, it is downloading gum.



##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/bash

Review Comment:
   Shall we use `#!/usr/bin/env bash`.



##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+
+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
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir gum_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")
+
+    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
+
+    gum_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)"
+    if [[ -z "$gum_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$gum_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$gum_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    GUM="$gum_path"
+    GUM_STATUS="installed"
+    GUM_REASON="temp, verified"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        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 card
+        title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink Agents Installer")"
+        card="$(printf '%s\n' "$title")"
+        "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding "1 2" "$card"
+        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
+}
+
+INSTALL_STAGE_TOTAL=3
+INSTALL_STAGE_CURRENT=0
+
+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))
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 20 "$key")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        echo -e "${MUTED}${key}:${NC} ${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
+}
+
+is_shell_function() {
+    local name="${1:-}"
+    [[ -n "$name" ]] && declare -F "$name" >/dev/null 2>&1
+}
+
+is_gum_raw_mode_failure() {
+    local err_log="$1"
+    [[ -s "$err_log" ]] || return 1
+    grep -Eiq '(setrawmode|inappropriate ioctl for device)' "$err_log"
+}
+
+configure_verbose() {
+    if [[ "$VERBOSE" != "1" ]]; then
+        return 0
+    fi
+    set -x
+}
+
+FLINK_VERSION="${FLINK_VERSION:-2.2.0}"
+FLINK_AGENTS_VERSION="${FLINK_AGENTS_VERSION:-0.2.1}"

Review Comment:
   The default version `0.2.1` mismatch the default version `0.3.0` in help message.



##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+
+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
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir gum_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")
+
+    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
+
+    gum_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)"
+    if [[ -z "$gum_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$gum_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$gum_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    GUM="$gum_path"
+    GUM_STATUS="installed"
+    GUM_REASON="temp, verified"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        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 card
+        title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink Agents Installer")"
+        card="$(printf '%s\n' "$title")"
+        "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding "1 2" "$card"
+        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
+}
+
+INSTALL_STAGE_TOTAL=3
+INSTALL_STAGE_CURRENT=0
+
+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))
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 20 "$key")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        echo -e "${MUTED}${key}:${NC} ${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
+}
+
+is_shell_function() {
+    local name="${1:-}"
+    [[ -n "$name" ]] && declare -F "$name" >/dev/null 2>&1
+}
+
+is_gum_raw_mode_failure() {
+    local err_log="$1"
+    [[ -s "$err_log" ]] || return 1
+    grep -Eiq '(setrawmode|inappropriate ioctl for device)' "$err_log"
+}
+
+configure_verbose() {
+    if [[ "$VERBOSE" != "1" ]]; then
+        return 0
+    fi
+    set -x
+}
+
+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_MAJOR_MINOR="${FLINK_VERSION%.*}"
+FLINK_BASE_URL="${FLINK_BASE_URL:-https://dlcdn.apache.org/flink}"
+
+INSTALL_FLINK="${INSTALL_FLINK:-ask}"
+ENABLE_PYFLINK="${ENABLE_PYFLINK:-ask}"
+INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/flink}"
+VENV_DIR="${VENV_DIR:-.flink-agents-env}"
+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
+
+ARCHIVE_NAME="flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ARCHIVE_URL="${FLINK_BASE_URL}/flink-${FLINK_VERSION}/${ARCHIVE_NAME}"
+
+require_cmd() {
+    command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
+}
+
+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
+        selection="$("$GUM" choose \
+            --header "$prompt" \
+            --cursor-prefix "❯ " \
+            "Yes" "No" < /dev/tty || true)"
+        [[ "$selection" == "Yes" ]]
+        return
+    fi
+
+    local answer=""
+    printf '%s [y/n]: ' "$prompt" > /dev/tty
+    read -r answer < /dev/tty || true
+
+    [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+install_flink_if_needed() {
+    case "$INSTALL_FLINK" in
+        yes|true|1)
+            ;;
+        no|false|0)
+            ui_info "Skipping Flink download/install (INSTALL_FLINK=${INSTALL_FLINK})."
+            return
+            ;;
+        ask)
+            if ! choose_install_method_interactive "Do you want this script to download and install Flink ${FLINK_VERSION}?"; then
+                ui_info "Skipping Flink download/install by user choice."
+                return
+            fi
+            ;;
+        *)
+            die "Unsupported INSTALL_FLINK value: ${INSTALL_FLINK}. Use: ask|yes|no"
+            ;;
+    esac
+
+    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="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+}
+
+resolve_flink_home() {
+    if [[ -z "${FLINK_HOME:-}" ]]; then
+        die "FLINK_HOME is not set. Set FLINK_HOME or run with INSTALL_FLINK=yes."
+    fi
+    [[ -d "$FLINK_HOME" ]] || die "FLINK_HOME does not exist: $FLINK_HOME"
+    [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib directory): $FLINK_HOME"
+}
+
+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/"
+}
+
+setup_python_env() {
+    check_python || die "Python environment check failed. Please install Python >=3.10."
+
+    if [[ ! -d "$VENV_DIR" ]]; then
+        ui_info "Creating virtual environment: $VENV_DIR"
+        python3 -m venv "$VENV_DIR"
+    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
+
+    ui_info "Installing Python packages"
+    python -m pip install --upgrade pip
+    python -m pip install "flink-agents==${FLINK_AGENTS_VERSION}" "apache-flink==${FLINK_VERSION}"
+}
+
+copy_flink_agents_jars() {
+    local pkg_root
+    pkg_root="$(python - <<'PY'
+import pathlib
+import flink_agents
+print(pathlib.Path(flink_agents.__file__).resolve().parent)
+PY
+)"
+
+    local version_lib_dir="${pkg_root}/lib/flink-${FLINK_MAJOR_MINOR}"
+
+    [[ -d "$version_lib_dir" ]] || die "Flink Agents lib directory not found: $version_lib_dir"
+
+    local copied=0
+    local jar
+    for jar in "$version_lib_dir"/flink-agents-dist-*.jar; do
+        [[ -f "$jar" ]] || continue
+        cp "$jar" "$FLINK_HOME/lib/"
+        copied=1
+    done
+
+    [[ "$copied" -eq 1 ]] || die "No flink-agents-dist jar found in: $version_lib_dir"
+}
+
+should_enable_pyflink() {
+    case "$ENABLE_PYFLINK" in
+        yes|true|1)
+            return 0
+            ;;
+        no|false|0)
+            return 1
+            ;;
+        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
+                return 0
+            fi
+            return 1
+            ;;
+        *)
+            die "Unsupported ENABLE_PYFLINK value: ${ENABLE_PYFLINK}. Use: ask|yes|no"
+            ;;
+    esac
+}
+
+print_usage() {
+    cat <<EOF
+Apache Flink Agents Installer
+
+Usage:
+  bash install.sh [options]
+  curl -fsSL <url>/install.sh | bash -s -- [options]
+
+Options:
+  --yes, -y               Non-interactive mode (accept all defaults)

Review Comment:
   I think `--yes` is a bit semantically unclear. It might be better to change it to something like `--non-interactive`.



##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+
+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
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir gum_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")
+
+    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
+
+    gum_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)"
+    if [[ -z "$gum_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$gum_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$gum_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    GUM="$gum_path"
+    GUM_STATUS="installed"
+    GUM_REASON="temp, verified"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        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 card
+        title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink Agents Installer")"
+        card="$(printf '%s\n' "$title")"
+        "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding "1 2" "$card"
+        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
+}
+
+INSTALL_STAGE_TOTAL=3
+INSTALL_STAGE_CURRENT=0
+
+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))
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 20 "$key")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        echo -e "${MUTED}${key}:${NC} ${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
+}
+
+is_shell_function() {
+    local name="${1:-}"
+    [[ -n "$name" ]] && declare -F "$name" >/dev/null 2>&1
+}
+
+is_gum_raw_mode_failure() {
+    local err_log="$1"
+    [[ -s "$err_log" ]] || return 1
+    grep -Eiq '(setrawmode|inappropriate ioctl for device)' "$err_log"
+}
+
+configure_verbose() {
+    if [[ "$VERBOSE" != "1" ]]; then
+        return 0
+    fi
+    set -x
+}
+
+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_MAJOR_MINOR="${FLINK_VERSION%.*}"
+FLINK_BASE_URL="${FLINK_BASE_URL:-https://dlcdn.apache.org/flink}"
+
+INSTALL_FLINK="${INSTALL_FLINK:-ask}"
+ENABLE_PYFLINK="${ENABLE_PYFLINK:-ask}"
+INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/flink}"
+VENV_DIR="${VENV_DIR:-.flink-agents-env}"
+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
+
+ARCHIVE_NAME="flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ARCHIVE_URL="${FLINK_BASE_URL}/flink-${FLINK_VERSION}/${ARCHIVE_NAME}"
+
+require_cmd() {
+    command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
+}
+
+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
+        selection="$("$GUM" choose \
+            --header "$prompt" \
+            --cursor-prefix "❯ " \
+            "Yes" "No" < /dev/tty || true)"
+        [[ "$selection" == "Yes" ]]
+        return
+    fi
+
+    local answer=""
+    printf '%s [y/n]: ' "$prompt" > /dev/tty
+    read -r answer < /dev/tty || true
+
+    [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+install_flink_if_needed() {
+    case "$INSTALL_FLINK" in
+        yes|true|1)
+            ;;
+        no|false|0)
+            ui_info "Skipping Flink download/install (INSTALL_FLINK=${INSTALL_FLINK})."
+            return
+            ;;
+        ask)
+            if ! choose_install_method_interactive "Do you want this script to download and install Flink ${FLINK_VERSION}?"; then
+                ui_info "Skipping Flink download/install by user choice."
+                return
+            fi
+            ;;
+        *)
+            die "Unsupported INSTALL_FLINK value: ${INSTALL_FLINK}. Use: ask|yes|no"
+            ;;
+    esac
+
+    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="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+}
+
+resolve_flink_home() {
+    if [[ -z "${FLINK_HOME:-}" ]]; then
+        die "FLINK_HOME is not set. Set FLINK_HOME or run with INSTALL_FLINK=yes."
+    fi
+    [[ -d "$FLINK_HOME" ]] || die "FLINK_HOME does not exist: $FLINK_HOME"
+    [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib directory): $FLINK_HOME"
+}
+
+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/"
+}
+
+setup_python_env() {
+    check_python || die "Python environment check failed. Please install Python >=3.10."
+
+    if [[ ! -d "$VENV_DIR" ]]; then
+        ui_info "Creating virtual environment: $VENV_DIR"
+        python3 -m venv "$VENV_DIR"
+    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
+
+    ui_info "Installing Python packages"
+    python -m pip install --upgrade pip
+    python -m pip install "flink-agents==${FLINK_AGENTS_VERSION}" "apache-flink==${FLINK_VERSION}"
+}
+
+copy_flink_agents_jars() {
+    local pkg_root
+    pkg_root="$(python - <<'PY'
+import pathlib
+import flink_agents
+print(pathlib.Path(flink_agents.__file__).resolve().parent)
+PY
+)"
+
+    local version_lib_dir="${pkg_root}/lib/flink-${FLINK_MAJOR_MINOR}"
+
+    [[ -d "$version_lib_dir" ]] || die "Flink Agents lib directory not found: $version_lib_dir"
+
+    local copied=0
+    local jar
+    for jar in "$version_lib_dir"/flink-agents-dist-*.jar; do
+        [[ -f "$jar" ]] || continue
+        cp "$jar" "$FLINK_HOME/lib/"
+        copied=1
+    done
+
+    [[ "$copied" -eq 1 ]] || die "No flink-agents-dist jar found in: $version_lib_dir"
+}
+
+should_enable_pyflink() {
+    case "$ENABLE_PYFLINK" in
+        yes|true|1)
+            return 0
+            ;;
+        no|false|0)
+            return 1
+            ;;
+        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
+                return 0
+            fi
+            return 1
+            ;;
+        *)
+            die "Unsupported ENABLE_PYFLINK value: ${ENABLE_PYFLINK}. Use: ask|yes|no"
+            ;;
+    esac
+}
+
+print_usage() {
+    cat <<EOF
+Apache Flink Agents Installer
+
+Usage:
+  bash install.sh [options]
+  curl -fsSL <url>/install.sh | bash -s -- [options]
+
+Options:
+  --yes, -y               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
+  --help, -h              Show this help
+
+Environment variables:
+  FLINK_VERSION             Flink version to install (default: 2.2.0)
+  FLINK_AGENTS_VERSION      Flink Agents version to install (default: 0.3.0)
+  FLINK_SCALA_VERSION       Scala version suffix (default: 2.12)
+  FLINK_BASE_URL            Mirror base URL (default: https://dlcdn.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)
+  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 --yes
+  FLINK_VERSION=2.2.0 bash install.sh --verbose
+  bash install.sh --dry-run
+EOF
+}
+
+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 -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
+}
+
+check_python() {
+    if ! command -v python3 &>/dev/null; then
+        ui_error "python3 not found on PATH"
+        return 1
+    fi
+
+    local py_version_output
+    py_version_output="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || true)"
+
+    if [[ -z "$py_version_output" ]]; then
+        ui_error "Could not parse python3 version"
+        return 1
+    fi
+
+    local py_major py_minor
+    py_major="${py_version_output%%.*}"
+    py_minor="${py_version_output##*.}"
+
+    if [[ "$py_major" -ne 3 ]] || [[ "$py_minor" -lt 10 ]]; then

Review Comment:
   The conditions do not require Python < 3.12.



##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+
+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
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir gum_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")
+
+    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
+
+    gum_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)"
+    if [[ -z "$gum_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$gum_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$gum_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    GUM="$gum_path"
+    GUM_STATUS="installed"
+    GUM_REASON="temp, verified"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        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 card
+        title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink Agents Installer")"
+        card="$(printf '%s\n' "$title")"
+        "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding "1 2" "$card"
+        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
+}
+
+INSTALL_STAGE_TOTAL=3
+INSTALL_STAGE_CURRENT=0
+
+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))
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 20 "$key")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        echo -e "${MUTED}${key}:${NC} ${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
+}
+
+is_shell_function() {
+    local name="${1:-}"
+    [[ -n "$name" ]] && declare -F "$name" >/dev/null 2>&1
+}
+
+is_gum_raw_mode_failure() {
+    local err_log="$1"
+    [[ -s "$err_log" ]] || return 1
+    grep -Eiq '(setrawmode|inappropriate ioctl for device)' "$err_log"
+}
+
+configure_verbose() {
+    if [[ "$VERBOSE" != "1" ]]; then
+        return 0
+    fi
+    set -x
+}
+
+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_MAJOR_MINOR="${FLINK_VERSION%.*}"
+FLINK_BASE_URL="${FLINK_BASE_URL:-https://dlcdn.apache.org/flink}"
+
+INSTALL_FLINK="${INSTALL_FLINK:-ask}"
+ENABLE_PYFLINK="${ENABLE_PYFLINK:-ask}"
+INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/flink}"
+VENV_DIR="${VENV_DIR:-.flink-agents-env}"
+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
+
+ARCHIVE_NAME="flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ARCHIVE_URL="${FLINK_BASE_URL}/flink-${FLINK_VERSION}/${ARCHIVE_NAME}"
+
+require_cmd() {
+    command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
+}
+
+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
+        selection="$("$GUM" choose \
+            --header "$prompt" \
+            --cursor-prefix "❯ " \
+            "Yes" "No" < /dev/tty || true)"
+        [[ "$selection" == "Yes" ]]
+        return
+    fi
+
+    local answer=""
+    printf '%s [y/n]: ' "$prompt" > /dev/tty
+    read -r answer < /dev/tty || true
+
+    [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+install_flink_if_needed() {
+    case "$INSTALL_FLINK" in
+        yes|true|1)
+            ;;
+        no|false|0)
+            ui_info "Skipping Flink download/install (INSTALL_FLINK=${INSTALL_FLINK})."
+            return
+            ;;
+        ask)
+            if ! choose_install_method_interactive "Do you want this script to download and install Flink ${FLINK_VERSION}?"; then
+                ui_info "Skipping Flink download/install by user choice."
+                return
+            fi
+            ;;
+        *)
+            die "Unsupported INSTALL_FLINK value: ${INSTALL_FLINK}. Use: ask|yes|no"
+            ;;
+    esac
+
+    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="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+}
+
+resolve_flink_home() {
+    if [[ -z "${FLINK_HOME:-}" ]]; then
+        die "FLINK_HOME is not set. Set FLINK_HOME or run with INSTALL_FLINK=yes."
+    fi
+    [[ -d "$FLINK_HOME" ]] || die "FLINK_HOME does not exist: $FLINK_HOME"
+    [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib directory): $FLINK_HOME"
+}
+
+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/"
+}
+
+setup_python_env() {
+    check_python || die "Python environment check failed. Please install Python >=3.10."
+
+    if [[ ! -d "$VENV_DIR" ]]; then
+        ui_info "Creating virtual environment: $VENV_DIR"
+        python3 -m venv "$VENV_DIR"
+    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
+
+    ui_info "Installing Python packages"
+    python -m pip install --upgrade pip
+    python -m pip install "flink-agents==${FLINK_AGENTS_VERSION}" "apache-flink==${FLINK_VERSION}"
+}
+
+copy_flink_agents_jars() {
+    local pkg_root
+    pkg_root="$(python - <<'PY'
+import pathlib
+import flink_agents
+print(pathlib.Path(flink_agents.__file__).resolve().parent)
+PY
+)"
+
+    local version_lib_dir="${pkg_root}/lib/flink-${FLINK_MAJOR_MINOR}"
+
+    [[ -d "$version_lib_dir" ]] || die "Flink Agents lib directory not found: $version_lib_dir"
+
+    local copied=0
+    local jar
+    for jar in "$version_lib_dir"/flink-agents-dist-*.jar; do
+        [[ -f "$jar" ]] || continue
+        cp "$jar" "$FLINK_HOME/lib/"

Review Comment:
   In #567, we split the dist jar into two parts: common jar and thin jar. Here, only the thin jar was copied.
   
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 commented on code in PR #599:
URL: https://github.com/apache/flink-agents/pull/599#discussion_r3144742223


##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+
+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
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir gum_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")
+
+    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
+
+    gum_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)"
+    if [[ -z "$gum_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$gum_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$gum_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    GUM="$gum_path"
+    GUM_STATUS="installed"
+    GUM_REASON="temp, verified"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        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 card
+        title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink Agents Installer")"
+        card="$(printf '%s\n' "$title")"
+        "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding "1 2" "$card"
+        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
+}
+
+INSTALL_STAGE_TOTAL=3
+INSTALL_STAGE_CURRENT=0
+
+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))
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 20 "$key")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        echo -e "${MUTED}${key}:${NC} ${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
+}
+
+is_shell_function() {
+    local name="${1:-}"
+    [[ -n "$name" ]] && declare -F "$name" >/dev/null 2>&1
+}
+
+is_gum_raw_mode_failure() {
+    local err_log="$1"
+    [[ -s "$err_log" ]] || return 1
+    grep -Eiq '(setrawmode|inappropriate ioctl for device)' "$err_log"
+}
+
+configure_verbose() {
+    if [[ "$VERBOSE" != "1" ]]; then
+        return 0
+    fi
+    set -x
+}
+
+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_MAJOR_MINOR="${FLINK_VERSION%.*}"
+FLINK_BASE_URL="${FLINK_BASE_URL:-https://dlcdn.apache.org/flink}"
+
+INSTALL_FLINK="${INSTALL_FLINK:-ask}"
+ENABLE_PYFLINK="${ENABLE_PYFLINK:-ask}"
+INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/flink}"
+VENV_DIR="${VENV_DIR:-.flink-agents-env}"
+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
+
+ARCHIVE_NAME="flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ARCHIVE_URL="${FLINK_BASE_URL}/flink-${FLINK_VERSION}/${ARCHIVE_NAME}"
+
+require_cmd() {
+    command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
+}
+
+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
+        selection="$("$GUM" choose \
+            --header "$prompt" \
+            --cursor-prefix "❯ " \
+            "Yes" "No" < /dev/tty || true)"
+        [[ "$selection" == "Yes" ]]
+        return
+    fi
+
+    local answer=""
+    printf '%s [y/n]: ' "$prompt" > /dev/tty
+    read -r answer < /dev/tty || true
+
+    [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+install_flink_if_needed() {
+    case "$INSTALL_FLINK" in
+        yes|true|1)
+            ;;
+        no|false|0)
+            ui_info "Skipping Flink download/install (INSTALL_FLINK=${INSTALL_FLINK})."
+            return
+            ;;
+        ask)
+            if ! choose_install_method_interactive "Do you want this script to download and install Flink ${FLINK_VERSION}?"; then
+                ui_info "Skipping Flink download/install by user choice."
+                return
+            fi
+            ;;
+        *)
+            die "Unsupported INSTALL_FLINK value: ${INSTALL_FLINK}. Use: ask|yes|no"
+            ;;
+    esac
+
+    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="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+}
+
+resolve_flink_home() {
+    if [[ -z "${FLINK_HOME:-}" ]]; then
+        die "FLINK_HOME is not set. Set FLINK_HOME or run with INSTALL_FLINK=yes."
+    fi
+    [[ -d "$FLINK_HOME" ]] || die "FLINK_HOME does not exist: $FLINK_HOME"
+    [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib directory): $FLINK_HOME"
+}
+
+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/"
+}
+
+setup_python_env() {
+    check_python || die "Python environment check failed. Please install Python >=3.10."
+
+    if [[ ! -d "$VENV_DIR" ]]; then
+        ui_info "Creating virtual environment: $VENV_DIR"
+        python3 -m venv "$VENV_DIR"
+    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
+
+    ui_info "Installing Python packages"
+    python -m pip install --upgrade pip
+    python -m pip install "flink-agents==${FLINK_AGENTS_VERSION}" "apache-flink==${FLINK_VERSION}"
+}
+
+copy_flink_agents_jars() {
+    local pkg_root
+    pkg_root="$(python - <<'PY'
+import pathlib
+import flink_agents
+print(pathlib.Path(flink_agents.__file__).resolve().parent)
+PY
+)"
+
+    local version_lib_dir="${pkg_root}/lib/flink-${FLINK_MAJOR_MINOR}"
+
+    [[ -d "$version_lib_dir" ]] || die "Flink Agents lib directory not found: $version_lib_dir"
+
+    local copied=0
+    local jar
+    for jar in "$version_lib_dir"/flink-agents-dist-*.jar; do
+        [[ -f "$jar" ]] || continue
+        cp "$jar" "$FLINK_HOME/lib/"
+        copied=1
+    done
+
+    [[ "$copied" -eq 1 ]] || die "No flink-agents-dist jar found in: $version_lib_dir"
+}
+
+should_enable_pyflink() {
+    case "$ENABLE_PYFLINK" in
+        yes|true|1)
+            return 0
+            ;;
+        no|false|0)
+            return 1
+            ;;
+        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
+                return 0
+            fi
+            return 1
+            ;;
+        *)
+            die "Unsupported ENABLE_PYFLINK value: ${ENABLE_PYFLINK}. Use: ask|yes|no"
+            ;;
+    esac
+}
+
+print_usage() {
+    cat <<EOF
+Apache Flink Agents Installer
+
+Usage:
+  bash install.sh [options]
+  curl -fsSL <url>/install.sh | bash -s -- [options]
+
+Options:
+  --yes, -y               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
+  --help, -h              Show this help
+
+Environment variables:
+  FLINK_VERSION             Flink version to install (default: 2.2.0)
+  FLINK_AGENTS_VERSION      Flink Agents version to install (default: 0.3.0)
+  FLINK_SCALA_VERSION       Scala version suffix (default: 2.12)
+  FLINK_BASE_URL            Mirror base URL (default: https://dlcdn.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)
+  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 --yes
+  FLINK_VERSION=2.2.0 bash install.sh --verbose
+  bash install.sh --dry-run
+EOF
+}
+
+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 -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
+}
+
+check_python() {
+    if ! command -v python3 &>/dev/null; then
+        ui_error "python3 not found on PATH"
+        return 1
+    fi
+
+    local py_version_output
+    py_version_output="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || true)"
+
+    if [[ -z "$py_version_output" ]]; then
+        ui_error "Could not parse python3 version"
+        return 1
+    fi
+
+    local py_major py_minor
+    py_major="${py_version_output%%.*}"
+    py_minor="${py_version_output##*.}"
+
+    if [[ "$py_major" -ne 3 ]] || [[ "$py_minor" -lt 10 ]]; then

Review Comment:
   I think we should modify the code constraints, for flink-agents 0.2.1 only supports python 3.10 and python 3.11.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 commented on PR #599:
URL: https://github.com/apache/flink-agents/pull/599#issuecomment-4324018552

   Hi, @JinkunLiu, thanks for your update. 
   
   Since flink-agents provides multi-version support, and considering that some developers' clusters may not yet be compatible with the latest Flink version, I believe it might be too inflexible to always download the latest Flink version.
   
   >  Now the install shell provides the latest version as default. Users can use all the features we provide in the latest version.If they need special version about Flink or Flink-agents they can use cli flag.
   
   This is what I mean when I say that users need to understand many CLI flags. Why can't users be allowed to select a specific Flink version, rather than being required to specify it via a CLI flag? We provide a GUI-like installation wizard, but users are still required to manually specify certain cli flag to achieve specific goals. I believe this creates a fragmented user experience.
   
   I think for some less frequent requirements, such as dry-run and verbose, we can simply only provide CLI flags. However, for some high-frequency requirements, I believe it would be a more user-friendly experience to allow users to select options during the installation guide, rather than requiring them to read and understand the information returned by help and setting CLI flags or environment variables. 
   
   I think these requirements contains:
   1. intall dir: we can provide two options: default vs. custom, and if user choose custom, they need input the custom dir.
   2. install flink: yes vs. no, and check the FLINK_HOME when user choose no.
   3. flink-version: 4 options, 1.20.3, 2.0.1, 2.1.1, 2.2.0(recommend)
   4. install pyflink: yes vs. no 
   
   After the user makes the above selections, we will then output the Install Plan.
   
   Additionally, when running the script on my Mac, I encountered the error die: command not found. You may need to replace it with a more universally available command.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "JinkunLiu (via GitHub)" <gi...@apache.org>.
JinkunLiu commented on PR #599:
URL: https://github.com/apache/flink-agents/pull/599#issuecomment-4479105909

   Hi, @wenjin272 
   Thanks for you help, please go ahead and push the commits directly to this branch.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 commented on PR #599:
URL: https://github.com/apache/flink-agents/pull/599#issuecomment-4499073502

   Hi, @JinkunLiu @xintongsong, I have made some additional updates to this PR, which address the following issues. PTAL
     
     - **`flink-agents` JAR missing when PyFlink skipped** — download JAR directly from ASF mirror (SHA512-verified); Python no longer required for Java users.
     - **Existing Flink version not detected** — parse `lib/flink-dist-*.jar`, fall back to `bin/flink --version` with a progress hint.
     - **"Install dir" / version shown even when not installing** — plan split into read-only **Environment** + editable **Plan**; install-only rows hidden on `INSTALL_FLINK=No`.
     - **Toggling `Install Flink` didn't re-prompt** — `No → Yes` now cascades into version + dir prompts.
     - **`JAVA_HOME` looked editable** — moved to read-only Environment section.
     - **No Tab-completion on paths** — `read -e -r -p` replaces `gum input`.
     - **`export FLINK_HOME=…` line unclear** — numbered **Next steps** block with rationale.
     - **Double slashes / `~` / trailing `/` in paths** — `normalize_path` helper wired into every path entry.
     - **Ctrl+C swallowed** — global `trap die_cancelled INT`; stderr cancel msg; `confirm` honors subshell exit code.
     - **ESC quit installer / no way back** — edit menu wrapped in `( )` subshell, ESC backs out at any nesting depth.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "JinkunLiu (via GitHub)" <gi...@apache.org>.
JinkunLiu commented on code in PR #599:
URL: https://github.com/apache/flink-agents/pull/599#discussion_r3294208999


##########
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):"

Review Comment:
   Should we add [configure-pythonpath](https://nightlies.apache.org/flink/flink-agents-docs-main/docs/get-started/installation/#configure-pythonpath) to here?
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 commented on PR #599:
URL: https://github.com/apache/flink-agents/pull/599#issuecomment-4458076097

   Hi, @JinkunLiu. I see that your updated code has addressed the first two comments from the @xintongsong review. Will the remaining comments and additional tests be updated later? Since I will start my vacation next Thursday, I may need to check whether another developer should be assigned to help track this task.
   
   Additionally, regarding the issue of supplementary testing, I believe we can leverage a coding agent to accomplish this task.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 commented on PR #599:
URL: https://github.com/apache/flink-agents/pull/599#issuecomment-4478036256

   Hi, @JinkunLiu, thanks for your update.  These updates look to me.
   
   As you mentioned, there are three remaining issues.
   1. make the pip install output rolling
   2. second chance to modify install plan
   3. add comprehensive tests.
   
   Given that the code freeze (5.31) for Flink-Agents 0.3 is approaching and communication via natural language may be inefficient,  I tried using Claude Code to address the three issues mentioned above and generated some code. I verified this code locally, and it seems to be working fine. Would you mind if I directly push the above commits to this branch? Then, you can accept or reject these commits, or make modifications based on them.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "JinkunLiu (via GitHub)" <gi...@apache.org>.
JinkunLiu commented on PR #599:
URL: https://github.com/apache/flink-agents/pull/599#issuecomment-4323792575

   > users are required to understand too many cli flag and environment variables. 
   
   CLI flags are reserved for non-interactive installation mode
   
   > I'm wondering if we could retain only a single CLI flag, --default, to indicate that all operations should follow the default path
   
   Actually, the code already does this — you can try running `bash install.sh` directly and the interactive mode for user selection will appear.
   
   > Users can interactively choose whether to install Flink, select the Flink version (1.20.3, 2.0.1, 2.1.1, 2.2.0), and specify the flink-agents version (0.1.1, 0.2.1, 0.3).
   
   Now the install shell provides the latest version as default. Users can use all the features we provide in the latest version.If they need special version about Flink or Flink-agents they can use cli flag.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "JinkunLiu (via GitHub)" <gi...@apache.org>.
JinkunLiu commented on code in PR #599:
URL: https://github.com/apache/flink-agents/pull/599#discussion_r3144696811


##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+
+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
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir gum_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")
+
+    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
+
+    gum_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)"
+    if [[ -z "$gum_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$gum_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$gum_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    GUM="$gum_path"
+    GUM_STATUS="installed"
+    GUM_REASON="temp, verified"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        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 card
+        title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink Agents Installer")"
+        card="$(printf '%s\n' "$title")"
+        "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding "1 2" "$card"
+        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
+}
+
+INSTALL_STAGE_TOTAL=3
+INSTALL_STAGE_CURRENT=0
+
+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))
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 20 "$key")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        echo -e "${MUTED}${key}:${NC} ${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
+}
+
+is_shell_function() {
+    local name="${1:-}"
+    [[ -n "$name" ]] && declare -F "$name" >/dev/null 2>&1
+}
+
+is_gum_raw_mode_failure() {
+    local err_log="$1"
+    [[ -s "$err_log" ]] || return 1
+    grep -Eiq '(setrawmode|inappropriate ioctl for device)' "$err_log"
+}
+
+configure_verbose() {
+    if [[ "$VERBOSE" != "1" ]]; then
+        return 0
+    fi
+    set -x
+}
+
+FLINK_VERSION="${FLINK_VERSION:-2.2.0}"
+FLINK_AGENTS_VERSION="${FLINK_AGENTS_VERSION:-0.2.1}"

Review Comment:
   Done, Fix the help message now



##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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() {

Review Comment:
   Done, I have add message and reduced the retry count



##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+
+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
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir gum_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")
+
+    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
+
+    gum_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)"
+    if [[ -z "$gum_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$gum_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$gum_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    GUM="$gum_path"
+    GUM_STATUS="installed"
+    GUM_REASON="temp, verified"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        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 card
+        title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink Agents Installer")"
+        card="$(printf '%s\n' "$title")"
+        "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding "1 2" "$card"
+        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
+}
+
+INSTALL_STAGE_TOTAL=3
+INSTALL_STAGE_CURRENT=0
+
+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))
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 20 "$key")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        echo -e "${MUTED}${key}:${NC} ${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
+}
+
+is_shell_function() {
+    local name="${1:-}"
+    [[ -n "$name" ]] && declare -F "$name" >/dev/null 2>&1
+}
+
+is_gum_raw_mode_failure() {
+    local err_log="$1"
+    [[ -s "$err_log" ]] || return 1
+    grep -Eiq '(setrawmode|inappropriate ioctl for device)' "$err_log"
+}
+
+configure_verbose() {
+    if [[ "$VERBOSE" != "1" ]]; then
+        return 0
+    fi
+    set -x
+}
+
+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_MAJOR_MINOR="${FLINK_VERSION%.*}"
+FLINK_BASE_URL="${FLINK_BASE_URL:-https://dlcdn.apache.org/flink}"
+
+INSTALL_FLINK="${INSTALL_FLINK:-ask}"
+ENABLE_PYFLINK="${ENABLE_PYFLINK:-ask}"
+INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/flink}"
+VENV_DIR="${VENV_DIR:-.flink-agents-env}"
+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
+
+ARCHIVE_NAME="flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ARCHIVE_URL="${FLINK_BASE_URL}/flink-${FLINK_VERSION}/${ARCHIVE_NAME}"
+
+require_cmd() {
+    command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
+}
+
+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
+        selection="$("$GUM" choose \
+            --header "$prompt" \
+            --cursor-prefix "❯ " \
+            "Yes" "No" < /dev/tty || true)"
+        [[ "$selection" == "Yes" ]]
+        return
+    fi
+
+    local answer=""
+    printf '%s [y/n]: ' "$prompt" > /dev/tty
+    read -r answer < /dev/tty || true
+
+    [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+install_flink_if_needed() {
+    case "$INSTALL_FLINK" in
+        yes|true|1)
+            ;;
+        no|false|0)
+            ui_info "Skipping Flink download/install (INSTALL_FLINK=${INSTALL_FLINK})."
+            return
+            ;;
+        ask)
+            if ! choose_install_method_interactive "Do you want this script to download and install Flink ${FLINK_VERSION}?"; then
+                ui_info "Skipping Flink download/install by user choice."
+                return
+            fi
+            ;;
+        *)
+            die "Unsupported INSTALL_FLINK value: ${INSTALL_FLINK}. Use: ask|yes|no"
+            ;;
+    esac
+
+    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="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+}
+
+resolve_flink_home() {
+    if [[ -z "${FLINK_HOME:-}" ]]; then
+        die "FLINK_HOME is not set. Set FLINK_HOME or run with INSTALL_FLINK=yes."
+    fi
+    [[ -d "$FLINK_HOME" ]] || die "FLINK_HOME does not exist: $FLINK_HOME"
+    [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib directory): $FLINK_HOME"
+}
+
+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/"
+}
+
+setup_python_env() {
+    check_python || die "Python environment check failed. Please install Python >=3.10."
+
+    if [[ ! -d "$VENV_DIR" ]]; then
+        ui_info "Creating virtual environment: $VENV_DIR"
+        python3 -m venv "$VENV_DIR"
+    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
+
+    ui_info "Installing Python packages"
+    python -m pip install --upgrade pip
+    python -m pip install "flink-agents==${FLINK_AGENTS_VERSION}" "apache-flink==${FLINK_VERSION}"
+}
+
+copy_flink_agents_jars() {
+    local pkg_root
+    pkg_root="$(python - <<'PY'
+import pathlib
+import flink_agents
+print(pathlib.Path(flink_agents.__file__).resolve().parent)
+PY
+)"
+
+    local version_lib_dir="${pkg_root}/lib/flink-${FLINK_MAJOR_MINOR}"
+
+    [[ -d "$version_lib_dir" ]] || die "Flink Agents lib directory not found: $version_lib_dir"
+
+    local copied=0
+    local jar
+    for jar in "$version_lib_dir"/flink-agents-dist-*.jar; do
+        [[ -f "$jar" ]] || continue
+        cp "$jar" "$FLINK_HOME/lib/"

Review Comment:
   I see that the current directory structure is like this. The shell specifies a specific Flink version, so it will only copy the corresponding thin jar.
   ```shell
   .
   ├── common
   │   └── flink-agents-dist-common-0.2.1.jar
   ├── flink-1.20
   │   └── flink-agents-dist-flink-1.20-0.2.1-thin.jar
   ├── flink-2.0
   │   └── flink-agents-dist-flink-2.0-0.2.1-thin.jar
   ├── flink-2.1
   │   └── flink-agents-dist-flink-2.1-0.2.1-thin.jar
   └── flink-2.2
       └── flink-agents-dist-flink-2.2-0.2.1-thin.jar
   ```



##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+        return
+    fi
+    wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+
+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
+
+    if ! command -v tar >/dev/null 2>&1; then
+        GUM_REASON="tar not found"
+        return 1
+    fi
+
+    local os arch asset base gum_tmpdir gum_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")
+
+    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
+
+    gum_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)"
+    if [[ -z "$gum_path" ]]; then
+        GUM_REASON="gum binary missing after extract"
+        return 1
+    fi
+
+    chmod +x "$gum_path" >/dev/null 2>&1 || true
+    if [[ ! -x "$gum_path" ]]; then
+        GUM_REASON="gum binary is not executable"
+        return 1
+    fi
+
+    GUM="$gum_path"
+    GUM_STATUS="installed"
+    GUM_REASON="temp, verified"
+    return 0
+}
+
+print_gum_status() {
+    case "$GUM_STATUS" in
+        found)
+            ui_success "gum available (${GUM_REASON})"
+            ;;
+        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 card
+        title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink Agents Installer")"
+        card="$(printf '%s\n' "$title")"
+        "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding "1 2" "$card"
+        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
+}
+
+INSTALL_STAGE_TOTAL=3
+INSTALL_STAGE_CURRENT=0
+
+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))
+    ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+ui_kv() {
+    local key="$1"
+    local value="$2"
+    if [[ -n "$GUM" ]]; then
+        local key_part value_part
+        key_part="$("$GUM" style --foreground "#5a6480" --width 20 "$key")"
+        value_part="$("$GUM" style --bold "$value")"
+        "$GUM" join --horizontal "$key_part" "$value_part"
+    else
+        echo -e "${MUTED}${key}:${NC} ${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
+}
+
+is_shell_function() {
+    local name="${1:-}"
+    [[ -n "$name" ]] && declare -F "$name" >/dev/null 2>&1
+}
+
+is_gum_raw_mode_failure() {
+    local err_log="$1"
+    [[ -s "$err_log" ]] || return 1
+    grep -Eiq '(setrawmode|inappropriate ioctl for device)' "$err_log"
+}
+
+configure_verbose() {
+    if [[ "$VERBOSE" != "1" ]]; then
+        return 0
+    fi
+    set -x
+}
+
+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_MAJOR_MINOR="${FLINK_VERSION%.*}"
+FLINK_BASE_URL="${FLINK_BASE_URL:-https://dlcdn.apache.org/flink}"
+
+INSTALL_FLINK="${INSTALL_FLINK:-ask}"
+ENABLE_PYFLINK="${ENABLE_PYFLINK:-ask}"
+INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/flink}"
+VENV_DIR="${VENV_DIR:-.flink-agents-env}"
+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
+
+ARCHIVE_NAME="flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ARCHIVE_URL="${FLINK_BASE_URL}/flink-${FLINK_VERSION}/${ARCHIVE_NAME}"
+
+require_cmd() {
+    command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
+}
+
+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
+        selection="$("$GUM" choose \
+            --header "$prompt" \
+            --cursor-prefix "❯ " \
+            "Yes" "No" < /dev/tty || true)"
+        [[ "$selection" == "Yes" ]]
+        return
+    fi
+
+    local answer=""
+    printf '%s [y/n]: ' "$prompt" > /dev/tty
+    read -r answer < /dev/tty || true
+
+    [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+install_flink_if_needed() {
+    case "$INSTALL_FLINK" in
+        yes|true|1)
+            ;;
+        no|false|0)
+            ui_info "Skipping Flink download/install (INSTALL_FLINK=${INSTALL_FLINK})."
+            return
+            ;;
+        ask)
+            if ! choose_install_method_interactive "Do you want this script to download and install Flink ${FLINK_VERSION}?"; then
+                ui_info "Skipping Flink download/install by user choice."
+                return
+            fi
+            ;;
+        *)
+            die "Unsupported INSTALL_FLINK value: ${INSTALL_FLINK}. Use: ask|yes|no"
+            ;;
+    esac
+
+    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="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+}
+
+resolve_flink_home() {
+    if [[ -z "${FLINK_HOME:-}" ]]; then
+        die "FLINK_HOME is not set. Set FLINK_HOME or run with INSTALL_FLINK=yes."
+    fi
+    [[ -d "$FLINK_HOME" ]] || die "FLINK_HOME does not exist: $FLINK_HOME"
+    [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib directory): $FLINK_HOME"
+}
+
+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/"
+}
+
+setup_python_env() {
+    check_python || die "Python environment check failed. Please install Python >=3.10."
+
+    if [[ ! -d "$VENV_DIR" ]]; then
+        ui_info "Creating virtual environment: $VENV_DIR"
+        python3 -m venv "$VENV_DIR"
+    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
+
+    ui_info "Installing Python packages"
+    python -m pip install --upgrade pip
+    python -m pip install "flink-agents==${FLINK_AGENTS_VERSION}" "apache-flink==${FLINK_VERSION}"
+}
+
+copy_flink_agents_jars() {
+    local pkg_root
+    pkg_root="$(python - <<'PY'
+import pathlib
+import flink_agents
+print(pathlib.Path(flink_agents.__file__).resolve().parent)
+PY
+)"
+
+    local version_lib_dir="${pkg_root}/lib/flink-${FLINK_MAJOR_MINOR}"
+
+    [[ -d "$version_lib_dir" ]] || die "Flink Agents lib directory not found: $version_lib_dir"
+
+    local copied=0
+    local jar
+    for jar in "$version_lib_dir"/flink-agents-dist-*.jar; do
+        [[ -f "$jar" ]] || continue
+        cp "$jar" "$FLINK_HOME/lib/"
+        copied=1
+    done
+
+    [[ "$copied" -eq 1 ]] || die "No flink-agents-dist jar found in: $version_lib_dir"
+}
+
+should_enable_pyflink() {
+    case "$ENABLE_PYFLINK" in
+        yes|true|1)
+            return 0
+            ;;
+        no|false|0)
+            return 1
+            ;;
+        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
+                return 0
+            fi
+            return 1
+            ;;
+        *)
+            die "Unsupported ENABLE_PYFLINK value: ${ENABLE_PYFLINK}. Use: ask|yes|no"
+            ;;
+    esac
+}
+
+print_usage() {
+    cat <<EOF
+Apache Flink Agents Installer
+
+Usage:
+  bash install.sh [options]
+  curl -fsSL <url>/install.sh | bash -s -- [options]
+
+Options:
+  --yes, -y               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
+  --help, -h              Show this help
+
+Environment variables:
+  FLINK_VERSION             Flink version to install (default: 2.2.0)
+  FLINK_AGENTS_VERSION      Flink Agents version to install (default: 0.3.0)
+  FLINK_SCALA_VERSION       Scala version suffix (default: 2.12)
+  FLINK_BASE_URL            Mirror base URL (default: https://dlcdn.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)
+  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 --yes
+  FLINK_VERSION=2.2.0 bash install.sh --verbose
+  bash install.sh --dry-run
+EOF
+}
+
+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 -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
+}
+
+check_python() {
+    if ! command -v python3 &>/dev/null; then
+        ui_error "python3 not found on PATH"
+        return 1
+    fi
+
+    local py_version_output
+    py_version_output="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || true)"
+
+    if [[ -z "$py_version_output" ]]; then
+        ui_error "Could not parse python3 version"
+        return 1
+    fi
+
+    local py_major py_minor
+    py_major="${py_version_output%%.*}"
+    py_minor="${py_version_output##*.}"
+
+    if [[ "$py_major" -ne 3 ]] || [[ "$py_minor" -lt 10 ]]; then

Review Comment:
   Sorry, I may have forgotten the background — do we still need to restrict the Python version in version 0.2.1? In other words, should I modify the code constraints or change the message for users?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "xintongsong (via GitHub)" <gi...@apache.org>.
xintongsong commented on code in PR #599:
URL: https://github.com/apache/flink-agents/pull/599#discussion_r3298534784


##########
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):"

Review Comment:
   Make sense to me. Would you like to open a separate PR for that?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 commented on PR #599:
URL: https://github.com/apache/flink-agents/pull/599#issuecomment-4484254934

    Hi, @JinkunLiu @xintongsong , I pushed 3 commits to address the remaining review comments. PTAL.
     
     - **e8f9c0ea** — Adds an 85-test bats-core suite under `tools/test/` (unit + PATH-shim integration, including an exact-argv pin on `download_file` as a regression guard for the `--` bug) plus a CI job running on Ubuntu and macOS.
     - **525adad5** — `pip_install_quiet` helper replaces noisy `pip install` output with a single rolling status line on tty (full log surfaced on failure), `gum spin` when gum is available, and `--quiet` in CI.
     - **54ccd1b9** — Confirm prompt is now Proceed / Edit a setting / Cancel; the Edit submenu re-prompts any currently-applicable field (Flink Yes/No, version, install dir, FLINK_HOME, PyFlink Yes/No, venv dir) and re-displays the plan.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "JinkunLiu (via GitHub)" <gi...@apache.org>.
JinkunLiu commented on code in PR #599:
URL: https://github.com/apache/flink-agents/pull/599#discussion_r3161786560


##########
tools/install.sh:
##########
@@ -0,0 +1,1117 @@
+#!/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.
+################################################################################
+
+set -euo 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
+
+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 -fsSL --proto '=https' --tlsv1.2 --connect-timeout 5 --max-time 30 --retry 2 --retry-delay 1 --retry-connrefused -o "$output" "$url"

Review Comment:
   Good catch. This is a mistake which introduced when i fix another issue. Fixed it.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 commented on code in PR #599:
URL: https://github.com/apache/flink-agents/pull/599#discussion_r3122133667


##########
tools/install.sh:
##########
@@ -0,0 +1,838 @@
+#!/bin/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.
+################################################################################
+
+set -euo 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
+
+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() {

Review Comment:
   When I first ran this script, there was no output for a long time; I later discovered it was downloading gum. However, due to my network issues, the download took too long.
   
   I was wondering if we could display a notification when the download time exceeds a certain threshold. For example, showing 'Installing [xxx], please wait,' or even providing a progress bar.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] [tools]Import Wizard for Installation Setup [flink-agents]

Posted by "wenjin272 (via GitHub)" <gi...@apache.org>.
wenjin272 merged PR #599:
URL: https://github.com/apache/flink-agents/pull/599


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@flink.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org