#!/bin/sh # 蓬莱 · Penglai 一键安装 —— 新机器只需联网: # curl -fsSL https://raw.githubusercontent.com/kevinchennewbee/PenglaiAgent/main/install.sh | sh # 自动完成:网络探测(国内走镜像) → 取代码(无 git 走压缩包) → 备好 Python(无则装 uv 托管版) # → 装依赖 → 进入向导。用户不需要预装任何东西,也不需要懂环境配置。 set -e OWNER_REPO="${PENGLAI_REPO:-kevinchennewbee/PenglaiAgent}" BRANCH="${PENGLAI_BRANCH:-main}" TARGET="${PENGLAI_DIR:-$HOME/PenglaiAgent}" SOURCE_DIR="${PENGLAI_SOURCE_DIR:-}" SKIP_SETUP="${PENGLAI_SKIP_SETUP:-}" INSTALL_VERIFY="${PENGLAI_INSTALL_VERIFY:-}" GH="https://github.com/$OWNER_REPO" ARCHIVE="$GH/archive/refs/heads/$BRANCH.tar.gz" CODELOAD="https://codeload.github.com/$OWNER_REPO/tar.gz/refs/heads/$BRANCH" PROXY="${PENGLAI_GH_PROXY-https://gh-proxy.com/}" case "$PROXY" in ""|*/) ;; *) PROXY="$PROXY/" ;; esac UV_BIN="$HOME/.local/bin/uv" DEPS_READY=0 CORE_DEPS="requests beautifulsoup4 bottle aiohttp lark-oapi qrcode pillow pyyaml" say() { printf '%s\n' "$1"; } die() { printf '❌ %s\n' "$1" >&2; exit 1; } say "🏮 蓬莱 · Penglai — 住在飞书、微信和终端里的中文 AI 管家" command -v curl >/dev/null || die "需要 curl(macOS/多数 Linux 自带)。Ubuntu: apt install -y curl" sq() { # POSIX shell single-quote escaping. printf "%s" "$1" | sed "s/'/'\\\\''/g; 1s/^/'/; \$s/\$/'/" } write_build_env() { env_file="$TARGET/env.sh" tmp_env="${env_file}.$$" { printf '# Generated by Penglai installer. Sourced by CLI wrapper and service units.\n' [ -n "${PENGLAI_INSTALL_SOURCE:-}" ] && printf 'export PENGLAI_INSTALL_SOURCE=%s\n' "$(sq "$PENGLAI_INSTALL_SOURCE")" [ -n "${PENGLAI_BUILD_BRANCH:-}" ] && printf 'export PENGLAI_BUILD_BRANCH=%s\n' "$(sq "$PENGLAI_BUILD_BRANCH")" [ -n "${PENGLAI_BUILD_COMMIT:-}" ] && printf 'export PENGLAI_BUILD_COMMIT=%s\n' "$(sq "$PENGLAI_BUILD_COMMIT")" [ -n "${PENGLAI_BUILD_TIME:-}" ] && printf 'export PENGLAI_BUILD_TIME=%s\n' "$(sq "$PENGLAI_BUILD_TIME")" } > "$tmp_env" if [ -s "$tmp_env" ]; then mv "$tmp_env" "$env_file" chmod 600 "$env_file" else rm -f "$tmp_env" fi } write_build_info() { info_file="$TARGET/.penglai-build.json" "$PY" - "$TARGET" "${SOURCE_DIR:-}" "$info_file" <<'PY' import datetime import json import os import subprocess import sys target, source_dir, info_file = sys.argv[1:4] def env(name): return os.environ.get(name, "").strip() def run_git(root, args): if not root or not os.path.isdir(root): return "" try: proc = subprocess.run( ["git", "-C", root, *args], capture_output=True, text=True, timeout=5, ) except Exception: return "" if proc.returncode != 0: return "" return (proc.stdout or "").strip() def is_git(root): return run_git(root, ["rev-parse", "--is-inside-work-tree"]) == "true" def read_build_info(root): if not root: return {} path = os.path.join(root, ".penglai-build.json") try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) return data if isinstance(data, dict) else {} except Exception: return {} def info_str(data, key): value = data.get(key) if value is None: return "" return str(value).strip() def info_bool(data, key): value = data.get(key) if isinstance(value, bool): return value if isinstance(value, str): return value.strip().lower() in {"1", "true", "yes", "y", "on"} return bool(value) probe = target if is_git(target) else (source_dir if is_git(source_dir) else "") source_info = read_build_info(source_dir) target_info = read_build_info(target) build_info = target_info or source_info commit = env("PENGLAI_BUILD_COMMIT") or run_git(probe, ["rev-parse", "--short=12", "HEAD"]) or info_str(build_info, "commit") branch = env("PENGLAI_BUILD_BRANCH") or run_git(probe, ["rev-parse", "--abbrev-ref", "HEAD"]) or info_str(build_info, "branch") dirty = bool(run_git(probe, ["status", "--porcelain"])) if probe else info_bool(build_info, "dirty") remote = "" remote_url = "" preferred = env("PENGLAI_RELEASE_REMOTE") for name in [preferred, "release", "origin", "upstream"]: if not name or name == remote: continue url = run_git(probe, ["remote", "get-url", name]) if url: remote = name remote_url = url break if not remote: remote = info_str(build_info, "remote") remote_url = info_str(build_info, "remote_url") source = env("PENGLAI_INSTALL_SOURCE") or info_str(build_info, "source") if not source: if is_git(target): source = "git" elif source_dir: source = "source" else: source = "archive" build_time = env("PENGLAI_BUILD_TIME") or info_str(build_info, "build_time") or datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z" data = { "schema": 1, "source": source, "branch": branch or "unknown", "commit": commit or "unknown", "dirty": dirty, "remote": remote, "remote_url": remote_url, "build_commit": env("PENGLAI_BUILD_COMMIT") or info_str(build_info, "build_commit") or commit, "build_time": build_time, } tmp = f"{info_file}.{os.getpid()}.tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True) f.write("\n") os.replace(tmp, info_file) PY chmod 600 "$info_file" 2>/dev/null || true } # ── 1. 网络探测:GitHub 直连不通则全程走 gh-proxy 镜像 ──────────────────────── MIRROR="" if ! curl -fsSL -m 6 -o /dev/null "https://github.com" 2>/dev/null; then MIRROR="$PROXY" [ -n "$MIRROR" ] || die "GitHub 直连受限,且 PENGLAI_GH_PROXY 为空。请设置可用镜像后重试" say " 🇨🇳 检测到 GitHub 直连受限,自动启用 GitHub 镜像: $MIRROR" fi download_archive() { tmp="${TARGET}.tar.gz.$$" rm -f "$tmp" urls="" [ -n "$MIRROR" ] && urls="$urls ${MIRROR}${ARCHIVE}" urls="$urls $CODELOAD $ARCHIVE" for url in $urls; do say " 尝试压缩包下载..." if curl -fL --connect-timeout 15 --max-time 180 -o "$tmp" "$url"; then # 备份用户数据(防止 rm -rf 删光) userdata_tmp="" if [ -d "$TARGET" ]; then userdata_tmp="${TARGET}.userdata.$$" mkdir -p "$userdata_tmp" for item in mykey.py mykey.json memory temp; do if [ -e "$TARGET/$item" ]; then cp -a "$TARGET/$item" "$userdata_tmp/" 2>/dev/null || true fi done say " 📦 用户数据已临时备份(mykey.py/memory/temp)" fi rm -rf "$TARGET" mkdir -p "$TARGET" tar -xzf "$tmp" -C "$TARGET" --strip-components=1 rm -f "$tmp" # 恢复用户数据 if [ -n "$userdata_tmp" ] && [ -d "$userdata_tmp" ]; then for item in mykey.py mykey.json memory temp; do if [ -e "$userdata_tmp/$item" ]; then cp -a "$userdata_tmp/$item" "$TARGET/" 2>/dev/null || true fi done rm -rf "$userdata_tmp" say " ✅ 用户数据已恢复" fi return 0 fi done rm -f "$tmp" return 1 } copy_source_dir() { [ -n "$SOURCE_DIR" ] || return 1 [ -f "$SOURCE_DIR/penglai" ] && [ -f "$SOURCE_DIR/agent_loop.py" ] \ || die "PENGLAI_SOURCE_DIR 不是有效蓬莱源码目录: $SOURCE_DIR" if [ -e "$TARGET" ] && [ -n "$(ls -A "$TARGET" 2>/dev/null)" ]; then die "目录 $TARGET 非空。测试分支源码安装请设置新的 PENGLAI_DIR" fi say " 📦 正在从本地测试分支源码复制蓬莱发行版..." mkdir -p "$TARGET" ( cd "$SOURCE_DIR" tar \ --exclude=.git \ --exclude=.venv \ --exclude=venv \ --exclude=temp \ --exclude=tmp \ --exclude=_internal \ --exclude=.internal \ --exclude=.env \ --exclude=.penglai-build.json \ --exclude=mykey.py \ --exclude=mykey.json \ --exclude=audit \ --exclude=.playwright-cli \ --exclude='*.log' \ -cf - . ) | (cd "$TARGET" && tar -xf -) } # ── 2. 取代码:有 git 用 git(日后 penglai update 可用),没有走 tarball ───────── if [ -n "$SOURCE_DIR" ]; then copy_source_dir elif [ -f "$TARGET/penglai" ] && [ -f "$TARGET/agent_loop.py" ]; then existing_version="$("$TARGET/penglai" version 2>/dev/null || true)" case "$existing_version" in *"Penglai "*) say " ✅ 发行版已存在:$TARGET ($existing_version)" ;; *) die "检测到已有蓬莱目录但版本不可识别:$TARGET。 建议先备份用户数据: cd $TARGET && penglai backup 然后设置新的 PENGLAI_DIR 重新安装,或运行 penglai update 升级。" ;; esac elif [ -e "$TARGET" ] && [ -n "$(ls -A "$TARGET" 2>/dev/null)" ]; then die "目录 $TARGET 非空且不是蓬莱发行版。设 PENGLAI_DIR=其他目录 后重试" else say " ⬇️ 正在获取蓬莱发行版..." if command -v git >/dev/null; then if ! git -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=20 clone --depth 1 --branch "$BRANCH" "${MIRROR}${GH}.git" "$TARGET"; then say " Git 克隆失败,改用压缩包下载..." download_archive || die "源码下载失败,请检查网络或设置 PENGLAI_GH_PROXY" fi else say " (未检测到 git,改用压缩包下载;日后升级请先安装 git)" download_archive || die "源码下载失败,请检查网络或设置 PENGLAI_GH_PROXY" fi fi cd "$TARGET" if [ -z "${PENGLAI_BUILD_BRANCH:-}" ] && [ -z "$SOURCE_DIR" ] && [ ! -d .git ]; then PENGLAI_BUILD_BRANCH="$BRANCH" export PENGLAI_BUILD_BRANCH fi # ── 3. Python:系统有 3.10+ 直接用;没有就装 uv,由 uv 托管一个独立 Python ────── PY="" for c in python3 python3.12 python3.11 python3.10; do # 版本 ≥3.10 且 venv 可用(裸 Ubuntu 的 python3 没装 python3-venv,ensurepip 缺失) if command -v "$c" >/dev/null 2>&1 \ && "$c" -c 'import sys, ensurepip; sys.exit(0 if sys.version_info >= (3,10) else 1)' 2>/dev/null; then PY="$c"; break fi done if [ -z "$PY" ]; then say " 🐍 未找到 Python 3.10+,自动安装 uv 托管版(不动系统,只装到你的用户目录)..." if [ ! -x "$UV_BIN" ] && ! command -v uv >/dev/null; then if [ -n "$MIRROR" ]; then # 镜像直取 uv 二进制(官方安装脚本的下载源在 GitHub,国内不可达) case "$(uname -sm)" in "Darwin arm64") UV_TRIPLE="aarch64-apple-darwin" ;; "Darwin x86_64") UV_TRIPLE="x86_64-apple-darwin" ;; "Linux aarch64") UV_TRIPLE="aarch64-unknown-linux-gnu" ;; *) UV_TRIPLE="x86_64-unknown-linux-gnu" ;; esac mkdir -p "$HOME/.local/bin" curl -fsSL "${PROXY}https://github.com/astral-sh/uv/releases/latest/download/uv-${UV_TRIPLE}.tar.gz" \ | tar -xz -C "$HOME/.local/bin" --strip-components=1 else curl -fsSL https://astral.sh/uv/install.sh | sh >/dev/null fi fi command -v uv >/dev/null || PATH="$HOME/.local/bin:$PATH" command -v uv >/dev/null || die "uv 安装失败,请手动安装 Python 3.10+ 后重试" [ -n "$MIRROR" ] && export UV_PYTHON_INSTALL_MIRROR="${PROXY}https://github.com/astral-sh/python-build-standalone/releases/download" if [ ! -x .venv/bin/python ]; then uv venv .venv --python 3.11 --quiet fi run_uv_pip() { if command -v timeout >/dev/null 2>&1; then timeout "${PENGLAI_PIP_TIMEOUT:-180}" uv pip install --python .venv/bin/python --quiet "$@" else uv pip install --python .venv/bin/python --quiet "$@" fi } say " 📦 正在安装依赖..." PIP_INDEX="${PENGLAI_PIP_INDEX:-}" PIP_MIRROR="https://pypi.tuna.tsinghua.edu.cn/simple" if [ -n "$PIP_INDEX" ]; then run_uv_pip -i "$PIP_INDEX" -e . $CORE_DEPS elif [ -n "$MIRROR" ]; then run_uv_pip -i "$PIP_MIRROR" -e . $CORE_DEPS else if ! run_uv_pip -e . $CORE_DEPS; then say " 默认 PyPI 安装失败或超时,改用清华镜像重试..." run_uv_pip -i "$PIP_MIRROR" -e . $CORE_DEPS fi fi PY=".venv/bin/python" DEPS_READY=1 say " ✅ Python 环境就绪(uv 托管,卸载=删除目录,零残留)" else say " ✅ 检测到 $($PY --version 2>&1)(依赖由向导自动安装)" fi install_source_deps() { if [ ! -x .venv/bin/python ]; then "$PY" -m venv .venv fi run_pip() { if command -v timeout >/dev/null 2>&1; then timeout "${PENGLAI_PIP_TIMEOUT:-180}" .venv/bin/python -m pip install --quiet "$@" else .venv/bin/python -m pip install --quiet "$@" fi } say " 📦 正在安装源码依赖..." PIP_INDEX="${PENGLAI_PIP_INDEX:-}" PIP_MIRROR="https://pypi.tuna.tsinghua.edu.cn/simple" if [ -n "$PIP_INDEX" ]; then run_pip -i "$PIP_INDEX" -e . $CORE_DEPS else if ! run_pip -e . $CORE_DEPS; then say " 默认 PyPI 安装失败或超时,改用清华镜像重试..." run_pip -i "$PIP_MIRROR" -e . $CORE_DEPS fi fi PY=".venv/bin/python" DEPS_READY=1 } if [ "${PENGLAI_INSTALL_DEPS:-}" = "1" ] && [ "$DEPS_READY" != "1" ]; then install_source_deps fi write_build_info write_build_env # ── 4. penglai 命令上 PATH(wrapper 固定使用本次安装选中的 Python) ────────────── mkdir -p "$HOME/.local/bin" cat > "$HOME/.local/bin/penglai" <> "$rc" done say " ℹ️ 已把 ~/.local/bin 加入 PATH(重开终端生效;本次会话可用 $TARGET/penglai)" ;; esac # ── 5. 进入向导 ────────────────────────────────────────────────────────────── say "" say "✅ 发行版就绪:$TARGET" if [ "$INSTALL_VERIFY" = "1" ]; then say " 正在运行安装预检..." "$PY" penglai install-check --json || die "安装预检失败" fi if [ "$SKIP_SETUP" = "1" ]; then say " 已按 PENGLAI_SKIP_SETUP=1 跳过交互向导。" say " 下一步: $TARGET/penglai setup 或 $TARGET/penglai doctor" exit 0 fi say " 进入安装向导(模型 → 飞书 → 可选微信扫码)..." say "" # curl|sh 模式下 stdin 是脚本管道,向导的交互必须改接终端(/dev/tty) if [ ! -t 1 ]; then die "安装向导需要交互终端。请改为【下载后运行】: curl -fsSLO https://raw.githubusercontent.com/$OWNER_REPO/refs/heads/$BRANCH/install.sh && PENGLAI_BRANCH=$BRANCH sh install.sh" fi if [ ! -t 0 ]; then if (: /dev/null; then exec "$PY" penglai setup