Files
john ce9787c694 fix(install): lock shared cache parent ancestry before mutation
Schema runtime cache shared-root installer hardening: validate and lock every parent directory before creation or chmod, and re-verify before the first permission change. Adds symlink-swap regression tests for non-sticky writable parents and sticky-parent acceptance.
2026-09-17 11:44:57 +08:00

2241 lines
95 KiB
Bash
Executable File
Raw Permalink 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
# 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=""
[ -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_stamp_root="$_bed_root/$_bed_stamp"
_bed_target="$_bed_stamp_root/$_bed_name"
_bed_i=1
# Bump not only when the payload path is taken but also when the stamp
# root exists without a verified ownership marker: a same-second foreign
# directory must never be stamped DWS-owned and made prunable. A
# marker-verified root from this run's same second stays reusable.
while [ -e "$_bed_target" ] || [ -L "$_bed_target" ] ||
{ [ -d "$_bed_stamp_root" ] && ! is_current_run_backup_stamp "$_bed_stamp_root" &&
[ "$(cat "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do
_bed_stamp_root="$_bed_root/$_bed_stamp-$_bed_i"
_bed_target="$_bed_stamp_root/$_bed_name"
_bed_i=$((_bed_i + 1))
if [ "$_bed_i" -gt 1000 ]; then
say " ⚠️ 备份目录冲突,保留原目录 $_bed_dir"
return 1
fi
done
_bed_stamp_root="$(dirname "$_bed_target")"
record_current_run_backup_stamp "$_bed_stamp_root"
# Freshness must be sampled before mkdir: the collision loop tests the
# payload path, so a second backup in the same stamp second reuses this
# root while a sibling payload from this run already lives in it.
_bed_fresh=1
[ ! -d "$_bed_stamp_root" ] || _bed_fresh=0
mkdir -p "$_bed_stamp_root" 2>/dev/null || {
say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir"
return 1
}
# Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a
# stamp-shaped name alone is not evidence, so pruning only ever removes
# roots carrying this marker — it must exist before any payload moves in.
printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || {
# Non-recursive cleanup, sibling protection: only a root this call
# created may be dropped, and only the marker plus an empty root (rmdir
# refuses a non-empty directory) — rm -rf here would destroy a completed
# same-second sibling backup whose original is already moved away.
if [ "$_bed_fresh" -eq 1 ]; then
rm -f "$_bed_stamp_root/.dws-skill-backup"
rmdir "$_bed_stamp_root" 2>/dev/null || true
fi
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
}
# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped
# backup directories from earlier runs under $HOME/.dws/skill-backups,
# matching Go's skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots
# carrying the .dws-skill-backup marker with the exact expected content are
# counted or removed: foreign data with a stamp-like name never consumes a
# keep slot and is never deleted, silently. Stamp directories created
# by the current process are never pruned (mirroring Go's run-root registry
# and install.js's currentRunBackupRoots), so a migration that retires more
# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal
# failure is reported, never fatal.
DWS_SKILL_BACKUP_KEEP=5
# Stamp directories created by this run, recorded by
# backup_and_remove_skill_dir so pruning can never delete a backup the
# running migration may still need to roll back to.
DWS_CURRENT_RUN_BACKUP_STAMPS=""
record_current_run_backup_stamp() {
case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
*" $1 "*) return 0 ;;
esac
DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1"
}
# is_current_run_backup_stamp reports whether the stamp root was created by
# this very process. Such a root is ours by construction and stays reusable
# even when its marker cannot be re-verified mid-run (for example a
# permission failure after the first payload moved in).
is_current_run_backup_stamp() {
case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
*" $1 "*) return 0 ;;
esac
return 1
}
# is_skill_backup_stamp accepts only directory names with the stamp shape DWS
# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix.
# Shape is necessary but not sufficient — pruning additionally verifies the
# .dws-skill-backup ownership marker — while any other entry in the backup
# root is foreign (user data, unrelated tooling) and is preserved so pruning
# can never remove a directory it cannot prove DWS created.
is_skill_backup_stamp() {
case "$1" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
return 0 ;;
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
# those 16 chars and require the remainder to be all digits.
_isbs_suffix="${1#????????????????}"
case "$_isbs_suffix" in
""|*[!0-9]*) return 1 ;;
esac
return 0 ;;
esac
return 1
}
prune_skill_backups() {
_psb_root="${HOME}/.dws/skill-backups"
[ -d "$_psb_root" ] || return 0
_psb_total=0
for _psb_entry in "$_psb_root"/*; do
[ -d "$_psb_entry" ] || continue
is_skill_backup_stamp "${_psb_entry##*/}" || continue
[ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
_psb_total=$((_psb_total + 1))
done
[ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0
_psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP))
for _psb_entry in "$_psb_root"/*; do
[ "$_psb_drop" -gt 0 ] || break
[ -d "$_psb_entry" ] || continue
is_skill_backup_stamp "${_psb_entry##*/}" || continue
[ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
*" $_psb_entry "*) continue ;;
esac
rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry"
_psb_drop=$((_psb_drop - 1))
done
}
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
# use it too. Ownership comes from the centralized skills-state.json.
is_managed_multi_skill_dir() {
_managed_dir="$1"
_managed_name="$(basename "$_managed_dir")"
is_legacy_official_multi_skill_name "$_managed_name" && return 0
[ -f "$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
}
# sha256_file <path>: content digest used by skill_dir_content_digest. Never
# emits on failure. Callers that capture its status directly detect that
# failure; skill_dir_content_digest cannot — see the pipeline caveat there:
# POSIX sh returns only the right-hand pipeline status, so a subshell exit on
# its left side is discarded and digest-failure detection stays best-effort.
sha256_file() {
if need_cmd sha256sum; then
sha256sum "$1" 2>/dev/null | awk '{print $1}'
elif need_cmd shasum; then
shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
elif need_cmd openssl; then
openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}'
else
return 1
fi
}
verify_release_asset_checksum() {
_checksum_asset="$1"
_checksum_path="$2"
_checksum_dir="$3"
_checksum_url="$(asset_url checksums.txt)"
[ -n "$_checksum_url" ] || err "Could not resolve checksums.txt for ${VERSION}."
download "$_checksum_url" "$_checksum_dir/checksums.txt" 2>/dev/null || \
err "Could not download checksums.txt for ${VERSION}; refusing unverified ${_checksum_asset}."
_checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")"
[ -n "$_checksum_expected" ] || err "${_checksum_asset} is missing from checksums.txt."
_checksum_actual="$(sha256_stdin < "$_checksum_path")" || \
err "Could not compute SHA256 for ${_checksum_asset}; install sha256sum, shasum, or openssl."
[ "$_checksum_actual" = "$_checksum_expected" ] || \
err "SHA256 checksum mismatch for ${_checksum_asset}. Expected ${_checksum_expected}, got ${_checksum_actual}."
say "✅ SHA256 checksum verified: ${_checksum_asset}"
}
digest_skill_dir() {
_digest_dir="$1"
_digest="$({
find "$_digest_dir" -type f -print | LC_ALL=C sort | while IFS= read -r _digest_file; do
_digest_rel="${_digest_file#"$_digest_dir"/}"
printf '%s\0' "$_digest_rel"
cat "$_digest_file"
printf '\0'
done
} | sha256_stdin)" || return 1
printf 'sha256:%s' "$_digest"
}
write_skills_state() {
_state_multi="$1"
_state_source="$2"
_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_quarantine_no_replace <quarantined> <dest>
# Puts an unmatched quarantined object back onto dest without replacing
# anything that occupied dest after the claim. Directories use mkdir-claim
# plus child moves; links are created at dest; files hard-link then drop
# the quarantine copy.
restore_quarantine_no_replace() {
_rqn_src="$1"
_rqn_dest="$2"
if [ -L "$_rqn_src" ]; then
_rqn_target="$(readlink "$_rqn_src")" || return 1
if ! ln -s "$_rqn_target" "$_rqn_dest" 2>/dev/null || ! [ -L "$_rqn_dest" ]; then
_rqn_nested="$_rqn_dest/${_rqn_target##*/}"
if [ -L "$_rqn_nested" ] && [ "$(readlink "$_rqn_nested" 2>/dev/null)" = "$_rqn_target" ]; then
rm -f "$_rqn_nested"
fi
return 1
fi
rm -f "$_rqn_src"
return 0
fi
if [ -d "$_rqn_src" ]; then
mkdir "$_rqn_dest" 2>/dev/null || return 1
_rqn_failed=0
for _rqn_child in "$_rqn_src"/* "$_rqn_src"/.[!.]* "$_rqn_src"/..?*; do
[ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue
if ! mv "$_rqn_child" "$_rqn_dest/"; then
_rqn_failed=1
break
fi
done
if [ "$_rqn_failed" -eq 0 ]; then
rmdir "$_rqn_src" 2>/dev/null || return 1
return 0
fi
for _rqn_child in "$_rqn_dest"/* "$_rqn_dest"/.[!.]* "$_rqn_dest"/..?*; do
[ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue
mv "$_rqn_child" "$_rqn_src/" || return 1
done
rmdir "$_rqn_dest" 2>/dev/null || return 1
return 1
fi
if [ -f "$_rqn_src" ]; then
# Shell `ln` treats an occupied directory as a container. Refuse that
# dest and drop any nested name instead of leaving a hardlink inside it.
if ! ln "$_rqn_src" "$_rqn_dest" 2>/dev/null || [ -d "$_rqn_dest" ]; then
_rqn_nested="$_rqn_dest/$(basename "$_rqn_src")"
[ -f "$_rqn_nested" ] && rm -f "$_rqn_nested"
return 1
fi
rm -f "$_rqn_src"
return 0
fi
return 1
}
# quarantine_retract_or_restore <dest> <expected-inode> [expected-link-target]
# Prove ownership at dest first (inode plus child names for directories).
# Quarantining before that check would move a concurrent replacement off its
# original path. Linux overlayfs recycles inodes, so inode alone is not
# enough — a dest whose children no longer match the recorded publication
# stays put. After a matching dest is claimed, identity is re-checked in
# quarantine; a mismatch is restored with no-replace.
quarantine_retract_or_restore() {
_qrr_dest="$1"
_qrr_inode="$2"
_qrr_link_target="${3-}"
if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then
return 0
fi
_qrr_owned=0
if [ -n "$_qrr_link_target" ]; then
if [ -L "$_qrr_dest" ] &&
[ "$(readlink "$_qrr_dest" 2>/dev/null)" = "$_qrr_link_target" ] &&
[ "$(skill_link_inode "$_qrr_dest")" = "$_qrr_inode" ]; then
_qrr_owned=1
fi
elif [ "$(skill_dir_identity "$_qrr_dest")" = "$_qrr_inode" ]; then
_qrr_owned=1
fi
if [ "$_qrr_owned" -eq 0 ]; then
say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest"
return 1
fi
_qrr_parent="$(dirname "$_qrr_dest")"
_qrr_base="$(basename "$_qrr_dest")"
_qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1
_qrr_payload="$_qrr_root/payload"
if ! mv "$_qrr_dest" "$_qrr_payload"; then
rmdir "$_qrr_root" 2>/dev/null || rm -rf "$_qrr_root"
if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then
return 0
fi
say " ⚠️ 无法隔离待回滚 Skill: $_qrr_dest"
return 1
fi
_qrr_owned=0
if [ -n "$_qrr_link_target" ]; then
if [ -L "$_qrr_payload" ] &&
[ "$(readlink "$_qrr_payload" 2>/dev/null)" = "$_qrr_link_target" ] &&
[ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then
_qrr_owned=1
fi
elif [ "$(skill_dir_identity "$_qrr_payload")" = "$_qrr_inode" ]; then
_qrr_owned=1
fi
if [ "$_qrr_owned" -eq 1 ]; then
rm -rf "$_qrr_root" || return 1
return 0
fi
if restore_quarantine_no_replace "$_qrr_payload" "$_qrr_dest"; then
rmdir "$_qrr_root" 2>/dev/null || true
say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest"
return 1
fi
say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest(并发对象保留于 $_qrr_payload)"
return 1
}
# restore_multi_skill_set <published-manifest> <backup-manifest>
# Removes partial new publications, then restores every old directory from
# its exact backup path. Publication manifest entries are <dest>:<inode>
# pairs recorded at publish time. Dest is claimed into quarantine first;
# identity is re-checked there so a concurrent replacement is renamed back
# instead of being deleted by 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_entry; do
[ -n "$_rms_entry" ] || continue
_rms_dest="${_rms_entry%:*}"
_rms_inode="${_rms_entry##*:}"
[ "$_rms_dest" != "$_rms_entry" ] || { say " ⚠️ 跳过格式异常的发布记录: $_rms_entry"; _rms_ok=0; continue; }
quarantine_retract_or_restore "$_rms_dest" "$_rms_inode" || _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 ! mkdir -p "$(dirname "$_rms_original")" || ! restore_quarantine_no_replace "$_rms_backup" "$_rms_original"; then
say " ⚠️ 无法恢复原 Skill: ${_rms_original}(备份保留于 ${_rms_backup})"
_rms_ok=0
fi
done < "$_rms_backups"
fi
[ "$_rms_ok" -eq 1 ]
}
# A link publication manifest stores destination/target/inode triples. Rollback
# only removes links that still have the identity created by this transaction;
# a path concurrently replaced with a file, directory, or new link is preserved.
skill_link_inode() {
_sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1
printf '%s\n' "$_sli_entry" | awk '{print $1}'
}
# Directory publication identity is inode, a stable child-name list, and a
# recursive content digest. Inode reuse on Linux overlayfs would otherwise
# make a concurrent directory look owned, and an in-place content edit after
# publish (same inode, same names) would otherwise be retracted as this
# transaction's object. The token has no colon so dest:identity manifest
# parsing stays dest="${entry%:*}" / identity="${entry##*:}".
skill_dir_identity() {
_sdi_inode="$(skill_link_inode "$1")" || return 1
if [ -L "$1" ] || [ ! -d "$1" ]; then
printf '%s\n' "$_sdi_inode"
return 0
fi
_sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')"
_sdi_digest="$(skill_dir_content_digest "$1")" || return 1
printf '%s,%s,%s\n' "$_sdi_inode" "$_sdi_names" "$_sdi_digest"
}
# Recursive content digest over a published tree: for each path (LC_ALL=C
# sorted, relative) emit path, type+permission bits from ls -ld, and either
# the sha256 of the file content or the symlink target. Sorted-path lines are
# piped through sha256_stdin, so one opaque token covers renames, mode flips,
# content edits and link retargets — the same in-place-edit guarantee the Go
# fingerprint provides. Failure detection is asymmetric and best-effort: with
# no hash tool installed sha256_stdin itself returns 1 and the identity
# genuinely fails, but an unreadable file only exits the left-hand subshell —
# /bin/sh has no pipefail, so the pipeline returns sha256_stdin's status and
# the digest is computed over a truncated stream instead of failing.
skill_dir_content_digest() {
(
cd "$1" 2>/dev/null || exit 1
find . -print 2>/dev/null |
LC_ALL=C sort |
while IFS= read -r _sdcd_path; do
_sdcd_mode="$(ls -ld "$_sdcd_path" 2>/dev/null | cut -c1-10)"
if [ -L "$_sdcd_path" ]; then
printf '%s|%s|link|%s\n' "$_sdcd_path" "$_sdcd_mode" "$(readlink "$_sdcd_path" 2>/dev/null)"
elif [ -d "$_sdcd_path" ]; then
printf '%s|%s|dir\n' "$_sdcd_path" "$_sdcd_mode"
elif [ -f "$_sdcd_path" ]; then
_sdcd_hash="$(sha256_file "$_sdcd_path")" || exit 1
printf '%s|%s|file|%s\n' "$_sdcd_path" "$_sdcd_mode" "$_sdcd_hash"
else
printf '%s|%s|other\n' "$_sdcd_path" "$_sdcd_mode"
fi
done
) | sha256_stdin
}
skill_link_matches() {
[ -L "$1" ] || return 1
_slm_target="$(readlink "$1" 2>/dev/null)" || return 1
[ "$_slm_target" = "$2" ] || return 1
_slm_expected_inode="${3-}"
[ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ]
}
restore_linked_skill_set() {
_rls_published="$1"
_rls_backups="$2"
_rls_ok=1
if [ -f "$_rls_published" ]; then
while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do
[ -n "$_rls_dest" ] || continue
quarantine_retract_or_restore "$_rls_dest" "$_rls_inode" "$_rls_target" || _rls_ok=0
done < "$_rls_published"
fi
restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0
[ "$_rls_ok" -eq 1 ]
}
cleanup_nested_staged_link() {
_cnsl_nested="$1/$2"
_cnsl_target="$3"
_cnsl_inode="${4-}"
if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then
rm -f "$_cnsl_nested"
fi
}
# publish_skill_dir_no_replace <staged-dir> <dest> <published-manifest>
# Publishes a staged Skill directory with an atomic no-replace claim: mkdir
# fails with EEXIST when anything a concurrent writer created occupies the
# destination, so the claim itself is the existence check — plain `mv` after a
# backup could still replace a concurrently created object. Staged children
# then move into the claim one by one (each rename targets a path inside a
# directory this transaction owns, so no step replaces a foreign object; the
# staging directory is created under the same umask as the claim, so their
# modes match by construction). A failed child move relocates the children
# back and removes only the claim. On success the destination's inode is
# recorded as <dest>:<inode,child-names,content-digest> so rollback only ever
# deletes this transaction's object even when the filesystem recycles the
# claim inode or the published files are edited in place afterwards.
publish_skill_dir_no_replace() {
_psd_stage="$1"
_psd_dest="$2"
_psd_manifest="$3"
if ! mkdir "$_psd_dest" 2>/dev/null; then
return 1
fi
_psd_failed=0
for _psd_child in "$_psd_stage"/* "$_psd_stage"/.[!.]* "$_psd_stage"/..?*; do
[ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue
if ! mv "$_psd_child" "$_psd_dest/"; then
_psd_failed=1
break
fi
done
if [ "$_psd_failed" -eq 0 ]; then
_psd_inode="$(skill_dir_identity "$_psd_dest")" || _psd_failed=1
if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then
return 0
fi
_psd_failed=1
fi
for _psd_child in "$_psd_dest"/* "$_psd_dest"/.[!.]* "$_psd_dest"/..?*; do
[ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue
mv "$_psd_child" "$_psd_stage/" || return 1
done
rmdir "$_psd_dest" 2>/dev/null || return 1
return 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
}
# The Linux release binaries are CGO builds linked against glibc, so they need
# the glibc dynamic loader. musl cannot load them, and the failure would only
# surface after install as an opaque loader error, so refuse up front.
#
# ldd is the authority on which libc the system actually uses. A glibc
# distribution that has musl or musl-tools installed also carries
# /lib/ld-musl-*.so.1 while its default loader stays glibc, so the loader file
# alone must not decide. It remains the fallback for musl distributions whose
# ldd reports no version, notably Alpine where BusyBox ldd only forwards to the
# loader.
require_glibc_on_linux() {
[ "$os" = "linux" ] || return 0
if command -v ldd >/dev/null 2>&1; then
ldd_version="$(ldd --version 2>&1)"
if printf '%s' "$ldd_version" | grep -qi musl; then
err "This Linux distribution uses musl libc, but ${BIN_NAME} release binaries are built against glibc and cannot run here. Use a glibc-based distribution."
fi
if printf '%s' "$ldd_version" | grep -qiE 'gnu libc|glibc'; then
return 0
fi
fi
if ls /lib/ld-musl-*.so.1 >/dev/null 2>&1; then
err "This Linux distribution uses musl libc, but ${BIN_NAME} release binaries are built against glibc and cannot run here. Use a glibc-based distribution."
fi
}
# 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"
staged_bin="$INSTALL_DIR/.${INSTALL_NAME}.tmp.$$"
cp "$built_bin" "$staged_bin"
chmod +x "$staged_bin"
mv "$staged_bin" "$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"
if ! publish_skill_dir_no_replace "$_mono_stage/$SKILL_NAME" "$_mono_dest" "$_mono_published"; 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)"
}
# Exact upstream registry (76 IDs): id|classification|effective-global-root.
# `-` means no global directory; `.agents/skills` means canonical-direct.
# Not readonly: the library must stay re-sourceable inside one shell.
DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills'
upstream_agent_registry() {
for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done
}
# DWS-only compatibility roots, same id|classification|root format. Qoderwork
# stays a real non-universal install target; the other four are global paths
# older DWS installers wrote by mistake, so they are retired like universal
# roots. Kept separate so the upstream pin above stays byte-comparable.
DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills'
legacy_agent_roots() {
for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done
}
# ~/.agents/skills is the canonical store under the universal convention.
# Universal Agents read it directly; other Agents receive relative links and
# fall back to copies only when links are unavailable.
#
# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned
# registries above, so a registry edit changes real install behaviour instead of
# only a test count, and pin/behaviour drift is impossible by construction.
is_universal_agent_dir() {
for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do
[ "${_iua_record##*|}" = "$1" ] || continue
case "$_iua_record" in
*"|U|"*) return 0 ;;
esac
done
return 1
}
# Effective global roots from vercel-labs/skills, in pinned registry order and
# de-duplicated (amp/replit/universal and zencoder/zenflow share a root).
# Records without a global root (`-`) and canonical-direct records
# (`.agents/skills`) are not per-Agent targets and are skipped.
agent_skill_dirs() {
{ upstream_agent_registry; legacy_agent_roots; } | awk -F'|' '
$3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }'
}
resolve_agent_skill_base() {
_ras_root="$1"; _ras_agent="$2"
case "$_ras_agent" in
".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;;
".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;;
".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;;
".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;;
".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;;
".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;;
".openclaw/skills")
for _ras_name in .openclaw .clawdbot .moltbot; do
[ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; }
done ;;
".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;;
".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;;
".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;;
".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;;
".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;;
".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;;
esac
printf '%s\n' "$_ras_root/$_ras_agent"
}
agent_skill_base_detected() {
_asbd_agent="$1"; _asbd_base="$2"
case "$_asbd_agent" in
".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;;
".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;;
".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
*) [ -d "$(dirname "$_asbd_base")" ] ;;
esac
}
same_physical_skill_root() {
[ -d "$1" ] && [ -d "$2" ] || return 1
# CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved
# directory into the command substitution, which would silently report two
# identical roots as different.
_sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
_sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
[ "$_sps_left" = "$_sps_right" ]
}
retire_agent_skill_root() {
_rgs_root="$1"
_rgs_base="$2"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1
_rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
[ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue
if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then
continue
fi
if ! backup_and_record_skill_dir "$_rgs_victim" "$_rgs_backups"; then
restore_multi_skill_set /dev/null "$_rgs_backups" || true
rm -rf "$_rgs_stage"
return 1
fi
done
rm -rf "$_rgs_stage"
}
# link_canonical_skills_to_base <root> <base> <mode> [bundle-src]
# <bundle-src> is the installed multi bundle and is REQUIRED in multi mode: the
# link set must come from the bundle, never from the whole canonical store.
# ~/.agents/skills is now shared, so third-party/user Skills live there too and
# must not be published into every detected Agent root (they are absent from
# skills-state.json, so nothing could ever reclaim those links). Go
# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list
# the same way.
link_canonical_skills_to_base() {
_lcs_root="$1"
_lcs_base="$2"
_lcs_mode="$3"
_lcs_bundle="${4-}"
_lcs_canonical="$_lcs_root/.agents/skills"
mkdir -p "$_lcs_base" || return 1
same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
if [ "$_lcs_mode" = "mono" ]; then
_lcs_names="dws"
else
[ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1
_lcs_names=""
for _lcs_skill in "$_lcs_bundle"/*/; do
[ -f "${_lcs_skill}SKILL.md" ] || continue
_lcs_names="$_lcs_names $(basename "$_lcs_skill")"
done
[ -n "$_lcs_names" ] || return 1
fi
# Collision pre-flight: the victim loop below deliberately refuses to back up
# entries that are not DWS-managed, and publication must never replace them.
# Report the exact colliding paths before touching anything, so the fall back
# to the legacy full-copy layout is observable and actionable.
_lcs_collision=0
for _lcs_name in $_lcs_names; do
_lcs_dest="$_lcs_base/$_lcs_name"
{ [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue
same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue
[ "$_lcs_name" = "dws" ] && continue
is_managed_multi_skill_dir "$_lcs_dest" && continue
say " ⚠️ $_lcs_dest 已存在且不是 DWS 安装的 Skill,无法在此建立指向 $_lcs_canonical/$_lcs_name 的共享链接"
_lcs_collision=1
done
if [ "$_lcs_collision" -eq 1 ]; then
say " ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;"
say " 移走或改名后重新运行安装即可切换为共享 $_lcs_canonical 布局。"
return 1
fi
_lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1
_lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1
_lcs_backups="$_lcs_stage/.backups"
_lcs_published="$_lcs_stage/.published"
_lcs_stage_token="$(basename "$_lcs_stage")"
: > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; }
: > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_publish_names=""
for _lcs_name in $_lcs_names; do
if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then
continue
fi
_lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
_lcs_stage_name="${_lcs_stage_token}.${_lcs_name}"
ln -s "$_lcs_link_target" "$_lcs_stage/$_lcs_stage_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
_lcs_dest="$_lcs_base/$_lcs_name"
_lcs_stage_name="${_lcs_stage_token}.${_lcs_name}"
_lcs_staged="$_lcs_stage/$_lcs_stage_name"
_lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
}
# Publish by creating the link directly at the destination: symlink(2)
# refuses an occupied path with EEXIST, so the creation itself is the
# atomic no-replace check. `mv` after any pre-check could still replace a
# file or symlink a concurrent writer created in between, and `ln -P`
# stays unusable (absent from BusyBox `ln`, which silently degraded every
# non-universal Agent to the copy layout on Alpine and most containers).
if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
# A directory that appeared at the destination turns `ln -s` into a
# container: the link lands inside it under the target basename. Remove
# exactly our link after an identity check and roll back; the foreign
# directory stays untouched.
if [ ! -L "$_lcs_dest" ]; then
_lcs_nested_name="${_lcs_link_target##*/}"
_lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode=""
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
_lcs_inode="$(skill_link_inode "$_lcs_dest")" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
}
if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then
if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
say " ↪ Skills → $_lcs_dest"
done
rm -rf "$_lcs_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=1
failed=0
retire_failed=0
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; }
for agent_dir in $(agent_skill_dirs)
do
base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base_dir" || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
continue
fi
if link_canonical_skills_to_base "$root" "$base_dir" mono; then
installed=$((installed + 1))
else
if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then
say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
done
if [ "$installed" -eq 0 ]; then
say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"
return 1
fi
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
fi
if [ "$failed" -gt 0 ]; then
say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败"
return 1
fi
rm -f "$SKILL_STATE_ROOT/skills-state.json"
say "✅ DWS Skills 安装完成"
say " 统一安装位置:$root/.agents/skills"
say " 已自动适配本机上检测到的 Agent"
say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
}
# 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=1
failed=0
retire_failed=0
if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; }
for agent_dir in $(agent_skill_dirs)
do
base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base_dir" || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
continue
fi
if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then
installed=$((installed + 1))
else
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
done
if [ "$installed" -eq 0 ]; then
say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"
return 1
fi
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
fi
if [ "$failed" -gt 0 ]; then
say " ⚠️ 有 ${failed} 个 Agent 目标安装失败"
return 1
fi
write_skills_state "$multi_src" "install.sh" || return 1
say "✅ DWS Skills 安装完成"
say " 统一安装位置:$root/.agents/skills"
say " 已自动适配本机上检测到的 Agent"
say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
}
_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"
if ! publish_skill_dir_no_replace "$_ms_stage/$_name" "$_dest" "$_ms_published"; 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)"
require_glibc_on_linux
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"
verify_release_asset_checksum "$archive_name" "$tmpdir/$archive_name" "$tmpdir"
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
found="$tmpdir/$BIN_NAME"
elif [ -f "$tmpdir/${BIN_NAME}-${os}-${arch}/$BIN_NAME" ]; then
found="$tmpdir/${BIN_NAME}-${os}-${arch}/$BIN_NAME"
else
found="$(find "$tmpdir" -name "$BIN_NAME" -type f | head -1)"
[ -n "$found" ] || err "Could not find the ${BIN_NAME} binary in the downloaded archive."
fi
staged_bin="$INSTALL_DIR/.${INSTALL_NAME}.tmp.$$"
cp "$found" "$staged_bin"
chmod +x "$staged_bin"
mv "$staged_bin" "$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
}
# ── Build shared schema cache ────────────────────────────────────────────────
# The schema cache is generated on this machine from the installed binary's
# live declarations (no compile-time identity seal). Build it once at the
# system shared location so every user reuses it. Persistent backends are
# compiled in for darwin/linux/windows on amd64/arm64. This POSIX installer
# only warms darwin/linux shared locations; Windows uses install.ps1.
# Other ends skip silently and never claim success. Root-owned sticky ancestry such as
# macOS /Library/Caches is accepted by the runtime; if the shared base is not
# writable the installer leaves per-user cache generation to the first schema
# command.
# Owner uid a shared cache must carry to be consumable by users other than the
# installer. The runtime accepts a shared cache only when every path it walks
# is owned by root or by the reading user (internal/schemacache/platform_unix.go
# validateOwnedDirectory/validateCacheFile), so a warm-up run by an ordinary
# user under a writable custom SHARED_DIR produces a cache that user alone can
# consume. Root ownership is therefore the requirement; installer tests
# override the value to exercise both decisions without a second account.
shared_schema_owner_uid="${DWS_SCHEMA_CACHE_SHARED_OWNER_UID:-0}"
# True when every current-generation artifact (each edition holding an
# identity.json sidecar) carries the owner uid that makes the cache consumable
# across users.
shared_schema_artifacts_shared_owner() {
[ -d "$1" ] || return 1
[ -n "$shared_schema_owner_uid" ] || return 1
[ -n "$(find "$1" -mindepth 3 -maxdepth 3 -name identity.json -type f -print -quit 2>/dev/null)" ] || return 1
find "$1" -mindepth 3 -maxdepth 3 -name identity.json -type f -exec sh -c '
owner="$1"
shift
for f do
edition_dir="$(dirname "$(dirname "$f")")"
[ -z "$(find "$edition_dir" ! -uid "$owner" -print -quit 2>/dev/null)" ] || exit 1
done' sh "$shared_schema_owner_uid" {} + 2>/dev/null
}
# Mirror of the runtime's per-level ancestry rule
# (internal/schemacache/platform_unix.go validateAncestryDirectory): a shared
# cache ancestor must be a directory owned by root or by one of the reader
# uids, and any group/other write bit requires the sticky bit (root-owned
# sticky ancestors such as /tmp and /Library/Caches are the platform
# convention). A level violating this makes every reader's runtime reject the
# cache with ErrUnsafePath even though the files are other-readable, so the
# installer must not advertise cross-user success over such ancestry.
shared_schema_ancestry_level_safe() {
[ -d "$1" ] || return 1
[ -z "$(find "$1" -maxdepth 0 ! \( -uid "$2" -o -uid "$3" \) -print 2>/dev/null)" ] || return 1
if [ -n "$(find "$1" -maxdepth 0 \( -perm -0020 -o -perm -0002 \) -print 2>/dev/null)" ]; then
[ -n "$(find "$1" -maxdepth 0 -perm -01000 -print 2>/dev/null)" ] || return 1
fi
return 0
}
# Existing cache paths must be real directories before any cleanup or chmod.
# Missing paths are allowed because this installer creates them below.
shared_schema_layout_safe() {
for path do
[ ! -L "$path" ] || return 1
if [ -e "$path" ] && [ ! -d "$path" ]; then
return 1
fi
done
}
# Ownership lock for pre-existing mutated levels. find -delete, chmod, and
# chmod -R all bind through pathnames, so a level writable by another
# principal could in principle be swapped between validation and mutation,
# redirecting the walk outside the verified tree. Requiring every existing
# ancestor of the mutated tree (shared root and dws) to be owned by root or
# the invoking user, with no group/world write bits, makes the filesystem
# itself enforce the stability those checks assume — the swap primitive the
# race needs no longer exists. Levels missing at gate time are created by
# this run under umask 077 and are locked by construction; a mutable
# pre-existing root fails closed to the per-user cache.
shared_schema_levels_locked() {
_sc_invoker="$(id -u)"
for level do
[ -d "$level" ] || continue
[ -n "$(find "$level" -maxdepth 0 \( -uid 0 -o -uid "$_sc_invoker" \) 2>/dev/null)" ] || return 1
[ -z "$(find "$level" -maxdepth 0 \( -perm -0020 -o -perm -0002 \) 2>/dev/null)" ] || return 1
done
}
# Lock the ancestry ABOVE the shared root: every existing parent, up to /,
# must satisfy shared_schema_ancestry_level_safe for root or the invoking
# user (group/world write requires sticky, so platform conventions such as
# 1777 /tmp and /Library/Caches stay usable). levels_locked only covers the
# root and dws levels themselves; without a parent lock, an untrusted
# principal with rename rights on a non-sticky group/world-writable parent
# can swap the freshly validated root for a symlink in the window before
# chmod a+rX, and chmod then follows the link and widens the target's
# permissions. With every existing parent locked, the filesystem itself
# denies that rename for the whole install run — the same stability-by-
# ownership argument levels_locked makes for the mutated levels. Missing
# parents are skipped: create_missing_levels builds them under umask 077
# below a locked anchor, so they are invoker-only by construction, and the
# pre-chmod re-run of this predicate sees them and re-verifies. Relative
# roots are rejected outright.
shared_schema_root_ancestry_locked() {
_sra_root="$1"
case "$_sra_root" in
/*) ;;
*) return 1 ;;
esac
_sra_invoker="$(id -u)"
_sra_walk="$(dirname "$_sra_root")"
while [ "$_sra_walk" != "/" ]; do
if [ -e "$_sra_walk" ]; then
shared_schema_ancestry_level_safe "$_sra_walk" 0 "$_sra_invoker" || return 1
fi
_sra_walk="$(dirname "$_sra_walk")"
done
return 0
}
# Create the missing levels of an absolute directory path one component at
# a time with plain mkdir under umask 077. mkdir without -p is an atomic
# create-or-EEXIST on the leaf and never resolves a symlink there, so a
# level an attacker swapped in or pre-planted between validation and
# creation — the missing-root replacement race — fails mkdir instead of
# letting the later mktemp probe, warm-up, find, and chmod writes follow
# the link out of the cache tree; each intermediate missing level becomes
# the leaf exactly once, so the same guarantee holds for every component.
# A target that already exists as a symlink is rejected outright, and
# creation under umask 077 makes every level this run creates owned and
# writable only by the invoking user — the ownership lock by construction.
# The anchor (nearest pre-existing level) is not lstat-checked:
# platform-conventional symlinked ancestors stay legitimate, matching the
# runtime's ancestry rules; DWS-owned levels above were already gated by
# shared_schema_layout_safe and shared_schema_levels_locked.
shared_schema_create_missing_levels() {
_scm_target="$1"
case "$_scm_target" in
/*) ;;
*) return 1 ;;
esac
[ ! -L "$_scm_target" ] || return 1
_scm_anchor="$_scm_target"
_scm_tail=""
while [ "$_scm_anchor" != "/" ] && [ ! -e "$_scm_anchor" ]; do
_scm_tail="/$(basename "$_scm_anchor")$_scm_tail"
_scm_anchor="$(dirname "$_scm_anchor")"
done
[ -d "$_scm_anchor" ] || return 1
[ -n "$_scm_tail" ] || return 0
_scm_saved_umask="$(umask)"
umask 077
_scm_failed=0
while [ -n "$_scm_tail" ]; do
_scm_head="${_scm_tail#/}"
_scm_name="${_scm_head%%/*}"
_scm_anchor="$_scm_anchor/$_scm_name"
_scm_tail="${_scm_head#*"$_scm_name"}"
_scm_tail="${_scm_tail#/}"
mkdir "$_scm_anchor" 2>/dev/null || {
_scm_failed=1
break
}
done
umask "$_scm_saved_umask"
[ "$_scm_failed" -eq 0 ]
}
# Cleanup and permission changes only operate on regular files and directories
# that have one link. Rejecting other objects before walking the tree prevents
# a symlink, FIFO, or hardlink from redirecting or widening the operation.
shared_schema_tree_objects_safe() {
tree="$1"
[ -d "$tree" ] && [ ! -L "$tree" ] || return 1
[ -z "$(find "$tree" -type l -print -quit 2>/dev/null)" ] || return 1
[ -z "$(find "$tree" ! \( -type f -o -type d \) -print -quit 2>/dev/null)" ] || return 1
[ -z "$(find "$tree" -type f ! -links 1 -print -quit 2>/dev/null)" ] || return 1
}
# Mirror of the runtime's entry rules (validateOwnedDirectory /
# validateCacheFile) for every current-generation edition (identity.json marks
# it): no symlinks (secure openat rejects them), every entry owned by root or
# one of the reader uids, directories without group/other write bits, and
# single-link regular files without group/other write bits. chmod a+rX only
# adds bits and never clears write bits, so a stale group-writable shard must
# downgrade the advertised claim instead of claiming success.
shared_schema_editions_runtime_safe() {
[ -d "$1" ] || return 1
[ -n "$(find "$1" -mindepth 3 -maxdepth 3 -name identity.json -type f -print -quit 2>/dev/null)" ] || return 1
find "$1" -mindepth 3 -maxdepth 3 -name identity.json -type f -exec sh -c '
uid_a="$1"
uid_b="$2"
shift 2
for f do
edition_dir="$(dirname "$(dirname "$f")")"
[ -z "$(find "$edition_dir" -type l -print -quit 2>/dev/null)" ] || exit 1
[ -z "$(find "$edition_dir" ! \( -uid "$uid_a" -o -uid "$uid_b" \) -print -quit 2>/dev/null)" ] || exit 1
[ -z "$(find "$edition_dir" -type d \( -perm -0020 -o -perm -0002 \) -print -quit 2>/dev/null)" ] || exit 1
[ -z "$(find "$edition_dir" -type f ! -links 1 -print -quit 2>/dev/null)" ] || exit 1
[ -z "$(find "$edition_dir" -type f \( -perm -0020 -o -perm -0002 \) -print -quit 2>/dev/null)" ] || exit 1
done' sh "$2" "$3" {} + 2>/dev/null
}
# Share only the current editions after the warm-up has passed the same object
# checks used before cleanup (no symlinks, hardlinks, or non-regular files),
# so the recursion can never widen anything outside the verified tree.
shared_schema_make_artifacts_readable() {
tree="$1"
find "$tree" -mindepth 3 -maxdepth 3 -name identity.json -type f -exec sh -c '
for f do
edition_dir="$(dirname "$(dirname "$f")")"
chmod -R a+rX "$edition_dir" 2>/dev/null || exit 1
done' sh {} + 2>/dev/null
}
build_shared_schema_cache() {
os="$(detect_os)"
arch="$(detect_arch)"
case "$os/$arch" in
linux/amd64|linux/arm64) shared_dir="/var/cache/dws" ;;
darwin/amd64|darwin/arm64) shared_dir="/Library/Caches/dws" ;;
*) return 0 ;;
esac
custom_shared_root=0
if [ -n "${DWS_SCHEMA_CACHE_SHARED_DIR:-}" ]; then
shared_dir="$DWS_SCHEMA_CACHE_SHARED_DIR"
custom_shared_root=1
fi
# Distinguish installer-created dedicated roots from pre-existing custom
# DWS_SCHEMA_CACHE_SHARED_DIR ancestors. Only the former may be chmod'd at
# the root; a private caller-owned base (e.g. 0700) must not be broadened.
shared_dir_preexisted=0
if [ -d "$shared_dir" ]; then
shared_dir_preexisted=1
fi
# Runtime layout under any base is dws/schema/<edition-sha256>/v1. Only clear
# sidecars inside that DWS tree — never recurse a wide custom SHARED_DIR or
# other apps' identity.json files.
schema_tree="${shared_dir}/dws/schema"
dws_intermediate="$(dirname "$schema_tree")"
# Pre-existence is captured before mkdir: a custom root may already contain
# a caller-owned private dws/ or dws/schema/, and those levels must never be
# broadened — only levels this installer run creates may be chmod'd.
dws_tree_preexisted=0
[ -d "$dws_intermediate" ] && dws_tree_preexisted=1
schema_tree_preexisted=0
[ -d "$schema_tree" ] && schema_tree_preexisted=1
# Path safety precedes every mutation: a touch-based probe follows symlinked
# path entries, so an existing shared_dir/dws/schema level that is a symlink
# must be rejected before the write probe can mutate whatever it points at.
# Platform-conventional symlinked ancestors (e.g. /var on macOS) are not
# rejected as objects; they are governed instead by the ancestry lock below.
# The ownership lock additionally binds every later pathname-based mutation
# (find -delete, chmod, chmod -R) to levels no other principal can swap:
# validation and mutation can no longer be separated by a replacement race.
# Missing levels are created by shared_schema_create_missing_levels — plain
# mkdir per level under umask 077 — so a level replaced between validation
# and creation fails creation instead of being written through, and the full
# gate re-runs over all three levels before the first write probe. The
# ancestry lock additionally covers every parent above the root, so a root
# reachable through a non-sticky group/world-writable level — where an
# untrusted rename could still swap it for a symlink ahead of the chmod —
# fails closed here, before the first creation or write.
if ! shared_schema_layout_safe "$shared_dir" "$dws_intermediate" "$schema_tree" ||
! shared_schema_levels_locked "$shared_dir" "$dws_intermediate" ||
! shared_schema_root_ancestry_locked "$shared_dir"; then
say "⚠️ Shared schema cache skipped: unsafe cache path."
return 0
fi
# Skip silently when we cannot write to the system location (non-root install
# or an unusable shared ancestry). The runtime then uses the per-user cache.
if ! shared_schema_create_missing_levels "$shared_dir"; then
return 0
fi
if ! shared_schema_create_missing_levels "$schema_tree"; then
say "⚠️ Shared schema cache skipped: unsafe cache path."
return 0
fi
# Re-run the whole gate over all three levels after creation and before the
# first write: layout (no symlink anywhere in the DWS-owned levels), the
# ownership lock (pre-existing levels were gated above; levels this run
# created are invoker-owned with no group/world write bits), and the tree
# object walk. Every later mutation in this function happens inside the
# locked tree.
if ! shared_schema_layout_safe "$shared_dir" "$dws_intermediate" "$schema_tree" ||
! shared_schema_levels_locked "$shared_dir" "$dws_intermediate" "$schema_tree" ||
! shared_schema_tree_objects_safe "$schema_tree"; then
say "⚠️ Shared schema cache skipped: unsafe cache contents."
return 0
fi
# The write probe uses mktemp (mkstemp: O_CREAT|O_EXCL, unpredictable name)
# instead of a fixed-name touch: a fixed probe name can be pre-planted as a
# symlink to an outside victim, and shell noclobber checks are stat-based
# and would still follow a symlink whose target does not exist. Nothing
# outside the freshly created probe file is touched.
if ! _probe="$(mktemp "$shared_dir/.dws-schema-cache-write-test.XXXXXX")" 2>/dev/null; then
return 0
fi
rm -f "$_probe"
say "🔧 Building shared schema cache (local identity, shared across users)..."
# Drop the previous per-edition sidecar and leftover fingerprint-suffixed
# files so upgrade always generate-then-use from this binary's live
# declarations. identity.json is the only success marker. Only single-link
# regular files in the verified tree may be removed.
find "$schema_tree" -name 'identity.json' -type f -links 1 -delete 2>/dev/null || true
find "$schema_tree" -name 'identity.*.json' -type f -links 1 -delete 2>/dev/null || true
# DWS_SCHEMA_CACHE_DIR makes the runtime treat the location as a shared cache
# and populate it. Any schema command triggers generate + publish.
if DWS_SCHEMA_CACHE_DIR="$shared_dir" "$INSTALL_DIR/$INSTALL_NAME" schema --all --format json >/dev/null 2>&1 &&
schema_cache_artifacts_present "$schema_tree"; then
# World-readable/traversable on DWS-owned paths only: integrity rests on the
# locally generated identity plus shard digests, not on file ownership.
# Never chmod a+rX a pre-existing custom SHARED_DIR root, nor pre-existing
# caller-owned dws/ / dws/schema/ levels (repro: 0700 → 0755 exposing
# unrelated children). Within the DWS-owned schema tree, re-share every
# edition this warm-up just wrote: the sidecar cleanup above removed every
# identity.json, so only the freshly (re)generated current edition holds
# one — including an upgrade replacing artifacts inside an existing edition,
# whose atomic staging files and identity.json land as 0600.
shared_chmod_ok=1
# Re-run the whole gate including the parent ancestry lock right before
# the first chmod: the warm-up ran an external binary and time has passed,
# so this re-establishes that no level above or inside the root became
# swappable before a+rX follows the pathnames.
if ! shared_schema_layout_safe "$shared_dir" "$dws_intermediate" "$schema_tree" ||
! shared_schema_levels_locked "$shared_dir" "$dws_intermediate" "$schema_tree" ||
! shared_schema_root_ancestry_locked "$shared_dir" ||
! shared_schema_tree_objects_safe "$schema_tree"; then
shared_chmod_ok=0
fi
if [ "$shared_chmod_ok" -eq 1 ] && [ "$custom_shared_root" -eq 1 ] && [ "$shared_dir_preexisted" -eq 1 ]; then
# Pre-existing custom ancestor: chmod only the levels this run created;
# every caller-owned level must already be traversable as-is or we fall
# back to the per-user cache instead of broadening it.
if [ "$dws_tree_preexisted" -eq 0 ]; then
chmod a+rX "$dws_intermediate" 2>/dev/null || shared_chmod_ok=0
fi
elif [ "$shared_chmod_ok" -eq 1 ]; then
# Installer-created dedicated root (or default system base): umask 077 would
# otherwise leave $shared_dir and $shared_dir/dws at 0700 while only the
# schema tree is 0755 — other users could not reach the cache.
chmod a+rX "$shared_dir" "$dws_intermediate" 2>/dev/null || shared_chmod_ok=0
fi
if [ "$shared_chmod_ok" -eq 1 ] &&
{ [ "$custom_shared_root" -eq 0 ] || [ "$schema_tree_preexisted" -eq 0 ]; }; then
chmod a+rX "$schema_tree" 2>/dev/null || shared_chmod_ok=0
fi
if [ "$shared_chmod_ok" -eq 1 ] &&
! shared_schema_make_artifacts_readable "$schema_tree"; then
shared_chmod_ok=0
fi
# Three claims, mirroring exactly what the reading runtime accepts
# (internal/schemacache/platform_unix.go): cross-user success requires
# every walked level to be owned by root or the expected reader uid and
# satisfy the runtime's mode rules; installer-only requires the same with
# the installer's own uid; anything else — a group/world-writable level
# without sticky, a foreign-owned ancestor, a writable shard — is a cache
# no runtime accepts, so the per-user fallback is reported instead. The
# first failing cross-user gate travels with the fallback message so a
# rejected warm-up is diagnosable from installer output alone.
_sc_reader_a=0
_sc_reader_b="$shared_schema_owner_uid"
_sc_installer_uid="$(id -u)"
_sc_cross_gate=""
if [ "$shared_chmod_ok" -ne 1 ]; then
_sc_cross_gate="re-share"
elif ! shared_schema_root_reachable "$_sc_reader_a" "$_sc_reader_b" "$shared_dir"; then
_sc_cross_gate="root-reachable"
elif ! shared_schema_ancestors_traversable "$_sc_reader_a" "$_sc_reader_b" "$shared_dir" "$dws_intermediate" "$schema_tree"; then
_sc_cross_gate="ancestors-traversable"
elif ! shared_schema_artifacts_readable "$schema_tree"; then
_sc_cross_gate="artifacts-readable"
elif ! shared_schema_editions_runtime_safe "$schema_tree" "$_sc_reader_a" "$_sc_reader_b"; then
_sc_cross_gate="editions-runtime"
elif ! shared_schema_artifacts_shared_owner "$schema_tree"; then
_sc_cross_gate="shared-owner"
fi
if [ -z "$_sc_cross_gate" ]; then
say "✅ Shared schema cache built: ${shared_dir}"
elif [ "$shared_chmod_ok" -eq 1 ] &&
shared_schema_root_reachable "$_sc_reader_a" "$_sc_installer_uid" "$shared_dir" &&
shared_schema_ancestors_traversable "$_sc_reader_a" "$_sc_installer_uid" "$shared_dir" "$dws_intermediate" "$schema_tree" &&
shared_schema_artifacts_readable "$schema_tree" &&
shared_schema_editions_runtime_safe "$schema_tree" "$_sc_reader_a" "$_sc_installer_uid"; then
# The runtime accepts a shared cache only when every artifact is owned
# by root or by the reading user, so a warm-up run by an ordinary user
# under a writable custom root yields a cache its own user consumes and
# every other user rejects. Report that instead of advertising a
# shared cache.
say "⚠️ Schema cache built for the installing user only (owner uid ${shared_schema_owner_uid} required for cross-user reads); other users fall back to a per-user cache."
else
say "⚠️ Shared schema cache not shared (cross-user gate: ${_sc_cross_gate}); other users fall back to a per-user cache."
fi
else
say "⚠️ Shared schema cache not written; first schema command will build a per-user cache."
fi
}
# True when every ancestor of the given directory up to the filesystem root
# is other-readable and other-executable and satisfies the runtime ancestry
# rule (shared_schema_ancestry_level_safe) for the given reader uid pair, so a
# non-owner user can actually reach and accept the shared cache. Check-only:
# caller-owned ancestors are never widened; an unreachable or unsafe parent
# must downgrade to the per-user fallback instead of advertising a shared
# cache nobody else can open.
shared_schema_root_reachable() {
_sc_uid_a="$1"
_sc_uid_b="$2"
_sc_anc="$(dirname -- "$3")"
while [ "$_sc_anc" != "/" ]; do
[ "$(find "$_sc_anc" -maxdepth 0 -perm -005 2>/dev/null)" = "$_sc_anc" ] || return 1
shared_schema_ancestry_level_safe "$_sc_anc" "$_sc_uid_a" "$_sc_uid_b" || return 1
_sc_anc="$(dirname -- "$_sc_anc")"
done
return 0
}
# True when each listed directory is other-readable and other-executable and
# satisfies the runtime ancestry rule for the given reader uid pair, so
# non-owner users can traverse into the shared schema cache.
shared_schema_ancestors_traversable() {
_sc_uid_a="$1"
_sc_uid_b="$2"
shift 2
for _sc_anc in "$@"; do
[ -d "$_sc_anc" ] || return 1
# find -perm -005: other has read+execute (portable across GNU/BSD find).
[ "$(find "$_sc_anc" -maxdepth 0 -perm -005 2>/dev/null)" = "$_sc_anc" ] || return 1
shared_schema_ancestry_level_safe "$_sc_anc" "$_sc_uid_a" "$_sc_uid_b" || return 1
done
return 0
}
# True when every current-generation artifact (each edition holding an
# identity.json sidecar) is readable and traversable by other users, so the
# advertised shared cache is actually consumable by them. Stale caller-owned
# trees without a sidecar are not part of the advertised generation.
shared_schema_artifacts_readable() {
[ -d "$1" ] || return 1
# At least one current-generation sidecar must exist, and every file and
# directory under each sidecar's edition must be other-readable and
# traversable. Sidecar paths travel as find arguments so spaces in a custom
# SHARED_DIR cannot split entries and hollow out the guard.
[ -n "$(find "$1" -mindepth 3 -maxdepth 3 -name identity.json -type f -print -quit 2>/dev/null)" ] || return 1
find "$1" -mindepth 3 -maxdepth 3 -name identity.json -type f -exec sh -c '
for f do
edition_dir="$(dirname "$(dirname "$f")")"
[ -z "$(find "$edition_dir" -type f ! -perm -004 -print -quit 2>/dev/null)" ] || exit 1
[ -z "$(find "$edition_dir" -type d ! -perm -005 -print -quit 2>/dev/null)" ] || exit 1
done' sh {} + 2>/dev/null
}
schema_cache_artifacts_present() {
# Caller must pass the precise DWS schema tree (.../dws/schema), not a wide
# base like $HOME or a custom SHARED_DIR root.
_sc_dir="$1"
[ -d "$_sc_dir" ] || return 1
case "$_sc_dir" in
*/dws/schema|*/dws/schema/) ;;
*) return 1 ;;
esac
_sc_meta="$(find "$_sc_dir" -name 'meta.cache' -type f 2>/dev/null | head -n 1)"
_sc_registry="$(find "$_sc_dir" -name 'registry.shards.cache' -type f 2>/dev/null | head -n 1)"
_sc_payloads="$(find "$_sc_dir" -name 'payloads.shards.cache' -type f 2>/dev/null | head -n 1)"
_sc_identity="$(find "$_sc_dir" -name 'identity.json' -type f 2>/dev/null | head -n 1)"
[ -n "$_sc_meta" ] && [ -s "$_sc_meta" ] &&
[ -n "$_sc_registry" ] && [ -s "$_sc_registry" ] &&
[ -n "$_sc_payloads" ] && [ -s "$_sc_payloads" ] &&
[ -n "$_sc_identity" ] && [ -s "$_sc_identity" ]
}
# ── 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
verify_release_asset_checksum "$skills_archive" "$tmpdir_skills/$skills_archive" "$tmpdir_skills"
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
# Generate the local Schema identity and shared cache once so every user
# reuses it. Skipped for skills-only installs (no binary), unsupported
# os/arch (cache backend compiled out), and non-root installs (no system write).
if [ "$SKILLS_ONLY" != "1" ]; then
build_shared_schema_cache
fi
# Every transaction of this run has finished, so old stamped archives can no
# longer be needed for a rollback.
prune_skill_backups
printf '\n'
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