#!/usr/bin/env bash ################################################################################ # 闪电API - OpenCode 一键安装脚本 # # 功能: 安装 OpenCode + 配置 闪电API (Claude + GPT 两个供应商) # 官网: flashapi.top # 模型调用: ai.flashapi.top (国内直连) / cdn.flashapi.top (海外CDN) # # 用法: curl -fsSL https://sh.flashapi.top/opencode.sh | bash # # 不需要 sudo,所有操作在用户目录下完成 ################################################################################ set -euo pipefail export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 ################################################################################ # 常量定义 ################################################################################ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' WHITE='\033[1;37m' GRAY='\033[0;90m' BOLD='\033[1m' BLUE='\033[38;5;33m' BRIGHT_YELLOW='\033[38;5;226m' NC='\033[0m' OS_TYPE="" IS_WSL=false NPM_MIRROR="https://registry.npmmirror.com" OPENCODE_PACKAGE="opencode-ai" OPENCODE_INSTALL_URL="https://opencode.ai/install" PROVIDER_CLAUDE="flashapi-claude" PROVIDER_GPT="flashapi-gpt" DEFAULT_CLAUDE_MODEL="claude-opus-5-5" DEFAULT_GPT_MODEL="gpt-6.1-sol" DEFAULT_EFFORT="medium" # OpenCode 配置文件位置 (与 OpenCode 自身的查找逻辑一致): # 1. $OPENCODE_CONFIG (用户显式指定的配置文件) # 2. $OPENCODE_CONFIG_DIR (用户显式指定的配置目录) # 3. ${XDG_CONFIG_HOME:-~/.config}/opencode (全局配置,各平台一致;Windows 为 %USERPROFILE%\.config\opencode) # 目录内优先 opencode.jsonc (已存在时),否则 opencode.json OC_CONFIG_DIR="" OC_CONFIG_FILE="" FETCHED_IDS="" JS_FILE="" ORIG_PATH="$PATH" ################################################################################ # 工具函数 ################################################################################ print_banner() { echo "" echo -e " ${BOLD}${BRIGHT_YELLOW} ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡${NC}" echo -e " ${BOLD}${BRIGHT_YELLOW} ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗${NC}" echo -e " ${BOLD}${BRIGHT_YELLOW} ██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝${NC}" echo -e " ${BOLD}${BRIGHT_YELLOW} ██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗${NC}" echo -e " ${BOLD}${BRIGHT_YELLOW} ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝${NC}" echo -e " ${BOLD}${BRIGHT_YELLOW} ╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗${NC}" echo -e " ${BOLD}${BRIGHT_YELLOW} ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝${NC}" echo -e " ${BOLD}${BLUE} ⚡ 闪电API × OpenCode ⚡${NC}" echo -e " ${BOLD}${BRIGHT_YELLOW} ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡${NC}" echo "" echo -e " ${GRAY}───────────────────────────────────────────────────────${NC}" echo -e " ${WHITE}⚡ 闪电API${NC} ${GRAY}|${NC} ${CYAN}官网${NC} ${BOLD}${BRIGHT_YELLOW}flashapi.top${NC}" echo -e " ${WHITE}OpenCode 一键配置${NC} ${GRAY}|${NC} ${CYAN}让顶尖模型触手可及${NC}" echo -e " ${GRAY}───────────────────────────────────────────────────────${NC}" echo -e " ${GRAY}国内直连:${NC} ${GREEN}ai.flashapi.top${NC} ${GRAY}海外CDN:${NC} ${GREEN}cdn.flashapi.top${NC}" echo -e " ${GRAY}───────────────────────────────────────────────────────${NC}" echo "" } print_status() { local type="$1" local message="$2" case "$type" in ok) echo -e " ${GREEN}[⚡]${NC} $message" ;; info) echo -e " ${CYAN}[*]${NC} $message" ;; warn) echo -e " ${YELLOW}[!]${NC} $message" ;; error) echo -e " ${RED}[x]${NC} $message" ;; *) echo " $message" ;; esac } detect_os() { local uname_s uname_s="$(uname -s)" IS_WSL=false case "$uname_s" in Darwin) OS_TYPE="macos" ;; Linux) OS_TYPE="linux" if grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then IS_WSL=true fi ;; MINGW*|MSYS*|CYGWIN*) OS_TYPE="windows" ;; *) OS_TYPE="unknown" print_status "warn" "未知操作系统: ${uname_s},将尝试继续" ;; esac } # 读取密码输入 (bash/zsh 兼容,带 * 遮盖) read_secret() { local prompt="$1" local varname="$2" local input="" local char echo -en "$prompt" >&2 while true; do if [[ -n "${ZSH_VERSION:-}" ]]; then IFS= read -r -s -k1 char < /dev/tty else IFS= read -r -s -n1 char < /dev/tty fi if [[ $char == $'\n' ]] || [[ $char == $'\r' ]] || [[ -z "$char" ]]; then break elif [[ $char == $'\177' ]] || [[ $char == $'\b' ]]; then if [[ -n "$input" ]]; then input="${input%?}" echo -en "\b \b" >&2 fi else input+="$char" echo -n "*" >&2 fi done echo "" >&2 eval "$varname=\$input" } mask_key() { local value="$1" if [[ -z "$value" ]]; then echo "" return fi if [[ ${#value} -le 12 ]]; then echo "$value" return fi echo "${value:0:8}...${value: -4}" } backup_if_exists() { local file="$1" if [[ -f "$file" ]]; then local stamp backup stamp="$(date +%Y%m%d%H%M%S)" backup="${file}.bak.${stamp}" cp "$file" "$backup" chmod 600 "$backup" 2>/dev/null || true print_status "info" "已备份现有文件: $backup" fi } ask_yes_no() { local prompt="$1" local answer echo -en " ${YELLOW}${prompt} (y/N): ${NC}" >&2 read -r answer < /dev/tty [[ "$answer" == "y" || "$answer" == "Y" ]] } expand_tilde() { local p="$1" case "$p" in "~") p="$HOME" ;; "~/"*) p="$HOME/${p#\~/}" ;; esac printf '%s' "$p" } # Git Bash 下把 /c/Users/... 转成 C:\Users\... 再交给 node / opencode (原生 Windows 程序) native_path() { if [[ "$OS_TYPE" == "windows" ]] && command -v cygpath >/dev/null 2>&1; then cygpath -w "$1" else printf '%s' "$1" fi } resolve_config_path() { if [[ -n "${OPENCODE_CONFIG:-}" ]]; then OC_CONFIG_FILE="$(expand_tilde "$OPENCODE_CONFIG")" OC_CONFIG_DIR="$(dirname "$OC_CONFIG_FILE")" return 0 fi if [[ -n "${OPENCODE_CONFIG_DIR:-}" ]]; then OC_CONFIG_DIR="$(expand_tilde "$OPENCODE_CONFIG_DIR")" else local base="${XDG_CONFIG_HOME:-}" [[ -z "$base" ]] && base="$HOME/.config" OC_CONFIG_DIR="$base/opencode" fi if [[ -f "$OC_CONFIG_DIR/opencode.jsonc" ]]; then OC_CONFIG_FILE="$OC_CONFIG_DIR/opencode.jsonc" else OC_CONFIG_FILE="$OC_CONFIG_DIR/opencode.json" fi } ################################################################################ # JS 运行环境: 优先 node;没有 node 时借用 OpenCode 自带的 Bun 运行时 # (官方安装脚本装的是独立二进制,不依赖 node;BUN_BE_BUN=1 让它充当 bun) ################################################################################ find_opencode_bin() { if command -v opencode >/dev/null 2>&1; then command -v opencode return 0 fi local c for c in "$HOME/.opencode/bin/opencode" "$HOME/.opencode/bin/opencode.exe"; do if [[ -x "$c" ]]; then echo "$c" return 0 fi done return 1 } have_js_runtime() { command -v node >/dev/null 2>&1 && return 0 find_opencode_bin >/dev/null 2>&1 && return 0 return 1 } run_js() { [[ -n "$JS_FILE" && -f "$JS_FILE" ]] || write_js_helper local js js="$(native_path "$JS_FILE")" if command -v node >/dev/null 2>&1; then node "$js" else local oc oc="$(find_opencode_bin)" || return 1 BUN_BE_BUN=1 "$oc" "$js" fi } cleanup() { [[ -n "$JS_FILE" ]] && rm -f "$JS_FILE" 2>/dev/null return 0 } trap cleanup EXIT write_js_helper() { JS_FILE="$(mktemp "${TMPDIR:-/tmp}/flashapi-opencode.XXXXXX")" cat > "$JS_FILE" <<'JS' const fs = require("fs"); const path = require("path"); const env = process.env; // ── JSONC 容错解析: 去掉 // 与 /* */ 注释、尾逗号 (字符串内容不受影响) ── function stripJsonc(text) { let out = ""; let i = 0; let hadComments = false; const n = text.length; while (i < n) { const c = text[i]; if (c === '"') { let j = i + 1; while (j < n && text[j] !== '"') j += text[j] === "\\" ? 2 : 1; out += text.slice(i, j + 1); i = j + 1; } else if (c === "/" && text[i + 1] === "/") { hadComments = true; while (i < n && text[i] !== "\n") i++; } else if (c === "/" && text[i + 1] === "*") { hadComments = true; const end = text.indexOf("*/", i + 2); i = end < 0 ? n : end + 2; out += " "; } else { out += c; i++; } } // 尾逗号 let res = ""; i = 0; const m = out.length; while (i < m) { const c = out[i]; if (c === '"') { let j = i + 1; while (j < m && out[j] !== '"') j += out[j] === "\\" ? 2 : 1; res += out.slice(i, j + 1); i = j + 1; } else if (c === ",") { let k = i + 1; while (k < m && /\s/.test(out[k])) k++; if (out[k] === "}" || out[k] === "]") { i++; continue; } res += c; i++; } else { res += c; i++; } } return { text: res, hadComments }; } function readConfig(file) { if (!fs.existsSync(file)) return { data: {}, hadComments: false, existed: false }; const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, ""); if (!raw.trim()) return { data: {}, hadComments: false, existed: true }; try { return { data: JSON.parse(raw), hadComments: false, existed: true }; } catch (e) { const s = stripJsonc(raw); let data; try { data = JSON.parse(s.text); } catch (e2) { throw new Error(`${file} 不是合法的 JSON/JSONC: ${e2.message}`); } return { data, hadComments: s.hadComments, existed: true }; } } const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v); // ── 模型元数据 ── const BIG_CLAUDE = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-fable-5-1", "claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"]; const BIG_GPT = ["gpt-6.1-sol", "gpt-6-sol", "gpt-6-astra", "gpt-6-luna"]; function prettyName(id) { const parts = id.split("-"); if (parts[0] === "gpt") { const rest = parts.slice(2).map((p) => p.charAt(0).toUpperCase() + p.slice(1)); return [`GPT-${parts[1] || ""}`].concat(rest).join(" ").trim(); } const words = []; let nums = []; const flush = () => { if (nums.length) { words.push(nums.join(".")); nums = []; } }; for (const p of parts) { if (/^\d{1,2}$/.test(p)) { nums.push(p); continue; } flush(); if (/^\d{8}$/.test(p)) words.push(`(${p})`); else words.push(p.charAt(0).toUpperCase() + p.slice(1)); } flush(); return words.join(" "); } // family 用于 OpenCode 自动挑选「小模型」(生成标题/摘要): 它会优先选 family=claude-haiku 的模型 function modelFamily(family, id) { if (family === "gpt") return "gpt"; const m = /^claude-([a-z]+)/.exec(id); return m ? `claude-${m[1]}` : "claude"; } // Claude 默认推理参数 (与 OpenCode 1.18 provider/transform.ts 的 variants 结构一致): // Claude >= 4.7 (含 5.x): 自适应思考 + effort,display=summarized 以便显示思考摘要 // Opus/Sonnet 4.6: 自适应思考 + effort // 更早的模型 (如 haiku-4-5): 不写默认参数,由 OpenCode 自行处理 function claudeOptions(id, effort) { const v = /claude-(?:[a-z]+-)?(\d+)(?:[.-](\d{1,2}))?(?:[.@-]|$)/i.exec(id); if (!v) return null; const major = Number(v[1]); const minor = Number(v[2] || 0); const modern = major > 4 || (major === 4 && minor >= 7); const is46 = /(opus|sonnet)-4[.-]6/.test(id); if (modern) return { thinking: { type: "adaptive", display: "summarized" }, effort }; if (is46) return { thinking: { type: "adaptive" }, effort }; return null; } function buildModel(family, id, effort) { const big = family === "claude" ? BIG_CLAUDE.includes(id) : BIG_GPT.includes(id); const model = { name: prettyName(id), family: modelFamily(family, id), attachment: true, reasoning: true, tool_call: true, limit: family === "claude" ? { context: big ? 1000000 : 200000, output: big ? 128000 : 64000 } : { context: big ? 1050000 : 272000, output: 128000 }, modalities: { input: ["text", "image"], output: ["text"] }, }; if (family === "claude") { const o = claudeOptions(id, effort); if (o) model.options = o; } else { model.options = { reasoningEffort: effort, reasoningSummary: "auto", include: ["reasoning.encrypted_content"] }; } return model; } function buildModels(family, idsCsv, defaultId, effort) { const ids = [defaultId]; for (const id of (idsCsv || "").split(",")) { const t = id.trim(); if (t && !ids.includes(t)) ids.push(t); } const out = {}; for (const id of ids) out[id] = buildModel(family, id, effort); return out; } function writeConfig(file, data) { fs.mkdirSync(path.dirname(file), { recursive: true }); const tmp = `${file}.tmp-${process.pid}`; fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); fs.renameSync(tmp, file); try { fs.chmodSync(file, 0o600); } catch {} } function main() { const mode = env.FA_MODE; const file = env.FA_CONFIG_FILE; if (mode === "read") { // 输出: claudeKey \t gptKey \t baseURL \t model try { const { data } = readConfig(file); const p = isObj(data.provider) ? data.provider : {}; const c = isObj(p[env.PROVIDER_CLAUDE]) && isObj(p[env.PROVIDER_CLAUDE].options) ? p[env.PROVIDER_CLAUDE].options : {}; const g = isObj(p[env.PROVIDER_GPT]) && isObj(p[env.PROVIDER_GPT].options) ? p[env.PROVIDER_GPT].options : {}; const s = (v) => (typeof v === "string" ? v.replace(/[\u001f\n\r]/g, " ") : ""); process.stdout.write([s(c.apiKey), s(g.apiKey), s(c.baseURL) || s(g.baseURL), s(data.model)].join("\u001f")); } catch { process.exit(1); } return; } if (mode === "models") { try { const j = JSON.parse(fs.readFileSync(env.FA_BODY_FILE, "utf8")); const list = Array.isArray(j.data) ? j.data : (Array.isArray(j) ? j : []); process.stdout.write(list.map((m) => (m && typeof m.id === "string" ? m.id : "")).filter(Boolean).join("\n")); } catch {} return; } if (mode === "write") { const { data, hadComments } = readConfig(file); if (!isObj(data)) throw new Error(`${file} 根节点不是 JSON 对象`); if (hadComments) console.error("WARN_COMMENTS"); const effort = env.DEFAULT_EFFORT; const root = env.BASE_URL; if (typeof data.$schema !== "string") { const rest = { ...data }; delete rest.$schema; for (const k of Object.keys(data)) delete data[k]; data.$schema = "https://opencode.ai/config.json"; Object.assign(data, rest); } if (!isObj(data.provider)) { if (data.provider !== undefined) console.error("WARN_PROVIDER_RESET"); data.provider = {}; } const ours = []; if (env.CLAUDE_KEY) { ours.push(env.PROVIDER_CLAUDE); data.provider[env.PROVIDER_CLAUDE] = { npm: "@ai-sdk/anthropic", name: "闪电API Claude", options: { baseURL: `${root}/v1`, apiKey: env.CLAUDE_KEY }, models: buildModels("claude", env.CLAUDE_IDS, env.DEFAULT_CLAUDE_MODEL, effort), }; } if (env.GPT_KEY) { ours.push(env.PROVIDER_GPT); data.provider[env.PROVIDER_GPT] = { npm: "@ai-sdk/openai", name: "闪电API GPT", options: { baseURL: `${root}/v1`, apiKey: env.GPT_KEY }, models: buildModels("gpt", env.GPT_IDS, env.DEFAULT_GPT_MODEL, effort), }; } // V2 原生格式 (providers.) 中的同名条目会与 V1 条目冲突,移除我们自己的 if (isObj(data.providers)) { for (const id of ours) { if (Object.prototype.hasOwnProperty.call(data.providers, id)) { delete data.providers[id]; console.error(`WARN_V2_REMOVED:${id}`); } } } // 若用户用 enabled_providers 白名单 / disabled_providers 黑名单,确保我们的供应商可用 if (Array.isArray(data.enabled_providers)) { for (const id of ours) if (!data.enabled_providers.includes(id)) data.enabled_providers.push(id); } if (Array.isArray(data.disabled_providers)) { data.disabled_providers = data.disabled_providers.filter((id) => !ours.includes(id)); } data.model = env.DEFAULT_MODEL; writeConfig(file, data); return; } throw new Error(`unknown mode ${mode}`); } try { main(); } catch (e) { console.error(`ERROR:${e && e.message ? e.message : e}`); process.exit(1); } JS } ################################################################################ # 功能 1: 安装 OpenCode ################################################################################ add_npm_bin_to_path() { command -v npm >/dev/null 2>&1 || return 0 local prefix prefix="$(npm prefix -g 2>/dev/null || true)" [[ -z "$prefix" ]] && return 0 if [[ "$OS_TYPE" == "windows" ]]; then export PATH="$prefix:$PATH" else export PATH="$prefix/bin:$PATH" fi } add_official_bin_to_path() { if [[ -d "$HOME/.opencode/bin" ]] && [[ ":$PATH:" != *":$HOME/.opencode/bin:"* ]]; then export PATH="$HOME/.opencode/bin:$PATH" fi } check_opencode_installed() { local oc v oc="$(find_opencode_bin 2>/dev/null)" || return 1 v="$("$oc" --version 2>/dev/null | head -n 1 || true)" [[ -z "$v" ]] && v="unknown" echo "$v" return 0 } install_official() { print_status "info" "使用官方安装脚本 (curl -fsSL ${OPENCODE_INSTALL_URL} | bash) ..." echo "" local tmp tmp="$(mktemp "${TMPDIR:-/tmp}/opencode-install.XXXXXX")" if ! curl -fsSL --connect-timeout 15 --max-time 60 "$OPENCODE_INSTALL_URL" -o "$tmp" 2>/dev/null; then rm -f "$tmp" print_status "warn" "无法下载官方安装脚本" return 1 fi if ! bash "$tmp" < /dev/null; then rm -f "$tmp" return 1 fi rm -f "$tmp" add_official_bin_to_path return 0 } # npm 12+ 默认不执行全局包的 install 脚本,而 opencode-ai 需要 postinstall 来放置平台二进制 npm_extra_args() { local major major="$(npm --version 2>/dev/null | cut -d. -f1)" if [[ "$major" =~ ^[0-9]+$ ]] && [[ "$major" -ge 11 ]]; then echo "--allow-scripts=${OPENCODE_PACKAGE}" fi } # 兜底: 若 postinstall 仍未执行 (opencode --version 报错),手动执行一次 fix_npm_postinstall() { local root pkg_dir root="$(npm root -g 2>/dev/null || true)" [[ -z "$root" ]] && return 0 pkg_dir="$root/${OPENCODE_PACKAGE}" [[ -f "$pkg_dir/postinstall.mjs" ]] || return 0 local oc oc="$(find_opencode_bin 2>/dev/null || true)" if [[ -n "$oc" ]] && "$oc" --version >/dev/null 2>&1; then return 0 fi print_status "info" "执行 ${OPENCODE_PACKAGE} 的 postinstall 脚本 ..." (cd "$pkg_dir" && node ./postinstall.mjs) || true } install_with_npm() { if ! command -v npm >/dev/null 2>&1; then print_status "warn" "未找到 npm,无法使用 npm 安装" return 1 fi print_status "ok" "npm 已就绪 ($(npm --version 2>/dev/null)), Node $(node -v 2>/dev/null)" print_status "info" "使用 npm 安装 ${OPENCODE_PACKAGE} ..." echo "" local extra extra="$(npm_extra_args)" # shellcheck disable=SC2086 if npm install -g "${OPENCODE_PACKAGE}@latest" $extra; then add_npm_bin_to_path fix_npm_postinstall return 0 fi print_status "warn" "默认源安装失败,可能是网络问题" install_with_mirror || return 1 add_npm_bin_to_path fix_npm_postinstall return 0 } install_with_mirror() { print_status "info" "切换国内镜像 (npmmirror.com) 安装 ..." local extra extra="$(npm_extra_args)" # shellcheck disable=SC2086 npm install -g "${OPENCODE_PACKAGE}@latest" --registry="$NPM_MIRROR" $extra } install_opencode() { echo "" print_status "info" "⚡ 正在安装 OpenCode ..." echo "" [[ "$IS_WSL" == "true" ]] && print_status "info" "检测到 WSL 环境" [[ "$OS_TYPE" == "windows" ]] && print_status "info" "检测到 Windows (Git Bash) 环境" local ver if ver="$(check_opencode_installed)"; then print_status "ok" "OpenCode 已安装 ($ver)" echo "" if ! ask_yes_no "是否重新安装/升级到最新版?"; then print_status "info" "跳过安装" return 0 fi fi if ! command -v curl >/dev/null 2>&1; then print_status "warn" "未找到 curl,跳过官方安装脚本" fi local ok=false if command -v curl >/dev/null 2>&1 && install_official; then ok=true else print_status "warn" "官方安装脚本失败 (可能无法访问 GitHub),改用 npm 安装 ..." echo "" if install_with_npm; then ok=true fi fi if [[ "$ok" != "true" ]]; then print_status "error" "所有安装方式均失败,请检查网络连接" echo "" echo -e " ${WHITE}其他安装方式:${NC}" echo -e " ${CYAN}Node.js:${NC} 先从 https://nodejs.org/ 安装 Node.js,再运行 npm i -g ${OPENCODE_PACKAGE}@latest --registry=${NPM_MIRROR}" echo -e " ${CYAN}Homebrew:${NC} brew install anomalyco/tap/opencode" echo -e " ${CYAN}手动下载:${NC} https://github.com/anomalyco/opencode/releases" print_status "info" "如果提示 EACCES 权限错误,请参考 https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally" return 1 fi echo "" hash -r 2>/dev/null || true if ver="$(check_opencode_installed)"; then print_status "ok" "⚡ OpenCode 安装成功 ($ver)" local oc_path oc_path="$(find_opencode_bin 2>/dev/null || true)" if [[ "$oc_path" == "$HOME/.opencode/bin/"* ]] && [[ ":$ORIG_PATH:" != *":$HOME/.opencode/bin:"* ]]; then print_status "info" "OpenCode 安装在 ~/.opencode/bin,请重新打开终端后使用 opencode 命令" print_status "info" "若新终端仍提示找不到 opencode,请把下面这行加入 ~/.bashrc 或 ~/.zshrc:" echo -e " ${CYAN}export PATH=\"\$HOME/.opencode/bin:\$PATH\"${NC}" fi else print_status "warn" "安装已完成,但未在 PATH 中找到 opencode" print_status "info" "请重新打开终端后再试" fi } ################################################################################ # 功能 2: 配置 API ################################################################################ # 读取当前配置: 输出 "claudeKeygptKeybaseURLmodel" (US = \x1f 分隔符) read_current_config() { [[ -f "$OC_CONFIG_FILE" ]] || return 1 have_js_runtime || return 1 FA_MODE=read FA_CONFIG_FILE="$(native_path "$OC_CONFIG_FILE")" \ PROVIDER_CLAUDE="$PROVIDER_CLAUDE" PROVIDER_GPT="$PROVIDER_GPT" \ run_js 2>/dev/null } # 用 /v1/models 验证 Key,结果写入 FETCHED_IDS (换行分隔) # 返回: 0=200 成功 1=401 2=网络错误 3=其他状态码 fetch_models() { local base_url="$1" local api_key="$2" local body_file http_code FETCHED_IDS="" body_file="$(mktemp)" http_code="$(curl -s -o "$body_file" -w '%{http_code}' \ --max-time 15 \ -H "Authorization: Bearer ${api_key}" \ "${base_url}/v1/models" 2>/dev/null || true)" [[ -z "$http_code" ]] && http_code="000" if [[ "$http_code" == "200" ]]; then FETCHED_IDS="$(FA_MODE=models FA_BODY_FILE="$(native_path "$body_file")" run_js 2>/dev/null || true)" rm -f "$body_file" return 0 fi rm -f "$body_file" case "$http_code" in 401) return 1 ;; 000) return 2 ;; *) FETCHED_IDS="$http_code"; return 3 ;; esac } # 过滤某家族的模型 ID: filter_family_ids (输入 FETCHED_IDS) filter_family_ids() { local family="$1" local id out="" while IFS= read -r id; do id="${id%$'\r'}" [[ -z "$id" ]] && continue case "$family" in claude) [[ "$id" == claude-* ]] || continue ;; gpt) [[ "$id" == gpt-* ]] || continue [[ "$id" == *compact* ]] && continue ;; esac out="${out}${id}," done < <结果变量名> # 成功时把该家族模型 ID (逗号分隔) 写入结果变量;返回 1 表示用户取消 validate_key() { local family="$1" local base_url="$2" local api_key="$3" local result_var="$4" local label="Claude" [[ "$family" == "gpt" ]] && label="GPT" print_status "info" "正在验证 ${label} 分组 API Key ..." local rc=0 fetch_models "$base_url" "$api_key" || rc=$? local ids="" case "$rc" in 0) ids="$(filter_family_ids "$family")" if [[ -n "$ids" ]]; then local count count="$(echo "$ids" | tr ',' '\n' | grep -c . || true)" print_status "ok" "⚡ ${label} Key 验证通过,获取到 ${count} 个 ${label} 模型" else print_status "warn" "Key 有效,但模型列表里没有 ${label} 模型 —— 这个 Key 可能不是 ${label} 分组" print_status "info" "请到 flashapi.top 确认该令牌所属分组,将只写入默认模型" fi ;; 1) print_status "error" "${label} API Key 无效 (401 Unauthorized)" if ! ask_yes_no "验证未通过,是否仍要保存?"; then print_status "info" "已取消配置" return 1 fi ;; 2) print_status "warn" "无法连接到 ${base_url} (网络错误)" if ! ask_yes_no "验证未通过,是否仍要保存?"; then print_status "info" "已取消配置" return 1 fi ;; *) print_status "warn" "API 返回状态码: ${FETCHED_IDS} (可能仍可用),将只写入默认模型" ;; esac eval "$result_var=\$ids" return 0 } write_opencode_config() { local base_url="$1" local claude_key="$2" local claude_ids="$3" local gpt_key="$4" local gpt_ids="$5" local default_model="$6" mkdir -p "$OC_CONFIG_DIR" backup_if_exists "$OC_CONFIG_FILE" FA_MODE=write FA_CONFIG_FILE="$(native_path "$OC_CONFIG_FILE")" \ BASE_URL="$base_url" \ CLAUDE_KEY="$claude_key" CLAUDE_IDS="$claude_ids" \ GPT_KEY="$gpt_key" GPT_IDS="$gpt_ids" \ PROVIDER_CLAUDE="$PROVIDER_CLAUDE" PROVIDER_GPT="$PROVIDER_GPT" \ DEFAULT_CLAUDE_MODEL="$DEFAULT_CLAUDE_MODEL" DEFAULT_GPT_MODEL="$DEFAULT_GPT_MODEL" \ DEFAULT_MODEL="$default_model" DEFAULT_EFFORT="$DEFAULT_EFFORT" \ run_js || return 1 chmod 600 "$OC_CONFIG_FILE" 2>/dev/null || true } choose_families() { echo -e " ${WHITE}请选择要配置的模型家族:${NC}" echo "" echo -e " ${BRIGHT_YELLOW}⚡${NC} ${CYAN}1)${NC} Claude + GPT ${GRAY}(回车默认)${NC}" echo -e " ${BRIGHT_YELLOW}⚡${NC} ${CYAN}2)${NC} 仅 Claude" echo -e " ${BRIGHT_YELLOW}⚡${NC} ${CYAN}3)${NC} 仅 GPT" echo "" echo -en " ${WHITE}请输入选项 [回车=1]: ${NC}" >&2 local choice read -r choice < /dev/tty case "$choice" in ""|1) DO_CLAUDE=true; DO_GPT=true; FAMILY_LABEL="Claude + GPT" ;; 2) DO_CLAUDE=true; DO_GPT=false; FAMILY_LABEL="仅 Claude" ;; 3) DO_CLAUDE=false; DO_GPT=true; FAMILY_LABEL="仅 GPT" ;; *) print_status "error" "无效选项,已取消"; return 1 ;; esac echo "" print_status "ok" "已选择: ${FAMILY_LABEL}" } configure_api() { echo "" print_status "info" "⚡ 配置 闪电API (Claude + GPT)" echo "" resolve_config_path if ! have_js_runtime; then print_status "error" "未找到 Node.js,也未找到 OpenCode,无法写入配置" print_status "info" "请先执行菜单 1 安装 OpenCode (或从 https://nodejs.org/ 安装 Node.js)" return 1 fi local cur="" cur_ck="" cur_gk="" cur_url="" cur_model="" cur="$(read_current_config || true)" if [[ -n "$cur" ]]; then IFS=$'\x1f' read -r cur_ck cur_gk cur_url cur_model <&2 local choice read -r choice < /dev/tty local base_url case "$choice" in 1) base_url="https://ai.flashapi.top" ;; 2) base_url="https://cdn.flashapi.top" ;; *) print_status "error" "无效选项,已取消"; return 1 ;; esac echo "" print_status "info" "已选择: ${base_url}" echo "" # ── 令牌分组提醒 ── echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}" echo -e " ${YELLOW}║${NC} ${WHITE}⚡ 重要提示${NC} ${YELLOW}║${NC}" echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}" echo -e " ${YELLOW}║${NC} ${GRAY}请先在官网${NC} ${BRIGHT_YELLOW}flashapi.top${NC} ${GRAY}创建${NC} ${GREEN}令牌分组${NC} ${YELLOW}║${NC}" echo -e " ${YELLOW}║${NC} ${GRAY}未创建令牌分组将无法获取模型列表!${NC} ${YELLOW}║${NC}" echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}" echo "" # ── 输入 API Key (密码遮盖) ── local claude_key="" gpt_key="" if [[ "$DO_CLAUDE" == "true" ]]; then read_secret " ${WHITE}请输入 Claude 分组 API Key: ${NC}" claude_key if [[ -z "$claude_key" ]]; then print_status "error" "API Key 不能为空,已取消" return 1 fi fi if [[ "$DO_GPT" == "true" ]]; then if [[ "$DO_CLAUDE" == "true" ]]; then read_secret " ${WHITE}请输入 GPT 分组 API Key ${GRAY}(回车 = 与 Claude Key 相同)${WHITE}: ${NC}" gpt_key [[ -z "$gpt_key" ]] && gpt_key="$claude_key" else read_secret " ${WHITE}请输入 GPT 分组 API Key: ${NC}" gpt_key fi if [[ -z "$gpt_key" ]]; then print_status "error" "API Key 不能为空,已取消" return 1 fi fi echo "" # ── 验证 ── local claude_ids="" gpt_ids="" if [[ "$DO_CLAUDE" == "true" ]]; then validate_key claude "$base_url" "$claude_key" claude_ids || return 1 fi if [[ "$DO_GPT" == "true" ]]; then validate_key gpt "$base_url" "$gpt_key" gpt_ids || return 1 fi echo "" local default_model if [[ "$DO_CLAUDE" == "true" ]]; then default_model="${PROVIDER_CLAUDE}/${DEFAULT_CLAUDE_MODEL}" else default_model="${PROVIDER_GPT}/${DEFAULT_GPT_MODEL}" fi local write_claude_key="" write_gpt_key="" [[ "$DO_CLAUDE" == "true" ]] && write_claude_key="$claude_key" [[ "$DO_GPT" == "true" ]] && write_gpt_key="$gpt_key" # ── 写入 OpenCode 配置 ── local err_file err_file="$(mktemp)" if ! write_opencode_config "$base_url" "$write_claude_key" "$claude_ids" "$write_gpt_key" "$gpt_ids" \ "$default_model" 2>"$err_file"; then local msg msg="$(sed -n 's/^ERROR://p' "$err_file" | head -n 1)" [[ -z "$msg" ]] && msg="$(head -n 3 "$err_file")" print_status "error" "写入配置失败: ${msg}" print_status "info" "原文件未被修改 (如已备份,可在同目录找到 .bak 文件)" rm -f "$err_file" return 1 fi if grep -q '^WARN_COMMENTS' "$err_file"; then print_status "warn" "$(basename "$OC_CONFIG_FILE") 中原有的注释已在重写时丢失 (原文件已备份,可对照恢复)" fi if grep -q '^WARN_PROVIDER_RESET' "$err_file"; then print_status "warn" "原配置中的 provider 字段不是对象,已重置为仅包含闪电API 供应商" fi if grep -q '^WARN_V2_REMOVED' "$err_file"; then print_status "info" "已移除 providers (V2 格式) 中旧的闪电API 同名条目,统一写入 provider" fi rm -f "$err_file" echo "" if [[ "$DO_CLAUDE" == "true" ]]; then print_status "ok" "Claude Key 已保存: $(mask_key "$claude_key") ${GRAY}(供应商 ${PROVIDER_CLAUDE})${NC}" print_status "ok" "Claude BASE URL: ${base_url}/v1 ${GRAY}(@ai-sdk/anthropic)${NC}" fi if [[ "$DO_GPT" == "true" ]]; then print_status "ok" "GPT Key 已保存: $(mask_key "$gpt_key") ${GRAY}(供应商 ${PROVIDER_GPT})${NC}" print_status "ok" "GPT BASE URL: ${base_url}/v1 ${GRAY}(@ai-sdk/openai, Responses API)${NC}" fi print_status "ok" "默认模型: ${default_model}" print_status "ok" "推理强度: ${DEFAULT_EFFORT} ${GRAY}(写在各模型的 options 中;会话内 Ctrl+T 切换档位)${NC}" print_status "ok" "配置文件已写入: ${OC_CONFIG_FILE}" if [[ -n "${OPENCODE_CONFIG:-}" || -n "${OPENCODE_CONFIG_DIR:-}" ]]; then print_status "info" "检测到 OPENCODE_CONFIG / OPENCODE_CONFIG_DIR 环境变量,已写入其指向的位置" fi echo "" echo -e " ${WHITE}使用方法:${NC}" echo -e " ${CYAN}启动:${NC} opencode" echo -e " ${CYAN}快速验证:${NC} opencode run \"Reply with exactly: OK\"" echo -e " ${CYAN}查看模型:${NC} opencode models ${PROVIDER_CLAUDE} ${GRAY}/${NC} opencode models ${PROVIDER_GPT}" if [[ "$DO_GPT" == "true" ]]; then echo -e " ${CYAN}用 GPT:${NC} opencode run -m ${PROVIDER_GPT}/${DEFAULT_GPT_MODEL} \"你好\"" fi if [[ "$DO_CLAUDE" == "true" ]]; then echo -e " ${CYAN}用 Claude:${NC} opencode run -m ${PROVIDER_CLAUDE}/${DEFAULT_CLAUDE_MODEL} \"你好\"" fi echo -e " ${CYAN}会话中切换:${NC} 输入 /models 选择模型,Ctrl+T 切换推理档位 (low/medium/high/...)" if [[ "$DO_CLAUDE" == "true" ]]; then echo "" print_status "info" "提示: 若 Claude 请求报错 \"Requests from the ... framework are not supported\"," echo -e " ${GRAY}说明该 Claude Key 所在分组仅限官方客户端;请到 flashapi.top 在允许第三方工具的分组下${NC}" echo -e " ${GRAY}新建令牌,然后重新运行本脚本选择「2) 配置 闪电API」${NC}" fi } ################################################################################ # 状态检测与菜单 ################################################################################ show_status_summary() { resolve_config_path echo -e " ${WHITE}当前状态:${NC}" local os_label="$OS_TYPE" [[ "$IS_WSL" == "true" ]] && os_label="linux (WSL)" echo -e " 系统环境: ${CYAN}${os_label}${NC}" local oc_ver if oc_ver="$(check_opencode_installed 2>/dev/null)"; then echo -e " OpenCode: ${GREEN}已安装${NC} (${oc_ver})" else echo -e " OpenCode: ${RED}未安装${NC}" fi local cur="" ck="" gk="" url="" model="" cur="$(read_current_config 2>/dev/null || true)" if [[ -n "$cur" ]]; then IFS=$'\x1f' read -r ck gk url model <&2 } ################################################################################ # 主流程 ################################################################################ main() { detect_os write_js_helper add_official_bin_to_path print_banner show_status_summary while true; do show_menu local choice read -r choice < /dev/tty case "$choice" in 1) install_opencode || true echo "" ;; 2) configure_api || true echo "" ;; 3) install_opencode || true echo "" echo -e " ${GRAY}───────────────────────────────────────────────────────${NC}" if configure_api; then echo "" echo -e " ${GRAY}───────────────────────────────────────────────────────${NC}" echo "" print_status "ok" "⚡ 全部完成! 请重新打开终端后运行 opencode" fi echo "" ;; 0) echo "" echo -e " ${BRIGHT_YELLOW}⚡${NC} ${WHITE}感谢使用闪电API,祝你Vibe Coding愉快!${NC}" echo -e " ${GRAY}官网: flashapi.top${NC}" echo "" exit 0 ;; *) print_status "error" "无效选项,请重新选择" echo "" ;; esac done } main "$@"