fix(skills): use canonical global installation
This commit is contained in:
1 parent
9e3a5d6fbd
commit
44d640bbae
16 files changed
+1020
-714
No files matched your search
+101
-29
@@ -65,6 +65,7 @@ var (
|
||||
skillSetupInteractive = isInteractiveTerminal
|
||||
skillSetupReadDir = os.ReadDir
|
||||
skillSetupStat = os.Stat
|
||||
skillSetupSymlink = os.Symlink
|
||||
skillSetupExecutable = os.Executable
|
||||
skillSetupGetwd = os.Getwd
|
||||
skillSetupUserHomeDir = os.UserHomeDir
|
||||
@@ -74,6 +75,7 @@ var (
|
||||
skillSetupWalk = filepath.Walk
|
||||
skillSetupRel = filepath.Rel
|
||||
skillSetupReadlink = os.Readlink
|
||||
skillSetupEvalSymlinks = filepath.EvalSymlinks
|
||||
skillSetupOpen = os.Open
|
||||
skillSetupOpenFile = os.OpenFile
|
||||
skillSetupWriteFile = os.WriteFile
|
||||
@@ -91,9 +93,11 @@ type skillSetupBackup struct {
|
||||
}
|
||||
|
||||
type skillSetupTargetPlan struct {
|
||||
Destination string
|
||||
Backups []skillSetupBackup
|
||||
CleanupOnly bool
|
||||
Destination string
|
||||
CanonicalBase string
|
||||
Backups []skillSetupBackup
|
||||
CleanupOnly bool
|
||||
LinkCanonical bool
|
||||
}
|
||||
|
||||
type skillSetupPlan struct {
|
||||
@@ -149,8 +153,9 @@ multi 模式支持按产品挑选:
|
||||
备份失败时保留原目录并跳过该目标,绝不静默删除。
|
||||
· 所有将被移除的目录都会在确认前逐条列出。
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录;
|
||||
未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。
|
||||
不带 --mode 时进入交互式询问;Skill 始终安装到 canonical 目录 ~/.agents/skills。
|
||||
原生支持该目录的 Agent 直接读取;其他已检测到的 Agent 目录创建相对链接,
|
||||
链接不可用时回退为直接复制,避免同一 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`,
|
||||
@@ -910,8 +915,9 @@ func isSkillSourceRoot(path, mode string) bool {
|
||||
}
|
||||
|
||||
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
|
||||
// If target == "all", returns every agent home whose parent directory exists.
|
||||
// Otherwise returns the single matching home (whether or not it currently exists).
|
||||
// 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 目录)
|
||||
@@ -923,6 +929,7 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) {
|
||||
}
|
||||
|
||||
target = strings.ToLower(strings.TrimSpace(target))
|
||||
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
|
||||
if target == "" || target == "all" {
|
||||
return detectExistingAgentHomes(home, mode), nil
|
||||
}
|
||||
@@ -931,7 +938,11 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
|
||||
}
|
||||
return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
|
||||
dest := agentHomeForMode(filepath.Join(home, rel), mode)
|
||||
if filepath.Clean(dest) == filepath.Clean(canonical) {
|
||||
return []string{canonical}, nil
|
||||
}
|
||||
return []string{canonical, dest}, nil
|
||||
}
|
||||
|
||||
// agentHomeForMode appends the mode-specific tail segment to an agent home base.
|
||||
@@ -943,7 +954,8 @@ func agentHomeForMode(base, mode string) string {
|
||||
}
|
||||
|
||||
func detectExistingAgentHomes(home, mode string) []string {
|
||||
var specific []string
|
||||
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
|
||||
dests := []string{canonical}
|
||||
for i, rel := range skillSetupAgentHomes {
|
||||
if i == 0 {
|
||||
continue
|
||||
@@ -953,12 +965,45 @@ func detectExistingAgentHomes(home, mode string) []string {
|
||||
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
specific = append(specific, agentHomeForMode(base, mode))
|
||||
dests = append(dests, agentHomeForMode(base, mode))
|
||||
}
|
||||
if len(specific) > 0 {
|
||||
return specific
|
||||
return dests
|
||||
}
|
||||
|
||||
func skillSetupBaseForMode(dest, mode string) string {
|
||||
if mode == skillSetupModeMono {
|
||||
return filepath.Dir(dest)
|
||||
}
|
||||
return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)}
|
||||
return dest
|
||||
}
|
||||
|
||||
func isUniversalSkillSetupBase(base string) bool {
|
||||
base = filepath.ToSlash(filepath.Clean(base))
|
||||
for rel := range map[string]bool{
|
||||
".cursor/skills": true, ".gemini/skills": true, ".codex/skills": true,
|
||||
".github/skills": true, ".cline/skills": true, ".amp/skills": true,
|
||||
} {
|
||||
if strings.HasSuffix(base, "/"+rel) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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 && filepath.Clean(leftReal) == filepath.Clean(rightReal)
|
||||
}
|
||||
|
||||
func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) {
|
||||
@@ -1032,8 +1077,17 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
sortedDests := append([]string(nil), dests...)
|
||||
sort.Strings(sortedDests)
|
||||
managedNames := currentManagedSkillNames()
|
||||
canonicalBase := canonicalSkillSetupBase(sortedDests, mode)
|
||||
for _, dest := range sortedDests {
|
||||
target := skillSetupTargetPlan{Destination: dest}
|
||||
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] {
|
||||
@@ -1077,6 +1131,9 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
}
|
||||
}
|
||||
for _, path := range replacements {
|
||||
if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) {
|
||||
continue
|
||||
}
|
||||
info, statErr := skillSetupStat(path)
|
||||
if statErr != nil {
|
||||
if errors.Is(statErr, os.ErrNotExist) {
|
||||
@@ -1089,14 +1146,9 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
}
|
||||
}
|
||||
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
|
||||
plan.Targets = append(plan.Targets, target)
|
||||
}
|
||||
cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames)
|
||||
if cleanupErr != nil {
|
||||
return nil, cleanupErr
|
||||
}
|
||||
if cleanupTarget != nil {
|
||||
plan.Targets = append(plan.Targets, *cleanupTarget)
|
||||
if !target.CleanupOnly || len(target.Backups) > 0 {
|
||||
plan.Targets = append(plan.Targets, target)
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
@@ -1151,7 +1203,9 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
|
||||
fmt.Fprintln(out, " destinations:")
|
||||
for _, target := range plan.Targets {
|
||||
if target.CleanupOnly {
|
||||
fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination)
|
||||
fmt.Fprintf(out, " - %s (仅迁移 universal Agent 中的旧 DWS 副本)\n", target.Destination)
|
||||
} else if target.LinkCanonical {
|
||||
fmt.Fprintf(out, " - %s (链接到 %s,失败时回退复制)\n", target.Destination, target.CanonicalBase)
|
||||
} else {
|
||||
fmt.Fprintf(out, " - %s\n", target.Destination)
|
||||
}
|
||||
@@ -1649,6 +1703,21 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
|
||||
|
||||
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
|
||||
}
|
||||
relTarget, relErr := filepath.Rel(filepath.Dir(dest), canonicalTarget)
|
||||
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)
|
||||
}
|
||||
@@ -1752,16 +1821,13 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
}
|
||||
for _, target := range plan.Targets {
|
||||
if target.CleanupOnly {
|
||||
if skipped > 0 {
|
||||
continue
|
||||
}
|
||||
if homeErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr)
|
||||
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
|
||||
fmt.Fprintf(errOut, " ✗ universal Agent 旧副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
|
||||
skipped++
|
||||
}
|
||||
continue
|
||||
@@ -1777,6 +1843,12 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
}
|
||||
|
||||
stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target)
|
||||
if stageErr != nil && target.LinkCanonical {
|
||||
fmt.Fprintf(errOut, " ⚠️ %s 无法创建 Skill 链接,回退为直接复制: %v\n", target.Destination, stageErr)
|
||||
fallback := target
|
||||
fallback.LinkCanonical = false
|
||||
stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback)
|
||||
}
|
||||
if stageErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr)
|
||||
skipped += perTarget
|
||||
@@ -1836,7 +1908,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || keepSet[e.Name()] {
|
||||
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) {
|
||||
|
||||
Reference in new issue
Block a user