// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 package upgrade import ( "fmt" "os" "path/filepath" "runtime" ) // Permission constants following Unix best practices. const ( dirPermSecure os.FileMode = 0o700 dirPermShared os.FileMode = 0o755 filePermBinary os.FileMode = 0o755 filePermConfig os.FileMode = 0o644 ) // knownSkillDirs lists all known Agent skill directories (relative to $HOME). // Kept in sync with: // - build/npm/install.js AGENT_DIRS // - scripts/install.sh for-in list // - scripts/install.ps1 $AgentDirs // - scripts/install-skills.sh for-in list // - build/homebrew.rb.tmpl targets // - test/scripts/package_script_test.go expectedPackagedSkillTargets // - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS // // The first entry (.agents/skills) is always updated; subsequent entries are // only updated when their parent directory already exists. var knownSkillDirs = []string{ ".agents/skills", ".claude/skills", ".cursor/skills", ".qoder/skills", ".qoderwork/skills", ".gemini/skills", ".codex/skills", ".github/skills", ".windsurf/skills", ".augment/skills", ".cline/skills", ".amp/skills", ".kiro/skills", ".trae/skills", ".openclaw/skills", ".hermes/skills", } var ( upgradeUserHomeDir = os.UserHomeDir upgradeExecutable = os.Executable upgradeEvalSymlinks = filepath.EvalSymlinks upgradeCopyDir = copyDir upgradeEnsureDir = ensureDir ) // skillDirBlacklist contains parent directories whose skills are managed by // external mechanisms (e.g. IDE extensions) and must NOT be touched by upgrade. var skillDirBlacklist = []string{ ".real", } // SkillDirStatus describes the installation outcome for a single skill directory. type SkillDirStatus int const ( SkillDirOK SkillDirStatus = iota // successfully installed SkillDirSkipped // agent not detected, directory skipped SkillDirBlacklisted // blacklisted, never touched SkillDirFailed // installation attempted but failed ) // SkillDirResult holds the per-directory install result. type SkillDirResult struct { Dir string // destination directory (e.g. ~/.claude/skills/dws) Status SkillDirStatus // outcome Err error // non-nil when Status == SkillDirFailed } // SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call. type SkillUpgradeResult struct { Results []SkillDirResult } // Succeeded returns directories that were successfully updated. func (r *SkillUpgradeResult) Succeeded() []SkillDirResult { var out []SkillDirResult for _, d := range r.Results { if d.Status == SkillDirOK { out = append(out, d) } } return out } // Failed returns directories where installation was attempted but failed. func (r *SkillUpgradeResult) Failed() []SkillDirResult { var out []SkillDirResult for _, d := range r.Results { if d.Status == SkillDirFailed { out = append(out, d) } } return out } // UpgradeSkillLocations installs skills from extractedDir into all locations // where they are currently installed or expected. // // Strategy (matches npm install.js installSkillsToHomes): // - ~/.agents/skills/dws/ is ALWAYS updated (primary install location) // - Other agent dirs (claude, cursor, ...) are updated only when the parent // directory exists (e.g. ~/.claude/ exists => user has Claude) // - ~/.real/ and other blacklisted paths are NEVER touched // - If no location was updated at all, fall back to ~/.agents/skills/dws/ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) { homeDir, err := upgradeUserHomeDir() if err != nil { return nil, err } result := &SkillUpgradeResult{} for i, agentDir := range knownSkillDirs { destDir := filepath.Join(homeDir, agentDir, "dws") if isBlacklisted(agentDir) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } if i > 0 { parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) if _, err := os.Stat(parentGate); os.IsNotExist(err) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } } os.RemoveAll(destDir) if err := upgradeCopyDir(extractedDir, destDir); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) continue } result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK}) } // Fallback: if nothing succeeded, force the primary location if len(result.Succeeded()) == 0 { dest := filepath.Join(homeDir, ".agents", "skills", "dws") os.MkdirAll(filepath.Dir(dest), dirPermShared) if err := upgradeCopyDir(extractedDir, dest); err != nil { return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err) } // Replace the earlier failed entry for this dir (if any) or append a new one replaced := false for idx, r := range result.Results { if r.Dir == dest { result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK} replaced = true break } } if !replaced { result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK}) } } return result, nil } // LocateSkillMD finds the directory containing SKILL.md in an extracted zip. // It handles both flat layouts (SKILL.md at root) and nested layouts (dws/SKILL.md). func LocateSkillMD(extractDir string) string { // Check nested: {extractDir}/dws/SKILL.md nested := filepath.Join(extractDir, "dws", "SKILL.md") if _, err := os.Stat(nested); err == nil { return filepath.Join(extractDir, "dws") } // Check flat: {extractDir}/SKILL.md flat := filepath.Join(extractDir, "SKILL.md") if _, err := os.Stat(flat); err == nil { return extractDir } return "" } // EnsureUpgradeDirectories creates the directories needed for upgrade operations. func EnsureUpgradeDirectories() error { homeDir, err := upgradeUserHomeDir() if err != nil { return err } dirs := []struct { path string perm os.FileMode }{ {filepath.Join(homeDir, ".dws"), dirPermSecure}, {filepath.Join(homeDir, ".dws", "data"), dirPermSecure}, {filepath.Join(homeDir, ".dws", "data", "backups"), dirPermSecure}, {filepath.Join(homeDir, ".dws", "cache"), dirPermSecure}, {filepath.Join(homeDir, ".dws", "cache", "downloads"), dirPermSecure}, } for _, d := range dirs { if err := upgradeEnsureDir(d.path, d.perm); err != nil { return err } } return nil } // DownloadCacheDir returns the path for temporary downloads during upgrade. func DownloadCacheDir() string { homeDir, _ := upgradeUserHomeDir() return filepath.Join(homeDir, ".dws", "cache", "downloads") } // CurrentBinaryPath returns the resolved path of the currently running binary. func CurrentBinaryPath() (string, error) { exe, err := upgradeExecutable() if err != nil { return "", err } return upgradeEvalSymlinks(exe) } // BinaryName returns the platform-specific binary name. func BinaryName() string { return binaryNameFor(runtime.GOOS) } func binaryNameFor(goos string) string { if goos == "windows" { return "dws.exe" } return "dws" } func isBlacklisted(agentDir string) bool { for _, bl := range skillDirBlacklist { // agentDir is like ".real/skills" — check if it starts with a blacklisted prefix if len(agentDir) >= len(bl) && agentDir[:len(bl)] == bl { next := len(bl) if next == len(agentDir) || agentDir[next] == '/' { return true } } } return false } func ensureDir(path string, perm os.FileMode) error { info, err := os.Stat(path) if os.IsNotExist(err) { return os.MkdirAll(path, perm) } if err != nil { return err } if runtime.GOOS != "windows" && info.Mode().Perm() != perm { if info.Mode().Perm()&^perm != 0 { return os.Chmod(path, perm) } } return nil }