Files
dingtalk-workspace-cli/scripts/install-skills.sh
T

876 lines
38 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <dir>
# Moves <dir> into $HOME/.dws/skill-backups/<stamp>/<name> 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
}
# 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
}
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 <victim> <manifest>
# 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 <published-manifest> <backup-manifest>
# 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 ]
}
# publish_skill_cache <source> <cache-dir>
# 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
}
# ~/.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.
is_universal_agent_dir() {
case "$1" in
".config/agents/skills"|".gemini/antigravity/skills"|".gemini/antigravity-cli/skills"|".codex/skills"|".cursor/skills"|".deepagents/agent/skills"|".firebender/skills"|".gemini/skills"|".copilot/skills"|".config/opencode/skills"|".github/skills"|".windsurf/skills"|".cline/skills"|".amp/skills") return 0 ;;
*) return 1 ;;
esac
}
# Exact upstream registry (76 IDs): id|classification|effective-global-root.
# `-` means no global directory; `.agents/skills` means canonical-direct.
readonly 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
}
agent_skill_dirs() {
printf '%s\n' \
".config/agents/skills" ".gemini/antigravity/skills" ".gemini/antigravity-cli/skills" \
".codex/skills" ".cursor/skills" ".deepagents/agent/skills" ".firebender/skills" \
".gemini/skills" ".copilot/skills" ".config/opencode/skills" \
".aider-desk/skills" ".astrbot/data/skills" ".autohand/skills" ".augment/skills" \
".bob/skills" ".claude/skills" ".openclaw/skills" ".codeartsdoer/skills" \
".codebuddy/skills" ".codemaker/skills" ".codestudio/skills" ".commandcode/skills" \
".continue/skills" ".snowflake/cortex/skills" ".config/crush/skills" \
".config/devin/skills" ".factory/skills" ".forge/skills" ".config/goose/skills" \
".grok/skills" ".hermes/skills" ".inferencesh/skills" ".jazz/skills" ".junie/skills" \
".iflow/skills" ".kilocode/skills" ".config/kimchi/harness/skills" ".kiro/skills" \
".kode/skills" ".lingma/skills" ".mcpjam/skills" ".minimax/skills" ".vibe/skills" \
".moxby/skills" ".mux/skills" ".openhands/skills" ".ona/skills" ".pi/agent/skills" \
".qoder/skills" ".qoder-cn/skills" ".qwen/skills" ".reasonix/skills" \
".rovodev/skills" ".roo/skills" ".tabnine/agent/skills" ".terramind/skills" \
".tinycloud/skills" ".trae/skills" ".trae-cn/skills" ".codeium/windsurf/skills" \
".zcode/skills" ".zencoder/skills" ".neovate/skills" ".pochi/skills" ".adal/skills" \
".qoderwork/skills" ".github/skills" ".windsurf/skills" ".cline/skills" ".amp/skills"
}
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
_sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1
_sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || 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() {
_lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"
_lcs_canonical="$_lcs_root/.agents/skills"
mkdir -p "$_lcs_base" || return 1
same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
_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_backups" || { rm -rf "$_lcs_stage"; return 1; }
: > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; }
if [ "$_lcs_mode" = "mono" ]; then
_lcs_names="dws"
else
_lcs_names=""
for _lcs_skill in "$_lcs_canonical"/*/; do
[ -f "${_lcs_skill}SKILL.md" ] || continue
_lcs_names="$_lcs_names $(basename "$_lcs_skill")"
done
fi
_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<nt?"/":""); if(out=="")out="."; print out }')"
ln -s "$_lcs_link_target" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_publish_names="$_lcs_publish_names $_lcs_name"
done
for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do
[ -e "$_lcs_victim" ] || [ -L "$_lcs_victim" ] || continue
[ "$_lcs_victim" = "$_lcs_stage" ] && continue
same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue
if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then continue; fi
if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then
restore_multi_skill_set /dev/null "$_lcs_backups" || true
rm -rf "$_lcs_stage"; return 1
fi
done
for _lcs_name in $_lcs_publish_names; do
printf '%s\n' "$_lcs_base/$_lcs_name" >> "$_lcs_published" || {
restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1;
}
if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then
restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
printf ' ↪ Skills → %s\n' "$_lcs_base/$_lcs_name"
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
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" || failed=$((failed + 1))
continue
fi
if link_canonical_skills_to_base "$root" "$base_dir" multi; 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 [ "$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
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" || failed=$((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 [ "$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"
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
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