#!/bin/sh set -eu # Install DWS agent skills from GitHub Releases into agent skill directories. # Usage: # curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh # # Downloads dws-skills.zip from GitHub Releases and copies it under each target # path using the same rules as build/npm/install.js installSkillsToHomes # (AGENT_DIRS + parent-directory gate), with root defaulting to the current # directory. Set DWS_SKILLS_ROOT=$HOME to match npm install layout exactly. # # Environment variables (optional): # DWS_VERSION — release tag (default: latest) # DWS_SKILLS_ROOT — base path for agent dirs (default: $PWD) # DWS_SKILL_MODE — mono | multi (default: multi) # DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the # Gitee API instead of GitHub (China mirror) REPO="DingTalk-Real-AI/dingtalk-workspace-cli" # China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via Gitee API. GITEE_REPO="${DWS_GITEE_REPO:-}" # Auto-fallback Gitee mirror used when GitHub is unreachable (see pick_source). GITEE_FALLBACK_REPO="${DWS_GITEE_FALLBACK_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}" VERSION="${DWS_VERSION:-latest}" SKILL_NAME="dws" ROOT="${DWS_SKILLS_ROOT:-$PWD}" DWS_CACHE_ROOT="${DWS_CACHE_ROOT:-$HOME/.dws}" DWS_STATE_ROOT="${DWS_CONFIG_DIR:-$DWS_CACHE_ROOT}" MANAGED_SKILL_DIGEST_SCOPE="skill-directory-v1" SKILL_MODE="$(printf '%s' "${DWS_SKILL_MODE:-multi}" | tr '[:upper:]' '[:lower:]')" case "$SKILL_MODE" in mono|multi) ;; *) printf '❌ Invalid DWS_SKILL_MODE=%s. Use mono or multi.\n' "${DWS_SKILL_MODE:-}" >&2; exit 1 ;; esac # ── Helpers ────────────────────────────────────────────────────────────────── need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then printf '❌ Missing required command: %s\n' "$1" >&2 exit 1 fi } # backup_and_remove_skill_dir # Moves into $HOME/.dws/skill-backups// instead of # destroying it (non-interactive installs cannot confirm, so removals must # stay reversible). Missing paths are a no-op success. On any backup failure # the directory is left in place and a non-zero status is returned so callers # skip that target rather than silently deleting data. DWS_LAST_SKILL_BACKUP="" backup_and_remove_skill_dir() { _bed_dir="$1" DWS_LAST_SKILL_BACKUP="" [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0 _bed_root="${HOME}/.dws/skill-backups" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_name="$(basename "$_bed_dir")" _bed_target="$_bed_root/$_bed_stamp/$_bed_name" _bed_i=1 while [ -e "$_bed_target" ] || [ -L "$_bed_target" ]; do _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" _bed_i=$((_bed_i + 1)) if [ "$_bed_i" -gt 1000 ]; then printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$_bed_dir" return 1 fi done mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir" return 1 } if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then DWS_LAST_SKILL_BACKUP="$_bed_target" printf ' × 已备份并移除 %s → %s\n' "$_bed_dir" "$_bed_target" return 0 fi printf ' ⚠️ 备份失败,保留原目录 %s\n' "$_bed_dir" return 1 } # prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup # directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep / # pruneSkillBackups. Best-effort: a removal failure is reported, never fatal. # Only safe to call once every backup manifest of this run has been consumed, # because a single transaction can span more than one timestamp. DWS_SKILL_BACKUP_KEEP=5 # is_skill_backup_stamp accepts only directory names DWS itself writes: UTC # YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in # the backup root is foreign (user data, unrelated tooling) and is preserved # so pruning can never remove a directory it cannot prove DWS created. is_skill_backup_stamp() { case "$1" in [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) return 0 ;; [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip # those 16 chars and require the remainder to be all digits. _isbs_suffix="${1#????????????????}" case "$_isbs_suffix" in ""|*[!0-9]*) return 1 ;; esac return 0 ;; esac return 1 } prune_skill_backups() { _psb_root="${HOME}/.dws/skill-backups" [ -d "$_psb_root" ] || return 0 _psb_total=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_total=$((_psb_total + 1)) done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) _psb_seen=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_seen=$((_psb_seen + 1)) [ "$_psb_seen" -le "$_psb_drop" ] || break rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry" done } # A dingtalk-* prefix alone is not ownership evidence: market/user skills may # use it too. Ownership comes from the centralized skills-state.json. is_managed_multi_skill_dir() { _managed_dir="$1" _managed_name="$(basename "$_managed_dir")" is_legacy_official_multi_skill_name "$_managed_name" && return 0 [ -f "$DWS_STATE_ROOT/skills-state.json" ] || return 1 _managed_json_name="$(json_escape "$_managed_name")" _managed_compact='"name":"'"$_managed_json_name"'"' _managed_spaced='"name": "'"$_managed_json_name"'"' DWS_MANAGED_COMPACT="$_managed_compact" DWS_MANAGED_SPACED="$_managed_spaced" awk ' /^[[:space:]]*"managed_skills"[[:space:]]*:[[:space:]]*\[[[:space:]]*$/ { inside = 1; next } inside && /^[[:space:]]*\][[:space:]]*,?[[:space:]]*$/ { closed = 1; exit } inside && (index($0, ENVIRON["DWS_MANAGED_COMPACT"]) || index($0, ENVIRON["DWS_MANAGED_SPACED"])) { found = 1 } END { exit !(closed && found) } ' "$DWS_STATE_ROOT/skills-state.json" } # Frozen exact names shipped before centralized ownership metadata. Never replace this # with a dingtalk-* prefix check: user/market Skills may use that prefix. is_legacy_official_multi_skill_name() { case "$1" in dingtalk-agoal|dingtalk-aiapp|dingtalk-aisearch|dingtalk-aitable|dingtalk-attendance|dingtalk-calendar|dingtalk-chat|dingtalk-contact|dingtalk-dev|dingtalk-devapp|dingtalk-devdoc|dingtalk-ding|dingtalk-doc|dingtalk-drive|dingtalk-event|dingtalk-hrbrain|dingtalk-live|dingtalk-mail|dingtalk-markdown|dingtalk-minutes|dingtalk-misc|dingtalk-oa|dingtalk-pat|dingtalk-profile|dingtalk-report|dingtalk-shared|dingtalk-sheet|dingtalk-skill|dingtalk-todo|dingtalk-wiki|dws-shared) return 0 ;; esac return 1 } json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } sha256_stdin() { if command -v sha256sum >/dev/null 2>&1; then sha256sum | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 | awk '{print $1}' elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 | awk '{print $NF}' else return 1 fi } verify_release_asset_checksum() { _checksum_asset="$1" _checksum_path="$2" _checksum_dir="$3" _checksum_url="$(asset_url checksums.txt)" [ -n "$_checksum_url" ] || { printf '❌ Could not resolve checksums.txt for %s.\n' "$VERSION" >&2; return 1; } curl -fsSL "$_checksum_url" -o "$_checksum_dir/checksums.txt" || { printf '❌ Could not download checksums.txt for %s; refusing unverified %s.\n' "$VERSION" "$_checksum_asset" >&2 return 1 } _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" [ -n "$_checksum_expected" ] || { printf '❌ %s is missing from checksums.txt.\n' "$_checksum_asset" >&2; return 1; } _checksum_actual="$(sha256_stdin < "$_checksum_path")" || { printf '❌ Could not compute SHA256 for %s; install sha256sum, shasum, or openssl.\n' "$_checksum_asset" >&2 return 1 } [ "$_checksum_actual" = "$_checksum_expected" ] || { printf '❌ SHA256 checksum mismatch for %s. Expected %s, got %s.\n' "$_checksum_asset" "$_checksum_expected" "$_checksum_actual" >&2 return 1 } printf ' ✅ SHA256 checksum verified: %s\n' "$_checksum_asset" } digest_skill_dir() { _digest_dir="$1" _digest="$({ find "$_digest_dir" -type f -print | LC_ALL=C sort | while IFS= read -r _digest_file; do _digest_rel="${_digest_file#"$_digest_dir"/}" printf '%s\0' "$_digest_rel" cat "$_digest_file" printf '\0' done } | sha256_stdin)" || return 1 printf 'sha256:%s' "$_digest" } write_skills_state() { _state_multi="$1" _state_source="$2" mkdir -p "$DWS_STATE_ROOT" || return 1 _state_tmp="$(mktemp "$DWS_STATE_ROOT/.skills-state.XXXXXX")" || return 1 _state_version="$(json_escape "$VERSION")" _state_names="" for _state_dir in "$_state_multi"/*/; do [ -f "${_state_dir}SKILL.md" ] || continue _state_names="${_state_names}$(basename "$_state_dir")\n" done { printf '{\n "version": "%s",\n' "$_state_version" for _state_field in official_skills updated_skills; do printf ' "%s": [' "$_state_field" _state_first=1 printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do [ -n "$_state_name" ] || continue [ "$_state_first" -eq 1 ] || printf ', ' printf '"%s"' "$(json_escape "$_state_name")" _state_first=0 done printf '],\n' done printf ' "managed_skills": [\n' _state_first=1 printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do [ -n "$_state_name" ] || continue _state_digest="$(digest_skill_dir "$_state_multi/$_state_name")" || exit 1 [ "$_state_first" -eq 1 ] || printf ',\n' printf ' {"name":"%s","version":"%s","source":"%s","digest":"%s","digest_scope":"%s"}' "$(json_escape "$_state_name")" "$_state_version" "$_state_source" "$_state_digest" "$MANAGED_SKILL_DIGEST_SCOPE" _state_first=0 done printf '\n ],\n "updated_at": "%s"\n}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > "$_state_tmp" || { rm -f "$_state_tmp"; return 1; } mv "$_state_tmp" "$DWS_STATE_ROOT/skills-state.json" } # backup_and_record_skill_dir # Records exact original/backup pairs so a multi-set transaction can restore # earlier moves when any later backup or publication fails. backup_and_record_skill_dir() { _bars_victim="$1" _bars_manifest="$2" backup_and_remove_skill_dir "$_bars_victim" || return 1 if [ -n "$DWS_LAST_SKILL_BACKUP" ]; then if ! printf '%s\n%s\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP" >> "$_bars_manifest"; then mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || printf ' ⚠️ 备份记录失败且无法自动恢复: %s(备份位于 %s)\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP" return 1 fi fi } # restore_multi_skill_set # Removes partial new publications, then restores every old directory from # its exact backup path. Paths containing newlines are outside the supported # installer path contract; spaces are preserved. restore_multi_skill_set() { _rms_published="$1" _rms_backups="$2" _rms_ok=1 if [ -f "$_rms_published" ]; then while IFS= read -r _rms_dest; do [ -n "$_rms_dest" ] || continue rm -rf "$_rms_dest" || _rms_ok=0 done < "$_rms_published" fi if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -n "$_rms_backup" ] || continue if [ -e "$_rms_original" ] || [ -L "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup" _rms_ok=0 fi done < "$_rms_backups" fi [ "$_rms_ok" -eq 1 ] } # A link publication manifest stores destination/target/inode triples. Rollback # only removes links that still have the identity created by this transaction; # a path concurrently replaced with a file, directory, or new link is preserved. skill_link_inode() { _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1 printf '%s\n' "$_sli_entry" | awk '{print $1}' } skill_link_matches() { [ -L "$1" ] || return 1 _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 [ "$_slm_target" = "$2" ] || return 1 _slm_expected_inode="${3-}" [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ] } restore_linked_skill_set() { _rls_published="$1" _rls_backups="$2" _rls_ok=1 if [ -f "$_rls_published" ]; then while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do [ -n "$_rls_dest" ] || continue if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then rm -f "$_rls_dest" || _rls_ok=0 else printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_rls_dest" _rls_ok=0 fi done < "$_rls_published" fi restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 [ "$_rls_ok" -eq 1 ] } cleanup_nested_staged_link() { _cnsl_nested="$1/$2" _cnsl_target="$3" _cnsl_inode="${4-}" if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then rm -f "$_cnsl_nested" fi } # publish_skill_cache # Stages a complete sibling cache before publishing it. Any copy or publish # failure leaves the previous cache in place (or in the reported recovery dir # when even restoration fails). publish_skill_cache() { _psc_src="$1" _psc_cache="$2" _psc_parent="$(dirname "$_psc_cache")" _psc_name="$(basename "$_psc_cache")" _psc_stage="" _psc_old="" mkdir -p "$_psc_parent" || return 1 _psc_stage="$(mktemp -d "$_psc_parent/.${_psc_name}.tmp.XXXXXX")" || return 1 if ! cp -R "$_psc_src/." "$_psc_stage/" 2>/dev/null && \ ! cp -r "$_psc_src/." "$_psc_stage/" 2>/dev/null; then rm -rf "$_psc_stage" return 1 fi if [ -e "$_psc_cache" ]; then _psc_old="$(mktemp -d "$_psc_parent/.${_psc_name}.old.XXXXXX")" || { rm -rf "$_psc_stage" return 1 } rmdir "$_psc_old" || { rm -rf "$_psc_stage" "$_psc_old" return 1 } if ! mv "$_psc_cache" "$_psc_old"; then rm -rf "$_psc_stage" return 1 fi fi if mv "$_psc_stage" "$_psc_cache"; then if [ -n "$_psc_old" ] && ! rm -rf "$_psc_old"; then printf ' ⚠️ 新 Skill 缓存已生效,但旧缓存清理失败: %s\n' "$_psc_old" fi return 0 fi rm -rf "$_psc_stage" if [ -n "$_psc_old" ] && ! mv "$_psc_old" "$_psc_cache"; then printf ' ⚠️ Skill 缓存发布失败,原缓存保留在 %s\n' "$_psc_old" fi return 1 } # Fetch a Gitee API endpoint, retrying transient 502/503 from Gitee's gateway. gitee_api() { _url="$1" _try=1 while [ "$_try" -le 4 ]; do if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then printf '%s' "$_resp" return 0 fi _try=$((_try + 1)) sleep 2 done return 1 } pick_source() { [ -n "$GITEE_REPO" ] && return 0 [ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0 curl -fsS --connect-timeout 5 --max-time 12 -o /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0 GITEE_REPO="$GITEE_FALLBACK_REPO" printf ' ⚠ GitHub 不可达,自动切换国内 Gitee 镜像: %s\n' "$GITEE_REPO" } resolve_version() { if [ "$VERSION" = "latest" ]; then if [ -n "$GITEE_REPO" ]; then # Gitee's /releases/latest and /releases endpoints are unreliable, so # resolve the newest vN.N.N tag from the git tags endpoint instead. VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \ | grep -o '"name":[ ]*"v[0-9][0-9.]*"' \ | sed 's/.*"name":[ ]*"//;s/"$//' \ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ | sort -V | tail -1)" else VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \ | grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')" fi if [ -z "$VERSION" ]; then printf '❌ Could not determine the latest version. Set DWS_VERSION explicitly.\n' >&2 exit 1 fi fi } # Resolve a release asset's download URL by name (GitHub template vs Gitee API). asset_url() { _name="$1" if [ -z "$GITEE_REPO" ]; then printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}" return 0 fi gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \ | tr '}' '\n' \ | grep "\"name\":[ ]*\"${_name}\"" \ | grep -o '"browser_download_url":[ ]*"[^"]*"' \ | head -1 | sed 's/.*"browser_download_url":[ ]*"//;s/"$//' } extract_zip() { archive="$1" dest="$2" if command -v unzip >/dev/null 2>&1; then unzip -q "$archive" -d "$dest" return 0 fi if command -v tar >/dev/null 2>&1 && tar -xf "$archive" -C "$dest" >/dev/null 2>&1; then return 0 fi printf '❌ Missing required command: unzip (or tar with zip support)\n' >&2 exit 1 } # One-line summary copy (2nd+ targets). _copy_skill_summary() { _src="$1" _dest="$2" _label="$3" if [ -d "$_dest" ]; then backup_and_remove_skill_dir "$_dest" || { printf ' ⚠️ 跳过 %s(保留原目录)\n' "$_dest" return 1 } fi mkdir -p "$_dest" || return 1 if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then printf ' ⚠️ Skill 复制失败,目标未计为安装成功: %s\n' "$_dest" return 1 fi file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')" printf ' ✅ Skills → %s (%s files)\n' "$_label" "$file_count" } # Full copy with top-level listing (1st target). _copy_skill() { _src="$1" _dest="$2" _label="$3" if [ -d "$_dest" ]; then backup_and_remove_skill_dir "$_dest" || { printf ' ⚠️ 跳过 %s(保留原目录)\n' "$_dest" return 1 } fi mkdir -p "$_dest" || return 1 if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then printf ' ⚠️ Skill 复制失败,目标未计为安装成功: %s\n' "$_dest" return 1 fi file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')" printf ' ✅ Skills → %s (%s files)\n' "$_label" "$file_count" for entry in "$_dest"/*; do entry_name="$(basename "$entry")" if [ -d "$entry" ]; then sub_count="$(find "$entry" -type f | wc -l | tr -d ' ')" printf ' 📁 %s/ (%s files)\n' "$entry_name" "$sub_count" else printf ' 📄 %s\n' "$entry_name" fi done } # multi_tree_has_skills returns 0 only when the given multi bundle directory # contains at least one product skill (a subdirectory with a SKILL.md). An # empty or corrupt multi/ tree must never select the multi branch: installing # it would delete existing dws/ + dingtalk-* skills and lay down nothing. # (Go bundleSkillNames and install.js multiTreeHasSkills guard the same way.) multi_tree_has_skills() { _dir="$1" [ -d "$_dir" ] || return 1 for _sub in "$_dir"/*/; do if [ -f "${_sub}SKILL.md" ]; then return 0 fi done return 1 } # Exact upstream registry (76 IDs): id|classification|effective-global-root. # `-` means no global directory; `.agents/skills` means canonical-direct. # Not readonly: the library must stay re-sourceable inside one shell. DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' upstream_agent_registry() { for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done } # DWS-only compatibility roots, same id|classification|root format. Qoderwork # stays a real non-universal install target; the other four are global paths # older DWS installers wrote by mistake, so they are retired like universal # roots. Kept separate so the upstream pin above stays byte-comparable. DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills' legacy_agent_roots() { for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done } # ~/.agents/skills is the canonical store under the universal convention. # Universal Agents read it directly; other Agents receive relative links and # fall back to copies only when links are unavailable. # # Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned # registries above, so a registry edit changes real install behaviour instead of # only a test count, and pin/behaviour drift is impossible by construction. is_universal_agent_dir() { for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do [ "${_iua_record##*|}" = "$1" ] || continue case "$_iua_record" in *"|U|"*) return 0 ;; esac done return 1 } # Effective global roots, in pinned registry order, de-duplicated. Records # without a global root (`-`) and canonical-direct records (`.agents/skills`) # are not per-Agent targets and are skipped. agent_skill_dirs() { { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' ' $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }' } resolve_agent_skill_base() { _ras_root="$1"; _ras_agent="$2" case "$_ras_agent" in ".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;; ".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;; ".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;; ".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;; ".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;; ".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;; ".openclaw/skills") for _ras_name in .openclaw .clawdbot .moltbot; do [ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; } done ;; ".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;; ".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;; ".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;; ".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;; ".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;; ".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;; esac printf '%s\n' "$_ras_root/$_ras_agent" } agent_skill_base_detected() { _asbd_agent="$1"; _asbd_base="$2" case "$_asbd_agent" in ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; *) [ -d "$(dirname "$_asbd_base")" ] ;; esac } same_physical_skill_root() { [ -d "$1" ] && [ -d "$2" ] || return 1 # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved # directory into the command substitution, which would silently report two # identical roots as different. _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 [ "$_sps_left" = "$_sps_right" ] } retire_agent_skill_root() { _rgs_root="$1" _rgs_base="$2" _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1 _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then continue fi if ! backup_and_record_skill_dir "$_rgs_victim" "$_rgs_backups"; then restore_multi_skill_set /dev/null "$_rgs_backups" || true rm -rf "$_rgs_stage" return 1 fi done rm -rf "$_rgs_stage" } # link_canonical_skills_to_base [bundle-src] # is the installed multi bundle and is REQUIRED in multi mode: the # link set must come from the bundle, never from the whole canonical store. # ~/.agents/skills is now shared, so third-party/user Skills live there too and # must not be published into every detected Agent root (they are absent from # skills-state.json, so nothing could ever reclaim those links). Go # publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list # the same way. link_canonical_skills_to_base() { _lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"; _lcs_bundle="${4-}" _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 if [ "$_lcs_mode" = "mono" ]; then _lcs_names="dws" else [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1 _lcs_names="" for _lcs_skill in "$_lcs_bundle"/*/; do [ -f "${_lcs_skill}SKILL.md" ] || continue _lcs_names="$_lcs_names $(basename "$_lcs_skill")" done [ -n "$_lcs_names" ] || return 1 fi # Collision pre-flight: the victim loop below deliberately refuses to back up # entries that are not DWS-managed, and publication must never replace them. # Report the exact colliding paths before touching anything, so the fall back # to the legacy full-copy layout is observable and actionable. _lcs_collision=0 for _lcs_name in $_lcs_names; do _lcs_dest="$_lcs_base/$_lcs_name" { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue [ "$_lcs_name" = "dws" ] && continue is_managed_multi_skill_dir "$_lcs_dest" && continue printf ' ⚠️ %s 已存在且不是 DWS 安装的 Skill,无法在此建立指向 %s 的共享链接\n' "$_lcs_dest" "$_lcs_canonical/$_lcs_name" _lcs_collision=1 done if [ "$_lcs_collision" -eq 1 ]; then printf ' ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;\n' printf ' 移走或改名后重新运行安装即可切换为共享 %s 布局。\n' "$_lcs_canonical" return 1 fi _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" _lcs_stage_token="$(basename "$_lcs_stage")" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } _lcs_publish_names="" for _lcs_name in $_lcs_names; do if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null)" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } # The staged symlink keeps its inode across the rename below (the staging # directory is a sibling inside the same Agent root), so the identity # recorded in the publication manifest stays exact. _lcs_inode="$(skill_link_inode "$_lcs_staged")" || { restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 } # Anything still present here appeared after the pre-flight scan, i.e. a # concurrent writer. Never let `mv` clobber it. if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi # Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is # absent from BusyBox `ln`, which silently degraded every non-universal # Agent to the copy layout on Alpine and most containers). install-event.sh # and install-devapp.sh already publish this way. The staged basename stays # unique, so a destination a concurrent process turned into a directory can # only receive our link and post-validation removes exactly that. if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 fi printf ' ↪ Skills → %s\n' "$_lcs_dest" done rm -rf "$_lcs_stage" } # Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD). install_multi_skills_to_root() { multi_src="$1" root="$2" installed=0 attempted=1 failed=0 retire_failed=0 if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; } for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) continue fi if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then installed=$((installed + 1)) else if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir" installed=$((installed + 1)) else failed=$((failed + 1)) fi fi done if [ "$installed" -eq 0 ]; then printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n' return 1 fi if [ "$retire_failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装失败\n' "$failed" return 1 fi write_skills_state "$multi_src" "install-skills.sh" || return 1 printf ' ✅ DWS Skills 安装完成\n' printf ' 统一安装位置:%s/.agents/skills\n' "$root" printf ' 已自动适配本机上检测到的 Agent\n' printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n' } _install_multi_to_base() { _msrc="$1" _base="$2" _root="$3" _agent_dir="$4" mkdir -p "$_base" || return 1 # Build the complete replacement set before moving any Agent-visible # directory. The manifests remain inside the private staging directory. _ms_stage="$(mktemp -d "$_base/.dws-multi-set.XXXXXX")" || return 1 _ms_backups="$_ms_stage/.backups" _ms_published="$_ms_stage/.published" : > "$_ms_backups" || { rm -rf "$_ms_stage"; return 1; } : > "$_ms_published" || { rm -rf "$_ms_stage"; return 1; } for skill_dir in "$_msrc"/*/; do [ -f "${skill_dir}SKILL.md" ] || continue _name="$(basename "$skill_dir")" _ms_staged_skill="$_ms_stage/$_name" mkdir -p "$_ms_staged_skill" || { rm -rf "$_ms_stage"; return 1; } if ! cp -R "$skill_dir/." "$_ms_staged_skill/" 2>/dev/null && ! cp -r "$skill_dir/." "$_ms_staged_skill/"; then rm -rf "$_ms_stage" return 1 fi done # Mutual exclusion: back up + remove the mono leftover. if ! backup_and_record_skill_dir "$_base/$SKILL_NAME" "$_ms_backups"; then restore_multi_skill_set "$_ms_published" "$_ms_backups" || true rm -rf "$_ms_stage" return 1 fi # Back up + remove stale, proven DWS-managed skills not in the new bundle. # Never infer ownership from the dingtalk-* prefix alone. for existing in "$_base"/*/; do [ -d "$existing" ] || continue _name="$(basename "$existing")" if is_managed_multi_skill_dir "$existing" && [ ! -f "$_msrc/$_name/SKILL.md" ]; then if ! backup_and_record_skill_dir "$existing" "$_ms_backups"; then restore_multi_skill_set "$_ms_published" "$_ms_backups" || true rm -rf "$_ms_stage" return 1 fi fi done if [ -d "$_base/dws-shared" ] && [ ! -f "$_msrc/dws-shared/SKILL.md" ]; then if ! backup_and_record_skill_dir "$_base/dws-shared" "$_ms_backups"; then restore_multi_skill_set "$_ms_published" "$_ms_backups" || true rm -rf "$_ms_stage" return 1 fi fi # Back up all replaced skills as one logical operation. Any failure restores # every earlier move before this target reports failure. for skill_dir in "$_msrc"/*/; do [ -f "${skill_dir}SKILL.md" ] || continue _name="$(basename "$skill_dir")" _dest="$_base/$_name" if ! backup_and_record_skill_dir "$_dest" "$_ms_backups"; then restore_multi_skill_set "$_ms_published" "$_ms_backups" || true rm -rf "$_ms_stage" return 1 fi done _count=0 for skill_dir in "$_msrc"/*/; do [ -f "${skill_dir}SKILL.md" ] || continue _name="$(basename "$skill_dir")" _dest="$_base/$_name" printf '%s\n' "$_dest" >> "$_ms_published" || { restore_multi_skill_set "$_ms_published" "$_ms_backups" || true rm -rf "$_ms_stage" return 1 } if ! mv "$_ms_stage/$_name" "$_dest"; then printf ' ⚠️ multi Skill 集合发布失败,正在恢复原集合: %s\n' "$_dest" restore_multi_skill_set "$_ms_published" "$_ms_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n' rm -rf "$_ms_stage" return 1 fi _count=$((_count + 1)) done rm -rf "$_ms_stage" || return 1 if [ "$_root" = "$HOME" ]; then _label="~/$_agent_dir/" else _label="$_root/$_agent_dir/" fi printf ' ✅ Skills → %s (%s product skills)\n' "$_label" "$_count" } # Publish mono and all mutually-exclusive managed multi directories as one # transaction. The complete dws/ tree is staged before any visible directory # moves; any later backup or publish failure restores the exact old set. _install_mono_to_base() { _mono_src="$1" _mono_base="$2" _mono_label="$3" mkdir -p "$_mono_base" || return 1 _mono_stage="$(mktemp -d "$_mono_base/.dws-mono-set.XXXXXX")" || return 1 _mono_backups="$_mono_stage/.backups" _mono_published="$_mono_stage/.published" : > "$_mono_backups" || { rm -rf "$_mono_stage"; return 1; } : > "$_mono_published" || { rm -rf "$_mono_stage"; return 1; } mkdir -p "$_mono_stage/$SKILL_NAME" || { rm -rf "$_mono_stage"; return 1; } if ! cp -R "$_mono_src/." "$_mono_stage/$SKILL_NAME/" 2>/dev/null && ! cp -r "$_mono_src/." "$_mono_stage/$SKILL_NAME/"; then rm -rf "$_mono_stage" return 1 fi if ! backup_and_record_skill_dir "$_mono_base/$SKILL_NAME" "$_mono_backups"; then restore_multi_skill_set "$_mono_published" "$_mono_backups" || true rm -rf "$_mono_stage" return 1 fi for existing in "$_mono_base"/*/; do [ -d "$existing" ] || continue is_managed_multi_skill_dir "$existing" || continue if ! backup_and_record_skill_dir "$existing" "$_mono_backups"; then restore_multi_skill_set "$_mono_published" "$_mono_backups" || true rm -rf "$_mono_stage" return 1 fi done _mono_dest="$_mono_base/$SKILL_NAME" printf '%s\n' "$_mono_dest" >> "$_mono_published" || { restore_multi_skill_set "$_mono_published" "$_mono_backups" || true rm -rf "$_mono_stage" return 1 } if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then printf ' ⚠️ mono Skill 集合发布失败,正在恢复原集合: %s\n' "$_mono_dest" restore_multi_skill_set "$_mono_published" "$_mono_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n' rm -rf "$_mono_stage" return 1 fi rm -rf "$_mono_stage" || return 1 _mono_count="$(find "$_mono_dest" -type f | wc -l | tr -d ' ')" printf ' ✅ Skills → %s (%s files)\n' "$_mono_label" "$_mono_count" } # Same semantics as build/npm/install.js installSkillsToHomes (root = DWS_SKILLS_ROOT or PWD). install_skills_to_root() { skill_src="$1" root="$2" installed=0 attempted=1 failed=0 retire_failed=0 if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; } for agent_dir in $(agent_skill_dirs) do base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" agent_skill_base_detected "$agent_dir" "$base_dir" || continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) if is_universal_agent_dir "$agent_dir"; then retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) continue fi if link_canonical_skills_to_base "$root" "$base_dir" mono; then installed=$((installed + 1)) else if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir" installed=$((installed + 1)) else failed=$((failed + 1)) fi fi done if [ "$installed" -eq 0 ]; then printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n' return 1 fi if [ "$retire_failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装 mono Skill 失败\n' "$failed" return 1 fi rm -f "$DWS_STATE_ROOT/skills-state.json" printf ' ✅ DWS Skills 安装完成\n' printf ' 统一安装位置:%s/.agents/skills\n' "$root" printf ' 已自动适配本机上检测到的 Agent\n' printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n' } # ── Main ───────────────────────────────────────────────────────────────────── main() { need_cmd curl pick_source resolve_version printf '\n' printf ' ┌──────────────────────────────────────┐\n' printf ' │ DWS Skill Installer │\n' printf ' │ DingTalk Workspace CLI │\n' printf ' └──────────────────────────────────────┘\n' printf '\n' TMPDIR_WORK="$(mktemp -d)" trap 'rm -rf "$TMPDIR_WORK"' EXIT INT TERM ASSET_URL="$(asset_url dws-skills.zip)" [ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; } printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION" curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip" verify_release_asset_checksum "dws-skills.zip" "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK" extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted" # Prefer the explicit mono/ subtree; fall back to legacy nested or zip root. SKILL_SRC="$TMPDIR_WORK/extracted" if [ -d "$TMPDIR_WORK/extracted/mono" ] && [ -f "$TMPDIR_WORK/extracted/mono/SKILL.md" ]; then SKILL_SRC="$TMPDIR_WORK/extracted/mono" elif [ -f "$TMPDIR_WORK/extracted/${SKILL_NAME}/SKILL.md" ]; then SKILL_SRC="$TMPDIR_WORK/extracted/${SKILL_NAME}" fi printf '\n' printf ' Installing under root: %s (mode: %s)\n' "$ROOT" "$SKILL_MODE" # Multi first: a release may ship only the multi/ tree without the root # mono copy, so the mono SKILL.md gate must never block a multi install. # An empty/corrupt multi/ tree (no */SKILL.md) falls back to mono with a # warning — installing it would wipe existing skills and lay down nothing. if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$TMPDIR_WORK/extracted/multi"; then install_multi_skills_to_root "$TMPDIR_WORK/extracted/multi" "$ROOT" else if [ "$SKILL_MODE" = "multi" ]; then printf ' ⚠️ Multi skill tree not found or empty in release asset; falling back to mono.\n' fi if [ ! -f "$SKILL_SRC/SKILL.md" ]; then printf ' ❌ Skill source not found in release asset\n' >&2 exit 1 fi install_skills_to_root "$SKILL_SRC" "$ROOT" fi # Cache multi/ (and a mono copy) under ~/.dws/skills so that subsequent # `dws skill setup --mode multi|mono` invocations can find a source. An # empty/corrupt tree must never wipe a previously good cache. if multi_tree_has_skills "$TMPDIR_WORK/extracted/multi"; then cache_dir="${DWS_CACHE_ROOT}/skills/multi" if publish_skill_cache "$TMPDIR_WORK/extracted/multi" "$cache_dir"; then file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')" printf ' ✅ Cached multi skills → %s (%s files)\n' "$cache_dir" "$file_count" else printf ' ⚠️ Multi Skill 缓存刷新失败,未覆盖原缓存: %s\n' "$cache_dir" fi fi if [ -f "$SKILL_SRC/SKILL.md" ]; then mono_cache="${DWS_CACHE_ROOT}/skills/mono" if ! publish_skill_cache "$SKILL_SRC" "$mono_cache"; then printf ' ⚠️ Mono Skill 缓存刷新失败,未覆盖原缓存: %s\n' "$mono_cache" fi fi # Every transaction of this run has finished, so old stamped archives can no # longer be needed for a rollback. prune_skill_backups printf '\n' printf ' 📖 Skill includes:\n' printf ' • SKILL.md — Main skill with product overview and intent routing\n' printf ' • references/ — Detailed product command references\n' printf ' • scripts/ — Batch operation scripts for all products\n' printf '\n' printf ' ⚡ Requires: dws CLI installed and on $PATH\n' printf ' Install: go install github.com/%s/cmd@latest\n' "$REPO" printf '\n' } main