#!/usr/bin/env bash
# =============================================================================
# hub-agent ワンライナーインストーラ (Sprint L)
#
# Hub の `GET /api/cockpit/agents/install-script` が、このスクリプトに
# enrollment token + hub url を埋め込んだ personalized 版を返す。
#
# 単独使用 (token なし):
#   curl -fsSL https://raw.githubusercontent.com/cocorograph/D00000_hub-agent/main/scripts/install.sh | bash
#
# 内容:
#   1. macOS なら Homebrew (なければ install)
#      - 管理者権限が無い環境では自動で $HOME/homebrew にユーザーローカル
#        インストールに切り替わる (HUB_AGENT_USER_BREW=1 で強制も可)
#   2. tmux + node + python3 (なければ install via brew / apt 等)
#   3. GNU coreutils の timeout (補助コマンド。macOS では brew で入れて gtimeout への
#      symlink を ~/.local/bin に作成。Linux は存在確認のみで、不在なら手動導入を案内)
#   4. npm i -g @cocorograph/hub-agent
#   5. npm i -g @anthropic-ai/claude-code (既存があれば upgrade のみ)
#   6. npm i -g @openai/codex (常に install / upgrade)
#   7. HUB_AGENT_TOKEN が設定されていれば hub-agent enroll を自動実行
#   8. hub-agent install-service で OS サービス化
# =============================================================================

set -euo pipefail

# brew の auto-update / hint 出力は初回 install で長時間化する原因の最大要因。
# 環境変数で抑止して 1 発完走率を上げる。最新 Formula が欲しいときは
# ユーザーが `brew update` を別途実行する前提。
export HOMEBREW_NO_AUTO_UPDATE=1
export HOMEBREW_NO_ENV_HINTS=1
export HOMEBREW_NO_INSTALL_CLEANUP=1

# Node.js のサポート範囲ポリシー（Active LTS のみ）。
# - 既存 node のメジャーが [MIN, MAX] に収まっていれば現状維持
# - 範囲外なら NODE_DEFAULT_BREW_FORMULA (最新 Active LTS) にアップ / ダウングレード
# 2026-05 時点の LTS スケジュール:
#   - node 22: Active LTS → 2027/04 EoL
#   - node 24: Active LTS → 2028/04 EoL（最新、サポート期間最長）
#   - node 26: current（LTS ではない、SSL/CA 周りの問題報告あり）
NODE_MIN_MAJOR=22
NODE_MAX_MAJOR=24
NODE_DEFAULT_BREW_FORMULA="node@24"

PACKAGE_NAME="@cocorograph/hub-agent"
CLAUDE_CODE_PACKAGE="@anthropic-ai/claude-code"
CODEX_PACKAGE="@openai/codex"
UV_VERSION="0.12.8"
HUB_PYTHON_MINOR="3.13"
HUB_VENV_DIR="$HOME/.local/share/hub-venv"
# Hub Python の依存バージョンを更新するときは、この配列を変更する。
HUB_PYTHON_PACKAGES=("PyYAML==6.0.3" "Pillow==12.3.0" "openpyxl==3.1.5")
# HUB_PYTHON_PACKAGES と同じ 3 件を指す。依存を増やすときは両方を更新する。
HUB_PYTHON_IMPORTS=("yaml" "PIL" "openpyxl")

color_step() { printf "\033[1;34m==> %s\033[0m\n" "$1"; }
color_ok()   { printf "\033[1;32m✓ %s\033[0m\n" "$1"; }
color_warn() { printf "\033[1;33m! %s\033[0m\n" "$1"; }
color_err()  { printf "\033[1;31m✗ %s\033[0m\n" "$1" >&2; }

present() { command -v "$1" >/dev/null 2>&1; }

# step counter (main() で使う)。色は出すが詳細メッセージは関数内に任せる。
# STEP_TOTAL は main の冒頭で再設定する想定。
STEP_TOTAL=10
STEP_NUM=0
step_header() {
  STEP_NUM=$((STEP_NUM + 1))
  printf "\033[1;36m\n━━━ [%d/%d] %s ━━━\033[0m\n" "$STEP_NUM" "$STEP_TOTAL" "$1"
}

# 指定コマンドを最大 N 回まで指数 backoff で retry する。
# transient な network / brew / npm 失敗を耐える用途。
# 使い方: retry 3 brew install foo
#         retry 3 npm install -g bar
#
# 重要: bash の `set -e` 下では `if cmd` パターンは cmd の戻り値で分岐するため、
#       cmd 失敗時に script が exit しない (`set -e` の標準仕様)。
#       retry 全体が失敗した時のみ呼び出し元が exit する。
retry() {
  local max="$1"; shift
  local i=1
  local delay=2
  while true; do
    if "$@"; then
      return 0
    fi
    if (( i >= max )); then
      color_err "コマンドが ${max} 回連続失敗: $*"
      return 1
    fi
    color_warn "失敗 (${i}/${max}) → ${delay}s 後に再試行: $*"
    sleep "$delay"
    i=$((i + 1))
    delay=$((delay * 2))
  done
}

# 現在 PATH にある brew が、このユーザーで書き込み可能か判定する。
# 「brew はあるが Cellar が他ユーザー所有で書き込み不可」というケースを検知して
# user-local Homebrew にフォールバックするための判定関数。
_existing_brew_writable() {
  local prefix
  prefix="$(brew --prefix 2>/dev/null || echo '')"
  [[ -z "$prefix" ]] && return 1
  # Cellar / opt が書き込み可能か（または未作成でも prefix 自体が書き込み可能か）
  if [[ -w "$prefix/Cellar" ]] || ( [[ ! -e "$prefix/Cellar" ]] && [[ -w "$prefix" ]] ); then
    return 0
  fi
  return 1
}

ensure_brew() {
  if [[ "$(uname)" != "Darwin" ]]; then return 0; fi

  # 既に brew がインストールされている場合の判定:
  # - ユーザーが書き込み可能 → そのまま使用（system / user-local どちらでも OK）
  # - 書き込み不可（他ユーザー所有の brew が居る）→ $HOME/homebrew に並列で user-local 化
  if present brew; then
    if _existing_brew_writable; then
      color_ok "brew already installed and writable ($(brew --prefix))"
      persist_brew_shellenv
      return 0
    fi
    color_warn "既存 brew ($(brew --prefix 2>/dev/null)) は書き込み不可 → \$HOME/homebrew にユーザーローカル Homebrew を並列インストール"
    _install_user_local_brew
    return 0
  fi

  # brew コマンドが PATH にない場合の判定:
  # - 環境変数 HUB_AGENT_USER_BREW=1 で明示強制
  # - sudo がパスワードなしで通らない = 管理者ではない可能性が高いと判定
  local use_user_mode=0
  if [[ "${HUB_AGENT_USER_BREW:-0}" == "1" ]]; then
    use_user_mode=1
    color_step "HUB_AGENT_USER_BREW=1 → ユーザーローカル Homebrew を使用"
  elif ! sudo -n true 2>/dev/null; then
    color_warn "管理者権限なしと判定 → \$HOME/homebrew にユーザーローカル Homebrew をインストール"
    use_user_mode=1
  fi

  if (( use_user_mode == 1 )); then
    _install_user_local_brew
  else
    color_step "Homebrew をシステムインストール"
    # Homebrew 公式 install.sh をダウンロードして実行。
    # curl は GitHub raw のレート制限や transient 502 を retry でカバーする。
    retry 3 bash -c '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
    # Apple Silicon の brew はデフォルト PATH に入らないので追加
    if [[ -d /opt/homebrew/bin ]]; then
      export PATH="/opt/homebrew/bin:$PATH"
    fi
    persist_brew_shellenv
  fi
  present brew || { color_err "brew install failed"; exit 1; }
}

# user-local Homebrew のインストール本体（重複排除のため関数化）。
# 既に $HOME/homebrew が展開済みなら再展開せず PATH/shellenv だけ整える。
_install_user_local_brew() {
  if [[ -x "$HOME/homebrew/bin/brew" ]]; then
    color_ok "\$HOME/homebrew は既に展開済み"
  else
    color_step "Homebrew をユーザーローカルインストール (\$HOME/homebrew)"
    mkdir -p "$HOME/homebrew"
    # GitHub tarball ダウンロード + tar 展開を 1 つの bash -c に包んで retry にかける。
    # pipe 途中の curl 失敗を retry のスコープに収めるため。
    retry 3 bash -c 'curl -fsSL https://github.com/Homebrew/brew/tarball/master | tar xz --strip 1 -C "$HOME/homebrew"'
  fi
  # PATH を user-local 優先で並べる（既存 /usr/local/bin/brew より先）
  export PATH="$HOME/homebrew/bin:$PATH"
  hash -r 2>/dev/null || true  # bash のコマンドキャッシュをクリア
  persist_user_brew_shellenv
}

# Apple Silicon Mac で brew のパスを zsh / bash 永続化する。既に追記済みなら no-op。
# `eval "$(/opt/homebrew/bin/brew shellenv)"` を書くことで、Homebrew が用意する
# HOMEBREW_PREFIX / PATH / MANPATH / INFOPATH すべてを新規シェルで自動セットする。
persist_brew_shellenv() {
  local brew_bin=""
  # $HOME/homebrew が PATH 上で優先されているなら、user-local 側で永続化する
  if [[ -x "$HOME/homebrew/bin/brew" ]] && [[ "$(command -v brew)" == "$HOME/homebrew/bin/brew" ]]; then
    persist_user_brew_shellenv
    return 0
  fi
  if [[ -x /opt/homebrew/bin/brew ]]; then
    brew_bin="/opt/homebrew/bin/brew"
  elif [[ -x /usr/local/bin/brew ]]; then
    # Intel Mac は `/usr/local/bin` がデフォルト PATH に入るので追記不要
    return 0
  else
    return 0
  fi
  local snippet="eval \"\$(${brew_bin} shellenv)\""
  local marker="# >>> hub-agent: brew shellenv (Apple Silicon PATH) >>>"
  local end_marker="# <<< hub-agent: brew shellenv <<<"
  local target
  for target in "$HOME/.zprofile" "$HOME/.bash_profile"; do
    if [[ -f "$target" ]] && grep -Fq "$snippet" "$target"; then
      color_ok "brew shellenv は既に $target にあります"
      continue
    fi
    color_step "$target に brew shellenv を追記"
    {
      printf '\n%s\n' "$marker"
      printf '%s\n' "$snippet"
      printf '%s\n' "$end_marker"
    } >> "$target"
    color_ok "$target に追記しました (新規シェルから有効)"
  done
}

# user-mode（$HOME/homebrew）用の shellenv 永続化。
# system 版 persist_brew_shellenv と同じ仕組みだが対象 brew が $HOME 配下。
persist_user_brew_shellenv() {
  local brew_bin="$HOME/homebrew/bin/brew"
  [[ -x "$brew_bin" ]] || return 0
  local snippet="eval \"\$(${brew_bin} shellenv)\""
  local marker="# >>> hub-agent: brew shellenv (user-local) >>>"
  local end_marker="# <<< hub-agent: brew shellenv <<<"
  local target
  for target in "$HOME/.zprofile" "$HOME/.bash_profile"; do
    if [[ -f "$target" ]] && grep -Fq "$snippet" "$target"; then
      color_ok "user-local brew shellenv は既に $target にあります"
      continue
    fi
    color_step "$target に user-local brew shellenv を追記"
    {
      printf '\n%s\n' "$marker"
      printf '%s\n' "$snippet"
      printf '%s\n' "$end_marker"
    } >> "$target"
    color_ok "$target に追記しました (新規シェルから有効)"
  done
}

# snippet をマーカーで囲んで各 profile に冪等追記する。
# 使い方: persist_profile_snippet '<snippet>' '<label>' '<marker>' '<end_marker>' <profile>...
# 既に「snippet と完全一致する行」があれば skip する（部分一致だとコメントアウトされた
# `#export PATH=...` を既存扱いにして追記を飛ばす。マーカーの有無は見ない——マーカーだけ
# 残って snippet が欠けた部分書込みを再実行で補完できるようにするため）。
# 追記できない profile（root 所有・読み取り専用）は警告して次へ進み、最後に 1 を返す。
# 呼び出し側が失敗を致命にするか警告に留めるかを決める（set -e 下で裸で呼べば致命）。
persist_profile_snippet() {
  local snippet="$1" label="$2" marker="$3" end_marker="$4"
  shift 4
  local target rc=0
  for target in "$@"; do
    if [[ -f "$target" ]] && grep -Fxq "$snippet" "$target"; then
      continue
    fi
    if {
      printf '\n%s\n' "$marker"
      printf '%s\n' "$snippet"
      printf '%s\n' "$end_marker"
    } >> "$target" 2>/dev/null; then
      color_ok "$target に $label を追記 (新規シェルから有効)"
    else
      color_warn "$target に $label を追記できません (書き込み権限を確認してください)"
      rc=1
    fi
  done
  return "$rc"
}

# bash の login profile を 1 つ選ぶ。bash は ~/.bash_profile → ~/.bash_login → ~/.profile の
# うち最初に存在するものだけを読むため、~/.profile しか無い環境で ~/.bash_profile を
# 新規作成すると ~/.profile が読まれなくなる。既存のものを優先し、無ければ ~/.bash_profile。
bash_login_profile() {
  local f
  for f in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
    if [[ -f "$f" ]]; then
      printf '%s\n' "$f"
      return 0
    fi
  done
  printf '%s\n' "$HOME/.bash_profile"
}

# user-mode の場合、npm の global prefix を $HOME/.npm-global に切り替える。
# brew が $HOME/homebrew にいるとき、その prefix への書き込み権限はあるものの、
# 後で system 版 node / npm に切り替わる可能性も考えて user 領域に逃がしておく。
# system mode（/opt/homebrew 等）では何もしない（既存挙動を温存）。
ensure_npm_user_prefix() {
  # macOS は user-mode (brew が $HOME/homebrew) のときだけ切替（既存挙動を温存）。
  # Linux は apt 等の global prefix が sudo 必須 / global bin が PATH に通らない問題を
  # 起こしやすい（npm i -g は成功しても hub-agent / claude が command not found になる）。
  # そのため Linux では常に $HOME/.npm-global へ寄せ、sudo 不要・bin パス確定・PATH 反映を
  # 一貫させる（WSL クリーン Ubuntu で発覚）。
  if [[ "$(uname)" == "Darwin" ]]; then
    [[ -d "$HOME/homebrew" ]] || return 0  # user-mode 検知
  fi

  # 既に $HOME/.npm-global が prefix なら何もしない
  local cur_prefix
  cur_prefix="$(npm config get prefix 2>/dev/null || echo '')"
  if [[ "$cur_prefix" == "$HOME/.npm-global" ]]; then
    # ワンライナー (bash -c) は非ログイン非対話で profile が読まれないため、
    # 既存 prefix の再セットアップ時に $HOME/.npm-global/bin が PATH に乗らず
    # 後段の `hub-agent` / `claude` 呼び出しが command not found になる事故があった。
    # 実行中シェルの PATH を毎回明示的に整える。
    export PATH="$HOME/.npm-global/bin:$PATH"
    color_ok "npm global prefix は既に $HOME/.npm-global"
    return 0
  fi

  color_step "npm global prefix を \$HOME/.npm-global に切替 (user-mode)"
  mkdir -p "$HOME/.npm-global"
  npm config set prefix "$HOME/.npm-global"
  export PATH="$HOME/.npm-global/bin:$PATH"

  local snippet='export PATH="$HOME/.npm-global/bin:$PATH"'
  # 永続先は OS で出し分け。Linux の hub-agent は systemd --user 起動時や
  # `$SHELL -lic` 経由の env 注入で PATH を解決するため、login(.profile) と
  # interactive(.bashrc) の双方に通す。macOS は従来どおり zsh/bash の profile。
  local targets
  if [[ "$(uname)" == "Darwin" ]]; then
    targets=("$HOME/.zprofile" "$HOME/.bash_profile")
  else
    targets=("$HOME/.bashrc" "$HOME/.profile")
  fi
  persist_profile_snippet "$snippet" "npm-global PATH" \
    "# >>> hub-agent: npm-global PATH (user-mode) >>>" "# <<< hub-agent: npm-global PATH <<<" \
    "${targets[@]}"
}

# =============================================================================
# Node TLS 環境の自動修復
#
# 背景: npm install で `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` が出る環境がある。
# 原因は「node は OS の信頼ストアを使わず、自分にコンパイル時に焼き込まれた
# CA リストだけで TLS 検証する」設計にある:
#
#   - curl は macOS Keychain (or /etc/ssl/certs) を使う → 通る
#   - node はバンドル CA のみ → 通らない
#
# テナント環境では以下のいずれかで MITM 的な証明書差し替えが起きうる:
#   - ウィルス対策ソフト (Sophos / Trend Micro / Norton / Symantec / Kaspersky 等)
#   - 企業の SSL インスペクション proxy (ZScaler / Cloudflare WARP for Teams 等)
#   - VPN クライアントによるトラフィック検査
#   - 広告ブロッカー (NextDNS / AdGuard / 1.1.1.1 等)
#   - 親会社配布のセキュリティアプリ
#
# どれもユーザー操作なしには検出しにくく、ユーザー自身も気づいていないことが多い。
#
# 対処: ユーザーが既に OS で信頼している CA バンドルを node にも渡せば、curl と
# node の信頼ストアの乖離が解消する。`NODE_EXTRA_CA_CERTS` 環境変数を使えば
# node のバンドル CA に「追加で」信頼する証明書を渡せる (バンドル CA を置き換える
# わけではないので、通常環境への副作用はない)。
#
# 1. pre-flight check で node の TLS が通るかテスト
# 2. 失敗時のみ、OS の信頼ストアを PEM に書き出して NODE_EXTRA_CA_CERTS にセット
# 3. シェル profile (.zprofile / .bash_profile) にも追記して永続化
# 4. 再テスト → ダメなら明確な日本語エラーガイドで終了
# =============================================================================

# node 側 TLS で npm registry に到達できるかテスト。成功で 0、失敗で 1。
# stderr の最後の行を grep 用に echo するので、呼び出し側で原因種別を判定できる。
_test_node_tls() {
  if ! present node; then
    return 1
  fi
  # node -e で出る ERR_TLS_CERT_ALTNAME_INVALID 等の他種別エラーは別途扱う必要が
  # あるため戻り値だけで判定。stdout/stderr は呼び出し側で破棄してよい。
  node -e "require('https').get('https://registry.npmjs.org/', r => { process.exit(r.statusCode >= 400 ? 1 : 0); }).on('error', e => { console.error(e.code || e.message); process.exit(1); });" 2>&1
}

# OS の信頼ストア (system / login keychain or /etc/ssl/certs) を PEM に書き出す。
# 返り値: 成功なら 0 + ファイルパスを stdout に echo / 失敗なら 1。
_export_os_ca_bundle() {
  local out_pem="$HOME/.hub-agent-ca.pem"
  case "$(uname -s)" in
    Darwin)
      # System.keychain には企業配布 CA や手動追加 CA、SystemRootCertificates.keychain
      # には Apple 配布の標準 root CA が入っている。両方をマージ。
      # `-p` で PEM 形式、`-a` で全件出力。pemcat に近い操作。
      {
        security find-certificate -a -p /Library/Keychains/System.keychain 2>/dev/null || true
        security find-certificate -a -p /System/Library/Keychains/SystemRootCertificates.keychain 2>/dev/null || true
      } > "$out_pem"
      ;;
    Linux)
      # Debian/Ubuntu 系
      if [[ -r /etc/ssl/certs/ca-certificates.crt ]]; then
        cp /etc/ssl/certs/ca-certificates.crt "$out_pem"
      # RHEL/CentOS/Fedora 系
      elif [[ -r /etc/pki/tls/certs/ca-bundle.crt ]]; then
        cp /etc/pki/tls/certs/ca-bundle.crt "$out_pem"
      # SUSE 系
      elif [[ -r /var/lib/ca-certificates/ca-bundle.pem ]]; then
        cp /var/lib/ca-certificates/ca-bundle.pem "$out_pem"
      else
        return 1
      fi
      ;;
    *)
      return 1
      ;;
  esac
  if [[ ! -s "$out_pem" ]]; then
    rm -f "$out_pem"
    return 1
  fi
  echo "$out_pem"
}

# シェル profile に `export NODE_EXTRA_CA_CERTS=...` を追記する。
# 既に同じパス指定の export 行があればスキップ (idempotent)。
_persist_node_extra_ca_certs() {
  local ca_path="$1"
  local snippet="export NODE_EXTRA_CA_CERTS=\"$ca_path\""
  local marker="# >>> hub-agent: NODE_EXTRA_CA_CERTS (TLS fallback) >>>"
  local end_marker="# <<< hub-agent: NODE_EXTRA_CA_CERTS <<<"
  local target
  for target in "$HOME/.zprofile" "$HOME/.bash_profile"; do
    [[ -e "$target" ]] || touch "$target"
    if grep -Fq "$snippet" "$target" 2>/dev/null; then
      continue
    fi
    {
      printf '\n%s\n' "$marker"
      printf '%s\n' "$snippet"
      printf '%s\n' "$end_marker"
    } >> "$target"
    color_ok "$target に NODE_EXTRA_CA_CERTS を追記"
  done
}

# 「UNABLE_TO_GET_ISSUER_CERT_LOCALLY が出た時に何を確認すべきか」のガイド表示。
# フォールバックも効かなかった最終手段ケース用。
_print_tls_failure_guidance() {
  # ヘッダーだけ printf で色付け。本文は cat <<EOF で複数行を読みやすく出す。
  printf '\n\033[1;31m✗ node の TLS 検証が修復できませんでした。\033[0m\n'
  cat <<EOF

  curl は通るのに node だけ落ちる場合、お使いの Mac/PC に
  HTTPS 通信を検査しているソフトが入っている可能性が高いです。
  典型例:
    - ウィルス対策ソフト (Sophos / Norton / Trend Micro / Symantec 等)
    - 親会社配布のセキュリティアプリ
    - SSL インスペクション機能つきの VPN クライアント
    - 広告ブロッカー (NextDNS / 1.1.1.1 for Families / AdGuard 等)

  以下のコマンドで「実際の証明書発行者」が見えます。
  ここに表示される \`issuer\` が \`Let's Encrypt\` や \`DigiCert\` ではなく、
  特定のソフト名 (ZScaler / Cocorograph / 製品名 等) なら、それが原因です:

    node -e 'const t=require("tls");const s=t.connect(443,"registry.npmjs.org",{servername:"registry.npmjs.org",rejectUnauthorized:false},()=>{let c=s.getPeerCertificate(true);while(c&&Object.keys(c).length){console.log("issuer:",JSON.stringify(c.issuer));if(!c.issuerCertificate||c.issuerCertificate===c)break;c=c.issuerCertificate;}s.end();});'

  暫定的に install を進めたい場合（自分の環境を信頼している前提）:

    npm install -g $PACKAGE_NAME --strict-ssl=false
    npm install -g $CLAUDE_CODE_PACKAGE --strict-ssl=false
    hub-agent enroll <token> --hub-url <hub-url>
    hub-agent install-service

  詳細サポートは Hub の cockpit チャネルへ。

EOF
}

# pre-flight TLS 検査 + 自動 fallback の本体。
# main() から ensure_npm_user_prefix の直後で呼ぶ想定。
ensure_node_tls_works() {
  # Linux の中には node を持たない経路 (本スクリプトより前に node が入る) もあるので
  # node が無ければスキップ (後段の ensure_global_install で別エラーになる)。
  present node || return 0

  color_step "node の TLS 検証を pre-flight check"

  # ステップ 0: brew link 直後はシェル command hash に古い node/npm パスが残る
  # ことがあるので、念のためクリアしてから検査する。
  hash -r 2>/dev/null || true

  if _test_node_tls >/dev/null 2>&1; then
    color_ok "node TLS 検証 OK (registry.npmjs.org に到達可能)"
    return 0
  fi

  color_warn "node TLS 検証失敗 → OS 信頼ストアから CA をエクスポートして再試行"

  local ca_pem
  if ! ca_pem=$(_export_os_ca_bundle); then
    color_err "OS の信頼ストア (macOS Keychain / Linux ca-certificates) からの CA エクスポートに失敗"
    _print_tls_failure_guidance
    exit 1
  fi
  color_ok "$ca_pem に OS 信頼ストアの CA を書き出し ($(wc -l < "$ca_pem" | tr -d ' ') 行)"

  export NODE_EXTRA_CA_CERTS="$ca_pem"
  _persist_node_extra_ca_certs "$ca_pem"

  # 再テスト
  if _test_node_tls >/dev/null 2>&1; then
    color_ok "node TLS 検証 OK (NODE_EXTRA_CA_CERTS=$ca_pem 経由)"
    return 0
  fi

  color_err "OS 信頼ストアを渡しても node TLS 検証が通りません"
  _print_tls_failure_guidance
  exit 1
}

# パッケージマネージャ分岐の共通処理。失敗は戻り値で返し、exit するかは呼び出し側が決める。
#   1 … 導入コマンドが失敗した / 2 … 対応するパッケージマネージャが無い
# ⚠️ display_cmd は復旧案内に出す「入れたい実行コマンド」。パッケージ名と一致しない
#    ことがある (pacman の python パッケージ / 検証対象は python3 コマンド)。
_pm_install() {
  local brew_pkg="$1"
  local apt_pkg="$2"
  local pacman_pkg="${3:-$apt_pkg}"
  local display_cmd="${4:-$brew_pkg}"
  if [[ "$(uname)" == "Darwin" ]]; then
    # brew install は transient (network / hash mismatch / pour 失敗) を retry で吸収
    retry 3 brew install "$brew_pkg" || return 1
  elif present apt-get; then
    retry 3 sudo apt-get update -y || return 1
    retry 3 sudo apt-get install -y "$apt_pkg" || return 1
  elif present dnf; then
    retry 3 sudo dnf install -y "$apt_pkg" || return 1
  elif present pacman; then
    retry 3 sudo pacman -S --noconfirm "$pacman_pkg" || return 1
  else
    color_err "対応するパッケージマネージャ (brew/apt/dnf/pacman) が見つかりません。$display_cmd を手動で install してください"
    return 2
  fi
}

ensure_pkg() {
  local cmd="$1"
  local brew_pkg="$2"
  local apt_pkg="${3:-$brew_pkg}"
  if present "$cmd"; then color_ok "$cmd already installed"; return 0; fi
  color_step "$cmd をインストール"
  _pm_install "$brew_pkg" "$apt_pkg" "" "$cmd" || exit 1
}

# macOS で brew 経由で Active LTS の node (NODE_DEFAULT_BREW_FORMULA) を導入/切替する。
# 既存の無印 node が link されていれば unlink してから新 formula を link --overwrite --force。
_install_node_lts_brew() {
  local formula="$NODE_DEFAULT_BREW_FORMULA"
  color_step "$formula (Active LTS) を install"
  retry 3 brew install "$formula"
  # 既存無印 node が link されていれば外す（v26 current 等を退かす）
  if brew list node >/dev/null 2>&1; then
    color_step "既存 'node' formula を unlink ($formula を優先するため)"
    brew unlink node 2>/dev/null || true
  fi
  color_step "$formula を link --overwrite --force"
  brew link --overwrite --force "$formula"
  hash -r 2>/dev/null || true
}

# Linux で Active LTS の node を導入する。
# Ubuntu の apt 素 nodejs は 24.04 でも v18 でポリシー (>=22) を満たせないため、
# NodeSource (setup_<major>.x) を使ってシステムワイドに最新 LTS を導入する。既存の
# apt 版 nodejs があっても apt-get install -y nodejs が NodeSource 版へ置き換える。
_install_node_lts_linux() {
  local major="${NODE_DEFAULT_BREW_FORMULA##*@}"   # "node@24" -> "24"
  color_step "NodeSource 経由で Node ${major} (Active LTS) を install"
  if present apt-get; then
    retry 3 bash -c "curl -fsSL https://deb.nodesource.com/setup_${major}.x | sudo -E bash -"
    retry 3 sudo apt-get install -y nodejs
  elif present dnf; then
    retry 3 bash -c "curl -fsSL https://rpm.nodesource.com/setup_${major}.x | sudo -E bash -"
    retry 3 sudo dnf install -y nodejs
  else
    color_err "NodeSource 非対応の環境です。Node ${NODE_MIN_MAJOR}+ を手動で install してください (nvm 推奨)"
    exit 1
  fi
  hash -r 2>/dev/null || true
  color_ok "node $(node --version 2>/dev/null || echo '?') を導入"
}

# ネイティブアドオンのビルドに必要な make / g++ / python3 を導入する。
# 現在の依存はすべて純 JS で通常は native ビルド不要だが、optional な native 依存
# (ws の bufferutil 等) が prebuild なしで拾われると node-gyp rebuild にフォールバック
# する。クリーンな Ubuntu には build-essential が無く `gyp ERR! not found: make` で
# 失敗する (WSL クリーン Ubuntu で発覚) ため保険で導入する。
# macOS は Xcode CLT 前提なので何もしない。
ensure_build_tools() {
  [[ "$(uname)" == "Darwin" ]] && return 0
  if present make && present g++; then
    color_ok "build tools (make / g++) already installed"
    return 0
  fi
  color_step "ネイティブビルド依存 (build-essential / python3) を install"
  if present apt-get; then
    retry 3 sudo apt-get update -y
    retry 3 sudo apt-get install -y build-essential python3
  elif present dnf; then
    retry 3 sudo dnf groupinstall -y "Development Tools"
    retry 3 sudo dnf install -y python3
  elif present pacman; then
    retry 3 sudo pacman -S --noconfirm base-devel python
  else
    color_err "対応するパッケージマネージャが見つかりません。make / g++ / python3 を手動で install してください"
    exit 1
  fi
}

# Hub AI bundle のフック・helper と hub-agent bootstrap が使う python3 を保証する。
# macOS の /usr/bin/python3 は Xcode CLT 未導入時にスタブの場合があるため、
# command の存在ではなく実際の version コマンドが成功するかで判定する。
ensure_python3() {
  if python3 --version >/dev/null 2>&1; then
    color_ok "$(python3 --version 2>&1)"
    _check_python3_version
    return 0
  fi

  color_step "python3 をインストール"
  # macOS で python3 が動かないのは、ほぼ Xcode CLT 未導入（/usr/bin/python3 がスタブ）。
  # brew 経路が失敗したら、set -e で黙って落ちる前に実際の復旧手順を出す。
  # pacman のパッケージ名は python3 でなく python。案内に出すコマンド名は python3。
  if ! _pm_install python python3 python python3; then
    if [[ "$(uname)" == "Darwin" ]]; then
      color_err "python3 を導入できません。\`xcode-select --install\` を実行してから再実行してください"
    fi
    exit 1
  fi
  hash -r 2>/dev/null || true

  if python3 --version >/dev/null 2>&1; then
    color_ok "$(python3 --version 2>&1)"
    _check_python3_version
  else
    color_err "python3 の導入後も実行できません"
    exit 1
  fi
}

_check_python3_version() {
  local version
  version="$(python3 -c 'import platform; print(platform.python_version())')" || {
    color_err "python3 のバージョンを確認できません"
    exit 1
  }
  if python3 -c 'import sys; raise SystemExit(0 if sys.version_info < (3, 9) else 1)'; then
    color_err "hub の配布物は Python 3.9 以上が必要です（現在: $version）"
    exit 1
  fi
  if python3 -c 'import sys; raise SystemExit(0 if sys.version_info < (3, 11) else 1)'; then
    color_warn "Python 3.11 未満では一部機能が無効になります（Codex の MCP 承認チェック）。現在: $version"
  fi
}

_hub_uv_triple() {
  local os arch libc="gnu"
  os="$(uname -s)"
  arch="$(uname -m)"
  if [[ "$os" == "Linux" ]] && ldd --version 2>&1 | grep -qi musl; then
    libc="musl"
  fi
  case "$os:$arch" in
    Darwin:arm64) printf '%s\n' "aarch64-apple-darwin" ;;
    Darwin:x86_64) printf '%s\n' "x86_64-apple-darwin" ;;
    Linux:x86_64) printf '%s\n' "x86_64-unknown-linux-$libc" ;;
    Linux:aarch64|Linux:arm64) printf '%s\n' "aarch64-unknown-linux-$libc" ;;
    *) color_err "uv が対応していないプラットフォームです: $os $arch"; return 1 ;;
  esac
}

_install_hub_uv() (
  set -e
  local triple url tmp expected actual uv_src local_bin destination staged=""
  triple="$(_hub_uv_triple)" || exit 1
  local_bin="$HOME/.local/bin"
  destination="$local_bin/uv"
  mkdir -p "$local_bin"
  if [[ -e "$destination" || -L "$destination" ]]; then
    color_err "$destination は uv と名前衝突しています。既存ファイルを退避するか PATH を修正してください"
    exit 1
  fi
  url="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${triple}.tar.gz"
  tmp="$(mktemp -d)"
  trap 'rm -rf "$tmp"' EXIT
  staged="$(mktemp "$local_bin/.uv.tmp.XXXXXX")"
  trap 'rm -rf "$tmp"; if [[ -n "$staged" ]]; then rm -f "$staged"; fi' EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM
  retry 3 curl -fsSL "$url" -o "$tmp/uv.tar.gz" || exit 1
  retry 3 curl -fsSL "$url.sha256" -o "$tmp/uv.tar.gz.sha256" || exit 1
  expected="$(head -1 "$tmp/uv.tar.gz.sha256")"
  expected="${expected%%[[:space:]]*}"
  if present shasum; then
    actual="$(shasum -a 256 "$tmp/uv.tar.gz")"
  elif present sha256sum; then
    actual="$(sha256sum "$tmp/uv.tar.gz")"
  else
    color_err "sha256 検算コマンド (shasum / sha256sum) がありません"
    exit 1
  fi
  actual="${actual%%[[:space:]]*}"
  if [[ -z "$expected" || "$actual" != "$expected" ]]; then
    color_err "uv tarball の sha256 が一致しません"
    exit 1
  fi
  tar -xzf "$tmp/uv.tar.gz" -C "$tmp"
  uv_src="$(find "$tmp" -type f -name uv | head -1)"
  [[ -n "$uv_src" ]] || { color_err "uv tarball に uv がありません"; exit 1; }
  mv "$uv_src" "$staged"
  chmod +x "$staged"
  "$staged" --version >/dev/null 2>&1 || { color_err "取得した uv を実行できません"; exit 1; }
  if [[ -e "$destination" || -L "$destination" ]]; then
    color_err "$destination は uv と名前衝突しています。既存ファイルを退避するか PATH を修正してください"
    exit 1
  fi
  mv "$staged" "$destination"
)

_hub_venv_usable() {
  local venv_dir="${1:-$HUB_VENV_DIR}" python base_prefix version package spec distribution expected
  python="$venv_dir/bin/python3"
  [[ -x "$python" ]] || return 1
  base_prefix="$($python -c 'import sys; print(sys.base_prefix)' 2>/dev/null)" || return 1
  [[ -e "$base_prefix/bin/python3" ]] || return 1
  version="$($python -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")' 2>/dev/null)" || return 1
  [[ "$version" == "$HUB_PYTHON_MINOR" ]] || return 1
  for package in "${HUB_PYTHON_PACKAGES[@]}"; do
    distribution="${package%%==*}"
    expected="${package#*==}"
    "$python" -c 'import importlib.metadata, sys; raise SystemExit(0 if importlib.metadata.version(sys.argv[1]) == sys.argv[2] else 1)' \
      "$distribution" "$expected" >/dev/null 2>&1 || return 1
  done
  for spec in "${HUB_PYTHON_IMPORTS[@]}"; do
    "$python" -c 'import importlib, sys; importlib.import_module(sys.argv[1])' "$spec" >/dev/null 2>&1 || return 1
  done
}

_build_hub_venv() (
  set -e
  local new_dir="$1" built=0
  trap '(( built == 1 )) || rm -rf "$new_dir"' EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM
  rm -rf "$new_dir"
  uv venv --python "$HUB_PYTHON_MINOR" "$new_dir" || exit 1
  uv pip install --python "$new_dir/bin/python3" --only-binary :all: "${HUB_PYTHON_PACKAGES[@]}" || exit 1
  _hub_venv_usable "$new_dir" || { color_err "構築した Hub Python venv の検証に失敗しました"; exit 1; }
  built=1
)

_acquire_hub_python_lock() {
  local lock_dir="$1" attempts=0 owner_file token owner_pid
  owner_file="$lock_dir/owner"
  # ⚠️ ここで時刻を採らない。`printf -v t '%(%s)T'` は bash 4.2 以降の機能で、macOS 標準の
  # bash 3.2 では「invalid format character」で失敗する。`set -euo pipefail` の下なので
  # インストーラ自体がここで止まる（実測。テストの bash は Homebrew の 5.x なので緑になってしまう）。
  # mkdir lock では「観測 → 回収 → 再取得」の間に別プロセスが取り直した lock を、遅れて来た
  # 回収者が持ち去れる。回収の rename が保証するのは、同時に回収する複数の勝者が 1 つになる
  # ことまでで、破壊的操作の直前に所有を再確認しても TOCTOU の窓は消えない。
  # そのため自動回収は行わず、異常終了後は表示されたディレクトリを人が削除して再実行する。
  token="$$-${RANDOM:-0}-${RANDOM:-0}"
  while ! mkdir "$lock_dir" 2>/dev/null; do
    attempts=$((attempts + 1))
    if (( attempts >= 50 )); then
      owner_pid=""
      if [[ -f "$owner_file" ]]; then
        {
          read -r _ || true
          read -r owner_pid || true
        } < "$owner_file"
      fi
      if [[ "$owner_pid" =~ ^[0-9]+$ ]]; then
        color_err "Hub Python lock を取得できませんでした: $lock_dir。保持しているプロセス (PID $owner_pid) が動いているか ps -p $owner_pid で確かめ、動いていなければこのディレクトリを削除して再実行してください"
      else
        color_err "Hub Python lock を取得できませんでした: $lock_dir。インストーラが走っていないならこのディレクトリを削除して再実行してください"
      fi
      return 1
    fi
    sleep 0.1
  done
  if ! printf '%s\n%s\n' "$token" "$$" > "$owner_file"; then
    rm -rf "$lock_dir" 2>/dev/null || true
    return 1
  fi
  HUB_PYTHON_LOCK_TOKEN="$token"
}

_replace_hub_venv() (
  local new_dir="$1" old_dir="$2" moved_old=0 installed=0
  cleanup_replacement() {
    if (( moved_old == 1 && installed == 0 )) && [[ -e "$old_dir" ]] && [[ ! -e "$HUB_VENV_DIR" ]]; then
      mv "$old_dir" "$HUB_VENV_DIR"
    fi
  }
  trap cleanup_replacement EXIT
  if [[ -e "$HUB_VENV_DIR" || -L "$HUB_VENV_DIR" ]]; then
    mv "$HUB_VENV_DIR" "$old_dir" || return 1
    moved_old=1
  fi
  mv "$new_dir" "$HUB_VENV_DIR" || return 1
  installed=1
)

_publish_hub_python_wrapper() (
  local local_bin="$HOME/.local/bin" wrapper="$HOME/.local/bin/hub-python3"
  local staged content wrapper_content
  local marker='# hub-agent managed: hub-python3 (hub-agent のインストーラが生成・更新します。手で編集しないでください)'
  local managed_line="exec \"$HUB_VENV_DIR/bin/python3\" \"\$@\""
  mkdir -p "$local_bin"
  if [[ -L "$wrapper" ]]; then
    color_err "$wrapper は名前衝突しています。既存 symlink を退避するか PATH を修正してください"
    return 1
  fi
  staged="$(mktemp "$local_bin/.hub-python3.tmp.XXXXXX")" || return 1
  trap 'rm -f "$staged"' EXIT
  printf '%s\n' '#!/usr/bin/env sh' "$marker" "$managed_line" > "$staged" || return 1
  chmod +x "$staged" || return 1
  # ⚠️ `$(<file)` は末尾の改行を落とすので、この比較は「末尾改行の数の違い」と「最終行の改行の
  # 有無」を区別しない。内容のある行が 1 行でも違えば不一致になり、末尾改行だけが違う wrapper を
  # 上書きしても失われる情報は無いので許容する。サンドボックスに cat / stat / wc / cmp が無く、
  # バイト単位の比較には別の機構が要るため、そのために機構を足すことはしない。
  # 以前付けていた sentinel の "x" は末尾改行の差を捉えているように見えて実際は効いていなかった
  # （実測: `$(<a)x` は "a\n" と "a\n\n\n" と "a" のすべてで同じ値になる）ので外した。
  content="$(<"$staged")"
  [[ "$content" == $'#!/usr/bin/env sh\n'"$marker"$'\n'"$managed_line" ]] || return 1
  _hub_python_wrapper_is_managed() {
    [[ -f "$wrapper" && ! -L "$wrapper" ]] || return 1
    wrapper_content="$(<"$wrapper")"
    [[ "$wrapper_content" == "$content" ]] || return 1
    # 未公開ブランチにしか存在しなかった marker なしの旧形は受け入れない。将来内容を変える版では
    # marker で識別し、既知の旧内容を完全一致で列挙する。パスのワイルドカード許可には戻さない。
    # marker をコピーした同一ユーザーの別 wrapper も管理物とみなす。脅威モデルは攻撃者ではなく、
    # 同一ユーザーによる並行実行と中断であるため、このなりすまし耐性は担保しない。
  }
  if [[ -e "$wrapper" ]] && ! _hub_python_wrapper_is_managed; then
    color_err "$wrapper は名前衝突しています。既存ファイルを退避するか PATH を修正してください"
    return 1
  fi
  if [[ -L "$wrapper" ]] || { [[ -e "$wrapper" ]] && ! _hub_python_wrapper_is_managed; }; then
    color_err "$wrapper は公開直前に名前衝突しました。既存ファイルを退避するか PATH を修正してください"
    return 1
  fi
  mv "$staged" "$wrapper" || return 1
)

# lock がまだ自分のものかを確かめる。これは相互排除ではなく best-effort の検出であり、確認と
# 操作の間の TOCTOU の窓は残る。自動回収の削除後に自分の lock を失うのは、人が lock
# ディレクトリを削除して別のインストーラを起動した場合だけなので、そのとき黙って並行実行しない
# ために破壊的操作の直前で検出する。
_hub_python_lock_is_mine() {
  local lock_dir="$1" want="$2" owner_token=""
  [[ -n "$want" && -f "$lock_dir/owner" ]] || return 1
  read -r owner_token < "$lock_dir/owner" || true
  [[ "$owner_token" == "$want" ]]
}

_hub_python_wrapper_verified() {
  # ⚠️ realpath(sys.executable) で比較しないこと。venv の bin/python3 は土台 interpreter への
  # symlink なので、realpath は venv でも素の土台でも同じ値に解決される（実測）。それでは
  # 「site-packages を持たない素の uv interpreter を指す wrapper」を成功扱いにしてしまう。
  # 識別に使えるのは sys.prefix（venv 自身のパス）。
  local expected_prefix actual_prefix spec
  expected_prefix="$(cd "$HUB_VENV_DIR" 2>/dev/null && pwd -P)" || expected_prefix=""
  actual_prefix="$(hub-python3 -c 'import os, sys; print(os.path.realpath(sys.prefix))' 2>/dev/null)" || actual_prefix=""
  if [[ -z "$expected_prefix" || "$actual_prefix" != "$expected_prefix" ]]; then
    color_err "hub-python3 が Hub Python venv を指していません (expected prefix: ${expected_prefix:-unknown}, actual: ${actual_prefix:-unknown})"
    return 1
  fi
  # 版一致だけでは wheel の破損を見逃すため、wrapper 経由で実際に import できることまで確認する。
  # ⚠️ この import 検査は `_hub_venv_usable` の import 検査と同じ interpreter を通るため重複しており、
  # 独立した回帰テストは無い（wrapper 側だけを失敗させる状態を作れない）。担保は上の prefix 比較で、
  # ここは公開後の wrapper 経路を一度通す念のための確認だと理解して読むこと。
  for spec in "${HUB_PYTHON_IMPORTS[@]}"; do
    if ! hub-python3 -c 'import importlib, sys; importlib.import_module(sys.argv[1])' "$spec" >/dev/null 2>&1; then
      color_err "hub-python3 経由で $spec を import できません"
      return 1
    fi
  done
  color_ok "hub-python3 が Hub Python venv を指すことを確認"
}

_finalize_hub_python() (
  # 退避先は lock token ごとの実行固有名にし、他実行の退避物と削除対象を分離する。
  local new_dir="$1" old_dir="" leftover="" found_leftover=0
  local lock_dir="${HUB_VENV_DIR}.lock" lock_token="" owner_token=""
  trap 'rm -rf "$new_dir"; owner_token=""; if [[ -f "$lock_dir/owner" ]]; then read -r owner_token < "$lock_dir/owner" || true; fi; if [[ -n "$lock_token" && "$owner_token" == "$lock_token" ]]; then rm -rf "$lock_dir"; fi' EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM
  _acquire_hub_python_lock "$lock_dir" || exit 1
  lock_token="$HUB_PYTHON_LOCK_TOKEN"
  old_dir="${HUB_VENV_DIR}.old.${lock_token}"
  if _hub_venv_usable; then
    color_ok "Hub Python venv は利用可能 ($HUB_VENV_DIR)"
  elif [[ -n "$new_dir" ]]; then
    if ! _hub_python_lock_is_mine "$lock_dir" "$lock_token"; then
      color_err "Hub Python lock を他のインストーラに引き継がれたため差し替えを中止しました: $lock_dir"
      exit 1
    fi
    # 残骸を別名で溜めず、stale lock と同様に手動復旧へ統一する。
    # ⚠️ ここへ来るのは公開先が使えないと判定した場合なので、残骸は「直前まで使えていた venv」で
    # ある可能性が高い。削除だけを案内せず、戻す選択肢を先に示す。
    for leftover in "${HUB_VENV_DIR}".old*; do
      if [[ -e "$leftover" || -L "$leftover" ]]; then
        color_err "退避先が既に存在します: $leftover"
        found_leftover=1
      fi
    done
    if (( found_leftover == 1 )); then
      color_err "前回の中断が残っている可能性があります。直前まで使えていた venv かもしれません。中身を確認して $HUB_VENV_DIR へ手で入れ替えるか、不要なら削除してから再実行してください"
      exit 1
    fi
    _replace_hub_venv "$new_dir" "$old_dir" || exit 1
    color_ok "Hub Python venv を構築 ($HUB_VENV_DIR)"
  else
    color_err "Hub Python venv の再構築結果がありません"
    exit 1
  fi
  if ! _hub_python_lock_is_mine "$lock_dir" "$lock_token"; then
    color_err "Hub Python lock を他のインストーラに引き継がれたため wrapper の公開を中止しました: $lock_dir"
    exit 1
  fi
  _publish_hub_python_wrapper || exit 1
  if ! _hub_python_wrapper_verified; then
    if [[ -e "$old_dir" || -L "$old_dir" ]]; then
      color_err "公開後の検証に失敗しました。公開先 $HUB_VENV_DIR は変更していません。直前まで使えていた venv は $old_dir にあります。中身を確認して手で入れ替えるか、$old_dir を削除してから再実行してください"
    else
      color_err "公開後の検証に失敗しました。公開先 $HUB_VENV_DIR は変更していません。手で確認してください"
    fi
    exit 1
  fi
  # 公開後の最終検証に成功してから、lock を保持したまま実行固有名の退避物を削除する。
  # 差し替えを行わなかった実行ではこのパスは存在せず、他実行が残した退避物は削除しない。
  if ! rm -rf "$old_dir" 2>/dev/null; then
    color_warn "元の venv を削除できませんでした: $old_dir。中身を確認して手動で削除してください"
  fi
)

ensure_hub_python() {
  local local_bin="$HOME/.local/bin" uv_path
  export PATH="$PATH:$local_bin"
  hash -r 2>/dev/null || true
  if present uv; then
    uv_path="$(command -v uv)"
    uv_path="$(cd "$(dirname "$uv_path")" && pwd -P)/$(basename "$uv_path")"
    if ! uv --version >/dev/null 2>&1 || ! uv venv --help >/dev/null 2>&1 \
      || ! uv pip --help >/dev/null 2>&1 || ! uv python --help >/dev/null 2>&1; then
      color_err "発見した uv を利用できません: $uv_path（上書きせず停止します）"
      exit 1
    fi
    color_ok "uv already installed ($uv_path)"
  else
    _install_hub_uv || exit 1
    color_ok "uv $UV_VERSION を $local_bin に導入"
  fi
  persist_profile_snippet 'export PATH="$PATH:$HOME/.local/bin"' "~/.local/bin PATH" \
    "# >>> hub-agent: local-bin PATH (Hub Python) >>>" "# <<< hub-agent: local-bin PATH <<<" \
    "$HOME/.zprofile" "$(bash_login_profile)" || exit 1

  retry 3 uv python install "$HUB_PYTHON_MINOR" || exit 1
  if _hub_venv_usable; then
    _finalize_hub_python "" || exit 1
  else
    # ⚠️ 構築成功から finalize の trap 設置までの中断で未公開の .new が残らないよう、
    # 構築と finalize を同じ cleanup スコープに入れる。差し替え成功後は .new のパスが
    # 消えているので、この trap の rm は no-op になる。
    (
      new_dir="${HUB_VENV_DIR}.new.$$"
      trap 'rm -rf "$new_dir"' EXIT
      trap 'exit 130' INT
      trap 'exit 143' TERM
      _build_hub_venv "$new_dir" || exit 1
      _finalize_hub_python "$new_dir" || exit 1
    ) || exit 1
  fi
  # 公開後の検証成功後にだけ、Hub Python を指す profile を更新する。
  export HUB_AI_PYTHON="$HUB_VENV_DIR/bin/python3"
  persist_profile_snippet "export HUB_AI_PYTHON=\"$HUB_VENV_DIR/bin/python3\"" "HUB_AI_PYTHON" \
    "# >>> hub-agent: HUB_AI_PYTHON >>>" "# <<< hub-agent: HUB_AI_PYTHON <<<" \
    "$HOME/.zprofile" "$(bash_login_profile)" || exit 1
}

# timeout を保証する（補助コマンド。AI セッションの Bash やスクリプトが使うが、
# hub-agent の常駐動作には必須ではないため、導入失敗は警告して継続する。
# 必須ランタイムの python3 とは契約を分けている）。
# macOS の coreutils は gtimeout 名で入るため、timeout だけを ~/.local/bin に公開する。
# coreutils の gnubin 全体を PATH に足すと sed / date 等まで GNU 版へ置き換わり、
# 既存スクリプトの挙動が変わるため採用しない。
_report_timeout() {
  if present timeout; then
    color_ok "timeout $(timeout --version 2>/dev/null </dev/null | head -1 || echo 'installed')"
  else
    color_warn "timeout コマンドが PATH に見つかりません (補助コマンドのため継続。新規シェルで再確認してください)"
  fi
}

ensure_timeout() {
  if [[ "$(uname)" != "Darwin" ]]; then
    # Linux の timeout は coreutils (Debian の essential / busybox にも同梱) なので
    # 通常は存在する。無い環境はインストーラが推測で入れず、警告に留める。
    if ! present timeout; then
      color_warn "timeout が見つかりません (coreutils を手動で導入してください。補助コマンドのため継続)"
    fi
    _report_timeout
    return 0
  fi

  local local_bin="$HOME/.local/bin"
  local link="$local_bin/timeout"
  local found
  found="$(command -v timeout 2>/dev/null || true)"
  # このインストーラ管理の symlink 以外の timeout が既に見えていれば、それを尊重する
  if [[ -n "$found" && "$found" != "$link" ]]; then
    color_ok "timeout already installed ($found)"
    return 0
  fi

  if [[ -e "$link" && ! -L "$link" ]]; then
    # ユーザーが自前で置いた実体は上書きしない（PATH 永続化だけ揃える）
    color_ok "$link は手動管理のため変更しません"
  else
    local gtimeout
    gtimeout="$(command -v gtimeout 2>/dev/null || true)"
    if [[ -z "$gtimeout" ]]; then
      color_step "GNU coreutils (timeout) をインストール"
      if ! retry 3 brew install coreutils; then
        color_warn "coreutils の導入に失敗しました (timeout は補助コマンドのため継続)"
        return 0
      fi
      hash -r 2>/dev/null || true
      gtimeout="$(command -v gtimeout 2>/dev/null || true)"
    fi
    if [[ -z "$gtimeout" ]]; then
      color_warn "gtimeout が PATH に見つかりません (timeout は補助コマンドのため継続)"
      return 0
    fi
    if ! mkdir -p "$local_bin" 2>/dev/null || ! ln -sfn "$gtimeout" "$link" 2>/dev/null; then
      color_warn "$link を作成できません (書き込み権限を確認してください。補助コマンドのため継続)"
      return 0
    fi
  fi

  # symlink の有無と PATH 永続化は別々に収束させる。再実行時に片方だけ欠けていても
  # ここで補完される（早期 return で profile の修復を飛ばさない）。
  # ~/.local/bin は PATH の「末尾」に足す。先頭に置くと pipx / pyenv 等が置いた
  # ~/.local/bin/python3 や node のシムが brew / system のものを隠してしまう。
  # timeout を見つけるだけなら末尾で足りる（別の timeout があれば上で return 済み）。
  # PATH 代入で bash の command hash は全消去されるので、この後の hash -r は不要。
  export PATH="$PATH:$local_bin"
  # npm prefix と同じく zsh / bash 双方の login profile へ永続化する。
  # 追記失敗（root 所有の profile 等）はこの補助コマンドの契約に合わせて警告に留める。
  persist_profile_snippet 'export PATH="$PATH:$HOME/.local/bin"' "~/.local/bin PATH" \
    "# >>> hub-agent: local-bin PATH (timeout) >>>" "# <<< hub-agent: local-bin PATH <<<" \
    "$HOME/.zprofile" "$(bash_login_profile)" \
    || color_warn "PATH の永続化に失敗した profile があります (timeout は補助コマンドのため継続)"
  _report_timeout
}

# Node.js のサポート範囲ポリシー判定。
# - 未インストール → LTS を install
# - [MIN, MAX] 範囲内 → 現状維持（ユーザーの環境を尊重）
# - 範囲未満 → LTS にアップグレード
# - 範囲超過（current 系等） → LTS にダウングレード
ensure_node_version() {
  local v
  # node 皆無のクリーン環境では `node --version` が exit 127。set -euo pipefail 下
  # では pipefail がこれを拾ってスクリプトごと死ぬため、`|| v=""` で必ず吸収する
  # (macOS テスト機には既に node があり露見しなかった潜在バグ。WSL クリーン Ubuntu で発覚)。
  v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) || v=""
  if [[ -z "$v" ]]; then
    color_step "node が未インストール → $NODE_DEFAULT_BREW_FORMULA (Active LTS) を install"
    if [[ "$(uname)" == "Darwin" ]]; then
      _install_node_lts_brew
    else
      _install_node_lts_linux
    fi
    return
  fi

  if (( v >= NODE_MIN_MAJOR && v <= NODE_MAX_MAJOR )); then
    color_ok "node v$v は動作保証範囲 (${NODE_MIN_MAJOR} ≤ v ≤ ${NODE_MAX_MAJOR}) 内。現状維持"
    return
  fi

  if (( v < NODE_MIN_MAJOR )); then
    color_warn "node v$v は古い (>=${NODE_MIN_MAJOR} 必須) → $NODE_DEFAULT_BREW_FORMULA にアップグレード"
  else
    color_warn "node v$v は新しすぎ (current 系) → 安定動作のため $NODE_DEFAULT_BREW_FORMULA (LTS) にダウングレード"
  fi
  if [[ "$(uname)" == "Darwin" ]]; then
    _install_node_lts_brew
  else
    _install_node_lts_linux
  fi
}

ensure_global_install() {
  # @latest 明示 + --force で npm cache stale 起因の「同 version 判定 skip」を回避。
  # 過去事例: 0.5.24 が既に入っているマシンで `npm i -g xxx` が 0.5.26 に更新
  # しない事象があった (npm の "no changes needed" 誤発火)。--force でこの判定を
  # 飛ばし、@latest で確実に最新版を解決する。
  if present hub-agent; then
    local cur
    cur=$(hub-agent --version 2>/dev/null || echo "unknown")
    color_step "hub-agent (現在 $cur) を最新版にアップデート"
  else
    color_step "$PACKAGE_NAME を install"
  fi
  retry 3 npm install -g "${PACKAGE_NAME}@latest" --force
  hash -r 2>/dev/null || true
  color_ok "hub-agent $(hub-agent --version)"
}

# npm 配信の CLI を導入/upgrade する共通処理。
#   required=1 … 新規導入の失敗を非 0 で呼び出し側へ返す。main から素の
#                コマンドとして呼べば set -e でセットアップ全体が止まる
#                (この関数自身は exit しないので、条件式から呼べば拾える)
#   required=0 … 新規導入が失敗しても警告のみでセットアップを継続する
#   上記以外   … 契約違反として 2 を返す (タイプミスを optional へ倒さない)
# ⚠️ fail_hint は「導入できなかったとき何が使えなくなるか」を呼び出し側が渡す。
#    この関数に CLI 固有の文言を書かないこと (次の CLI を足したとき嘘になる)。
_ensure_npm_cli() {
  local cmd="$1"
  local package="$2"
  local label="$3"
  local required="$4"
  local fail_hint="${5:-}"
  # ⚠️ required は 0/1 のみ受け付ける。"true" 等のタイプミスを optional 扱いへ
  #    黙って倒すと、必須 CLI が入らないままセットアップが完了する。
  case "$required" in
    0|1) ;;
    *)
      color_err "_ensure_npm_cli: required は 0/1 のみです (受け取った値: '${required}')"
      return 2
      ;;
  esac
  if present "$cmd"; then
    local cur
    cur=$("$cmd" --version 2>/dev/null </dev/null || echo "unknown")
    color_step "$label (現在 $cur) を最新版にアップデート"
    # claude は Anthropic 配信。失敗しても既存版で継続 (warning のみ)。
    retry 2 npm install -g "${package}@latest" --force \
      || color_warn "$label upgrade に失敗 (既存版で継続)"
  else
    color_step "$package を install"
    if [[ "$required" == "1" ]]; then
      # ⚠️ 明示的に return する。条件式 (`if f; then`) の中では関数内の errexit が
      #    抑止されるため、暗黙の set -e に頼ると必須契約が呼び出し方で消える。
      retry 3 npm install -g "${package}@latest" --force || return 1
    elif ! retry 3 npm install -g "${package}@latest" --force; then
      color_warn "$label の導入に失敗しました。${fail_hint:-セットアップは継続します}"
      color_warn "  手動で実行: npm install -g ${package}@latest"
    fi
  fi
  hash -r 2>/dev/null || true
  if present "$cmd"; then
    color_ok "$cmd $("$cmd" --version 2>/dev/null </dev/null || echo 'installed')"
  else
    color_warn "$cmd コマンドが PATH に見つかりません。新規シェルで再確認してください"
  fi
}

# Claude Code CLI を同梱でセットアップ。Cockpit 側から claude を呼び出すので、
# 同じ npm-global prefix に入れておくとパス解決が一貫する。
# 既存があれば upgrade のみ（破壊しない）。hub-agent のチャットに必須なので required=1。
ensure_claude_code() {
  _ensure_npm_cli claude "$CLAUDE_CODE_PACKAGE" "Claude Code" 1
}

# Codex CLI を同梱でセットアップ。hub-agent 自体の動作には必須ではないため、
# 新規導入・upgrade とも失敗時は警告してセットアップを継続する。
ensure_codex() {
  _ensure_npm_cli codex "$CODEX_PACKAGE" "Codex CLI" 0 \
    "Codex チャット / 委譲 / 画像生成は使えません"
}

do_enroll() {
  if [[ -z "${HUB_AGENT_TOKEN:-}" ]]; then
    color_warn "HUB_AGENT_TOKEN が未設定。enroll は skip します"
    color_warn "  手動で実行: hub-agent enroll <token> --hub-url ${HUB_AGENT_URL:-https://api.hub.cocorograph.com}"
    return 0
  fi
  local hub_url="${HUB_AGENT_URL:-https://api.hub.cocorograph.com}"
  # enroll は token が短命の可能性があるため retry は 1 回のみ。
  # (transient network なら retry が効くが、token 期限切れだと回数を増やしても無駄)
  if [[ -f "$HOME/.hub/agent.json" ]]; then
    color_warn "~/.hub/agent.json が既にあります。--force で上書きします"
    retry 2 hub-agent enroll "$HUB_AGENT_TOKEN" --hub-url "$hub_url" --force
  else
    retry 2 hub-agent enroll "$HUB_AGENT_TOKEN" --hub-url "$hub_url"
  fi

  # enroll 内部の syncBundle 失敗は warning だけで握りつぶされる (enroll 自体は
  # success 扱い) ため、ここで bundle 展開状況を verify する。
  # 2 段チェック:
  #   - CLAUDE.md の HUB-AI-RULES マーカー (運用ルール本体が入っているか)
  #   - scripts/manifest.json の存在 (バンドル本体のファイル群が落ちているか)
  # マーカーは通常 syncBundle の最終ステップで書かれるが、scripts/manifest.json
  # が無いと SessionStart hook / hub-helper.py が動かないため両方必須。どちらかが
  # 欠けていれば半完了状態とみなして sync-bundle を retry。
  # 検出された Claude/Codex 双方を個別検証し、不完全な CLI だけ再同期する
  # （2026-07-15。旧実装は ~/.claude しか見ず Codex の欠損を検出・修復できなかった）。
  _hub_reconcile_bundles
}

# 検証対象の CLI を列挙する。**PATH で検出された CLI だけ**を対象にし、どちらも未検出の
# ときのみ claude にフォールバックする（JS 側 resolveSyncTargets と一致。2026-07-15 差し戻し対応。
# 旧実装は claude を常に対象にしていたため、Codex-only 環境で不要な Claude bundle 欠損警告・
# bootstrap が走っていた）。各行 "cli_kind:dir:config_md:hooks_file"。
_hub_target_clis() {
  local any=0
  if present claude; then
    echo "claude:.claude:CLAUDE.md:settings.json"
    any=1
  fi
  if present codex; then
    echo "codex:.codex:AGENTS.md:hooks.json"
    any=1
  fi
  if [ "$any" -eq 0 ]; then
    echo "claude:.claude:CLAUDE.md:settings.json"
  fi
}

# 単一 CLI の bundle 完全性を判定する。完全なら 0、欠けていれば 1。
# $1=dir(.claude/.codex) $2=config_md(CLAUDE.md/AGENTS.md) $3=hooks_file(settings.json/hooks.json)
# 検証項目: scripts/manifest.json / CONFIG_MD の HUB-AI-RULES マーカー /
#           hooks 設定に Hub SessionStart hook(session_start.py) / 正規 .hub_token.json
_hub_bundle_incomplete_cli() {
  local dir="$1" config_md="$2" hooks_file="$3"
  local base="$HOME/$dir"
  [[ -f "$base/scripts/manifest.json" ]] || return 0
  [[ -f "$base/$config_md" ]] || return 0
  grep -q "BEGIN HUB-AI-RULES" "$base/$config_md" 2>/dev/null || return 0
  grep -q "session_start.py" "$base/$hooks_file" 2>/dev/null || return 0
  [[ -f "$base/.hub_token.json" ]] || return 0
  return 1
}

# 後方互換: 従来 claude 前提で呼んでいた箇所向けの薄いラッパ。
_hub_bundle_incomplete() {
  _hub_bundle_incomplete_cli ".claude" "CLAUDE.md" "settings.json"
}

# 検出された各 CLI の bundle 完全性を検証し、**不完全な CLI だけ** を
# `hub-agent sync-bundle --cli-kind <kind>` で再同期する（Codex 未インストールなら対象外）。
_hub_reconcile_bundles() {
  local line cli_kind dir config_md hooks_file
  while IFS= read -r line; do
    [[ -z "$line" ]] && continue
    IFS=":" read -r cli_kind dir config_md hooks_file <<< "$line"
    if _hub_bundle_incomplete_cli "$dir" "$config_md" "$hooks_file"; then
      color_warn "Hub AI bundle が ~/$dir に完全展開されていない可能性 ($cli_kind)。sync-bundle を再試行"
      if ! retry 2 hub-agent sync-bundle --cli-kind "$cli_kind"; then
        color_warn "hub-agent sync-bundle --cli-kind $cli_kind が継続失敗。あとで手動実行してください:"
        color_warn "  hub-agent sync-bundle --cli-kind $cli_kind"
      fi
    fi
  done < <(_hub_target_clis)
}

do_install_service() {
  color_step "OS サービスとして自動起動を登録"
  # install-service は launchctl bootout/bootstrap の transient で初回失敗する
  # ことがある (既存 unit の残骸 + 同名 bootstrap 競合 等)。retry でカバー。
  retry 2 hub-agent install-service
  color_ok "install-service 完了。ログ: ~/.hub/agent.log"
}

# enroll で bundle 配信された ~/.claude/scripts/setup_hub_ai.py を同期実行して
# ~/.claude/CLAUDE.md と ~/.claude/settings.json を初期化する。
#
# enroll.mjs 内でも kickSetupHubAi で best-effort spawn しているが、それは
# detached + stdio ignore で完了を待たないため、続く verify_setup が
# CLAUDE.md マーカー欠落を warn として拾ってしまう。install.sh では同期実行で
# verify を成功させる。setup_hub_ai 不在 (bundle 同期失敗) のときは色付き
# warn を出して継続。再実行時は step_* が冪等処理を返すだけで害なし。
do_bootstrap_hub_ai() {
  # 検出された各 CLI（claude 常に / codex は present 時）の setup_hub_ai.py を
  # 同期実行する。enroll/sync 内でも setup は走るが、install.sh では verify を確実に
  # 成功させるため各 CLI で同期実行する（一方の失敗で他方を止めない）。2026-07-15 に
  # Claude 固定から双方へ拡張。
  local line cli_kind dir config_md hooks_file setup_script
  while IFS= read -r line; do
    [[ -z "$line" ]] && continue
    IFS=":" read -r cli_kind dir config_md hooks_file <<< "$line"
    setup_script="$HOME/$dir/scripts/setup_hub_ai.py"
    if [[ ! -f "$setup_script" ]]; then
      color_warn "setup_hub_ai.py 不在 ($cli_kind, bundle 同期が失敗した可能性)。スキップ"
      continue
    fi
    color_step "setup_hub_ai.py --silent を実行 ($cli_kind: ~/$dir/$config_md / $hooks_file 初期化)"
    if python3 "$setup_script" --silent; then
      color_ok "Hub AI bootstrap 完了 ($cli_kind)"
    else
      color_warn "setup_hub_ai.py --silent が exit≠0 ($cli_kind, 継続)。手動再実行: python3 $setup_script"
    fi
  done < <(_hub_target_clis)
}

# セットアップ最終検証。
# 「online には見えるがバンドル未配信」「サービス起動失敗」等の半完了状態を
# 検知してユーザーに次の手を案内する。返り値は 0 (errors > 0 でも継続)。
verify_setup() {
  local errors=0

  # 1. hub-agent CLI
  if present hub-agent; then
    color_ok "hub-agent CLI: $(hub-agent --version 2>/dev/null || echo 'installed')"
  else
    color_err "hub-agent コマンドが PATH に見つかりません"
    errors=$((errors + 1))
  fi

  # 2. Claude Code (任意なので warn のみ)
  if present claude; then
    color_ok "Claude Code: $(claude --version 2>/dev/null || echo 'installed')"
  else
    color_warn "Claude Code (claude) コマンドが見つかりません (cockpit から claude を呼ぶ場合は必要)"
  fi

  # Codex CLI (任意なので warn のみ)
  if present codex; then
    color_ok "Codex CLI: $(codex --version 2>/dev/null </dev/null || echo 'installed')"
  else
    color_warn "Codex CLI (codex) コマンドが見つかりません (Codex チャット / 委譲 / 画像生成には必要)"
  fi

  # python3 (Hub AI bundle と hub-agent bootstrap に必須)
  if python3 --version >/dev/null 2>&1; then
    color_ok "Python 3: $(python3 --version 2>&1)"
  else
    color_err "python3 コマンドが実行できません"
    errors=$((errors + 1))
  fi

  # timeout は補助コマンド (ensure_timeout と同じ契約) なので warn のみ
  _report_timeout

  # 3. Hub AI bundle 展開を **CLI 別** に検証（manifest / CONFIG_MD マーカー /
  #    hooks の Hub SessionStart hook / 正規 .hub_token.json）。Codex 未インストールなら
  #    codex は検証しない（2026-07-15）。
  local line cli_kind dir config_md hooks_file mver
  while IFS= read -r line; do
    [[ -z "$line" ]] && continue
    IFS=":" read -r cli_kind dir config_md hooks_file <<< "$line"
    if ! _hub_bundle_incomplete_cli "$dir" "$config_md" "$hooks_file"; then
      mver="$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$HOME/$dir/scripts/manifest.json" 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
      color_ok "Hub AI bundle 展開済み ($cli_kind: ~/$dir/$config_md + scripts/manifest.json ${mver:+v$mver})"
    else
      color_warn "Hub AI bundle が半完了状態 ($cli_kind: ~/$dir の manifest / $config_md マーカー / $hooks_file の SessionStart hook / .hub_token.json のいずれか欠落)"
      color_warn "  手動再同期: hub-agent sync-bundle --cli-kind $cli_kind"
      errors=$((errors + 1))
    fi
  done < <(_hub_target_clis)

  # 4. OS サービス起動 (Darwin 限定の確認。Linux は systemctl --user で別途)
  # ⚠️ 判定は `launchctl print` で行う。install-service 側が登録の出現を待つのに使う
  # 道具と揃えるため (src/service-install.mjs の waitUntilBootedIn)。`launchctl list | grep`
  # に戻すと、待つ側と確認する側で別の状態を見ることになり誤検知が復活する。
  if [[ "$(uname)" == "Darwin" ]]; then
    if launchctl print "gui/$(id -u)/co.cocorograph.hub-agent" >/dev/null 2>&1; then
      color_ok "launchd service 起動中 (co.cocorograph.hub-agent)"
    else
      color_warn "launchd service が見つかりません"
      color_warn "  手動起動: hub-agent install-service"
      errors=$((errors + 1))
    fi
  fi

  if (( errors > 0 )); then
    color_warn ""
    color_warn "${errors} 件の不整合を検出。Hub UI で agent が online でない場合は以下を試してください:"
    color_warn "  hub-agent restart           # サービス再起動"
    color_warn "  hub-agent sync-bundle       # bundle 再配信"
    color_warn "  tail -20 ~/.hub/agent.log   # 詳細ログ"
  fi
}

macos_perm_guidance() {
  [[ "$(uname)" != "Darwin" ]] && return 0
  # 現在 PATH 上の node 実体パスを案内に埋め込む（フルディスクアクセス登録時に役立つ）
  local node_path
  node_path="$(command -v node 2>/dev/null || echo '/path/to/node')"
  cat <<EOF

📣 macOS の権限ダイアログについて
  hub-agent は tmux セッションとローカルの CLI (Claude / Codex) を中継するため、初回起動時に macOS から
  「node がローカルネットワーク上の機器を検出することを求めています」
  「node がアクセシビリティを制御することを求めています」
  「node がほかのアプリからのデータへのアクセス権を求めています」
  などのダイアログが出る場合があります。すべて「許可」を選んでください。
  許可は「システム設定 > プライバシーとセキュリティ」から後で変更できます。

  ▶ ダイアログが頻繁に出る場合（特に Claude Code 経由で様々な cwd を使う場合）:
    「フルディスクアクセス」に node 本体を追加すると静かになります。
      システム設定 > プライバシーとセキュリティ > フルディスクアクセス
      → ＋ ボタンで以下のパスを追加:
        ${node_path}
    （node バイナリパスが変わったら再登録が必要です）

EOF
}

main() {
  color_step "hub-agent ワンライナーセットアップを開始"
  STEP_TOTAL=16
  STEP_NUM=0

  step_header "Homebrew"
  ensure_brew
  step_header "tmux"
  ensure_pkg tmux tmux tmux
  step_header "Node.js (Active LTS)"
  ensure_node_version
  step_header "Build tools (native addons)"
  ensure_build_tools
  step_header "Python 3"
  ensure_python3
  step_header "Hub Python (uv / shared venv)"
  ensure_hub_python
  step_header "GNU coreutils (timeout)"
  ensure_timeout
  step_header "npm global prefix"
  ensure_npm_user_prefix
  # ensure_node_version 直後だと brew link でシェルの command hash がズレている
  # 場合があるため、prefix 設定後にまとめて TLS pre-flight check + 自動 fallback
  # を行う。失敗時はガイダンス付きで exit するので、後段の npm install で TLS
  # エラーを再度浴びる経路はカットされる。
  step_header "Node TLS pre-flight"
  ensure_node_tls_works
  step_header "hub-agent install"
  ensure_global_install
  step_header "Claude Code install"
  ensure_claude_code
  step_header "Codex CLI install"
  ensure_codex
  step_header "enroll + bundle 同期"
  do_enroll
  step_header "Hub AI bootstrap (CLAUDE.md / hooks 初期化)"
  do_bootstrap_hub_ai
  step_header "OS サービス化"
  do_install_service
  step_header "最終検証"
  verify_setup

  echo ""
  color_ok "セットアップ完了。Hub UI で online 表示を確認してください"
  echo "    https://hub.cocorograph.com/user/cockpit/agents"
  macos_perm_guidance
}

# このファイルが source されたか（実行されたか）を判定する。
#   - `bash -c "$(curl ...)"` / `curl ... | bash` / `bash -s`   : 実行
#   - `bash install.sh` / `./install.sh`                        : 実行
#   - `source install.sh`（test/install-sh.test.mjs）            : source
#
# ⚠️ `BASH_SOURCE[0]` と `$0` の比較で判定してはいけない。関数の中で見る
# `BASH_SOURCE[0]` は「その関数が定義されたファイル」を指すが、標準入力から読ませた
# ときの値は bash の版で割れる: bash 5.2 系は文字列 `main`、bash 3.2 / 5.3 は空。
# 5.2 系では `main` != `bash` が成立して **source と誤判定**され、
# `curl ... | bash` のワンライナーが main を呼ばずに無言・exit 0 で終わっていた
# （2026-09-09 実測。Ubuntu 24.04 の 5.2.21 / Debian trixie の 5.2.37 で再現し、
# macOS の 3.2.57 / 5.3.15 では再現しない。開発機だけで確かめると気づけない）。
#
# FUNCNAME はコールスタックで、bash は source したファイルのトップレベルを `source`
# という枠として積む。したがってこの関数をトップレベルから呼ぶと、source されたときだけ
# `FUNCNAME[1]` が `source` になる。3.2 / 5.2 / 5.3 のいずれでも一致する。
# ⚠️ この関数は**トップレベルから直接呼ぶこと**。別の関数を挟むと `FUNCNAME[1]` が
# その関数名になり、判定が壊れる。
# 環境変数で切り替えると、変数が残ったシェルや CI でワンライナーが無言の no-op になるため使わない。
_install_sh_is_sourced() {
  [[ "${FUNCNAME[1]:-}" == "source" ]]
}

if ! _install_sh_is_sourced; then
  main "$@"
fi
