Files
dingtalk-workspace-cli/scripts/install.sh
T
修雨 df1e33442b fix(skill): embed skill sources in the binary and cover qoder dirs in upgrade refresh
Two distribution defects left installed skills stale (issue PeterGuy326#8):

1. skill setup resolved its source from cwd / exe-adjacent checkouts, so
   an upgraded binary happily re-installed whatever stale checkout it was
   run from, and agents kept routing 'create robot' into the chat dead end.
   The skills/mono and skills/multi trees are now embedded (go:embed) and
   setup defaults to that copy, materialized into ~/.dws/skills/<mode> —
   upgrading the binary upgrades what setup installs. --source and
   DWS_SKILL_SOURCE stay as explicit overrides, and an override that has
   no skill root errors out instead of silently falling back.

2. dws upgrade's skill refresh (knownSkillDirs) skipped ~/.qoder and
   ~/.qoderwork, so Qoder/QoderWork hosts never got refreshed routing.
   Added both to the dir table and its seven mirrors (npm install.js,
   install.sh/ps1, install-skills.sh, homebrew template, package test,
   verify-package-managers.sh).

setup-side detection and --target qoder/qoderwork already work on this
branch; this closes the remaining upgrade-side and source-side gaps.
2026-06-12 17:57:48 +08:00

613 lines
18 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 mono)
#
# 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"
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_NAME="dws"
SKILL_MODE=""
# ── 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
}
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
}
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
}
# Resolve the latest version tag from GitHub
resolve_version() {
if [ "$VERSION" = "latest" ]; then
# Follow the redirect from /releases/latest to get the tag
if need_cmd curl; then
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 "https://github.com/${REPO}/releases/latest" 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: mono)
# 3. Fallback: mono (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) mono — install one bundled dws skill (stable / recommended)"
say " 2) multi 🧪 EXPERIMENTAL — split each product into its own skill (preview; run 'dws skill setup --mode multi' afterwards)"
say " ⚠ multi is not yet stable — interface, naming and cross-skill references may change"
printf ' Choice [1]: '
read choice || choice=""
case "$choice" in
""|1|mono) SKILL_MODE="mono" ;;
2|multi) SKILL_MODE="multi" ;;
*)
say "Unrecognized choice '${choice}', defaulting to mono."
SKILL_MODE="mono"
;;
esac
say "Skill mode: ${SKILL_MODE}"
return 0
fi
SKILL_MODE="mono"
}
print_multi_mode_notice() {
say ""
say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped."
say " ⚠ multi is not yet stable. 20 product-scoped skills pass dispatch verifier,"
say " but interface, naming and cross-skill references may change in future releases."
say " For production / shared environments, use mono mode (--mode mono)."
say ""
say " To install split skills, run:"
say " ${BIN_NAME} skill setup --mode multi"
say " (One skill per product family; requires the dws binary installed above.)"
}
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 [ ! -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"
# 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"
if [ ! -d "$src" ]; then
return 0
fi
rm -rf "$cache_dir"
mkdir -p "$cache_dir"
cp -R "$src/"* "$cache_dir/" 2>/dev/null || cp -r "$src/"* "$cache_dir/" 2>/dev/null || true
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"
if [ ! -d "$src" ]; then
return 0
fi
rm -rf "$cache_dir"
mkdir -p "$cache_dir"
cp -R "$src/"* "$cache_dir/" 2>/dev/null || cp -r "$src/"* "$cache_dir/" 2>/dev/null || true
}
# Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
install_skills_to_homes() {
skill_src="$1"
root="${HOME}"
installed=0
idx=0
for agent_dir in \
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".github/skills" \
".windsurf/skills" \
".augment/skills" \
".cline/skills" \
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
continue
fi
dest="$base_dir/$SKILL_NAME"
case "$root" in
"$HOME")
label="~/$agent_dir/$SKILL_NAME"
;;
*)
label="$root/$agent_dir/$SKILL_NAME"
;;
esac
if [ "$installed" -eq 0 ]; then
_copy_skill "$skill_src" "$dest" "$label"
else
_copy_skill_summary "$skill_src" "$dest" "$label"
fi
installed=$((installed + 1))
idx=$((idx + 1))
done
if [ "$installed" -eq 0 ]; then
case "$root" in
"$HOME")
flabel="~/.agents/skills/$SKILL_NAME"
;;
*)
flabel="$root/.agents/skills/$SKILL_NAME"
;;
esac
_copy_skill "$skill_src" "$root/.agents/skills/$SKILL_NAME" "$flabel"
fi
}
# One-line summary copy (used for 2nd+ agent targets).
_copy_skill_summary() {
_src="$1"
_dest="$2"
_label="$3"
if [ -d "$_dest" ]; then
rm -rf "$_dest"
fi
mkdir -p "$_dest"
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
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
rm -rf "$_dest"
fi
mkdir -p "$_dest"
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
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="https://github.com/${REPO}/releases/download/${VERSION}/${archive_name}"
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="https://github.com/${REPO}/releases/download/${VERSION}/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="https://github.com/${REPO}/releases/download/${VERSION}/${skills_archive}"
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
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"
# 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
# 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
if [ "$SKILL_MODE" = "multi" ]; then
print_multi_mode_notice
else
install_skills_local "$source_root"
fi
fi
elif [ "$SKILLS_ONLY" = "1" ]; then
if [ "$SKILL_MODE" = "multi" ]; then
print_multi_mode_notice
else
local_root="$(resolve_source_root || true)"
if [ -n "$local_root" ]; then
install_skills_local "$local_root"
else
install_skills
fi
fi
elif [ "$NO_SKILLS" = "1" ]; then
install_binary
else
install_binary
if [ "$SKILL_MODE" = "multi" ]; then
print_multi_mode_notice
else
install_skills
fi
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