#!/bin/bash # ------------------------------------------------------------------------------ # Agent Skills 一键安装:https://caocan.me/skills # # curl -fsSL https://caocan.me/skills/install.sh | bash 安装或更新全部 # curl -fsSL https://caocan.me/skills/install.sh | bash -s -- pdf docx 只装指定的 Skill # curl -fsSL https://caocan.me/skills/install.sh | bash -s -- --help 查看全部选项 # # 装到 Claude Code(~/.claude/skills)和 ChatGPT / Codex(~/.agents/skills)。 # 只用系统自带的 curl、tar 和 shasum,从 GitHub 下载每个 Skill 收藏时锁定的 commit, # 并核对 SKILL.md 与网站上展示的一致。 # ------------------------------------------------------------------------------ set -euo pipefail SITE_URL='https://caocan.me' MARKER_FILE='.caocan-skill' skills_data() { cat <<'__SKILLS_DATA__' animate|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/animate|39138996033e0dbe53e73217514116eaeca774bae84a0687c80d445a4e0cc3a6|2|Build an animation from scratch, making the decisions in the order that determines whether it feels right — should it animate at all, what purpose, which tool,… animate-expo|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/animate-expo|9a01a91701e6563c67677dde6b66ab30f066a5341d42c21a90ba51d99679274a|2|Build animations in React Native and Expo, making the decisions in the order that determines whether they feel right — should it animate, which thread it runs … animation-performance|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/animation-performance|6c60f45ab416ff3f7d7765a5427beead77fab0d82487fe610d2a298397558d0a|1|Keep web animations inside the frame budget — animate composite-only properties, keep motion off the main thread when the page is busy, and find the cause when… animation-vocabulary|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/animation-vocabulary|40d2b27adfdfeb0570a9f20e7d0255109d9233c50603878bcd0dcd04c923fd94|1|Reverse-lookup glossary that turns a vague description of a web animation or motion effect into its exact term ("the bouncy thing when a popover opens" → Pop i… apple-design|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/apple-design|77bb63b7043bb93aca2ff4ab040c249484eef35682bb6ff7163433c31d30adc7|1|Apple's approach to interface design and fluid, physical motion, translated for the web. Use when building or reviewing gesture-driven UI, spring animations, d… ask-sonner|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/ask-sonner|39f3ee185b3aa2cf70a689052ff078953ce7ba5915ed7a9391d232e9d4dcf57f|2|Guide to Sonner, the React toast library — install and wire up the Toaster, pick the right toast() call, promise and loading toasts, updating, dismissing and p… break-ui|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/break-ui|e7eec1d70ac56a04255e9c0dbe98084ea3d544cc04aaad34f32c95d6fa9dff5e|2|Try to break a piece of UI by feeding it worst-case data — long names, unbreakable emails, one-letter names, missing fields, huge counts, zero items, long labe… choreograph|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/choreograph|815c7d889230bafc72c03bb0769087aeede310130e8795c7949b3e3a1684e464|2|Coordinate several elements that animate together — decide what moves, in what order, how much they overlap, and what stays still — so a group reads as one int… emil-design-eng|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/emil-design-eng|965cc3108047a1f68e504977ae7816c66115b202a32e7509c0b8305314839c54|1|This skill encodes Emil Kowalski's philosophy on UI polish, component design, animation decisions, and the invisible details that make software feel great. find-animation-opportunities|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/find-animation-opportunities|d52a377819b84a71930f0c822cfe2849f40301605a2d5c7eccbfa3b257700df2|1|Search a codebase or UI for places that don't animate but should, and reject everything that shouldn't. Read-only; it proposes motion with exact values, it doe… gestures|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/gestures|0835dddc0ce4f4257fdc707ec137d75810f0bdbd8d4eaf69d1de55bfbbf260c2|1|Build drag, swipe, and sheet interactions that track the finger 1:1 and stay interruptible. Use when building or fixing anything the user drags, swipes, flings… improve-animations|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/improve-animations|73962447464433476343e2c12cd08ab39cc99a9e5f3c118c5fb54ed897574ef6|3|Survey a codebase's animation and motion code as a senior motion advisor, then produce a prioritized audit and self-contained implementation plans for other ag… mobile-native|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/mobile-native|888b7651d66d66dbac4e72b7c554638eb19bd971d686d6cbdf030a5ef3788f55|1|Make a web app feel native on a phone — the small CSS and meta-tag fixes that separate "a website in a browser" from something that feels installed. Covers sti… pick-ui-library|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/pick-ui-library|675fdce308683256ed617351a9b74d23457a5fbd0a562d9831b9c764b8c5e682|1|Pick the right library for a given frontend task from a curated, opinionated list — numbers, OTP inputs, charts, command menus, virtualization, drag and drop, … prototype|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/prototype|bba39c955eb1871c5a02733976ceec5d0324198e0812e667604756489c89f7b9|2|Build multiple genuinely different versions of a UI piece you describe, rendered behind a visual picker so you can flip through them live and promote the one t… remove-animations|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/remove-animations|dea95b7a7ef81cba312c7f70a3c5c67d7e06a287cb16b16d2f90f2b1fb0afff9|1|Find the animations an interface doesn't need and remove them — motion on keyboard actions, high-frequency surfaces, functional data, and decoration with no pu… review-animations|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/review-animations|5f9fb98b69cace7107af8b393a4196fd42fa42d98bce0e5afc1f866ff3b8a41c|2|Reviews animation and motion code against a high craft bar derived from Emil Kowalski's design engineering philosophy. Default to flagging; approval is earned. write-swift|emilkowalski|skills|715c1807e08c497cfd1dc4feb450a2a3300ef2a9|skills/write-swift|16a4b6adb74e338e55e704ce5753ad54d825438b8a63930eeaede9f3c04c9447|1|How to write modern Swift well — modeling with value types, Swift 6 data-race safety and approachable concurrency (@concurrent, main-actor-by-default, actors, … __SKILLS_DATA__ } if [ -t 1 ]; then BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; RESET=$'\033[0m' else BOLD=''; DIM=''; RED=''; GREEN=''; YELLOW=''; RESET='' fi say() { printf '%s\n' "$*"; } info() { printf '%s\n' "${DIM}$*${RESET}"; } ok() { printf ' %s✓%s %s\n' "$GREEN" "$RESET" "$*"; } warn() { printf ' %s!%s %s\n' "$YELLOW" "$RESET" "$*" >&2; } fail() { printf ' %s✗%s %s\n' "$RED" "$RESET" "$*" >&2; } die() { printf '%s错误:%s%s\n' "$RED" "$*" "$RESET" >&2; exit 1; } usage() { cat <<__USAGE__ 用法:curl -fsSL $SITE_URL/skills/install.sh | bash -s -- [选项] [Skill 名…] 不写 Skill 名时安装全部收藏。再次运行即可更新。 选项: --claude 只装到 Claude Code(\$CLAUDE_CONFIG_DIR/skills,默认 ~/.claude/skills) --codex 只装到 ChatGPT / Codex(~/.agents/skills) --list 列出可以安装的 Skill,不安装 --dry-run 只显示将要做的事,不改动任何文件 --force 同名 Skill 不是由本脚本安装时也替换(原目录先备份) --prune 删除由本脚本安装、但已从收藏中移除的 Skill(先备份) -h, --help 显示这段帮助 备份位置:~/.agent-skills-backup/<时间>/ 环境变量: GITHUB_TOKEN 可选。匿名下载失败(比如被 GitHub 限流)时用它重试 收藏页:$SITE_URL/skills __USAGE__ } # 数据行:name|owner|repo|sha|path|skill_md_sha256|expected_files|description NAMES=(); OWNERS=(); REPOS=(); SHAS=(); PATHS=(); MD_SHAS=(); FILE_COUNTS=(); DESCS=() load_skills() { local name owner repo sha path md_sha files desc while IFS='|' read -r name owner repo sha path md_sha files desc; do [ -n "$name" ] || continue NAMES+=("$name"); OWNERS+=("$owner"); REPOS+=("$repo"); SHAS+=("$sha"); PATHS+=("$path"); MD_SHAS+=("$md_sha"); FILE_COUNTS+=("$files"); DESCS+=("$desc") done < <(skills_data) } index_of() { local i for ((i = 0; i < ${#NAMES[@]}; i++)); do if [ "${NAMES[$i]}" = "$1" ]; then printf '%s' "$i" return 0 fi done return 1 } # 标记文件必须是普通文件:符号链接一律不认,免得读写都被引到别处 has_marker() { [ -f "$1/$MARKER_FILE" ] && [ ! -L "$1/$MARKER_FILE" ] } # 标记文件里记下了 commit(sha=)和装好时的内容哈希(hash=) marker_field() { has_marker "$1" || return 0 local line while IFS= read -r line || [ -n "$line" ]; do case "$line" in "$2"=*) printf '%s' "${line#*=}"; return 0 ;; esac done < "$1/$MARKER_FILE" return 0 } marker_sha() { marker_field "$1" sha } # 标准输入的 SHA-256(小写十六进制);算不出来时输出空串 sha256_stdin() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 | cut -d ' ' -f 1 elif command -v sha256sum >/dev/null 2>&1; then sha256sum | cut -d ' ' -f 1 else openssl dgst -sha256 -r 2>/dev/null | cut -d ' ' -f 1 fi } # 目录内容的哈希(不含标记文件):更新前用它判断本地有没有改过,改过的先备份再替换。 # Finder 的 .DS_Store、Python 运行时生成的 __pycache__ / *.pyc 不算改动。算不出来时输出空串,调用方按「改过」处理 content_hash() { local dir="$1" command -v shasum >/dev/null 2>&1 || return 0 ( cd "$dir" 2>/dev/null || exit 0 { find . -type f ! -name "$MARKER_FILE" ! -name '.DS_Store' ! -name '*.pyc' ! -path '*/__pycache__/*' -print0 | LC_ALL=C sort -z | xargs -0 shasum -a 256 2>/dev/null find . -type l ! -path '*/__pycache__/*' -print0 | LC_ALL=C sort -z | while IFS= read -r -d '' link; do printf 'link %s -> %s\n' "$link" "$(readlink "$link")" done } | shasum -a 256 | cut -d ' ' -f 1 ) 2>/dev/null || true } # 下载内容里的符号链接一律去掉:链接可能指向技能目录之外(读到别处的文件,或者让写入落到别处)。 # 用 find -delete,不逐行解析路径,文件名里带换行也处理得了 drop_symlinks() { local root="$1" name="$2" count count="$( (find "$root" -type l -print0 2>/dev/null || true) | tr -dc '\000' | wc -c | tr -d ' ')" [ "${count:-0}" -gt 0 ] || return 0 find "$root" -type l -delete 2>/dev/null || true info " · ${name}:去掉了 ${count} 个符号链接(装好的技能里不保留链接)" } # 技能文件夹里的插件清单会改变它的身份:Claude Code 会把带 .claude-plugin/plugin.json 的 # ~/.claude/skills/<名字> 当成插件加载(连同其中声明的 hooks、MCP 服务器),Codex 会给技能名加上插件前缀。 # 这里装的是技能,不是插件,安装时去掉 strip_plugin_manifests() { local root="$1" name="$2" dir removed='' for dir in .claude-plugin .codex-plugin .cursor-plugin; do if [ -e "$root/$dir" ]; then rm -rf "${root:?}/$dir" removed="$removed $dir" fi done [ -z "$removed" ] || info " · ${name}:去掉了插件清单 ${removed# }(只作为技能安装)" } BACKUP_DIR='' move_to_backup() { local source="$1" key="$2" name="$3" dest dest="$BACKUP_DIR/$key/$name" mkdir -p "$BACKUP_DIR/$key" || return 1 if [ -e "$dest" ] || [ -L "$dest" ]; then dest="$dest.$$.$RANDOM" fi mv "$source" "$dest" || return 1 info " 原来的已备份到 $dest" } curl_get() { local url="$1" out="$2" shift 2 # 低于 1 KB/s 持续 60 秒就放弃,免得网络卡住时一直没有输出 curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 20 --speed-limit 1024 --speed-time 60 "$@" -o "$out" "$url" } # 下载 owner/repo 在某个 commit 的源码包(同一个包只下载一次)。 # 先匿名从 codeload 下载(不占 API 额度);失败且设置了 GITHUB_TOKEN 时再经 API 带令牌重试, # 令牌通过文件描述符交给 curl,不出现在进程参数里 download_archive() { local owner="$1" repo="$2" sha="$3" archive archive="$WORK_DIR/archives/$owner--$repo--$sha.tar.gz" if [ ! -s "$archive" ]; then mkdir -p "$WORK_DIR/archives" || return 1 if ! curl_get "https://codeload.github.com/$owner/$repo/tar.gz/$sha" "$archive.part"; then [ -n "${GITHUB_TOKEN:-}" ] || return 1 warn "匿名下载 $owner/$repo 失败,改用 GITHUB_TOKEN 重试" curl_get "https://api.github.com/repos/$owner/$repo/tarball/$sha" "$archive.part" \ -H @<(printf 'Authorization: Bearer %s\n' "$GITHUB_TOKEN") || return 1 fi mv "$archive.part" "$archive" || return 1 fi printf '%s' "$archive" } # 从源码包里取出技能目录,输出解压后的目录路径 extract_skill() { local archive="$1" path="$2" stage="$3" top top="$( (tar -tzf "$archive" 2>/dev/null || true) | head -n 1)" top="${top%%/*}" if [ -z "$top" ] || [ "$top" = "." ] || [ "$top" = ".." ]; then return 1 fi rm -rf "$stage" mkdir -p "$stage" || return 1 if [ -n "$path" ]; then tar -xzf "$archive" -C "$stage" "$top/$path" 2>/dev/null || return 1 printf '%s' "$stage/$top/$path" else tar -xzf "$archive" -C "$stage" 2>/dev/null || return 1 printf '%s' "$stage/$top" fi } # dest 是指向另一个目标里同名目录的符号链接(如 ~/.agents/skills/x → ~/.claude/skills/x), # 而那一份是本脚本装的、并且已经是这个版本(或者这次运行也会更新它):跟着那一份走就行 links_to_other_target() { local dest="$1" name="$2" sha="$3" real other t u in_run real="$(cd "$dest" 2>/dev/null && pwd -P)" || return 1 for ((t = 0; t < ${#KNOWN_TARGET_DIRS[@]}; t++)); do other="${KNOWN_TARGET_DIRS[$t]}/$name" [ "$other" != "$dest" ] && [ ! -L "$other" ] && [ -d "$other" ] || continue [ "$(cd "$other" 2>/dev/null && pwd -P)" = "$real" ] || continue has_marker "$other" || return 1 [ "$(marker_sha "$other")" = "$sha" ] && return 0 in_run=0 for ((u = 0; u < ${#TARGET_DIRS[@]}; u++)); do [ "${TARGET_DIRS[$u]}" != "${KNOWN_TARGET_DIRS[$t]}" ] || in_run=1 done [ "$in_run" = 1 ] && return 0 return 1 done return 1 } # 每个目标里都已经是这个版本(本脚本装的,或链接到本脚本装的同一版本)时,连下载都可以省掉 is_current_everywhere() { local name="$1" sha="$2" t dest [ "$FORCE" != 1 ] || return 1 for ((t = 0; t < ${#TARGET_DIRS[@]}; t++)); do dest="${TARGET_DIRS[$t]}/$name" has_marker "$dest" || return 1 [ "$(marker_sha "$dest")" = "$sha" ] || return 1 done return 0 } # Codex 按 frontmatter 的名字认技能,同名的两份会同时列出,$名字 也就点不中了: # 旧位置 ~/.codex/skills 里的另一份、ChatGPT 自带的 .system 技能都会撞名 warn_codex_duplicates() { local name="$1" current="$2" legacy real_legacy real_current codex_home="${CODEX_HOME:-$HOME/.codex}" if [ -d "$codex_home/skills/.system/$name" ]; then warn "ChatGPT 自带一个同名的 ${name},ChatGPT 里会同时出现两个" fi legacy="$codex_home/skills/$name" [ -d "$legacy" ] || return 0 real_legacy="$(cd "$legacy" 2>/dev/null && pwd -P)" || return 0 real_current="$(cd "$current" 2>/dev/null && pwd -P)" || return 0 [ "$real_legacy" != "$real_current" ] || return 0 warn "旧位置 $legacy 还有一份同名的 ${name},ChatGPT 会同时看到两个;确认不需要后可以删掉它" } # 装到一个目标目录。结果写进全局 RESULT:installed / replaced / updated / unchanged / linked / skipped / failed install_into() { local name="$1" source="$2" target_dir="$3" label="$4" key="$5" sha="$6" origin="$7" local dest="$target_dir/$name" action='installed' RESULT='failed' if [ -L "$dest" ] && [ ! -e "$dest" ]; then # 指向已不存在位置的符号链接(以前别的工具留下的):先备份这个链接,再装上真正的目录 action='replaced' elif [ -L "$dest" ]; then if [ "$FORCE" != 1 ] && links_to_other_target "$dest" "$name" "$sha"; then RESULT='linked' return 0 fi if [ "$FORCE" != 1 ] && has_marker "$dest" && [ "$(marker_sha "$dest")" = "$sha" ]; then RESULT='unchanged' return 0 fi if [ "$FORCE" = 1 ]; then action='replaced' else warn "$label 里的 $name 是指向别处的符号链接,已跳过;加 --force 可换成独立的一份" RESULT='skipped' return 0 fi elif [ -e "$dest" ]; then if has_marker "$dest"; then if [ "$(marker_sha "$dest")" = "$sha" ] && [ "$FORCE" != 1 ]; then RESULT='unchanged' return 0 fi action='updated' elif [ "$FORCE" = 1 ]; then action='replaced' else warn "$label 里已有同名的 ${name}(不是由本脚本安装的),已跳过;加 --force 可替换" RESULT='skipped' return 0 fi fi if [ "$DRY_RUN" = 1 ]; then RESULT="$action" return 0 fi # 这个函数在 if 里调用,set -e 不生效:每一步都要自己检查。 # 先在技能目录的上一级(同一个磁盘,最后一步 mv 是原子的改名;又不在 Agent 扫描的技能目录里) # 备好完整的一份再换进去:技能目录里不会出现半成品 mkdir -p "$target_dir" || return 1 local staging_root staging hash staging_root="$(dirname "$target_dir")/.agent-skills-staging.$$" mkdir -p "$staging_root" || return 1 STAGING_ROOTS="$STAGING_ROOTS $staging_root" staging="$staging_root/$name" rm -rf "$staging" cp -R "$source" "$staging" || return 1 rm -rf "$staging/$MARKER_FILE" hash="$(content_hash "$staging")" { printf '# 由 %s/skills 的安装脚本管理:重新运行安装命令会更新这个目录;本地改过的会先备份再替换\n' "$SITE_URL" printf 'sha=%s\n' "$sha" printf 'hash=%s\n' "$hash" printf 'source=%s\n' "$origin" } > "$staging/$MARKER_FILE" || return 1 if [ "$action" = 'replaced' ]; then move_to_backup "$dest" "$key" "$name" || return 1 elif [ "$action" = 'updated' ]; then local recorded current recorded="$(marker_field "$dest" hash)" current="$(content_hash "$dest")" if [ -n "$recorded" ] && [ "$recorded" = "$current" ]; then rm -rf "$dest" || return 1 else warn "$label 里的 $name 在本地改过,旧目录先备份" move_to_backup "$dest" "$key" "$name" || return 1 fi fi mv "$staging" "$dest" || return 1 RESULT="$action" } STAGING_ROOTS='' cleanup() { [ -z "${WORK_DIR:-}" ] || rm -rf "$WORK_DIR" local root while IFS= read -r root; do [ -z "$root" ] || rm -rf "$root" done <<__STAGING__ $STAGING_ROOTS __STAGING__ } main() { local want_claude=0 want_codex=0 list=0 t DRY_RUN=0; FORCE=0; PRUNE=0 local requested=() while [ $# -gt 0 ]; do case "$1" in --claude) want_claude=1 ;; --codex) want_codex=1 ;; --list) list=1 ;; --dry-run) DRY_RUN=1 ;; --force) FORCE=1 ;; --prune) PRUNE=1 ;; -h|--help) usage; return 0 ;; -*) die "不认识的选项 $1(--help 查看用法)" ;; *) requested+=("$1") ;; esac shift done load_skills if [ "$list" = 1 ]; then say "${BOLD}可以安装的 Skill(${#NAMES[@]} 个)${RESET}" local i for ((i = 0; i < ${#NAMES[@]}; i++)); do printf ' %s%-28s%s %s\n' "$BOLD" "${NAMES[$i]}" "$RESET" "${DESCS[$i]}" done return 0 fi command -v curl >/dev/null 2>&1 || die '找不到 curl' command -v tar >/dev/null 2>&1 || die '找不到 tar' if [ "$want_claude" = 0 ] && [ "$want_codex" = 0 ]; then want_claude=1; want_codex=1 fi local claude_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills" # Codex 文档里的用户目录是 ~/.agents/skills;~/.codex/skills 是旧位置,只为兼容还在读 local agents_dir="$HOME/.agents/skills" # 判断「链接到另一个目标」时两个目录都要看,哪怕这次只装其中一个 KNOWN_TARGET_DIRS=("$claude_dir" "$agents_dir") # TARGET_KEYS 用作备份目录名,不能带斜杠 TARGET_DIRS=(); TARGET_LABELS=(); TARGET_KEYS=() if [ "$want_claude" = 1 ]; then TARGET_DIRS+=("$claude_dir"); TARGET_LABELS+=('Claude Code'); TARGET_KEYS+=('claude-code') fi if [ "$want_codex" = 1 ]; then TARGET_DIRS+=("$agents_dir"); TARGET_LABELS+=('ChatGPT / Codex'); TARGET_KEYS+=('codex') fi # 要装的 Skill 下标(命令行里重复的名字只算一次) local selected=() name index s duplicate if [ ${#requested[@]} -eq 0 ]; then local i for ((i = 0; i < ${#NAMES[@]}; i++)); do selected+=("$i"); done else for name in "${requested[@]}"; do index="$(index_of "$name")" || die "收藏里没有 ${name}(--list 查看全部)" duplicate=0 for s in ${selected[@]+"${selected[@]}"}; do [ "$s" != "$index" ] || duplicate=1 done [ "$duplicate" = 1 ] || selected+=("$index") done fi WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/agent-skills.XXXXXX")" trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM BACKUP_DIR="$HOME/.agent-skills-backup/$(date +%Y%m%d-%H%M%S)" say "${BOLD}安装 ${#selected[@]} 个 Skill${RESET}" for ((t = 0; t < ${#TARGET_DIRS[@]}; t++)); do info " → ${TARGET_LABELS[$t]}:${TARGET_DIRS[$t]}" done [ "$DRY_RUN" = 1 ] && info ' (试运行:不会改动任何文件)' say '' local installed=0 updated=0 unchanged=0 skipped=0 failed=0 local i for i in ${selected[@]+"${selected[@]}"}; do name="${NAMES[$i]}" local owner="${OWNERS[$i]}" repo="${REPOS[$i]}" sha="${SHAS[$i]}" path="${PATHS[$i]}" md_sha="${MD_SHAS[$i]}" expected="${FILE_COUNTS[$i]}" local origin="https://github.com/$owner/$repo/tree/$sha${path:+/${path// /%20}}" local archive source actual count if is_current_everywhere "$name" "$sha"; then info " · ${name}(已是最新)" unchanged=$((unchanged + 1)) if [ "$want_codex" = 1 ]; then warn_codex_duplicates "$name" "$agents_dir/$name" fi continue fi if ! archive="$(download_archive "$owner" "$repo" "$sha")"; then fail "${name}:下载 $owner/$repo 失败" failed=$((failed + 1)) continue fi if ! source="$(extract_skill "$archive" "$path" "$WORK_DIR/stage/$name")"; then fail "${name}:源码包里找不到 ${path:-仓库根目录}" failed=$((failed + 1)) continue fi # 文件数对不上(少了):多半是仓库的 .gitattributes 用 export-ignore 把文件排除在了打包之外 if [ -n "$expected" ]; then count="$( (find "$source" \( -type f -o -type l \) -print0 2>/dev/null || true) | tr -dc '\000' | wc -c | tr -d ' ')" if [ "${count:-0}" -lt "$expected" ]; then warn "${name}:源码包里少了 $((expected - count)) 个文件(可能被仓库的 .gitattributes 排除了),装上的可能不完整" fi fi drop_symlinks "$source" "$name" if [ ! -f "$source/SKILL.md" ]; then fail "${name}:源码包里找不到 SKILL.md" failed=$((failed + 1)) continue fi actual="$(tr -d '\r' < "$source/SKILL.md" | sha256_stdin || true)" if [ "$actual" != "$md_sha" ]; then fail "${name}:下载到的 SKILL.md 和网站上展示的不一致,已拒绝安装" failed=$((failed + 1)) continue fi strip_plugin_manifests "$source" "$name" rm -rf "${source:?}/$MARKER_FILE" if grep -rqs -e '^version https://git-lfs.github.com/spec/' "$source"; then warn "${name} 里有 Git LFS 指针文件:对应的大文件不在源码包里,可能用不了" fi local results='' for ((t = 0; t < ${#TARGET_DIRS[@]}; t++)); do if ! install_into "$name" "$source" "${TARGET_DIRS[$t]}" "${TARGET_LABELS[$t]}" "${TARGET_KEYS[$t]}" "$sha" "$origin"; then RESULT='failed' fi results="$results $RESULT" done case "$results" in *failed*) fail "${name}:写入失败"; failed=$((failed + 1)) ;; *installed*|*replaced*) if [ "$DRY_RUN" = 1 ]; then say " · 将安装 ${name}"; else ok "$name"; fi installed=$((installed + 1)) ;; *updated*) if [ "$DRY_RUN" = 1 ]; then say " · 将更新 ${name}"; else ok "${name}(已更新)"; fi updated=$((updated + 1)) ;; *skipped*) skipped=$((skipped + 1)) ;; *) info " · ${name}(已是最新)"; unchanged=$((unchanged + 1)) ;; esac if [ "$want_codex" = 1 ]; then warn_codex_duplicates "$name" "$agents_dir/$name" fi done # 由本脚本装过、但已不在收藏里的:加了 --prune 才移除,否则只提示数量。 # 另一个目标里链到被移除目录的符号链接会跟着失效,一并移走 local removed=0 stale=0 local dir base real other u for ((t = 0; t < ${#TARGET_DIRS[@]}; t++)); do [ -d "${TARGET_DIRS[$t]}" ] || continue for dir in "${TARGET_DIRS[$t]}"/*; do [ -d "$dir" ] && [ ! -L "$dir" ] && has_marker "$dir" || continue base="$(basename "$dir")" index_of "$base" >/dev/null && continue if [ "$PRUNE" != 1 ]; then stale=$((stale + 1)) continue fi if [ "$DRY_RUN" = 1 ]; then say " · 将从 ${TARGET_LABELS[$t]} 移除 ${base}(已不在收藏里)" removed=$((removed + 1)) continue fi # 先记下另一个目标里链到这个目录的符号链接(移走之后它们就失效了) real="$(cd "$dir" && pwd -P)" local linked=() for ((u = 0; u < ${#KNOWN_TARGET_DIRS[@]}; u++)); do other="${KNOWN_TARGET_DIRS[$u]}/$base" if [ -L "$other" ] && [ "$(cd "$other" 2>/dev/null && pwd -P)" = "$real" ]; then linked+=("$other") fi done if move_to_backup "$dir" "${TARGET_KEYS[$t]}" "$base"; then say " - 已从 ${TARGET_LABELS[$t]} 移除 ${base}(已不在收藏里)" removed=$((removed + 1)) for other in ${linked[@]+"${linked[@]}"}; do move_to_backup "$other" 'links' "$base" || true done else fail "${base}:从 ${TARGET_LABELS[$t]} 移除失败" failed=$((failed + 1)) fi done done say '' local summary="新装 ${installed},更新 ${updated},已是最新 ${unchanged},跳过 ${skipped},失败 ${failed}" [ "$PRUNE" = 1 ] && summary="${summary},移除 ${removed}" if [ "$DRY_RUN" = 1 ]; then say "${BOLD}试运行结束${RESET}(没有改动任何文件):${summary}" else say "${BOLD}完成${RESET}:${summary}" if [ $((installed + updated)) -gt 0 ]; then info 'ChatGPT 约 10 秒内自动发现新 Skill;Claude Code 正在运行的会话里看不到的话,输入 /reload-skills 或重开一次。' fi fi if [ "$stale" -gt 0 ]; then info "有 ${stale} 处由本脚本装过的 Skill 已不在收藏里,加 --prune 可以清理(先备份)。" fi [ "$failed" -eq 0 ] } { main "$@"; }