#!/usr/bin/env bash # --------------------------------------------------------------------------- # 企服科学 Skill 安装器 · qifu-news # # 一键: curl -fsSL https://qifukexue.com/qifu-agent/skill/install.sh | bash -s -- --target agents # 本地: bash install.sh --target all # 卸载: bash install.sh --target all --uninstall # # 设计要点: # · 不使用 sudo;一旦需要提权即失败退出,避免污染系统目录 # · 真身只装一份(默认 ~/.agents/skills/qifu-news),其余客户端以软链接指过去 # · 同分区 mktemp 暂存 + 原子改名,失败自动回滚 # · 下载后按 manifest.sha256 校验;已有目录做身份校验,防止覆盖同名他人技能 # # 公开只读服务,无需密钥。 # --------------------------------------------------------------------------- set -euo pipefail NAME="qifu-news" VERSION="1.0.0" DEFAULT_BASE="https://qifukexue.com/qifu-agent/skill" TARGET="agents" EXPLICIT_DIR="" SOURCE="" UNINSTALL=0 DRY_RUN=0 FORCE=0 VERIFY=1 NO_LINK=0 MIGRATE=0 STAGE="" BACKUP="" COMMITTED=0 DEST="" # ---------------------------------------------------------------- 输出 if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_OK=$'\033[32m'; C_WARN=$'\033[33m'; C_ERR=$'\033[31m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m' else C_OK=""; C_WARN=""; C_ERR=""; C_DIM=""; C_OFF="" fi say() { printf '%s\n' "$*"; } ok() { printf '%s✓%s %s\n' "$C_OK" "$C_OFF" "$*"; } warn() { printf '%s!%s %s\n' "$C_WARN" "$C_OFF" "$*" >&2; } die() { printf '%s✗%s %s\n' "$C_ERR" "$C_OFF" "$*" >&2; exit 1; } dim() { printf '%s%s%s\n' "$C_DIM" "$*" "$C_OFF"; } usage() { cat < 安装目标,默认 agents。可选: agents ~/.agents/skills 跨客户端事实标准目录(推荐) claude ~/.claude/skills Claude Code codex ~/.codex/skills Codex CLI gemini ~/.gemini/skills Gemini CLI opencode ~/.config/opencode/skills all 以上全部 --dir <路径> 直接装到指定目录(此时不建软链接) --source <目录|URL> 技能文件来源,默认 ${DEFAULT_BASE} --no-link 每个目标各自复制一份,不建软链接 --migrate-legacy 把散落在各客户端的旧副本收拢到真身目录,再软链接回去 --uninstall 卸载 --dry-run 只打印将要执行的动作 --no-verify 跳过 SHA-256 校验(不推荐) --force 跳过身份校验(目标目录已属其他技能时强制覆盖) -h, --help 显示本帮助 示例: bash install.sh --target all bash install.sh --dir ~/.config/mytool/skills/qifu-news bash install.sh --target agents --uninstall EOF } # ---------------------------------------------------------------- 参数 while [ $# -gt 0 ]; do case "$1" in --target) [ $# -ge 2 ] || die "--target 缺参数"; TARGET="$2"; shift 2 ;; --target=*) TARGET="${1#*=}"; shift ;; --dir) [ $# -ge 2 ] || die "--dir 缺参数"; EXPLICIT_DIR="$2"; shift 2 ;; --dir=*) EXPLICIT_DIR="${1#*=}"; shift ;; --source) [ $# -ge 2 ] || die "--source 缺参数"; SOURCE="$2"; shift 2 ;; --source=*) SOURCE="${1#*=}"; shift ;; --uninstall) UNINSTALL=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --no-verify) VERIFY=0; shift ;; --force) FORCE=1; shift ;; --no-link) NO_LINK=1; shift ;; --migrate-legacy) MIGRATE=1; shift ;; -h|--help) usage; exit 0 ;; *) die "未知参数:$1(用 --help 查看用法)" ;; esac done case "$TARGET" in agents|claude|codex|gemini|opencode|all) ;; *) die "--target 取值应为 agents|claude|codex|gemini|opencode|all,收到:$TARGET" ;; esac if [ "$(id -u)" -eq 0 ]; then warn "当前以 root 运行;本安装器不需要提权,建议改用普通用户执行。" fi if [ -n "${SUDO_USER:-}" ]; then die "检测到 sudo 环境。请不要用 sudo 安装:技能应装在当前用户家目录下。" fi HOME_DIR="${HOME:-}" [ -n "$HOME_DIR" ] || die "无法确定家目录(HOME 未设置)。" # ---------------------------------------------------------------- 目标路径 dir_for() { case "$1" in agents) printf '%s/.agents/skills/%s' "$HOME_DIR" "$NAME" ;; claude) printf '%s/.claude/skills/%s' "$HOME_DIR" "$NAME" ;; codex) printf '%s/.codex/skills/%s' "$HOME_DIR" "$NAME" ;; gemini) printf '%s/.gemini/skills/%s' "$HOME_DIR" "$NAME" ;; opencode) printf '%s/.config/opencode/skills/%s' "$HOME_DIR" "$NAME" ;; *) die "内部错误:未知目标 $1" ;; esac } if [ -n "$EXPLICIT_DIR" ]; then case "$EXPLICIT_DIR" in "~"/*) EXPLICIT_DIR="$HOME_DIR/${EXPLICIT_DIR#\~/}" ;; esac CANON="$EXPLICIT_DIR" TARGETS="" else CANON="$(dir_for agents)" if [ "$TARGET" = "all" ]; then TARGETS="claude codex gemini opencode" elif [ "$TARGET" = "agents" ]; then TARGETS="" else TARGETS="$TARGET" fi fi # ---------------------------------------------------------------- 清理与回滚 cleanup() { rc=$? if [ "$COMMITTED" -ne 1 ]; then if [ -n "$BACKUP" ] && [ -e "$BACKUP" ]; then warn "安装未完成,正在回滚…" rm -rf "$DEST" 2>/dev/null || true mv "$BACKUP" "$DEST" 2>/dev/null || true fi fi [ -n "$STAGE" ] && [ -e "$STAGE" ] && rm -rf "$STAGE" 2>/dev/null || true exit "$rc" } trap cleanup EXIT # ---------------------------------------------------------------- 下载器 HAVE_CURL=0; HAVE_WGET=0 if command -v curl >/dev/null 2>&1; then HAVE_CURL=1; fi if command -v wget >/dev/null 2>&1; then HAVE_WGET=1; fi fetch() { # fetch if [ "$HAVE_CURL" -eq 1 ]; then curl -fsSL --max-time 30 "$1" -o "$2" elif [ "$HAVE_WGET" -eq 1 ]; then wget -q -T 30 -O "$2" "$1" else die "需要 curl 或 wget 才能下载技能文件。" fi } sha256_of() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else printf '' fi } # ---------------------------------------------------------------- 来源解析 SCRIPT_DIR="" if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" fi SRC_BASE="${SOURCE:-$DEFAULT_BASE}" LOCAL_SRC="" case "$SRC_BASE" in http://*|https://*) ;; *) LOCAL_SRC="${SRC_BASE%/}" [ -d "$LOCAL_SRC/$NAME" ] || die "本地来源下没有 $NAME 目录:$LOCAL_SRC" ;; esac if [ -z "$LOCAL_SRC" ] && [ -z "$SOURCE" ] && [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/$NAME/SKILL.md" ]; then LOCAL_SRC="$SCRIPT_DIR" dim "使用本地文件:$LOCAL_SRC" fi FILES="SKILL.md references/api.md agents/openai.yaml" # ---------------------------------------------------------------- 身份校验 skill_name_of() { # 读 frontmatter 里的 name: [ -f "$1/SKILL.md" ] || return 1 awk ' NR == 1 && $0 == "---" { inbm = 1; next } inbm && $0 == "---" { exit } inbm && $0 ~ /^name:[[:space:]]*/ { sub(/^name:[[:space:]]*/, ""); gsub(/["'"'"']/, ""); print; exit } ' "$1/SKILL.md" } check_identity() { # $1 = 目录 [ -e "$1" ] || return 0 [ -e "$1/SKILL.md" ] || return 0 found="$(skill_name_of "$1" || true)" if [ -n "$found" ] && [ "$found" != "$NAME" ]; then if [ "$FORCE" -eq 1 ]; then warn "$1 内是技能「$found」,因 --force 继续覆盖。" else die "$1 已存在且属于技能「$found」,为避免误删已中止。确认要覆盖请加 --force。" fi fi return 0 } # ---------------------------------------------------------------- 卸载 if [ "$UNINSTALL" -eq 1 ]; then targets="$CANON" for t in $TARGETS; do targets="$targets $(dir_for "$t")"; done if [ -n "$EXPLICIT_DIR" ]; then targets="$CANON"; fi printf '%s\n' "$targets" | while IFS= read -r p; do [ -n "$p" ] || continue if [ -L "$p" ]; then say "移除软链接 $p" [ "$DRY_RUN" -eq 1 ] || rm -f "$p" elif [ -d "$p" ]; then say "删除目录 $p" [ "$DRY_RUN" -eq 1 ] || rm -rf "$p" fi done if [ "$DRY_RUN" -eq 1 ]; then dim "(--dry-run:以上动作未真正执行)"; else ok "已卸载。"; fi COMMITTED=1 exit 0 fi # ---------------------------------------------------------------- 收拢旧副本 if [ "$MIGRATE" -eq 1 ]; then for legacy in "$HOME_DIR/.claude/skills/$NAME" "$HOME_DIR/.config/opencode/skill/$NAME" "$HOME_DIR/.claude/skill/$NAME"; do if [ -L "$legacy" ]; then continue; fi if [ ! -d "$legacy" ]; then continue; fi if [ "$legacy" = "$CANON" ]; then continue; fi if [ ! -e "$CANON" ]; then say "收拢 $legacy → $CANON" if [ "$DRY_RUN" -eq 0 ]; then mkdir -p "$(dirname "$CANON")"; mv "$legacy" "$CANON"; fi else say "移除重复副本 $legacy(真身已存在)" if [ "$DRY_RUN" -eq 0 ]; then rm -rf "$legacy"; fi fi done fi # ---------------------------------------------------------------- 取文件到暂存区 PARENT="$(dirname "$CANON")" [ "$DRY_RUN" -eq 1 ] || mkdir -p "$PARENT" || die "无法创建目录:$PARENT" if [ "$DRY_RUN" -eq 1 ]; then say "将安装到:$CANON" printf '%s\n' "$FILES" | sed 's/^/ · /' if [ -n "$TARGETS" ] && [ "$NO_LINK" -eq 0 ]; then say "将建立软链接:$TARGETS"; fi dim "(--dry-run:未做任何改动)" COMMITTED=1 exit 0 fi STAGE="$(mktemp -d "$PARENT/.${NAME}.stage.XXXXXX")" STAGE_ROOT="$STAGE/$NAME" mkdir -p "$STAGE_ROOT/references" "$STAGE_ROOT/agents" if [ -n "$LOCAL_SRC" ]; then dim "复制自 $LOCAL_SRC" for f in $FILES; do src="$LOCAL_SRC/$NAME/$f" [ -f "$src" ] || die "缺少文件:$src" mkdir -p "$STAGE_ROOT/$(dirname "$f")" cp -p "$src" "$STAGE_ROOT/$f" done if [ -f "$LOCAL_SRC/manifest.sha256" ]; then cp -p "$LOCAL_SRC/manifest.sha256" "$STAGE_ROOT/manifest.sha256" fi else base="${SRC_BASE%/}" dim "下载自 $base" for f in $FILES; do mkdir -p "$STAGE_ROOT/$(dirname "$f")" fetch "$base/$NAME/$f" "$STAGE_ROOT/$f" || die "下载失败:$base/$NAME/$f" done if fetch "$base/manifest.sha256" "$STAGE_ROOT/manifest.sha256" 2>/dev/null; then :; else rm -f "$STAGE_ROOT/manifest.sha256" warn "未取到 manifest.sha256,跳过校验。" fi fi # ---------------------------------------------------------------- 校验 if [ "$VERIFY" -eq 1 ] && [ -f "$STAGE_ROOT/manifest.sha256" ]; then dim "校验 SHA-256…" bad=0 while IFS= read -r line; do [ -n "$line" ] || continue case "$line" in \#*) continue ;; esac want="$(printf '%s' "$line" | awk '{print $1}')" rel="$(printf '%s' "$line" | awk '{print $2}')" rel="${rel#\*}" [ -n "$want" ] && [ -n "$rel" ] || continue [ -f "$STAGE_ROOT/$rel" ] || { warn "清单里的文件不存在:$rel"; bad=1; continue; } got="$(sha256_of "$STAGE_ROOT/$rel")" if [ -z "$got" ]; then warn "本机缺少 shasum/sha256sum,无法校验。"; bad=0; break; fi if [ "$got" != "$want" ]; then warn "校验不通过:$rel"; bad=1; fi done < "$STAGE_ROOT/manifest.sha256" [ "$bad" -eq 0 ] || die "SHA-256 校验失败,已中止安装(如需跳过请加 --no-verify)。" ok "校验通过。" elif [ "$VERIFY" -eq 0 ]; then warn "已按 --no-verify 跳过完整性校验。" fi # ---------------------------------------------------------------- 落位(原子) DEST="$CANON" already_same=0 if [ -e "$DEST" ] && [ -f "$DEST/SKILL.md" ]; then a="$(sha256_of "$DEST/SKILL.md")"; b="$(sha256_of "$STAGE_ROOT/SKILL.md")" if [ -n "$a" ] && [ "$a" = "$b" ]; then already_same=1; fi fi if [ "$already_same" -eq 1 ]; then ok "内容已是最新,无需更新:$DEST" rm -rf "$STAGE_ROOT" else check_identity "$DEST" if [ -e "$DEST" ]; then BACKUP="$(mktemp -d "$PARENT/.${NAME}.backup.XXXXXX")" mv "$DEST" "$BACKUP/$NAME" || die "无法移走旧版本:$DEST" fi mv "$STAGE_ROOT" "$DEST" || die "无法写入:$DEST" COMMITTED=1 if [ -n "$BACKUP" ]; then rm -rf "$BACKUP"; fi BACKUP="" ok "已安装:$DEST" fi # ---------------------------------------------------------------- 软链接 if [ -n "$TARGETS" ]; then for t in $TARGETS; do p="$(dir_for "$t")" mkdir -p "$(dirname "$p")" || { warn "无法创建 $(dirname "$p"),跳过 $t"; continue; } if [ "$NO_LINK" -eq 1 ]; then rm -rf "$p" cp -Rp "$DEST" "$p" ok "$t → $p(独立副本)" continue fi if [ -L "$p" ]; then cur="$(readlink "$p" || true)" if [ "$cur" = "$DEST" ]; then ok "$t 已链接"; continue; fi rm -f "$p" elif [ -e "$p" ]; then ino_a="$(ls -di "$p" 2>/dev/null | awk '{print $1}')" ino_b="$(ls -di "$DEST" 2>/dev/null | awk '{print $1}')" if [ -n "$ino_a" ] && [ "$ino_a" = "$ino_b" ]; then ok "$t 与真身同一目录"; continue; fi check_identity "$p" rm -rf "$p" fi ln -s "$DEST" "$p" ok "$t → $p" done fi # ---------------------------------------------------------------- 收尾 say "" ok "企服科学 Skill「$NAME」v$VERSION 安装完成。" say "" say " 试一句:用企服科学技能,给我近 24 小时企业服务领域的 5 条要闻" say " 自检: curl -s https://qifukexue.com/qifu-agent/api/v1/meta | head -c 300" say "" dim "提示:技能在会话启动时扫描,请新开一个会话后再试。" dim "文档:https://qifukexue.com/qifu-agent/skill/README.md"