1222 lines
42 KiB
Bash
Executable File
1222 lines
42 KiB
Bash
Executable File
#!/bin/sh
|
||
# Copyright 2026 Alibaba Group
|
||
# Licensed under the Apache License, Version 2.0
|
||
#
|
||
# Installer for dws (DingTalk Workspace CLI).
|
||
# Downloads the pre-built binary from GitHub Releases and installs agent skills.
|
||
# No Go, Node.js, or other dependencies required.
|
||
#
|
||
# Usage:
|
||
# curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||
#
|
||
# Environment variables (all optional):
|
||
# DWS_INSTALL_DIR — where to put the binary (default: ~/.local/bin)
|
||
# DWS_VERSION — version to install (default: latest)
|
||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary)
|
||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else multi)
|
||
# DWS_GITEE_REPO — "owner/repo" on Gitee; when set, version + assets resolve
|
||
# via the Gitee API instead of GitHub (China mirror)
|
||
#
|
||
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
|
||
|
||
set -eu
|
||
|
||
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
|
||
BIN_NAME="dws"
|
||
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via
|
||
# the Gitee API (https://gitee.com/api/v5) instead of GitHub. Gitee attachment URLs
|
||
# carry an unstable numeric id, so every asset is resolved by name at install time.
|
||
GITEE_REPO="${DWS_GITEE_REPO:-}"
|
||
# Auto-fallback: when DWS_GITEE_REPO is not set, the installer probes GitHub and,
|
||
# if it is unreachable (typical in mainland China), automatically switches to this
|
||
# Gitee mirror — so a plain `curl … | sh` works everywhere with no env var.
|
||
# Set DWS_NO_FALLBACK=1 to disable the probe and force GitHub.
|
||
GITEE_FALLBACK_REPO="${DWS_GITEE_FALLBACK_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
|
||
INSTALL_NAME="${DWS_INSTALL_NAME:-$BIN_NAME}"
|
||
VERSION="${DWS_VERSION:-latest}"
|
||
NO_SKILLS="${DWS_NO_SKILLS:-0}"
|
||
SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}"
|
||
SKILL_STATE_ROOT="${DWS_CONFIG_DIR:-$HOME/.dws}"
|
||
SKILL_NAME="dws"
|
||
SKILL_MODE=""
|
||
MANAGED_SKILL_DIGEST_SCOPE="skill-directory-v1"
|
||
|
||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
say() {
|
||
printf ' %s\n' "$@"
|
||
}
|
||
|
||
err() {
|
||
printf ' ❌ %s\n' "$@" >&2
|
||
exit 1
|
||
}
|
||
|
||
need_cmd() {
|
||
if ! command -v "$1" >/dev/null 2>&1; then
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# 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=""
|
||
[ -d "$_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" ]; do
|
||
_bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
|
||
_bed_i=$((_bed_i + 1))
|
||
if [ "$_bed_i" -gt 1000 ]; then
|
||
say " ⚠️ 备份目录冲突,保留原目录 $_bed_dir"
|
||
return 1
|
||
fi
|
||
done
|
||
mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || {
|
||
say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir"
|
||
return 1
|
||
}
|
||
if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then
|
||
DWS_LAST_SKILL_BACKUP="$_bed_target"
|
||
say " × 已备份并移除 $_bed_dir → $_bed_target"
|
||
return 0
|
||
fi
|
||
say " ⚠️ 备份失败,保留原目录 $_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 "$SKILL_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) }
|
||
' "$SKILL_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 need_cmd sha256sum; then
|
||
sha256sum | awk '{print $1}'
|
||
elif need_cmd shasum; then
|
||
shasum -a 256 | awk '{print $1}'
|
||
elif need_cmd openssl; 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"
|
||
_state_root="$SKILL_STATE_ROOT"
|
||
mkdir -p "$_state_root" || return 1
|
||
_state_tmp="$(mktemp "$_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_name="$(basename "$_state_dir")"
|
||
_state_names="${_state_names}${_state_name}\n"
|
||
done
|
||
{
|
||
printf '{\n "version": "%s",\n' "$_state_version"
|
||
printf ' "official_skills": ['
|
||
_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 "updated_skills": ['
|
||
_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 "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" "$_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 || say " ⚠️ 备份记录失败且无法自动恢复: $_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" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
|
||
say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)"
|
||
_rms_ok=0
|
||
fi
|
||
done < "$_rms_backups"
|
||
fi
|
||
[ "$_rms_ok" -eq 1 ]
|
||
}
|
||
|
||
# publish_skill_cache <source> <cache-dir>
|
||
# Copies a complete cache into a sibling staging directory, then publishes it
|
||
# with rename. Copy/publish failures retain the previous cache; if restoration
|
||
# itself fails, the recovery directory is reported and left untouched.
|
||
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
|
||
say " ⚠️ 新 Skill 缓存已生效,但旧缓存清理失败: $_psc_old"
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
rm -rf "$_psc_stage"
|
||
if [ -n "$_psc_old" ] && ! mv "$_psc_old" "$_psc_cache"; then
|
||
say " ⚠️ Skill 缓存发布失败,原缓存保留在 $_psc_old"
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
resolve_source_root() {
|
||
script_path="$0"
|
||
if [ ! -f "$script_path" ]; then
|
||
return 1
|
||
fi
|
||
|
||
script_dir="$(CDPATH= cd -- "$(dirname -- "$script_path")" && pwd)"
|
||
candidate_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
|
||
if [ -f "$candidate_root/go.mod" ] && [ -d "$candidate_root/cmd" ]; then
|
||
printf '%s\n' "$candidate_root"
|
||
return 0
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
# Download a URL to a file. Uses curl or wget, whichever is available.
|
||
download() {
|
||
url="$1"
|
||
dest="$2"
|
||
if need_cmd curl; then
|
||
curl -fsSL "$url" -o "$dest"
|
||
elif need_cmd wget; then
|
||
wget -qO "$dest" "$url"
|
||
else
|
||
err "Neither curl nor wget found. Please install one and retry."
|
||
fi
|
||
}
|
||
|
||
# Fetch a Gitee API endpoint, retrying transient failures. Gitee's gateway
|
||
# returns sporadic 502/503, so a single curl is unreliable; retry a few times
|
||
# before giving up. Prints the response body on success.
|
||
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
|
||
}
|
||
|
||
# Resolve the download URL of a release asset by file name.
|
||
# GitHub: deterministic template <repo>/releases/download/<version>/<file>.
|
||
# Gitee : query the release API and pick the asset whose name matches
|
||
# (Gitee attachment URLs carry an unstable numeric id, so we never
|
||
# template them — we read browser_download_url straight from the 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 need_cmd unzip; then
|
||
unzip -q "$archive" -d "$dest"
|
||
return 0
|
||
fi
|
||
if need_cmd tar && tar -xf "$archive" -C "$dest" >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# Detect OS
|
||
detect_os() {
|
||
os="$(uname -s)"
|
||
case "$os" in
|
||
Linux*) echo "linux" ;;
|
||
Darwin*) echo "darwin" ;;
|
||
MINGW*|MSYS*|CYGWIN*) echo "windows" ;;
|
||
*) err "Unsupported OS: $os. Use the PowerShell installer on Windows." ;;
|
||
esac
|
||
}
|
||
|
||
# Detect architecture
|
||
detect_arch() {
|
||
arch="$(uname -m)"
|
||
case "$arch" in
|
||
x86_64|amd64) echo "amd64" ;;
|
||
arm64|aarch64) echo "arm64" ;;
|
||
*) err "Unsupported architecture: $arch" ;;
|
||
esac
|
||
}
|
||
|
||
# Decide the download source. An explicit DWS_GITEE_REPO always wins. Otherwise
|
||
# probe GitHub Releases; if it is unreachable (typical in mainland China), switch
|
||
# GITEE_REPO to the mirror so every subsequent resolve/download uses Gitee.
|
||
pick_source() {
|
||
[ -n "$GITEE_REPO" ] && return 0
|
||
[ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0
|
||
if need_cmd curl; then
|
||
curl -fsS --connect-timeout 5 --max-time 12 -o /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
|
||
elif need_cmd wget; then
|
||
wget -q --timeout=12 --tries=1 -O /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
|
||
fi
|
||
GITEE_REPO="$GITEE_FALLBACK_REPO"
|
||
say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: ${GITEE_REPO}"
|
||
}
|
||
|
||
# Resolve the latest version tag from GitHub
|
||
resolve_version() {
|
||
if [ "$VERSION" = "latest" ]; then
|
||
if [ -n "$GITEE_REPO" ]; then
|
||
# Gitee's /releases/latest and /releases endpoints are unreliable — they
|
||
# return 404 / an empty list even when releases exist — so resolve the
|
||
# newest version from the git tags endpoint instead: keep only vN.N.N
|
||
# tags and pick the highest by version order.
|
||
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)"
|
||
elif need_cmd curl; then
|
||
# Follow the redirect from /releases/latest to get the tag
|
||
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
|
||
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
|
||
elif need_cmd wget; then
|
||
VERSION="$(wget --spider --max-redirect=0 "$LATEST_URL" 2>&1 \
|
||
| grep -i 'Location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
|
||
fi
|
||
if [ -z "$VERSION" ]; then
|
||
err "Could not determine the latest version. Set DWS_VERSION explicitly."
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# ── Banner ───────────────────────────────────────────────────────────────────
|
||
|
||
print_banner() {
|
||
printf '\n'
|
||
say "┌──────────────────────────────────────┐"
|
||
say "│ DWS Installer │"
|
||
say "│ DingTalk Workspace CLI │"
|
||
say "└──────────────────────────────────────┘"
|
||
printf '\n'
|
||
}
|
||
|
||
# ── Skill Mode Resolution ────────────────────────────────────────────────────
|
||
#
|
||
# Priority (highest first):
|
||
# 1. DWS_SKILL_MODE env var (mono | multi, case-insensitive)
|
||
# 2. Interactive prompt when both stdin and stdout are TTYs (default: multi)
|
||
# 3. Fallback: multi (non-TTY without env var, e.g. curl | sh)
|
||
resolve_skill_mode() {
|
||
if [ -n "${DWS_SKILL_MODE:-}" ]; then
|
||
raw="$DWS_SKILL_MODE"
|
||
# Lower-case without bash-specific ${var,,}; tr is POSIX.
|
||
normalized="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
|
||
case "$normalized" in
|
||
mono|multi)
|
||
SKILL_MODE="$normalized"
|
||
say "Skill mode: ${SKILL_MODE} (from DWS_SKILL_MODE)"
|
||
return 0
|
||
;;
|
||
*)
|
||
err "Invalid DWS_SKILL_MODE='${raw}'. Use 'mono' or 'multi'."
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
if [ -t 0 ] && [ -t 1 ]; then
|
||
printf '\n'
|
||
say "Select skill installation mode:"
|
||
say " 1) multi (default) — split each product into its own skill (dingtalk-*)"
|
||
say " 2) mono — install one bundled dws skill (legacy)"
|
||
printf ' Choice [1]: '
|
||
read choice || choice=""
|
||
case "$choice" in
|
||
""|1|multi) SKILL_MODE="multi" ;;
|
||
2|mono) SKILL_MODE="mono" ;;
|
||
*)
|
||
say "Unrecognized choice '${choice}', defaulting to multi."
|
||
SKILL_MODE="multi"
|
||
;;
|
||
esac
|
||
say "Skill mode: ${SKILL_MODE}"
|
||
return 0
|
||
fi
|
||
|
||
SKILL_MODE="multi"
|
||
}
|
||
|
||
install_binary_from_source() {
|
||
root="$1"
|
||
|
||
need_cmd go || err "Missing required command: go"
|
||
need_cmd make || err "Missing required command: make"
|
||
|
||
say "Installing dws from source checkout: ${root}"
|
||
say "Install dir: ${INSTALL_DIR}"
|
||
|
||
# Build using make (produces ./dws in the project root)
|
||
make -C "$root" build
|
||
|
||
built_bin="$root/$BIN_NAME"
|
||
if [ ! -f "$built_bin" ]; then
|
||
err "make build did not produce ${built_bin}"
|
||
fi
|
||
|
||
mkdir -p "$INSTALL_DIR"
|
||
cp "$built_bin" "$INSTALL_DIR/$INSTALL_NAME"
|
||
chmod +x "$INSTALL_DIR/$INSTALL_NAME"
|
||
|
||
say "✅ Binary installed:"
|
||
say " → ${INSTALL_DIR}/${INSTALL_NAME}"
|
||
}
|
||
|
||
# ── Install Skills from Local Source ─────────────────────────────────────────
|
||
|
||
install_skills_local() {
|
||
root="$1"
|
||
skill_src="${root}/skills/mono"
|
||
multi_src="${root}/skills/multi"
|
||
|
||
if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$multi_src"; then
|
||
say ""
|
||
say "📦 Installing agent skills (multi) from local source: ${multi_src}"
|
||
install_multi_skills_to_homes "$multi_src"
|
||
else
|
||
if [ "$SKILL_MODE" = "multi" ]; then
|
||
say "⚠️ Multi skill tree not found or empty at ${multi_src}; falling back to mono."
|
||
fi
|
||
if [ ! -d "$skill_src" ]; then
|
||
say "⚠️ Local skills directory not found: ${skill_src}"
|
||
say " Skipping skills installation."
|
||
return 1
|
||
fi
|
||
|
||
say ""
|
||
say "📦 Installing agent skills from local source: ${skill_src}"
|
||
|
||
install_skills_to_homes "$skill_src"
|
||
fi
|
||
|
||
# Cache multi source for later `dws skill setup --mode multi`.
|
||
if [ -d "$multi_src" ]; then
|
||
cache_multi_skills "$multi_src"
|
||
fi
|
||
|
||
# Also cache a mono copy so `dws skill setup --mode mono` has a fallback
|
||
# under ~/.dws/skills/mono when invoked without --source.
|
||
cache_mono_skills "$skill_src"
|
||
|
||
return 0
|
||
}
|
||
|
||
# cache_multi_skills copies the multi/ tree (per-product skills) into
|
||
# ~/.dws/skills/multi/ so that `dws skill setup --mode multi` can find a
|
||
# source without needing the source checkout or a re-download.
|
||
cache_multi_skills() {
|
||
src="$1"
|
||
cache_dir="${HOME}/.dws/skills/multi"
|
||
|
||
# Never let an empty/corrupt multi/ tree wipe a previously good cache.
|
||
multi_tree_has_skills "$src" || return 0
|
||
|
||
if ! publish_skill_cache "$src" "$cache_dir"; then
|
||
say "⚠️ Multi Skill 缓存刷新失败,未覆盖原缓存: ${cache_dir}"
|
||
return 0
|
||
fi
|
||
|
||
file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')"
|
||
case "$cache_dir" in
|
||
"$HOME"/*) label="~/${cache_dir#$HOME/}" ;;
|
||
*) label="$cache_dir" ;;
|
||
esac
|
||
say "✅ Cached multi skills → ${label} (${file_count} files)"
|
||
}
|
||
|
||
# cache_mono_skills mirrors cache_multi_skills for the mono tree. Keeping the
|
||
# two modes symmetrical means `dws skill setup` can fall back to ~/.dws/skills
|
||
# regardless of which mode the user picks later.
|
||
cache_mono_skills() {
|
||
src="$1"
|
||
cache_dir="${HOME}/.dws/skills/mono"
|
||
|
||
# Only refresh when the new bundle actually carries a mono tree — a
|
||
# multi-only bundle must never wipe a previously good mono cache.
|
||
if [ ! -f "$src/SKILL.md" ]; then
|
||
return 0
|
||
fi
|
||
|
||
if ! publish_skill_cache "$src" "$cache_dir"; then
|
||
say "⚠️ Mono Skill 缓存刷新失败,未覆盖原缓存: ${cache_dir}"
|
||
fi
|
||
}
|
||
|
||
# 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
|
||
say " ⚠️ mono Skill 集合发布失败,正在恢复原集合: $_mono_dest"
|
||
restore_multi_skill_set "$_mono_published" "$_mono_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
|
||
rm -rf "$_mono_stage"
|
||
return 1
|
||
fi
|
||
rm -rf "$_mono_stage" || return 1
|
||
_mono_count="$(find "$_mono_dest" -type f | wc -l | tr -d ' ')"
|
||
say "✅ Skills → ${_mono_label} (${_mono_count} files)"
|
||
}
|
||
|
||
# Move DWS-owned copies out of the generic root once a concrete Agent root is
|
||
# active. This prevents Agents such as Codex from discovering the same Skill
|
||
# through both ~/.agents/skills and ~/.codex/skills.
|
||
retire_generic_skill_root() {
|
||
_rgs_root="$1"
|
||
_rgs_base="$_rgs_root/.agents/skills"
|
||
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.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
|
||
[ -d "$_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"
|
||
}
|
||
|
||
# Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
|
||
# Installing mono removes proven DWS-managed multi leftovers for mutual exclusion,
|
||
# mirroring `dws skill setup --mode mono`.
|
||
install_skills_to_homes() {
|
||
skill_src="$1"
|
||
root="${HOME}"
|
||
installed=0
|
||
attempted=0
|
||
failed=0
|
||
idx=0
|
||
specific_agents=0
|
||
for specific_dir in \
|
||
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
|
||
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
|
||
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
|
||
".trae/skills" ".openclaw/skills" ".hermes/skills"
|
||
do
|
||
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
|
||
done
|
||
for agent_dir in \
|
||
".agents/skills" \
|
||
".claude/skills" \
|
||
".cursor/skills" \
|
||
".qoder/skills" \
|
||
".qoderwork/skills" \
|
||
".gemini/skills" \
|
||
".codex/skills" \
|
||
".zcode/skills" \
|
||
".github/skills" \
|
||
".windsurf/skills" \
|
||
".augment/skills" \
|
||
".cline/skills" \
|
||
".amp/skills" \
|
||
".kiro/skills" \
|
||
".trae/skills" \
|
||
".openclaw/skills" \
|
||
".hermes/skills"
|
||
do
|
||
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
|
||
idx=$((idx + 1))
|
||
continue
|
||
fi
|
||
base_dir="$root/$agent_dir"
|
||
parent_gate="$(dirname "$base_dir")"
|
||
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
|
||
idx=$((idx + 1))
|
||
continue
|
||
fi
|
||
attempted=$((attempted + 1))
|
||
case "$root" in
|
||
"$HOME")
|
||
label="~/$agent_dir/$SKILL_NAME"
|
||
;;
|
||
*)
|
||
label="$root/$agent_dir/$SKILL_NAME"
|
||
;;
|
||
esac
|
||
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
|
||
installed=$((installed + 1))
|
||
else
|
||
failed=$((failed + 1))
|
||
fi
|
||
idx=$((idx + 1))
|
||
done
|
||
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
|
||
retire_generic_skill_root "$root" || failed=$((failed + 1))
|
||
fi
|
||
if [ "$attempted" -eq 0 ]; then
|
||
case "$root" in
|
||
"$HOME")
|
||
flabel="~/.agents/skills/$SKILL_NAME"
|
||
;;
|
||
*)
|
||
flabel="$root/.agents/skills/$SKILL_NAME"
|
||
;;
|
||
esac
|
||
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
|
||
installed=$((installed + 1))
|
||
else
|
||
failed=$((failed + 1))
|
||
fi
|
||
fi
|
||
if [ "$installed" -eq 0 ]; then
|
||
say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"
|
||
return 1
|
||
fi
|
||
if [ "$failed" -gt 0 ]; then
|
||
say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败"
|
||
return 1
|
||
fi
|
||
rm -f "$SKILL_STATE_ROOT/skills-state.json"
|
||
}
|
||
|
||
# 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
|
||
}
|
||
|
||
# Install the multi skill bundle (one subdirectory per product skill) into all
|
||
# agent homes as sibling directories, mirroring `dws skill setup --mode multi`.
|
||
# Mutual exclusion: the mono leftover (<home>/dws) and stale DWS-managed Skills
|
||
# not present in the new bundle are removed first.
|
||
install_multi_skills_to_homes() {
|
||
multi_src="$1"
|
||
root="${HOME}"
|
||
installed=0
|
||
attempted=0
|
||
failed=0
|
||
idx=0
|
||
specific_agents=0
|
||
for specific_dir in \
|
||
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
|
||
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
|
||
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
|
||
".trae/skills" ".openclaw/skills" ".hermes/skills"
|
||
do
|
||
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
|
||
done
|
||
for agent_dir in \
|
||
".agents/skills" \
|
||
".claude/skills" \
|
||
".cursor/skills" \
|
||
".qoder/skills" \
|
||
".qoderwork/skills" \
|
||
".gemini/skills" \
|
||
".codex/skills" \
|
||
".zcode/skills" \
|
||
".github/skills" \
|
||
".windsurf/skills" \
|
||
".augment/skills" \
|
||
".cline/skills" \
|
||
".amp/skills" \
|
||
".kiro/skills" \
|
||
".trae/skills" \
|
||
".openclaw/skills" \
|
||
".hermes/skills"
|
||
do
|
||
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
|
||
idx=$((idx + 1))
|
||
continue
|
||
fi
|
||
base_dir="$root/$agent_dir"
|
||
parent_gate="$(dirname "$base_dir")"
|
||
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
|
||
idx=$((idx + 1))
|
||
continue
|
||
fi
|
||
attempted=$((attempted + 1))
|
||
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
|
||
installed=$((installed + 1))
|
||
else
|
||
failed=$((failed + 1))
|
||
say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)"
|
||
fi
|
||
idx=$((idx + 1))
|
||
done
|
||
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
|
||
retire_generic_skill_root "$root" || failed=$((failed + 1))
|
||
fi
|
||
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
|
||
installed=$((installed + 1))
|
||
fi
|
||
if [ "$installed" -eq 0 ]; then
|
||
say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"
|
||
return 1
|
||
fi
|
||
if [ "$failed" -gt 0 ]; then
|
||
say " ⚠️ 有 ${failed} 个 Agent 目标安装失败"
|
||
return 1
|
||
fi
|
||
write_skills_state "$multi_src" "install.sh" || return 1
|
||
}
|
||
|
||
_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
|
||
say " ⚠️ multi Skill 集合发布失败,正在恢复原集合: $_dest"
|
||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
|
||
rm -rf "$_ms_stage"
|
||
return 1
|
||
fi
|
||
_count=$((_count + 1))
|
||
done
|
||
rm -rf "$_ms_stage" || return 1
|
||
|
||
case "$_root" in
|
||
"$HOME") _label="~/$_agent_dir/" ;;
|
||
*) _label="$_root/$_agent_dir/" ;;
|
||
esac
|
||
say "✅ Skills → ${_label} (${_count} product skills)"
|
||
}
|
||
|
||
# One-line summary copy (used for 2nd+ agent targets).
|
||
_copy_skill_summary() {
|
||
_src="$1"
|
||
_dest="$2"
|
||
_label="$3"
|
||
|
||
if [ -d "$_dest" ]; then
|
||
backup_and_remove_skill_dir "$_dest" || {
|
||
say " ⚠️ 跳过 ${_dest}(保留原目录)"
|
||
return 1
|
||
}
|
||
fi
|
||
|
||
mkdir -p "$_dest" || return 1
|
||
if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then
|
||
say " ⚠️ Skill 复制失败,目标未计为安装成功: $_dest"
|
||
return 1
|
||
fi
|
||
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
|
||
|
||
say "✅ Skills → ${_label} (${file_count} files)"
|
||
}
|
||
|
||
# Helper: copy skill files to a destination and print details
|
||
_copy_skill() {
|
||
_src="$1"
|
||
_dest="$2"
|
||
_label="$3"
|
||
|
||
if [ -d "$_dest" ]; then
|
||
backup_and_remove_skill_dir "$_dest" || {
|
||
say " ⚠️ 跳过 ${_dest}(保留原目录)"
|
||
return 1
|
||
}
|
||
fi
|
||
|
||
mkdir -p "$_dest" || return 1
|
||
if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then
|
||
say " ⚠️ Skill 复制失败,目标未计为安装成功: $_dest"
|
||
return 1
|
||
fi
|
||
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
|
||
|
||
say "✅ Skills → ${_label} (${file_count} files)"
|
||
|
||
for entry in "$_dest"/*; do
|
||
entry_name="$(basename "$entry")"
|
||
if [ -d "$entry" ]; then
|
||
sub_count="$(find "$entry" -type f | wc -l | tr -d ' ')"
|
||
say " 📁 ${entry_name}/ (${sub_count} files)"
|
||
else
|
||
say " 📄 ${entry_name}"
|
||
fi
|
||
done
|
||
}
|
||
|
||
# ── Install Binary ───────────────────────────────────────────────────────────
|
||
|
||
install_binary() {
|
||
os="$(detect_os)"
|
||
arch="$(detect_arch)"
|
||
resolve_version
|
||
|
||
archive_name="${BIN_NAME}-${os}-${arch}.tar.gz"
|
||
download_url="$(asset_url "$archive_name")"
|
||
[ -n "$download_url" ] || err "Could not resolve download URL for ${archive_name} (version ${VERSION})."
|
||
|
||
say "⬇ Downloading ${BIN_NAME} ${VERSION} (${os}/${arch})..."
|
||
|
||
tmpdir="$(mktemp -d)"
|
||
trap 'rm -rf "$tmpdir"' EXIT INT TERM
|
||
|
||
download "$download_url" "$tmpdir/$archive_name"
|
||
|
||
# Download and verify SHA256 checksum
|
||
checksum_url="$(asset_url checksums.txt)"
|
||
if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then
|
||
expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")"
|
||
if [ -n "$expected" ]; then
|
||
if need_cmd sha256sum; then
|
||
actual="$(sha256sum "$tmpdir/$archive_name" | awk '{print $1}')"
|
||
elif need_cmd shasum; then
|
||
actual="$(shasum -a 256 "$tmpdir/$archive_name" | awk '{print $1}')"
|
||
else
|
||
actual=""
|
||
fi
|
||
if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then
|
||
err "SHA256 checksum mismatch! Expected ${expected}, got ${actual}. Aborting."
|
||
fi
|
||
if [ -n "$actual" ]; then
|
||
say "✅ SHA256 checksum verified"
|
||
else
|
||
say "⚠️ Could not compute checksum (sha256sum/shasum not found); skipping verification"
|
||
fi
|
||
else
|
||
say "⚠️ Archive not found in checksums.txt; skipping verification"
|
||
fi
|
||
else
|
||
say "⚠️ Could not download checksums.txt; skipping verification"
|
||
fi
|
||
|
||
say "📦 Extracting..."
|
||
tar xzf "$tmpdir/$archive_name" -C "$tmpdir"
|
||
|
||
mkdir -p "$INSTALL_DIR"
|
||
|
||
# The archive may contain a top-level directory or just the binary
|
||
if [ -f "$tmpdir/$BIN_NAME" ]; then
|
||
cp "$tmpdir/$BIN_NAME" "$INSTALL_DIR/$INSTALL_NAME"
|
||
elif [ -f "$tmpdir/${BIN_NAME}-${os}-${arch}/$BIN_NAME" ]; then
|
||
cp "$tmpdir/${BIN_NAME}-${os}-${arch}/$BIN_NAME" "$INSTALL_DIR/$INSTALL_NAME"
|
||
else
|
||
# Search for the binary
|
||
found="$(find "$tmpdir" -name "$BIN_NAME" -type f | head -1)"
|
||
if [ -n "$found" ]; then
|
||
cp "$found" "$INSTALL_DIR/$INSTALL_NAME"
|
||
else
|
||
err "Could not find the ${BIN_NAME} binary in the downloaded archive."
|
||
fi
|
||
fi
|
||
|
||
chmod +x "$INSTALL_DIR/$INSTALL_NAME"
|
||
|
||
say "✅ Binary installed: ${INSTALL_DIR}/${INSTALL_NAME}"
|
||
|
||
# Check if install dir is in PATH
|
||
case ":$PATH:" in
|
||
*":$INSTALL_DIR:"*) ;;
|
||
*)
|
||
say ""
|
||
say "⚠️ ${INSTALL_DIR} is not in your PATH."
|
||
say " Add it with:"
|
||
say " export PATH=\"${INSTALL_DIR}:\$PATH\""
|
||
say " Or add this line to your ~/.bashrc / ~/.zshrc"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# ── Install Skills ───────────────────────────────────────────────────────────
|
||
|
||
install_skills() {
|
||
say ""
|
||
say "📦 Installing agent skills from GitHub Releases..."
|
||
|
||
resolve_version
|
||
skills_archive="dws-skills.zip"
|
||
download_url="$(asset_url "$skills_archive")"
|
||
[ -n "$download_url" ] || err "Could not resolve download URL for ${skills_archive} (version ${VERSION})."
|
||
|
||
tmpdir_skills="$(mktemp -d)"
|
||
trap 'rm -rf "$tmpdir_skills"' EXIT INT TERM
|
||
|
||
if ! download "$download_url" "$tmpdir_skills/$skills_archive" 2>/dev/null; then
|
||
say "⚠️ Release asset download failed. Trying local source..."
|
||
rm -rf "$tmpdir_skills"
|
||
local_root="$(resolve_source_root || true)"
|
||
if [ -n "$local_root" ]; then
|
||
install_skills_local "$local_root"
|
||
return
|
||
else
|
||
err "Cannot download skills from GitHub and no local source checkout found."
|
||
fi
|
||
fi
|
||
|
||
extract_root="$tmpdir_skills/skills"
|
||
mkdir -p "$extract_root"
|
||
if ! extract_zip "$tmpdir_skills/$skills_archive" "$extract_root" 2>/dev/null; then
|
||
say "⚠️ Could not extract release skill archive. Install unzip, or retry from a source checkout."
|
||
rm -rf "$tmpdir_skills"
|
||
local_root="$(resolve_source_root || true)"
|
||
if [ -n "$local_root" ]; then
|
||
install_skills_local "$local_root"
|
||
return
|
||
fi
|
||
err "Cannot extract release skill archive and no local source checkout found."
|
||
fi
|
||
|
||
# New release layout puts mono content both at the zip root (for backward
|
||
# compatibility with older installers) and under ./mono/, with multi/ as a
|
||
# sibling. Prefer ./mono/ when present so we never miss SKILL.md, then fall
|
||
# back to the legacy nested $SKILL_NAME/ shape, then the zip root.
|
||
skill_src="$extract_root"
|
||
if [ -d "$extract_root/mono" ] && [ -f "$extract_root/mono/SKILL.md" ]; then
|
||
skill_src="$extract_root/mono"
|
||
elif [ -f "$extract_root/$SKILL_NAME/SKILL.md" ]; then
|
||
skill_src="$extract_root/$SKILL_NAME"
|
||
fi
|
||
# 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 "$extract_root/multi"; then
|
||
install_multi_skills_to_homes "$extract_root/multi"
|
||
else
|
||
if [ "$SKILL_MODE" = "multi" ]; then
|
||
say "⚠️ Multi skill tree not found or empty in release asset; falling back to mono."
|
||
fi
|
||
if [ ! -f "$skill_src/SKILL.md" ]; then
|
||
say "⚠️ Skills not found in release asset. Trying local source..."
|
||
rm -rf "$tmpdir_skills"
|
||
local_root="$(resolve_source_root || true)"
|
||
if [ -n "$local_root" ]; then
|
||
install_skills_local "$local_root"
|
||
return
|
||
else
|
||
say "⚠️ No local source checkout found either. Skipping skills installation."
|
||
return
|
||
fi
|
||
fi
|
||
install_skills_to_homes "$skill_src"
|
||
fi
|
||
|
||
# Cache the multi tree (if present in the release asset) so a later
|
||
# `dws skill setup --mode multi` can find a source without re-downloading.
|
||
if [ -d "$extract_root/multi" ]; then
|
||
cache_multi_skills "$extract_root/multi"
|
||
fi
|
||
|
||
# And cache mono too for symmetry with --mode mono fallbacks.
|
||
cache_mono_skills "$skill_src"
|
||
|
||
rm -rf "$tmpdir_skills"
|
||
}
|
||
|
||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||
|
||
main() {
|
||
source_root=""
|
||
if [ "$SKILLS_ONLY" != "1" ] && [ "$VERSION" = "latest" ]; then
|
||
source_root="$(resolve_source_root || true)"
|
||
fi
|
||
|
||
print_banner
|
||
|
||
# Pick GitHub vs Gitee mirror (auto-fallback when GitHub is unreachable).
|
||
# Skipped when installing from a local source checkout (no download needed).
|
||
[ -z "$source_root" ] && pick_source
|
||
|
||
# Resolve skill mode only when we are actually going to touch skills.
|
||
if [ "$NO_SKILLS" != "1" ]; then
|
||
resolve_skill_mode
|
||
fi
|
||
|
||
if [ -n "$source_root" ]; then
|
||
install_binary_from_source "$source_root"
|
||
if [ "$NO_SKILLS" != "1" ]; then
|
||
install_skills_local "$source_root"
|
||
fi
|
||
elif [ "$SKILLS_ONLY" = "1" ]; then
|
||
local_root="$(resolve_source_root || true)"
|
||
if [ -n "$local_root" ]; then
|
||
install_skills_local "$local_root"
|
||
else
|
||
install_skills
|
||
fi
|
||
elif [ "$NO_SKILLS" = "1" ]; then
|
||
install_binary
|
||
else
|
||
install_binary
|
||
install_skills
|
||
fi
|
||
|
||
printf '\n'
|
||
say "🎉 Installation complete!"
|
||
say ""
|
||
say "Next steps:"
|
||
if [ "$SKILLS_ONLY" != "1" ]; then
|
||
say " ${BIN_NAME} version # verify installation"
|
||
say " ${BIN_NAME} auth login # authenticate with DingTalk"
|
||
fi
|
||
say " ${BIN_NAME} --help # explore commands"
|
||
printf '\n'
|
||
}
|
||
|
||
main
|