Files
玉澜 27aca3ccc3 fix(skills): close no-replace fallback TOCTOU and surface uncertain publications
The mkdir->rename->remove->rename directory fallback had a TOCTOU window
between the second remove and the second rename: a concurrent writer
creating an entry at the destination was silently clobbered. The fallback
now claims the destination once with mkdir and never unlinks it: the
fast-path rename publishes over the claim (Linux), and platforms that
refuse directory renames (macOS, Windows) move the staged children into
the claim through atomic no-clobber primitives (mkdir/os.Link/os.Symlink),
consuming the emptied source shell on success.

renameSkillPathNoReplace now returns the mkdir-claim identity captured by
the child-move path. PublishSkillPathNoReplace uses it as a three-state
ownership witness: the atomic/fast paths keep the staged-inode proof, the
child-move path proves dest is still the mkdir claim, and a mismatch
reports the new ErrSkillPathPublicationUncertain sentinel with the
destination retained. The witness is real on POSIX now: darwin and linux
report the dev:ino file identity instead of the empty no-op.

Upstream consumers honor the sentinel: the mono/multi upgrade copy
fallbacks no longer retry over an uncertain destination (the retry would
displace the concurrent writer's object), and skill setup reports the
retained destination instead of claiming a rollback.

Rewrites the fallback tests that pinned the removed remove-and-retry flow
and adds regression coverage: concurrent claim entries abort with the
destination retained, wholesale replacement after child-move reports the
uncertain sentinel, staged-set transactions pass the sentinel through,
and both copy fallbacks short-circuit (ablation-verified).
2026-08-19 17:42:20 +08:00

2299 lines
81 KiB
Go
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.
package app
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
// skillSetupAgentHomes is the ordered list of agent home subdirectories
// where dws skills get installed. Mirrors install.sh / install.ps1 /
// build/npm/install.js so that `dws skill setup` and the install scripts
// agree on the install footprint.
var skillSetupAgentHomes = []string{
".agents/skills",
".config/agents/skills",
".gemini/antigravity/skills",
".gemini/antigravity-cli/skills",
".deepagents/agent/skills",
".firebender/skills",
".copilot/skills",
".config/opencode/skills",
".aider-desk/skills",
".astrbot/data/skills",
".autohand/skills",
".augment/skills",
".bob/skills",
".claude/skills",
".openclaw/skills",
".codeartsdoer/skills",
".codebuddy/skills",
".codemaker/skills",
".codestudio/skills",
".commandcode/skills",
".continue/skills",
".snowflake/cortex/skills",
".config/crush/skills",
".config/devin/skills",
".factory/skills",
".forge/skills",
".config/goose/skills",
".grok/skills",
".hermes/skills",
".inferencesh/skills",
".jazz/skills",
".junie/skills",
".iflow/skills",
".kilocode/skills",
".config/kimchi/harness/skills",
".kiro/skills",
".kode/skills",
".lingma/skills",
".mcpjam/skills",
".minimax/skills",
".vibe/skills",
".moxby/skills",
".mux/skills",
".openhands/skills",
".ona/skills",
".pi/agent/skills",
".qoder/skills",
".qoder-cn/skills",
".qwen/skills",
".reasonix/skills",
".rovodev/skills",
".roo/skills",
".tabnine/agent/skills",
".terramind/skills",
".tinycloud/skills",
".trae/skills",
".trae-cn/skills",
".codeium/windsurf/skills",
".zcode/skills",
".zencoder/skills",
".neovate/skills",
".pochi/skills",
".adal/skills",
".qoderwork/skills",
// beta.6 compatibility roots: cleanup only.
".cursor/skills",
".gemini/skills",
".codex/skills",
".github/skills",
".windsurf/skills",
".cline/skills",
".amp/skills",
}
const (
skillSetupModeMono = "mono"
skillSetupModeMulti = "multi"
)
var (
skillSetupResolveMode = resolveSkillSetupMode
skillSetupResolveSource = resolveSkillSetupSourceOrEmbedded
skillSetupResolveTargets = resolveSkillSetupTargets
skillSetupListMulti = listMultiSkillNames
skillSetupFilterMulti = filterMultiSkillNames
skillSetupBuildPlan = buildSkillSetupPlan
skillSetupConfirmPlan = confirmSkillSetupPlan
skillSetupExecutePlan = executeSkillSetupPlan
skillSetupCopyDir = copyDir
skillSetupInstallMulti = installMultiSkillToHomes
skillSetupPublishTemp = os.MkdirTemp
skillSetupPublishRename = os.Rename
skillSetupMkdirTemp = os.MkdirTemp
skillSetupRename = os.Rename
skillSetupRunForm = (*huh.Form).Run
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
skillSetupStat = os.Stat
skillSetupLstat = os.Lstat
skillSetupGetenv = os.Getenv
skillSetupSymlink = os.Symlink
skillSetupExecutable = os.Executable
skillSetupGetwd = os.Getwd
skillSetupUserHomeDir = os.UserHomeDir
skillSetupRemoveAll = os.RemoveAll
skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir
skillSetupRestoreBackup = upgrade.RestoreSkillPath
skillSetupMkdirAll = os.MkdirAll
skillSetupWalk = filepath.Walk
skillSetupRel = filepath.Rel
skillSetupReadlink = os.Readlink
skillSetupEvalSymlinks = filepath.EvalSymlinks
skillSetupOpen = os.Open
skillSetupOpenFile = os.OpenFile
skillSetupWriteFile = os.WriteFile
skillSetupBuildProvenance = skillprovenance.Build
skillSetupCopy = io.Copy
skillSetupReadState = skillstate.Read
skillSetupWriteState = skillstate.Write
skillSetupRemoveState = skillstate.Remove
skillSetupPublishPath = upgrade.PublishSkillPathNoReplace
skillSetupRollbackPaths = upgrade.RollbackSkillPathPublications
skillSetupNow = time.Now
skillSetupFoldPathCase = runtime.GOOS == "windows"
)
type skillSetupBackup struct {
Path string
Reason string
}
type skillSetupTargetPlan struct {
Destination string
CanonicalBase string
Backups []skillSetupBackup
CleanupOnly bool
LinkCanonical bool
}
type skillSetupPlan struct {
Mode string
Source string
MultiSkillNames []string
Filtered bool
Targets []skillSetupTargetPlan
EventMiscMigrationTargets []string
InstallsEventMiscCompanion bool
}
type skillSetupStagedDir struct {
staged string
dest string
}
type skillSetupBackedUpDir struct {
original string
backup string
}
const (
skillSetupBackupMutual = "opposite layout"
skillSetupBackupStale = "stale official Skill"
skillSetupBackupReplace = "same-name Skill"
)
func newSkillSetupCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "setup",
Short: "安装 dws 自身 skill 到 Agent 目录",
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
支持两种模式:
multi 多 skill(默认)—— 按产品拆 N 个独立 skill(dingtalk-*)
mono 单 skill(legacy)—— 总入口 SKILL.md + references/products/
multi 模式支持按产品挑选:
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
用 -s/-x 挑选时未列出的已有 dingtalk-* skill 会保留(additive 叠加语义);
不带过滤条件的全量安装只清理统一状态中登记,或属于状态上线前精确官方
名称集合且不在 bundle 内的过期 Skill。
setup 成功后记录本次官方清单;后续每次 dws upgrade 都按新版本官方清单
全量覆盖预制 skill,因此本地删除或 setup 时排除的预制 skill 会在升级时恢复。
清理与备份(本命令可能移除的目录):
· 安装任一模式前会清理对面模式残留:装 mono 移除统一状态中登记,或属于
状态上线前精确官方名称集合的 multi Skill,
装 multi 移除 <agent-home>/dws/;全量 multi 安装还会移除不在 bundle 内的
过期受管 Skill。仅有 dingtalk-* 前缀的市场/用户 Skill 不会被清理。
· 被移除的目录与同名旧 skill 会先备份到 ~/.dws/skill-backups/<时间戳>/;
备份失败时保留原目录并跳过该目标,绝不静默删除。
· 所有将被移除的目录都会在确认前逐条列出。
不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。
DWS 会自动适配本机上检测到的 Agent;共享安装方式不可用时会自动改用兼容安装,
无需用户手动处理,也不会让同一个 Skill 重复出现。
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
Example: ` dws skill setup --mode multi --target claude --dry-run
dws skill setup --mode multi --target claude`,
DisableAutoGenTag: true,
RunE: runSkillSetup,
}
cmd.Flags().String("mode", "", "skill 模式:mono | multi(不指定则交互询问)")
cmd.Flags().String("target", "all", "目标 Agent:all | "+supportedTargets())
cmd.Flags().String("source", "", "skill 源目录(默认使用二进制内嵌的 skill 源,与当前版本一致)")
cmd.Flags().Bool("yes", false, "跳过确认提示(仅供脚本使用;删除操作仍会先备份到 ~/.dws/skill-backups/)")
cmd.Flags().StringSliceP("skill", "s", nil, "multi 模式:仅安装指定子 skill(可重复,接受短名 aitable 或全名 dingtalk-aitable)")
cmd.Flags().StringSliceP("exclude", "x", nil, "multi 模式:从全装中剔除指定子 skill(可重复,与 --skill 互斥)")
return cmd
}
func runSkillSetup(cmd *cobra.Command, _ []string) error {
mode, _ := cmd.Flags().GetString("mode")
target, _ := cmd.Flags().GetString("target")
source, _ := cmd.Flags().GetString("source")
autoYes, _ := cmd.Flags().GetBool("yes")
includeRaw, _ := cmd.Flags().GetStringSlice("skill")
excludeRaw, _ := cmd.Flags().GetStringSlice("exclude")
out := cmd.OutOrStdout()
errOut := cmd.ErrOrStderr()
mode, err := skillSetupResolveMode(mode, autoYes, out)
if err != nil {
return err
}
if mode == skillSetupModeMono && (len(includeRaw) > 0 || len(excludeRaw) > 0) {
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
}
skillSrc, srcCleanup, err := skillSetupResolveSource(source, mode)
if err != nil {
return err
}
defer srcCleanup()
dests, err := skillSetupResolveTargets(target, mode)
if err != nil {
return err
}
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
var multiSkillNames, allMultiSkillNames []string
var foldedEventMiscTargets []string
var migrateEventMiscTargets []string
var installsEventMiscCompanion bool
if mode == skillSetupModeMulti {
var listErr error
allMultiSkillNames, listErr = skillSetupListMulti(skillSrc)
if listErr != nil {
return listErr
}
if len(allMultiSkillNames) == 0 {
return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc)
}
filtered, filterErr := skillSetupFilterMulti(allMultiSkillNames, includeRaw, excludeRaw)
if filterErr != nil {
return filterErr
}
// dingtalk-shared carries the global rules every product skill declares as a
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
foldedEventMiscTargets = findFoldedEventMiscTargets(dests)
if len(foldedEventMiscTargets) > 0 {
hasEvent := containsSkillName(multiSkillNames, multiEventSkill)
hasMisc := containsSkillName(multiSkillNames, multiMiscSkill)
switch {
case normalizedSkillListContains(excludeRaw, multiEventSkill):
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能显式 --exclude event,请先完成 dingtalk-event 迁移")
case hasMisc && !hasEvent:
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能只覆盖 dingtalk-misc,必须同时迁移 dingtalk-event")
case hasEvent:
if normalizedSkillListContains(excludeRaw, multiMiscSkill) {
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;本次安装 dingtalk-event 必须同时迁移 dingtalk-misc,不能显式 --exclude misc")
}
if !containsSkillName(allMultiSkillNames, multiMiscSkill) {
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由,但当前 multi 源缺少迁移所需的 %s", multiMiscSkill)
}
if err := validateEventMiscMigrationSource(skillSrc); err != nil {
return err
}
migrateEventMiscTargets = append(migrateEventMiscTargets, foldedEventMiscTargets...)
installsEventMiscCompanion = !hasMisc
}
}
}
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
// install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。
filtered := len(includeRaw) > 0 || len(excludeRaw) > 0
plan, err := skillSetupBuildPlan(mode, skillSrc, dests, multiSkillNames, filtered)
if err != nil {
return fmt.Errorf("无法完整计算 Skill 安装计划: %w", err)
}
configureEventMiscMigrationPlan(plan, migrateEventMiscTargets, installsEventMiscCompanion)
// --dry-run 与交互确认消费同一份计划,所以展示的备份路径
// 与执行阶段传给 BackupAndRemove 的路径完全一致。
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
fmt.Fprintf(out, "[DRY-RUN] 预览(不写入任何文件):mode=%s\n", plan.Mode)
renderSkillSetupPlan(out, plan)
return nil
}
if !autoYes {
ok, err := skillSetupConfirmPlan(out, plan)
if err != nil {
return err
}
if !ok {
fmt.Fprintln(out, "已取消。")
return nil
}
}
var managedSkills []skillprovenance.Record
if mode == skillSetupModeMulti {
updatedSkillNames := append([]string(nil), multiSkillNames...)
if installsEventMiscCompanion && !containsSkillName(updatedSkillNames, multiMiscSkill) {
updatedSkillNames = append(updatedSkillNames, multiMiscSkill)
sort.Strings(updatedSkillNames)
}
managedSkills, err = buildSkillProvenanceRecords(plan.Source, updatedSkillNames, RawVersion(), skillprovenance.SourceSkillSetup)
if err != nil {
return fmt.Errorf("生成统一 Skill provenance 失败: %w", err)
}
if filtered {
home, homeErr := skillSetupUserHomeDir()
if homeErr != nil {
return fmt.Errorf("无法解析 HOME 以读取 Skill 统一状态: %w", homeErr)
}
previous, readable, readErr := skillSetupReadState(home)
if readErr != nil {
return fmt.Errorf("读取 Skill 统一状态失败: %w", readErr)
}
if readable {
managedSkills = skillprovenance.Merge(previous.ManagedSkills, managedSkills)
}
}
}
var installed, skipped int
if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 {
installed, skipped, err = installMultiSkillsWithEventMigration(
skillSrc,
multiSkillNames,
dests,
migrateEventMiscTargets,
filtered,
out,
errOut,
)
} else {
installed, skipped, err = skillSetupExecutePlan(plan, out, errOut)
}
if err != nil {
return err
}
if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 {
retiredNames := append([]string(nil), multiSkillNames...)
if installsEventMiscCompanion && !containsSkillName(retiredNames, multiMiscSkill) {
retiredNames = append(retiredNames, multiMiscSkill)
}
if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil {
// Retiring an obsolete universal copy installs nothing; report it and
// keep the successful installation rather than failing the run.
fmt.Fprintf(errOut, " ⚠️ %v\n", retireErr)
}
}
if skipped > 0 {
return fmt.Errorf(
"Skill 安装不完整(mode=%s, installed=%d, skipped=%d);修复失败原因后请重试 setup,或运行普通 upgrade 全量刷新预制 Skill",
mode,
installed,
skipped,
)
}
if installed > 0 {
home, homeErr := skillSetupUserHomeDir()
if homeErr != nil {
return fmt.Errorf("skill 已安装,但无法解析 HOME 以保存更新状态: %w", homeErr)
}
if mode == skillSetupModeMulti {
updatedSkillNames := append([]string(nil), multiSkillNames...)
if installsEventMiscCompanion && !containsSkillName(updatedSkillNames, multiMiscSkill) {
updatedSkillNames = append(updatedSkillNames, multiMiscSkill)
sort.Strings(updatedSkillNames)
}
state := skillstate.State{
Version: RawVersion(),
OfficialSkills: allMultiSkillNames,
UpdatedSkills: updatedSkillNames,
ManagedSkills: managedSkills,
UpdatedAt: skillSetupNow().UTC().Format(time.RFC3339),
}
if stateErr := skillSetupWriteState(home, state); stateErr != nil {
return fmt.Errorf("skill 已安装,但保存官方 Skill 信息快照失败: %w", stateErr)
}
} else if stateErr := skillSetupRemoveState(home); stateErr != nil {
return fmt.Errorf("mono Skill 已安装,但清理 multi 更新状态失败: %w", stateErr)
}
}
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
fmt.Fprintln(out, " 统一安装位置:~/.agents/skills")
fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent")
fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。")
return nil
}
// multiSkillPrefix is the canonical prefix for every per-product skill
// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar).
const multiSkillPrefix = "dingtalk-"
// multiSharedSkill is the shared, non-product skill that every per-product
// skill declares as a PREREQUISITE. It must always be installed in multi mode
// regardless of --skill / --exclude, otherwise the product skills reference a
// dingtalk-shared that was never installed.
const multiSharedSkill = "dingtalk-shared"
// legacySharedSkill is the pre-rename directory name of the shared skill.
// Installations created before the dws-shared -> dingtalk-shared rename keep a
// dws-shared directory on disk; cleanup paths must still recognize it so a
// full install / mode switch removes it instead of leaving an orphaned,
// unreferenced skill next to the new dingtalk-shared.
const legacySharedSkill = "dws-shared"
// isManagedDWSMultiSkillDir reports whether dir is named by unified metadata
// or by the frozen pre-state official migration list.
func isManagedDWSMultiSkillDir(dir string, managed ...map[string]bool) bool {
if skillstate.IsLegacyOfficialSkillName(filepath.Base(dir)) {
return true
}
return len(managed) > 0 && managed[0][filepath.Base(dir)]
}
func buildSkillProvenanceRecords(root string, names []string, version, source string) ([]skillprovenance.Record, error) {
records := make([]skillprovenance.Record, 0, len(names))
for _, name := range names {
record, err := skillSetupBuildProvenance(name, filepath.Join(root, name), version, source)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
records = append(records, record)
}
return records, nil
}
// publishDWSManagedSkillDir stages a complete Skill before exposing it.
func publishDWSManagedSkillDir(src, dest string) (err error) {
parent := filepath.Dir(dest)
stage, err := skillSetupPublishTemp(parent, "."+filepath.Base(dest)+".tmp-")
if err != nil {
return fmt.Errorf("创建 Skill staging 失败 %s: %w", parent, err)
}
published := false
defer func() {
if published {
return
}
if cleanupErr := skillSetupRemoveAll(stage); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stage, cleanupErr))
}
}()
if err := skillSetupCopyDir(src, stage); err != nil {
return fmt.Errorf("拷贝 Skill staging 失败 %s: %w", stage, err)
}
if err := skillSetupPublishRename(stage, dest); err != nil {
return fmt.Errorf("发布 Skill 失败 %s: %w", dest, err)
}
published = true
return nil
}
// legacyMultiSharedSkill is the retired name shipped by older multi-skill
// bundles. Once the replacement has been installed successfully, remove this
// exact directory so Agent discovery cannot load both routing contracts.
const legacyMultiSharedSkill = legacySharedSkill
const (
multiEventSkill = "dingtalk-event"
multiMiscSkill = "dingtalk-misc"
)
var eventMigrationRequiredReferences = []string{
"event-im.md",
"event-im-keys.md",
"event-im-lifecycle.md",
"event-im-operations.md",
"event-im-output.md",
"event-oa.md",
}
func containsSkillName(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
func normalizedSkillListContains(raw []string, want string) bool {
for _, name := range raw {
if normalizeMultiSkillName(name) == want {
return true
}
}
return false
}
// findFoldedEventMiscTargets identifies the short-lived multi-skill layout in
// which personal Event routing lived inside dingtalk-misc. Both markers are
// required so an unrelated misc install is never treated as a migration target.
func findFoldedEventMiscTargets(dests []string) []string {
var targets []string
for _, dest := range dests {
miscRoot := filepath.Join(dest, multiMiscSkill)
skillBody, err := os.ReadFile(filepath.Join(miscRoot, "SKILL.md"))
if err != nil || !containsPersonalEventRoute(skillBody) {
continue
}
eventRef, err := skillSetupStat(filepath.Join(miscRoot, "references", "event.md"))
if err != nil || eventRef.IsDir() {
continue
}
targets = append(targets, dest)
}
sort.Strings(targets)
return targets
}
func containsPersonalEventRoute(skillBody []byte) bool {
body := strings.ToLower(string(skillBody))
for _, marker := range []string{
"dws event",
"个人 event",
"个人 im 事件",
"个人 im/oa",
"personal event",
} {
if strings.Contains(body, marker) {
return true
}
}
return false
}
func printEventMiscMigrationPreview(out io.Writer, targets []string, installsCompanion bool) {
if len(targets) == 0 {
return
}
action := "将原子切换 dingtalk-event 与本次已选择的干净 dingtalk-misc"
if installsCompanion {
action = "将原子切换 dingtalk-event,并额外安装干净的 dingtalk-misc 作为迁移伴侣(仅限以下目标)"
}
fmt.Fprintf(out, "Event Skill 迁移:%s:\n", action)
for _, target := range targets {
fmt.Fprintf(out, " × %s\n", filepath.Join(target, multiEventSkill))
fmt.Fprintf(out, " × %s\n", filepath.Join(target, multiMiscSkill))
}
}
func validateEventMiscMigrationSource(src string) error {
if err := validateEventMigrationSkillRoot(filepath.Join(src, multiEventSkill)); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
if err := validateMigrationSkillRoot(filepath.Join(src, multiMiscSkill), multiMiscSkill, nil); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
if err := validateCleanEventMiscRoot(filepath.Join(src, multiMiscSkill)); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
return nil
}
func validateEventMigrationSkillRoot(root string) error {
required := make([]string, 0, len(eventMigrationRequiredReferences))
for _, name := range eventMigrationRequiredReferences {
required = append(required, filepath.Join("references", name))
}
return validateMigrationSkillRoot(root, multiEventSkill, required)
}
func validateMigrationSkillRoot(root, expectedName string, requiredFiles []string) error {
skillPath := filepath.Join(root, "SKILL.md")
skillBody, err := os.ReadFile(skillPath)
if err != nil {
return fmt.Errorf("无法读取 %s: %w", skillPath, err)
}
name, err := parseMigrationSkillFrontmatter(skillBody)
if err != nil {
return fmt.Errorf("%s 无效: %w", skillPath, err)
}
if name != expectedName {
return fmt.Errorf("%s 的 name=%q,期望 %q", skillPath, name, expectedName)
}
for _, rel := range requiredFiles {
path := filepath.Join(root, rel)
info, statErr := skillSetupStat(path)
if statErr != nil || info.IsDir() {
if statErr == nil {
statErr = errors.New("is a directory")
}
return fmt.Errorf("缺少有效文件 %s: %w", path, statErr)
}
body, readErr := os.ReadFile(path)
if readErr != nil {
return fmt.Errorf("无法读取 %s: %w", path, readErr)
}
if strings.TrimSpace(string(body)) == "" {
return fmt.Errorf("文件为空 %s", path)
}
}
return nil
}
func parseMigrationSkillFrontmatter(body []byte) (string, error) {
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
lines := strings.Split(normalized, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return "", errors.New("缺少 YAML frontmatter")
}
name := ""
description := ""
closingLine := -1
for i := 1; i < len(lines); i++ {
rawLine := lines[i]
line := strings.TrimSpace(rawLine)
if line == "---" {
closingLine = i
break
}
// Only inspect top-level frontmatter keys. Nested metadata may legally
// contain its own `name` without changing the Skill identity.
if strings.TrimLeft(rawLine, " \t") != rawLine {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
value = strings.Trim(strings.TrimSpace(value), "\"'")
switch strings.TrimSpace(key) {
case "name":
if name != "" {
return "", errors.New("frontmatter 含重复 name")
}
name = value
case "description":
description = value
}
}
if closingLine < 0 {
return "", errors.New("YAML frontmatter 未闭合")
}
if name == "" {
return "", errors.New("frontmatter 缺少 name")
}
if description == "" {
return "", errors.New("frontmatter 缺少 description")
}
if strings.TrimSpace(strings.Join(lines[closingLine+1:], "\n")) == "" {
return "", errors.New("SKILL.md 正文为空")
}
return name, nil
}
func validateCleanEventMiscRoot(miscRoot string) error {
miscSkillPath := filepath.Join(miscRoot, "SKILL.md")
miscBody, err := os.ReadFile(miscSkillPath)
if err != nil {
return fmt.Errorf("无法读取 %s: %w", miscSkillPath, err)
}
if containsPersonalEventRoute(miscBody) {
return fmt.Errorf("%s 仍包含个人 Event 路由", miscSkillPath)
}
refsRoot := filepath.Join(miscRoot, "references")
entries, err := skillSetupReadDir(refsRoot)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("无法检查 %s: %w", refsRoot, err)
}
for _, entry := range entries {
name := strings.ToLower(entry.Name())
if !entry.IsDir() && strings.HasPrefix(name, "event") && strings.HasSuffix(name, ".md") {
return fmt.Errorf("%s 仍存在折叠 Event 参考页", filepath.Join(refsRoot, entry.Name()))
}
}
return nil
}
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
func ensureMandatorySharedSkill(selected, all []string) []string {
hasShared := false
for _, n := range all {
if n == multiSharedSkill {
hasShared = true
break
}
}
if !hasShared {
return selected
}
for _, n := range selected {
if n == multiSharedSkill {
return selected
}
}
return append([]string{multiSharedSkill}, selected...)
}
// normalizeMultiSkillName accepts either the short form (aitable) or the
// full form (dingtalk-aitable) and returns the canonical full form.
// Empty input returns "". Comparison is case-insensitive.
func normalizeMultiSkillName(name string) string {
n := strings.ToLower(strings.TrimSpace(name))
if n == "" {
return ""
}
if strings.HasPrefix(n, multiSkillPrefix) {
return n
}
return multiSkillPrefix + n
}
// filterMultiSkillNames narrows `all` by include / exclude lists:
//
// - include + exclude are mutually exclusive (both → error)
// - names accept short or full form; normalized before matching
// - unknown names → error, with the available list inlined for discovery
// - both lists empty → return `all` (install everything)
// - exclude that drops every name → error (avoid silent no-op install)
//
// The caller threads whether a filter was used into installMultiSkillToHomes:
// filtered installs stay additive; a full unfiltered install also removes
// stale, proven DWS-managed directories that are no longer in the bundle.
func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
if len(include) > 0 && len(exclude) > 0 {
return nil, fmt.Errorf("--skill 与 --exclude 不能同时使用")
}
available := make(map[string]struct{}, len(all))
for _, n := range all {
available[n] = struct{}{}
}
validate := func(raw []string, flagName string) ([]string, error) {
var normalized []string
var unknown []string
seen := make(map[string]bool)
for _, r := range raw {
n := normalizeMultiSkillName(r)
if n == "" {
continue
}
if _, ok := available[n]; !ok {
unknown = append(unknown, r)
continue
}
if !seen[n] {
seen[n] = true
normalized = append(normalized, n)
}
}
if len(unknown) > 0 {
return nil, fmt.Errorf("%s 中的以下名称在 multi 源中找不到:%s\n可用列表(共 %d 个):%s",
flagName, strings.Join(unknown, ", "), len(all), strings.Join(all, ", "))
}
return normalized, nil
}
if len(include) > 0 {
names, err := validate(include, "--skill")
if err != nil {
return nil, err
}
sort.Strings(names)
return names, nil
}
if len(exclude) > 0 {
excluded, err := validate(exclude, "--exclude")
if err != nil {
return nil, err
}
excludedSet := make(map[string]bool, len(excluded))
for _, n := range excluded {
excludedSet[n] = true
}
var out []string
for _, n := range all {
if !excludedSet[n] {
out = append(out, n)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("--exclude 把全部 %d 个子 skill 都剔除了,没有可装的", len(all))
}
return out, nil
}
return all, nil
}
// listMultiSkillNames returns sorted names of subdirectories under src that
// contain a SKILL.md file (i.e. valid multi-mode skill bundles).
func listMultiSkillNames(src string) ([]string, error) {
entries, err := skillSetupReadDir(src)
if err != nil {
return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err)
}
var names []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := skillSetupStat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names, nil
}
// resolveSkillSetupMode resolves the mode either from the flag or via an
// interactive prompt. If no TTY is available and no mode was given, returns
// an error rather than silently picking a default.
func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, error) {
mode = strings.ToLower(strings.TrimSpace(mode))
switch mode {
case skillSetupModeMono, skillSetupModeMulti:
return mode, nil
case "":
// fall through to interactive prompt
default:
return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode)
}
if autoYes || !skillSetupInteractive() {
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 multi")
return skillSetupModeMulti, nil
}
var choice string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("选择 dws skill 安装模式").
Description("multi = 按产品拆分(默认)\nmono = 单 skill 入口(legacy)").
Options(
huh.NewOption("multi — 多 skill(默认)", skillSetupModeMulti),
huh.NewOption("mono — 单 skill(legacy)", skillSetupModeMono),
).
Value(&choice),
),
)
if err := skillSetupRunForm(form); err != nil {
return "", fmt.Errorf("交互式选择中止: %w", err)
}
return choice, nil
}
// resolveSkillSetupSource finds the local skill source directory for the
// given mode ("mono" or "multi"). Explicit overrides (--source flag or
// DWS_SKILL_SOURCE) win and never fall back to another source; without an
// override the ordered candidate list (binary-adjacent, working directory,
// ~/.dws/skills user cache) is probed for a valid skill root of that mode.
func resolveSkillSetupSource(explicit, mode string) (string, error) {
subdir := mode // "mono" or "multi"
// An explicit override (--source flag or DWS_SKILL_SOURCE) wins, and an
// override that does not contain a skill root is an error — never a
// silent fallback to another source the user did not ask for.
var overrides []string
if explicit != "" {
overrides = append(overrides, explicit, filepath.Join(explicit, "skills", subdir))
}
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
overrides = append(overrides, env, filepath.Join(env, "skills", subdir))
}
if len(overrides) > 0 {
for _, c := range overrides {
if isSkillSourceRoot(c, mode) {
return c, nil
}
}
hint := strings.Join(overrides, "\n - ")
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录(--source / DWS_SKILL_SOURCE 显式指定时不回退到内嵌源),已尝试:\n - %s", mode, hint)
}
// No explicit override: legacy fallback only — embedded materialization
// is handled by resolveSkillSetupSourceOrEmbedded (skill_setup_embed.go),
// the wrapper that callers use. This branch is reachable only when the
// wrapper passes through with an empty explicit/env (legacy direct call).
candidates := skillSourceCandidates("", subdir)
for _, c := range candidates {
if isSkillSourceRoot(c, mode) {
return c, nil
}
}
hint := strings.Join(candidates, "\n - ")
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录,已尝试:\n - %s\n\n请用 --source 显式指定包含 skills/%s 的仓库根目录", mode, hint, mode)
}
// skillSourceCandidates returns the ordered list of paths to probe for a
// skill source root, given an optional explicit override and the mode
// subdir (mono or multi).
func skillSourceCandidates(explicit, subdir string) []string {
var roots []string
if explicit != "" {
// allow either repo root or already-resolved skills/<mode> dir
roots = append(roots, explicit, filepath.Join(explicit, "skills", subdir))
}
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
roots = append(roots, env, filepath.Join(env, "skills", subdir))
}
if exe, err := skillSetupExecutable(); err == nil {
exeDir := filepath.Dir(exe)
roots = append(roots,
filepath.Join(exeDir, "skills", subdir),
filepath.Join(exeDir, "..", "skills", subdir),
filepath.Join(exeDir, "..", "share", "skills", "dws"),
)
}
if wd, err := skillSetupGetwd(); err == nil {
roots = append(roots, filepath.Join(wd, "skills", subdir))
}
// User-level cache populated by install.sh / install.ps1 / npm install.js
// from the dws-skills.zip release asset. Lets `dws skill setup` find a
// source even when the user has no source checkout on disk.
if home, err := skillSetupUserHomeDir(); err == nil {
roots = append(roots, filepath.Join(home, ".dws", "skills", subdir))
}
return roots
}
func isSkillSourceRoot(path, mode string) bool {
if path == "" {
return false
}
switch mode {
case skillSetupModeMono:
fi, err := skillSetupStat(filepath.Join(path, "SKILL.md"))
return err == nil && !fi.IsDir()
case skillSetupModeMulti:
entries, err := skillSetupReadDir(path)
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
if _, err := skillSetupStat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
return true
}
}
}
return false
}
return false
}
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
// The canonical ~/.agents/skills destination is always first. If target ==
// "all", detected concrete Agent roots follow it. A specific target follows
// canonical as well so unknown/future Agents retain the universal copy.
//
// 末段约定:
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
// - multi → <agent-home> (安装时把 src 下每个子目录拷成兄弟 skill)
func resolveSkillSetupTargets(target, mode string) ([]string, error) {
home, err := skillSetupUserHomeDir()
if err != nil {
return nil, fmt.Errorf("无法解析用户 HOME: %w", err)
}
target = strings.ToLower(strings.TrimSpace(target))
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
if target == "" || target == "all" {
return detectExistingAgentHomes(home, mode), nil
}
if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
return nil, errors.New(reason)
}
_, ok := agentSkillPaths[target]
if !ok {
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
}
dest := agentHomeForMode(resolveSkillSetupBase(home, target), mode)
if sameSkillSetupPath(dest, canonical) {
return []string{canonical}, nil
}
return []string{canonical, dest}, nil
}
func resolveOpenClawSetupBase(home string) string {
for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} {
base := filepath.Join(home, name)
if info, err := skillSetupStat(base); err == nil && info.IsDir() {
return filepath.Join(base, "skills")
}
}
return filepath.Join(home, ".openclaw", "skills")
}
func resolveSkillSetupBase(home, target string) string {
switch target {
case "claude", "claude-code":
if custom := strings.TrimSpace(skillSetupGetenv("CLAUDE_CONFIG_DIR")); custom != "" {
return filepath.Join(custom, "skills")
}
case "codex":
if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "hermes", "hermes-agent":
if custom := strings.TrimSpace(skillSetupGetenv("HERMES_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "autohand-code":
if custom := strings.TrimSpace(skillSetupGetenv("AUTOHAND_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "grok":
if custom := strings.TrimSpace(skillSetupGetenv("GROK_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "mistral-vibe":
if custom := strings.TrimSpace(skillSetupGetenv("VIBE_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "openclaw":
return resolveOpenClawSetupBase(home)
case "opencode", "amp", "replit", "universal", "crush", "devin", "goose", "kimchi":
configHome := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME"))
if configHome == "" {
configHome = filepath.Join(home, ".config")
}
switch target {
case "opencode":
return filepath.Join(configHome, "opencode", "skills")
case "amp", "replit", "universal":
return filepath.Join(configHome, "agents", "skills")
case "crush":
return filepath.Join(configHome, "crush", "skills")
case "devin":
return filepath.Join(configHome, "devin", "skills")
case "goose":
return filepath.Join(configHome, "goose", "skills")
case "kimchi":
return filepath.Join(configHome, "kimchi", "harness", "skills")
}
case "github", "github-copilot":
return filepath.Join(home, ".copilot", "skills")
case "windsurf":
return filepath.Join(home, ".codeium", "windsurf", "skills")
}
return filepath.Join(home, agentSkillPaths[target])
}
// agentHomeForMode appends the mode-specific tail segment to an agent home base.
func agentHomeForMode(base, mode string) string {
if mode == skillSetupModeMulti {
return base
}
return filepath.Join(base, "dws")
}
func detectExistingAgentHomes(home, mode string) []string {
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
dests := []string{canonical}
canonicalKey := skillSetupPathKey(canonical)
seen := map[string]bool{canonicalKey: true}
addDetected := func(rel, base string) {
detectedDir := filepath.Dir(base)
switch filepath.ToSlash(filepath.Clean(rel)) {
case ".config/kimchi/harness/skills", ".tabnine/agent/skills":
detectedDir = filepath.Dir(filepath.Dir(base))
case ".zcode/skills":
// Application bundles are machine-scoped detection signals. Keep this
// independent of HOME so setup matches npm, Shell, and PowerShell.
if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() {
detectedDir = ""
}
case ".minimax/skills":
if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() {
detectedDir = ""
}
}
if detectedDir != "" {
if info, err := skillSetupStat(detectedDir); err != nil || !info.IsDir() {
return
}
}
dest := agentHomeForMode(base, mode)
key := skillSetupPathKey(dest)
if !seen[key] {
seen[key] = true
dests = append(dests, dest)
}
}
for i, rel := range skillSetupAgentHomes {
if i == 0 {
continue
}
base := filepath.Join(home, rel)
switch filepath.ToSlash(filepath.Clean(rel)) {
case ".claude/skills":
base = resolveSkillSetupBase(home, "claude-code")
case ".codex/skills":
base = resolveSkillSetupBase(home, "codex")
case ".hermes/skills":
base = resolveSkillSetupBase(home, "hermes-agent")
case ".autohand/skills":
base = resolveSkillSetupBase(home, "autohand-code")
case ".grok/skills":
base = resolveSkillSetupBase(home, "grok")
case ".vibe/skills":
base = resolveSkillSetupBase(home, "mistral-vibe")
case ".openclaw/skills":
base = resolveSkillSetupBase(home, "openclaw")
case ".config/opencode/skills":
base = resolveSkillSetupBase(home, "opencode")
case ".config/agents/skills":
base = resolveSkillSetupBase(home, "amp")
case ".config/crush/skills":
base = resolveSkillSetupBase(home, "crush")
case ".config/devin/skills":
base = resolveSkillSetupBase(home, "devin")
case ".config/goose/skills":
base = resolveSkillSetupBase(home, "goose")
case ".config/kimchi/harness/skills":
base = resolveSkillSetupBase(home, "kimchi")
}
addDetected(rel, base)
}
for _, target := range []string{"github-copilot", "windsurf"} {
addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target))
}
return dests
}
func skillSetupBaseForMode(dest, mode string) string {
if mode == skillSetupModeMono {
return filepath.Dir(dest)
}
return dest
}
func isUniversalSkillSetupBase(base string) bool {
cleanBase := filepath.Clean(base)
if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" && sameSkillSetupPath(cleanBase, filepath.Join(custom, "skills")) {
return true
}
if custom := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")); custom != "" {
if sameSkillSetupPath(cleanBase, filepath.Join(custom, "agents", "skills")) ||
sameSkillSetupPath(cleanBase, filepath.Join(custom, "opencode", "skills")) {
return true
}
}
base = filepath.ToSlash(cleanBase)
for rel := range map[string]bool{
".config/agents/skills": true, ".gemini/antigravity/skills": true,
".gemini/antigravity-cli/skills": true, ".codex/skills": true,
".cursor/skills": true, ".deepagents/agent/skills": true,
".firebender/skills": true, ".gemini/skills": true,
".copilot/skills": true, ".config/opencode/skills": true,
// beta.6 compatibility roots are cleanup-only.
".github/skills": true, ".windsurf/skills": true,
".cline/skills": true, ".amp/skills": true,
} {
if strings.HasSuffix(base, "/"+rel) {
return true
}
}
return false
}
func sameSkillSetupPath(left, right string) bool {
return skillSetupPathKey(left) == skillSetupPathKey(right)
}
func skillSetupPathKey(path string) string {
clean := filepath.Clean(path)
if skillSetupFoldPathCase {
clean = strings.ToLower(clean)
}
return clean
}
func canonicalSkillSetupBase(dests []string, mode string) string {
for _, dest := range dests {
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode)))
if strings.HasSuffix(base, "/.agents/skills") {
return skillSetupBaseForMode(dest, mode)
}
}
return ""
}
func samePhysicalSkillSetupPath(left, right string) bool {
leftReal, leftErr := skillSetupEvalSymlinks(left)
rightReal, rightErr := skillSetupEvalSymlinks(right)
return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal)
}
func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) {
if mode != skillSetupModeMono && mode != skillSetupModeMulti {
return nil, fmt.Errorf("内部错误:未知 mode %q", mode)
}
plan := &skillSetupPlan{
Mode: mode,
Source: src,
MultiSkillNames: append([]string(nil), multiSkillNames...),
Filtered: filtered,
}
sort.Strings(plan.MultiSkillNames)
sortedDests := append([]string(nil), dests...)
sort.Slice(sortedDests, func(i, j int) bool {
leftCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[i], mode))), "/.agents/skills")
rightCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[j], mode))), "/.agents/skills")
if leftCanonical != rightCanonical {
return leftCanonical
}
return sortedDests[i] < sortedDests[j]
})
managedNames := currentManagedSkillNames()
canonicalBase := canonicalSkillSetupBase(sortedDests, mode)
for _, dest := range sortedDests {
base := skillSetupBaseForMode(dest, mode)
target := skillSetupTargetPlan{Destination: dest, CanonicalBase: canonicalBase}
if canonicalBase != "" && filepath.Clean(base) != filepath.Clean(canonicalBase) {
if isUniversalSkillSetupBase(base) {
target.CleanupOnly = true
} else {
target.LinkCanonical = true
}
}
seen := map[string]bool{}
add := func(path, reason string) {
if seen[path] {
return
}
seen[path] = true
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason})
}
mutual, err := mutualExclusionVictims(dest, mode, managedNames)
if err != nil {
return nil, err
}
for _, path := range mutual {
add(path, skillSetupBackupMutual)
}
if mode == skillSetupModeMulti && !filtered {
stale, staleErr := staleMultiSkillVictimsWithError(dest, multiSkillNames, managedNames)
if staleErr != nil {
return nil, staleErr
}
for _, path := range stale {
add(path, skillSetupBackupStale)
}
}
if mode == skillSetupModeMulti && filtered && containsSkillName(plan.MultiSkillNames, multiSharedSkill) {
legacyPath := filepath.Join(dest, legacySharedSkill)
info, statErr := skillSetupStat(legacyPath)
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
return nil, fmt.Errorf("检查已退役 Skill 失败 %s: %w", legacyPath, statErr)
}
if statErr == nil && info.IsDir() {
add(legacyPath, skillSetupBackupStale)
}
}
var replacements []string
if mode == skillSetupModeMono {
replacements = []string{dest}
} else {
for _, name := range plan.MultiSkillNames {
replacements = append(replacements, filepath.Join(dest, name))
}
}
for _, path := range replacements {
if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) {
continue
}
_, statErr := skillSetupLstat(path)
if statErr != nil {
if errors.Is(statErr, os.ErrNotExist) {
continue
}
return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr)
}
add(path, skillSetupBackupReplace)
}
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
if !target.CleanupOnly || len(target.Backups) > 0 {
plan.Targets = append(plan.Targets, target)
}
}
return plan, nil
}
func currentManagedSkillNames() map[string]bool {
home, err := skillSetupUserHomeDir()
if err != nil {
return map[string]bool{}
}
state, readable, err := skillSetupReadState(home)
if err != nil || !readable {
return map[string]bool{}
}
return skillstate.ManagedSkillNames(state)
}
func configureEventMiscMigrationPlan(plan *skillSetupPlan, targets []string, installsCompanion bool) {
plan.EventMiscMigrationTargets = append([]string(nil), targets...)
sort.Strings(plan.EventMiscMigrationTargets)
plan.InstallsEventMiscCompanion = installsCompanion
if len(targets) == 0 {
return
}
targetSet := make(map[string]bool, len(targets))
for _, target := range targets {
targetSet[target] = true
}
for i := range plan.Targets {
if !targetSet[plan.Targets[i].Destination] {
continue
}
filtered := plan.Targets[i].Backups[:0]
for _, backup := range plan.Targets[i].Backups {
base := filepath.Base(backup.Path)
if base == multiEventSkill || base == multiMiscSkill {
continue
}
filtered = append(filtered, backup)
}
plan.Targets[i].Backups = filtered
}
}
func retireMigratedUniversalSkills(targets, names []string, out io.Writer) error {
home, err := skillSetupUserHomeDir()
if err != nil {
return fmt.Errorf("无法解析 HOME 以退役 universal Agent 旧副本: %w", err)
}
seen := map[string]bool{}
var victims []skillSetupBackup
for _, target := range targets {
if !isUniversalSkillSetupBase(target) {
continue
}
for _, name := range names {
path := filepath.Join(target, name)
if seen[path] {
continue
}
seen[path] = true
victims = append(victims, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace})
}
}
sort.Slice(victims, func(i, j int) bool { return victims[i].Path < victims[j].Path })
if _, err := backupSkillSetupTarget(home, victims, out); err != nil {
return fmt.Errorf("退役 universal Agent Event/misc 旧副本失败,已回滚: %w", err)
}
return nil
}
func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
fmt.Fprintf(out, "📦 将安装 skill:\n mode: %s\n source: %s\n", plan.Mode, plan.Source)
if plan.Mode == skillSetupModeMulti {
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(plan.MultiSkillNames))
for _, name := range plan.MultiSkillNames {
fmt.Fprintf(out, " · %s\n", name)
}
}
fmt.Fprintln(out, " 安装与适配位置:")
for _, target := range plan.Targets {
if target.CleanupOnly {
fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination)
} else if target.LinkCanonical {
fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination)
} else {
fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination)
}
}
fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):")
count := 0
for _, target := range plan.Targets {
for _, backup := range target.Backups {
switch backup.Reason {
case skillSetupBackupMutual:
fmt.Fprintf(out, " × 将备份并移除对面模式残留 %s\n", backup.Path)
case skillSetupBackupStale:
fmt.Fprintf(out, " × 将备份并移除过期 skill %s\n", backup.Path)
default:
fmt.Fprintf(out, " × 将备份并移除同名 Skill %s\n", backup.Path)
}
count++
}
}
if count == 0 {
fmt.Fprintln(out, " (无)")
}
if len(plan.EventMiscMigrationTargets) > 0 {
fmt.Fprint(out, " ")
printEventMiscMigrationPreview(out, plan.EventMiscMigrationTargets, plan.InstallsEventMiscCompanion)
}
}
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string, filtered bool) (bool, error) {
plan, err := buildSkillSetupPlan(mode, src, dests, multiSkillNames, filtered)
if err != nil {
return false, err
}
return confirmSkillSetupPlan(out, plan)
}
func confirmSkillSetupPlan(out io.Writer, plan *skillSetupPlan) (bool, error) {
fmt.Fprintln(out)
renderSkillSetupPlan(out, plan)
if !skillSetupInteractive() {
return false, fmt.Errorf("非交互环境无法确认目录迁移;请先用 --dry-run 预览,确认后显式传入 --yes")
}
var confirm bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("确认安装?").
Affirmative("继续").
Negative("取消").
Value(&confirm),
),
)
if err := skillSetupRunForm(form); err != nil {
return false, fmt.Errorf("确认中止: %w", err)
}
return confirm, nil
}
// mutualExclusionVictims returns the paths that should be removed before
// installing into dest under the given mode, to prevent leftover files from
// the opposite mode from co-existing.
//
// - mono dest is <agent-home>/dws → multi 残留是统一状态中登记的兄弟目录
// - multi dest is <agent-home> → mono 残留是 <agent-home>/dws
//
// A scan failure (e.g. unreadable agent home) is returned as a non-nil error
// so callers can surface a warning instead of silently skipping cleanup.
func mutualExclusionVictims(dest, mode string, managed ...map[string]bool) ([]string, error) {
switch mode {
case skillSetupModeMono:
// dest = <agent-home>/dws → agent-home = parent
agentHome := filepath.Dir(dest)
entries, err := skillSetupReadDir(agentHome)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("扫描 multi 残留失败 %s: %w", agentHome, err)
}
var victims []string
for _, e := range entries {
path := filepath.Join(agentHome, e.Name())
if e.IsDir() && isManagedDWSMultiSkillDir(path, managed...) {
victims = append(victims, path)
}
}
sort.Strings(victims)
return victims, nil
case skillSetupModeMulti:
// dest = <agent-home> → mono 残留是 dest/dws
monoPath := filepath.Join(dest, "dws")
if _, err := skillSetupStat(monoPath); err == nil {
return []string{monoPath}, nil
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("检查 mono 残留失败 %s: %w", monoPath, err)
}
return nil, nil
}
return nil, nil
}
// cleanupMutualExclusion backs up + removes the opposite-mode
// leftovers. Removals are reversible: each victim is moved to
// ~/.dws/skill-backups/<stamp>/ first (skillSetupBackupAndRemove), and a
// victim whose backup fails is left in place with a warning instead of being
// destroyed. A failure is returned so the caller skips the complete Agent
// target and never installs both layouts together.
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) error {
victims, scanErr := mutualExclusionVictims(dest, mode, currentManagedSkillNames())
if scanErr != nil {
fmt.Fprintf(errOut, " ⚠️ 互斥清理扫描失败(跳过整个 Agent 目标) %s: %v\n", dest, scanErr)
return scanErr
}
if len(victims) == 0 {
return nil
}
home, homeErr := skillSetupUserHomeDir()
if homeErr != nil {
for _, victim := range victims {
fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,跳过删除(保留) %s: %v\n", victim, homeErr)
}
return homeErr
}
for _, victim := range victims {
backup, err := skillSetupBackupAndRemove(home, victim)
if err != nil {
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(保留原目录,跳过整个 Agent 目标) %s: %v\n", victim, err)
return err
}
fmt.Fprintf(out, " × 已备份并清理对面模式残留 %s → %s\n", victim, backup)
}
return nil
}
func cleanupLegacyMultiSharedSkill(dest string, out, errOut io.Writer) {
legacyPath := filepath.Join(dest, legacyMultiSharedSkill)
if _, err := skillSetupStat(legacyPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(errOut, " ⚠️ 无法检查已退役 Skill 残留 %s: %v\n", legacyPath, err)
}
return
}
if err := skillSetupRemoveAll(legacyPath); err != nil {
fmt.Fprintf(errOut, " ⚠️ 已退役 Skill 清理失败(已安装 %s) %s: %v\n", multiSharedSkill, legacyPath, err)
return
}
fmt.Fprintf(out, " × 已清理已退役 Skill 残留 %s\n", legacyPath)
}
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
plan, planErr := buildSkillSetupPlan(skillSetupModeMono, src, dests, nil, false)
if planErr != nil {
return 0, len(dests), planErr
}
return executeSkillSetupPlan(plan, out, errOut)
}
func installMultiSkillsWithEventMigration(
src string,
skillNames []string,
dests []string,
migrationTargets []string,
filtered bool,
out, errOut io.Writer,
) (installed, skipped int, err error) {
if len(migrationTargets) == 0 {
return skillSetupInstallMulti(src, skillNames, dests, out, errOut, filtered)
}
migrationSet := make(map[string]struct{}, len(migrationTargets))
physicalMigrationTargets := make([]string, 0, len(migrationTargets))
for _, dest := range migrationTargets {
migrationSet[dest] = struct{}{}
if !isUniversalSkillSetupBase(dest) {
physicalMigrationTargets = append(physicalMigrationTargets, dest)
}
}
var ordinaryTargets []string
for _, dest := range dests {
if _, migrates := migrationSet[dest]; !migrates {
ordinaryTargets = append(ordinaryTargets, dest)
}
}
var canonicalTargets, otherOrdinaryTargets []string
for _, dest := range ordinaryTargets {
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, skillSetupModeMulti)))
if strings.HasSuffix(base, "/.agents/skills") {
canonicalTargets = append(canonicalTargets, dest)
} else {
otherOrdinaryTargets = append(otherOrdinaryTargets, dest)
}
}
installOrdinary := func(names, targets []string) error {
if len(targets) == 0 {
return nil
}
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered)
installed += n
skipped += nSkipped
if err != nil {
return err
}
if nSkipped > 0 {
return fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
}
return nil
}
canonicalNames := append([]string(nil), skillNames...)
if !containsSkillName(canonicalNames, multiMiscSkill) {
canonicalNames = append(canonicalNames, multiMiscSkill)
sort.Strings(canonicalNames)
}
if err := installOrdinary(canonicalNames, canonicalTargets); err != nil {
return installed, skipped, err
}
if err := installOrdinary(skillNames, otherOrdinaryTargets); err != nil {
return installed, skipped, err
}
// The folded pair is excluded from the ordinary best-effort installer. All
// other selected skills (especially dingtalk-shared) must succeed before the
// old Event route is touched.
for _, dest := range physicalMigrationTargets {
if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil {
return installed, skipped + len(skillNames), cleanupErr
}
}
var prerequisiteNames []string
for _, name := range skillNames {
if name != multiEventSkill && name != multiMiscSkill {
prerequisiteNames = append(prerequisiteNames, name)
}
}
if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("event Skill 迁移前置安装不完整(skipped=%d);已保留折叠版 Event/misc", nSkipped)
}
}
migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut)
installed += migrated
if migrationErr != nil {
return installed, skipped, migrationErr
}
return installed, skipped, nil
}
type eventMiscMigration struct {
dest string
stageRoot string
stagedEvent string
stagedMisc string
backupEvent string
backupMisc string
eventPath string
miscPath string
eventBackedUp bool
miscBackedUp bool
newEventEnabled bool
newMiscEnabled bool
}
func prepareEventMiscMigration(src, dest string) (*eventMiscMigration, error) {
stageRoot, err := skillSetupMkdirTemp(dest, ".dws-event-migration-")
if err != nil {
return nil, fmt.Errorf("无法在目标文件系统创建 Event Skill 迁移 staging %s: %w", dest, err)
}
migration := &eventMiscMigration{
dest: dest,
stageRoot: stageRoot,
stagedEvent: filepath.Join(stageRoot, "new-event"),
stagedMisc: filepath.Join(stageRoot, "new-misc"),
backupEvent: filepath.Join(stageRoot, "old-event"),
backupMisc: filepath.Join(stageRoot, "old-misc"),
eventPath: filepath.Join(dest, multiEventSkill),
miscPath: filepath.Join(dest, multiMiscSkill),
}
cleanupOnError := func(cause error) (*eventMiscMigration, error) {
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
cause = errors.Join(cause, fmt.Errorf("清理 staging %s 失败: %w", stageRoot, cleanupErr))
}
return nil, cause
}
if err := skillSetupCopyDir(filepath.Join(src, multiEventSkill), migration.stagedEvent); err != nil {
return cleanupOnError(fmt.Errorf("预备 dingtalk-event 失败 %s: %w", dest, err))
}
if err := skillSetupCopyDir(filepath.Join(src, multiMiscSkill), migration.stagedMisc); err != nil {
return cleanupOnError(fmt.Errorf("预备 dingtalk-misc 失败 %s: %w", dest, err))
}
if err := validateEventMigrationSkillRoot(migration.stagedEvent); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedEvent, err))
}
if err := validateMigrationSkillRoot(migration.stagedMisc, multiMiscSkill, nil); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
}
if err := validateCleanEventMiscRoot(migration.stagedMisc); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
}
return migration, nil
}
func migrateEventMiscAtomically(src string, dests []string, out, errOut io.Writer) (int, error) {
sortedDests := append([]string(nil), dests...)
sort.Strings(sortedDests)
migrations := make([]*eventMiscMigration, 0, len(sortedDests))
// Stage every target before switching any target. This prevents a source or
// copy failure on a later Agent home from leaving earlier homes upgraded.
for _, dest := range sortedDests {
migration, err := prepareEventMiscMigration(src, dest)
if err != nil {
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return 0, err
}
migrations = append(migrations, migration)
}
committed := make([]*eventMiscMigration, 0, len(migrations))
for _, migration := range migrations {
if err := commitEventMiscMigration(migration); err != nil {
rollbackErr := rollbackEventMiscMigrations(committed)
if rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("已切换目标回滚失败: %w", rollbackErr))
}
var recoveryRoots []string
for _, candidate := range migrations {
if eventMiscMigrationNeedsRecovery(candidate) {
recoveryRoots = append(recoveryRoots, candidate.stageRoot)
}
}
if len(recoveryRoots) > 0 {
err = errors.Join(err, fmt.Errorf("回滚不完整,已保留恢复目录(请勿删除): %s", strings.Join(recoveryRoots, ", ")))
}
if cleanupErr := cleanupEventMiscStages(migrations, true, errOut); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return 0, err
}
committed = append(committed, migration)
}
for _, migration := range migrations {
fmt.Fprintf(out, " ✓ %s\n", migration.eventPath)
fmt.Fprintf(out, " ✓ %s(Event 原子迁移)\n", migration.miscPath)
}
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
fmt.Fprintf(errOut, " ⚠️ Event Skill 迁移已完成,但 staging 清理不完整: %v\n", cleanupErr)
}
return len(migrations) * 2, nil
}
func cleanupEventMiscStages(migrations []*eventMiscMigration, preserveRecovery bool, errOut io.Writer) error {
var cleanupErr error
for _, migration := range migrations {
if preserveRecovery && eventMiscMigrationNeedsRecovery(migration) {
fmt.Fprintf(errOut, " ⚠️ 已保留 Event Skill 恢复目录 %s\n", migration.stageRoot)
continue
}
if err := skillSetupRemoveAll(migration.stageRoot); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("清理 Event Skill staging %s 失败: %w", migration.stageRoot, err))
}
}
return cleanupErr
}
func eventMiscMigrationNeedsRecovery(migration *eventMiscMigration) bool {
return migration.eventBackedUp || migration.miscBackedUp || migration.newEventEnabled || migration.newMiscEnabled
}
func commitEventMiscMigration(migration *eventMiscMigration) error {
eventExists, err := skillSetupPathExists(migration.eventPath)
if err != nil {
return fmt.Errorf("无法检查旧 dingtalk-event %s: %w", migration.dest, err)
}
miscExists, err := skillSetupPathExists(migration.miscPath)
if err != nil {
return fmt.Errorf("无法检查旧 dingtalk-misc %s: %w", migration.dest, err)
}
if !miscExists {
return fmt.Errorf("event Skill 迁移中止:折叠版 dingtalk-misc 已不存在 %s", migration.dest)
}
rollbackFailure := func(cause error) error {
if rollbackErr := rollbackEventMiscMigration(migration); rollbackErr != nil {
return errors.Join(cause, fmt.Errorf("回滚 Event/misc 失败 %s: %w", migration.dest, rollbackErr))
}
return cause
}
if eventExists {
if err := skillSetupRename(migration.eventPath, migration.backupEvent); err != nil {
return fmt.Errorf("备份旧 dingtalk-event 失败 %s: %w", migration.dest, err)
}
migration.eventBackedUp = true
}
if err := skillSetupRename(migration.stagedEvent, migration.eventPath); err != nil {
return rollbackFailure(fmt.Errorf("切换 dingtalk-event 失败 %s: %w", migration.dest, err))
}
migration.newEventEnabled = true
if err := skillSetupRename(migration.miscPath, migration.backupMisc); err != nil {
return rollbackFailure(fmt.Errorf("备份旧 dingtalk-misc 失败 %s: %w", migration.dest, err))
}
migration.miscBackedUp = true
if err := skillSetupRename(migration.stagedMisc, migration.miscPath); err != nil {
return rollbackFailure(fmt.Errorf("切换 dingtalk-misc 失败 %s: %w", migration.dest, err))
}
migration.newMiscEnabled = true
return nil
}
func rollbackEventMiscMigrations(migrations []*eventMiscMigration) error {
var rollbackErr error
for i := len(migrations) - 1; i >= 0; i-- {
if err := rollbackEventMiscMigration(migrations[i]); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func rollbackEventMiscMigration(migration *eventMiscMigration) error {
move := func(enabled *bool, from, to, label string) error {
if !*enabled {
return nil
}
if err := skillSetupRename(from, to); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
*enabled = false
return nil
}
// Stop at the first rollback failure. In particular, do not remove the
// already-working standalone Event while the folded misc route has not been
// restored: even an incomplete rollback must leave at least one Event entry
// point live and preserve the remaining assets in staging for recovery.
steps := []struct {
enabled *bool
from string
to string
label string
}{
{&migration.newMiscEnabled, migration.miscPath, migration.stagedMisc, "移出新 dingtalk-misc"},
{&migration.miscBackedUp, migration.backupMisc, migration.miscPath, "恢复旧 dingtalk-misc"},
{&migration.newEventEnabled, migration.eventPath, migration.stagedEvent, "移出新 dingtalk-event"},
{&migration.eventBackedUp, migration.backupEvent, migration.eventPath, "恢复旧 dingtalk-event"},
}
for _, step := range steps {
if err := move(step.enabled, step.from, step.to, step.label); err != nil {
return err
}
}
return nil
}
func skillSetupPathExists(path string) (bool, error) {
_, err := skillSetupStat(path)
switch {
case err == nil:
return true, nil
case errors.Is(err, os.ErrNotExist):
return false, nil
default:
return false, err
}
}
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
// dest as a sibling skill directory. installed/skipped is counted per
// (agent-home × sub-skill) pair so the user sees granular progress.
//
// filtered mirrors whether runSkillSetup saw -s/--skill or -x/--exclude:
// a filtered install stays additive and never touches siblings outside the
// requested set; a full (unfiltered) install additionally removes stale,
// proven DWS-managed directories that are no longer in the bundle,
// matching install.sh / install.ps1 / install.js / upgrade paths.
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer, filtered bool) (installed, skipped int, err error) {
plan, planErr := buildSkillSetupPlan(skillSetupModeMulti, src, dests, skillNames, filtered)
if planErr != nil {
return 0, len(skillNames) * len(dests), planErr
}
return executeSkillSetupPlan(plan, out, errOut)
}
// stageSkillSetupTarget builds the complete replacement set next to its final
// destination before any Agent-visible directory is moved.
func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (stageRoot string, staged []skillSetupStagedDir, err error) {
stageParent := target.Destination
if plan.Mode == skillSetupModeMono {
stageParent = filepath.Dir(target.Destination)
}
if err := skillSetupMkdirAll(stageParent, 0o755); err != nil {
return "", nil, fmt.Errorf("创建 Skill 目标父目录失败 %s: %w", stageParent, err)
}
stageRoot, err = skillSetupPublishTemp(stageParent, ".dws-setup-set-")
if err != nil {
return "", nil, fmt.Errorf("创建 Skill staging 失败 %s: %w", stageParent, err)
}
defer func() {
if err == nil {
return
}
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
}()
realStageParent, realParentErr := skillSetupEvalSymlinks(stageParent)
if realParentErr != nil {
return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", stageParent, realParentErr)
}
stageOne := func(src, dest string) error {
stagedDir := filepath.Join(stageRoot, filepath.Base(dest))
if target.LinkCanonical {
canonicalTarget := filepath.Join(target.CanonicalBase, filepath.Base(dest))
if samePhysicalSkillSetupPath(dest, canonicalTarget) {
return nil
}
realCanonicalTarget, realTargetErr := skillSetupEvalSymlinks(canonicalTarget)
if realTargetErr != nil {
return fmt.Errorf("解析 canonical Skill 失败 %s: %w", canonicalTarget, realTargetErr)
}
relTarget, relErr := skillSetupRel(realStageParent, realCanonicalTarget)
if relErr != nil {
return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr)
}
if linkErr := skillSetupSymlink(relTarget, stagedDir); linkErr != nil {
return fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedDir, relTarget, linkErr)
}
staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest})
return nil
}
if err := skillSetupMkdirAll(stagedDir, 0o755); err != nil {
return fmt.Errorf("创建 Skill staging 目录失败 %s: %w", stagedDir, err)
}
if err := skillSetupCopyDir(src, stagedDir); err != nil {
return fmt.Errorf("拷贝 Skill staging 失败 %s: %w", stagedDir, err)
}
staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest})
return nil
}
if plan.Mode == skillSetupModeMono {
if err := stageOne(plan.Source, target.Destination); err != nil {
return stageRoot, nil, err
}
return stageRoot, staged, nil
}
for _, name := range plan.MultiSkillNames {
if err := stageOne(filepath.Join(plan.Source, name), filepath.Join(target.Destination, name)); err != nil {
return stageRoot, nil, err
}
}
return stageRoot, staged, nil
}
// restoreSkillSetupTarget removes a partially published replacement and
// restores every original directory from its exact backup path.
func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error {
restoreErr := skillSetupRollbackPaths(published)
for i := len(backups) - 1; i >= 0; i-- {
item := backups[i]
if _, err := skillSetupLstat(item.original); err == nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup))
continue
} else if !errors.Is(err, os.ErrNotExist) {
restoreErr = errors.Join(restoreErr, fmt.Errorf("检查 Skill 恢复目标失败 %s: %w;备份保留于 %s", item.original, err, item.backup))
continue
}
if err := skillSetupMkdirAll(filepath.Dir(item.original), 0o755); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup))
continue
}
if err := skillSetupRestoreBackup(item.backup, item.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup))
}
}
return restoreErr
}
func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writer) ([]skillSetupBackedUpDir, error) {
backups := make([]skillSetupBackedUpDir, 0, len(planned))
for _, item := range planned {
backup, err := skillSetupBackupAndRemove(home, item.Path)
if err != nil {
if restoreErr := restoreSkillSetupTarget(nil, backups); restoreErr != nil {
return nil, errors.Join(err, fmt.Errorf("恢复已备份 Skill 失败: %w", restoreErr))
}
return nil, err
}
if backup != "" {
backups = append(backups, skillSetupBackedUpDir{original: item.Path, backup: backup})
}
switch item.Reason {
case skillSetupBackupMutual:
fmt.Fprintf(out, " × 已备份并清理对面模式残留 %s → %s\n", item.Path, backup)
case skillSetupBackupStale:
fmt.Fprintf(out, " × 已备份并清理过期 skill %s → %s\n", item.Path, backup)
default:
fmt.Fprintf(out, " × 已备份并移除同名 Skill %s → %s\n", item.Path, backup)
}
}
return backups, nil
}
func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error {
published := make([]upgrade.SkillPathPublication, 0, len(staged))
for _, item := range staged {
publication, err := skillSetupPublishPath(item.staged, item.dest)
if err != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err)
if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr))
}
return publishErr
}
published = append(published, publication)
}
return nil
}
func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) {
home, homeErr := skillSetupUserHomeDir()
hasCanonicalDependents := false
for _, candidate := range plan.Targets {
if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) {
hasCanonicalDependents = true
break
}
}
for _, target := range plan.Targets {
perTarget := 1
if plan.Mode == skillSetupModeMulti {
perTarget = len(plan.MultiSkillNames)
}
isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase)
if target.CleanupOnly {
// Nothing is installed below a universal root, so a stale copy that
// resists retirement must not count as a skipped install: any skipped
// count fails the whole setup, even when every real target succeeded.
if homeErr != nil {
fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr)
continue
}
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
fmt.Fprintf(errOut, " ⚠️ universal Agent 旧副本迁移失败,已回滚,可手动删除 %s: %v\n", target.Destination, cleanupErr)
}
continue
}
if len(target.Backups) > 0 && homeErr != nil {
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("无法解析 HOME,canonical Skill 刷新中止 %s: %w", target.Destination, homeErr)
}
if plan.Mode == skillSetupModeMono {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr)
} else {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过整个 Agent 目标 %s: %v\n", target.Destination, homeErr)
}
skipped += perTarget
continue
}
stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target)
if stageErr != nil && target.LinkCanonical {
fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination)
fallback := target
fallback.LinkCanonical = false
stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback)
}
if stageErr != nil {
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr)
}
fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr)
skipped += perTarget
continue
}
backups, backupErr := backupSkillSetupTarget(home, target.Backups, out)
if backupErr != nil {
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr)
}
fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr)
skipped += perTarget
continue
}
publishErr := publishSkillSetupTarget(staged, backups)
cleanupErr := skillSetupRemoveAll(stageRoot)
if publishErr != nil {
if cleanupErr != nil {
publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
if isCanonical && hasCanonicalDependents {
if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %w", target.Destination, publishErr)
}
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr)
}
if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) {
// The destination may belong to a concurrent writer and was
// deliberately retained; the rollback refuses to displace it.
fmt.Fprintf(errOut, " ✗ Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %v\n", target.Destination, publishErr)
} else {
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
}
skipped += perTarget
continue
}
if cleanupErr != nil {
fmt.Fprintf(errOut, " ⚠️ Skill staging 清理失败 %s: %v\n", stageRoot, cleanupErr)
}
for _, item := range staged {
fmt.Fprintf(out, " ✓ %s\n", item.dest)
}
installed += perTarget
}
return installed, skipped, nil
}
// staleMultiSkillVictims lists proven DWS-managed directories under dest that
// a full install would delete because they are not part of the bundle.
func staleMultiSkillVictims(dest string, keep []string, managed ...map[string]bool) []string {
if len(managed) == 0 {
managed = []map[string]bool{currentManagedSkillNames()}
}
victims, _ := staleMultiSkillVictimsWithError(dest, keep, managed...)
return victims
}
func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[string]bool) ([]string, error) {
entries, err := skillSetupReadDir(dest)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("扫描过期 Skill 失败 %s: %w", dest, err)
}
keepSet := make(map[string]bool, len(keep))
for _, n := range keep {
keepSet[n] = true
}
var victims []string
for _, e := range entries {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] {
continue
}
if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(dest, e.Name()))
}
sort.Strings(victims)
return victims, nil
}
// removeStaleMultiSkills backs up + removes proven DWS-managed directories
// under dest that are not part of the current bundle. Removal is
// reversible: each stale directory is moved to ~/.dws/skill-backups/<stamp>/
// first, and a backup failure keeps the directory in place with a warning.
// A scan or backup failure is returned so callers do not write a new bundle
// into a partially reconciled Agent target.
func removeStaleMultiSkills(dest string, keep []string, out, errOut io.Writer) error {
managedNames := currentManagedSkillNames()
entries, err := skillSetupReadDir(dest)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
fmt.Fprintf(errOut, " ⚠️ 过期 skill 扫描失败(跳过整个 Agent 目标) %s: %v\n", dest, err)
return err
}
keepSet := make(map[string]bool, len(keep))
for _, n := range keep {
keepSet[n] = true
}
var stales []string
for _, e := range entries {
if !e.IsDir() || keepSet[e.Name()] {
continue
}
if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managedNames) {
continue
}
stales = append(stales, filepath.Join(dest, e.Name()))
}
if len(stales) == 0 {
return nil
}
home, homeErr := skillSetupUserHomeDir()
if homeErr != nil {
for _, stale := range stales {
fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,跳过删除(保留) %s: %v\n", stale, homeErr)
}
return homeErr
}
for _, stale := range stales {
backup, err := skillSetupBackupAndRemove(home, stale)
if err != nil {
fmt.Fprintf(errOut, " ⚠️ 过期 skill 清理失败(保留原目录,跳过整个 Agent 目标) %s: %v\n", stale, err)
return err
}
fmt.Fprintf(out, " × 已备份并清理过期 skill %s → %s\n", stale, backup)
}
return nil
}
func copyDir(src, dst string) error {
return skillSetupWalk(src, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := skillSetupRel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return skillSetupMkdirAll(target, info.Mode())
}
if info.Mode()&os.ModeSymlink != 0 {
// resolve symlink target and copy the underlying file
resolved, err := skillSetupReadlink(path)
if err != nil {
return err
}
if !filepath.IsAbs(resolved) {
resolved = filepath.Join(filepath.Dir(path), resolved)
}
return copyFileContent(resolved, target, info.Mode())
}
return copyFileContent(path, target, info.Mode())
})
}
func copyFileContent(src, dst string, mode os.FileMode) error {
if err := skillSetupMkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := skillSetupOpen(src)
if err != nil {
return err
}
defer in.Close()
out, err := skillSetupOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
if err != nil {
return err
}
defer out.Close()
_, err = skillSetupCopy(out, in)
return err
}
func isInteractiveTerminal() bool {
return isCharDevice(os.Stdin) && isCharDevice(os.Stdout) && isCharDevice(os.Stderr)
}
func isCharDevice(file *os.File) bool {
if file == nil {
return false
}
fi, err := file.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}