Files
玉澜 c0e579fe6b fix(skills): prove backup ownership before adopting or pruning stamp roots
A stamp-shaped directory name is not ownership proof: pruneSkillBackups
counted and RemoveAll'd any 20260819-120000-shaped entry under
~/.dws/skill-backups, so a user or tool that created such a directory
lost its contents once DWS held five backups, and the Go backup path
(MkdirAll) adopted a same-named foreign root outright. The PowerShell
installers already implemented the correct contract; every other
surface now matches it.

Go stamps a freshly created root with the exact marker bytes the
install scripts write (.dws-skill-backup = "dws skill backup v1")
before any payload moves in, claims the root with mkdir so an existing
unproven root bumps to a collision suffix instead of being adopted,
and prunes only roots whose marker verifies — unmarked or wrongly
worded stamp-shaped directories are foreign data, preserved and never
counted against the keep limit. The shell installers (install.sh,
install-skills.sh, install-event.sh, install-devapp.sh) and the npm
installer apply the same rule in their backup collision loops, with
roots recorded as created by the running process exempt from marker
re-verification so a mid-run marker permission failure still reuses
this run's own root and keeps the sibling payload intact.

Regression tests cover every surface: pruning an unmarked/wrongly
marked stamp-shaped directory alongside marked ones, refusing to adopt
a foreign root (payload moves to a suffixed root, foreign data and its
nonexistent marker untouched), same-stamp reuse of a proven root, and
marker-write failure cleaning the empty fresh root.
2026-08-19 21:29:47 +08:00

1300 lines
47 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.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
)
// 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 the canonical global Skill directory,
// following the universal .agents/skills convention.
// Agents that understand the universal .agents convention read it directly;
// detected non-universal Agents receive relative links to this canonical
// copy (with a direct-copy fallback when links are unavailable).
var knownSkillDirs = []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",
// DWS-only target retained for product compatibility.
".qoderwork/skills",
// beta.6 compatibility roots: cleanup only, never publish new copies.
".cursor/skills",
".gemini/skills",
".codex/skills",
".github/skills",
".windsurf/skills",
".cline/skills",
".amp/skills",
}
// universalSkillDirs are concrete Agent directories whose current clients
// natively scan ~/.agents/skills. Publishing another copy or link below these
// roots makes the same Skill appear twice. Keep this classification aligned
// with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally
// non-universal because it is a DWS-specific integration absent upstream.
var universalSkillDirs = 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 as well.
".github/skills": true,
".windsurf/skills": true,
".cline/skills": true,
".amp/skills": true,
}
var (
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeRemoveAll = os.RemoveAll
upgradeMkdirAll = os.MkdirAll
upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir
upgradeStat = os.Stat
upgradeLstat = os.Lstat
upgradeSymlink = os.Symlink
upgradeRel = filepath.Rel
upgradeGetenv = os.Getenv
upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read
upgradePublishSkillPath = PublishSkillPathNoReplace
upgradeRollbackPublishedSkillPaths = RollbackSkillPathPublications
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
upgradeWriteSkillState = skillstate.Write
upgradeNow = time.Now
upgradeFoldPathCase = runtime.GOOS == "windows"
)
// skillBackupSubdir is the user-level directory where skill directories are
// preserved before a layout-changing install/upgrade removes them. Non-
// interactive flows (install scripts, npm postinstall, `dws upgrade`) cannot
// ask for confirmation, so deletions must stay reversible instead.
const skillBackupSubdir = ".dws/skill-backups"
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel> instead of destroying it, and returns the backup path. It is
// fail-safe: a path that cannot be backed up is NOT removed and the
// error is returned so the caller can surface it (and never install the
// opposite layout next to it silently). Missing paths are no-ops. Directories,
// links (including dangling links), and ordinary files are all preserved:
// an unexpected file at a managed destination must never be overwritten.
func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
_, err := upgradeLstat(dir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err)
}
rel, relErr := filepath.Rel(homeDir, dir)
if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") {
rel = filepath.Base(dir)
}
name := strings.NewReplacer(string(filepath.Separator), "-", "/", "-").Replace(rel)
stamp := upgradeBackupStamp()
if err := upgradeMkdirAll(filepath.Join(homeDir, skillBackupSubdir), dirPermShared); err != nil {
return "", fmt.Errorf("创建备份根目录失败 %s: %w", filepath.Join(homeDir, skillBackupSubdir), err)
}
var backupRoot, target string
for i := 0; ; i++ {
candidate := stamp
if i > 0 {
candidate = fmt.Sprintf("%s-%d", stamp, i)
}
if i > 1000 {
return "", fmt.Errorf("备份目录冲突无法解决: %s", filepath.Join(homeDir, skillBackupSubdir, candidate))
}
backupRoot = filepath.Join(homeDir, skillBackupSubdir, candidate)
target = filepath.Join(backupRoot, name)
if _, err := upgradeLstat(target); err == nil {
continue // payload name already taken in this stamp root
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err)
}
if err := skillPathMkdir(backupRoot, dirPermShared); err == nil {
// We created the root: stamp ownership before any payload moves
// in, so an interrupted backup never leaves an unmarked
// (never-prunable) stamp behind.
if err := skillBackupWriteMarker(backupRoot); err != nil {
// Bounded cleanup: the root is empty and ours alone.
_ = skillPathRemove(backupRoot)
return "", fmt.Errorf("写入备份所有权标记失败 %s: %w", backupRoot, err)
}
break
} else if !os.IsExist(err) {
return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
} else if skillBackupMarkerValid(backupRoot) {
break // proven ours: same-stamp payloads from this run reuse it
}
// A stamp-shaped directory we cannot prove ours must never be
// adopted — writing the marker or the payload into it would turn
// its foreign contents into prunable DWS backups.
}
if err := moveSkillPathRecoverably(dir, target); err != nil {
return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err)
}
// Keep the backup directory bounded; a prune failure must not fail the
// install (the backup itself succeeded).
_ = pruneSkillBackups(homeDir)
return target, nil
}
// BackupAndRemoveSkillDir is the exported wrapper over
// backupAndRemoveSkillDir for callers outside the upgrade package (the
// skill-setup channel in internal/app).
func BackupAndRemoveSkillDir(homeDir, dir string) (string, error) {
return backupAndRemoveSkillDir(homeDir, dir)
}
// 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
// SkillDirRetireWarning marks a universal Agent whose obsolete private copy
// could not be retired. Nothing is installed below such a root, so the
// leftover is reported without counting as an install failure.
SkillDirRetireWarning
)
// 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 or SkillDirRetireWarning
}
// 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
}
// RetireWarnings returns universal Agent roots that still hold an obsolete
// private copy because retiring it failed.
func (r *SkillUpgradeResult) RetireWarnings() []SkillDirResult {
var out []SkillDirResult
for _, d := range r.Results {
if d.Status == SkillDirRetireWarning {
out = append(out, d)
}
}
return out
}
// UpgradeSkillLocations refreshes skills from extractedDir into agent homes.
// extractedDir may be a multi-skill bundle root (subdirectories each containing
// SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that
// resolve a release zip usually pass LocateSkillsRoot's result (multi/
// preferred when present).
//
// Package-driven layout:
// - release zip has multi/ → install and overwrite the complete official
// bundle. Locally deleted bundled skills are restored on the next upgrade;
// local absence is never treated as a persistent exclusion.
// - dingtalk-shared is mandatory whenever it exists in the bundle.
// - legacy zip with no multi tree → mono refresh path (unchanged fallback)
//
// Fresh install defaults to multi with opt-in mono via the interactive
// `dws skill setup --mode mono` flow.
//
// Strategy (matches npm install.js installSkillsToHomes):
// - ~/.agents/skills/ is always the canonical global store
// - universal Agents (Codex, Cursor, Gemini, Cline, Amp, Copilot) read the
// canonical store directly, so old agent-specific copies are retired
// - detected non-universal Agents receive relative links to canonical;
// filesystems that reject links receive a direct-copy fallback
// - ~/.real/ and other blacklisted paths are NEVER touched
// - canonical publication is mandatory and fails the upgrade loudly
//
// Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then
// removed so mono and multi never co-exist after an upgrade; a leftover that
// cannot be backed up is never removed and fails that home. Same-name bundle
// skills are refreshed in place (verified DWS-managed overwrite). Caches
// under ~/.dws/skills/{multi,mono} are refreshed best-effort.
func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
return UpgradeSkillLocationsWithOptions(extractedDir, SkillUpgradeOptions{})
}
type SkillUpgradeOptions struct {
Version string
}
func UpgradeSkillLocationsWithOptions(extractedDir string, opts SkillUpgradeOptions) (*SkillUpgradeResult, error) {
homeDir, err := upgradeUserHomeDir()
if err != nil {
return nil, err
}
multiRoot, skills := resolveMultiBundle(extractedDir)
if len(skills) > 0 {
official := append([]string(nil), skills...)
managedSkills, provenanceErr := buildUpgradeProvenanceRecords(multiRoot, official, opts.Version)
if provenanceErr != nil {
return nil, fmt.Errorf("生成统一 Skill provenance 失败: %w", provenanceErr)
}
result, installErr := upgradeMultiSkillLocations(homeDir, multiRoot, official)
if installErr != nil {
return result, installErr
}
if len(result.Failed()) == 0 && len(result.Succeeded()) > 0 {
state := skillstate.State{
Version: opts.Version,
OfficialSkills: official,
UpdatedSkills: official,
ManagedSkills: managedSkills,
UpdatedAt: upgradeNow().UTC().Format(time.RFC3339),
}
if writeErr := upgradeWriteSkillState(homeDir, state); writeErr != nil {
return result, fmt.Errorf("skill 已同步但状态未写入: %w", writeErr)
}
}
return result, nil
}
monoSrc := resolveMonoSkillSrc(extractedDir)
if monoSrc != "" {
return upgradeMonoSkillLocations(homeDir, monoSrc)
}
return nil, fmt.Errorf("升级包中找不到可安装的 skill 源")
}
func buildUpgradeProvenanceRecords(root string, names []string, version string) ([]skillprovenance.Record, error) {
records := make([]skillprovenance.Record, 0, len(names))
for _, name := range names {
record, err := upgradeBuildProvenance(name, filepath.Join(root, name), version, skillprovenance.SourceUpgrade)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
records = append(records, record)
}
return records, nil
}
// skillBackupKeep limits ~/.dws/skill-backups/ growth: only the newest
// backups are kept.
const skillBackupKeep = 5
// The ownership marker inside a stamp root. Every DWS surface writes the
// exact same bytes (install.sh, install-skills.sh, install-event.sh,
// install-devapp.sh, the PowerShell installers, and build/npm/install.js):
// a stamp-shaped directory name alone is not ownership proof, because users
// and unrelated tools can create 20260819-120000-shaped names. Counting or
// deleting a backup root requires the marker to verify, and adopting an
// existing root for a new backup requires it first.
const (
skillBackupMarkerName = ".dws-skill-backup"
skillBackupMarkerContent = "dws skill backup v1\n"
)
var (
skillBackupReadMarker = func(root string) (string, error) {
data, err := os.ReadFile(filepath.Join(root, skillBackupMarkerName))
return string(data), err
}
skillBackupWriteMarker = func(root string) error {
return os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644)
}
)
// skillBackupMarkerValid reports whether root carries the ownership marker
// with exactly the expected bytes; a missing, unreadable, or differently
// worded marker means the directory is foreign data.
func skillBackupMarkerValid(root string) bool {
body, err := skillBackupReadMarker(root)
return err == nil && body == skillBackupMarkerContent
}
// skillBackupStampRegexp matches only directory names DWS itself writes:
// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry
// in the backup root is foreign (user data, unrelated tooling) and must be
// left untouched, so pruning is restricted to names it can prove DWS created.
var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`)
// isSkillBackupStamp reports whether name is a DWS-created backup stamp.
func isSkillBackupStamp(name string) bool {
return skillBackupStampRegexp.MatchString(name)
}
// pruneSkillBackups removes the oldest backup directories when more than
// skillBackupKeep remain. Only directories whose names match the DWS backup
// stamp format AND whose ownership marker verifies are candidates; unknown
// or unmarked directories — including stamp roots created before the
// ownership marker existed — are foreign data and preserved. Best-effort:
// a removal failure never aborts, but pruning failures are reported so
// callers can warn the user.
func pruneSkillBackups(homeDir string) error {
root := filepath.Join(homeDir, skillBackupSubdir)
entries, err := upgradeReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() || !isSkillBackupStamp(e.Name()) {
continue
}
// A stamp-shaped name alone is not ownership proof: only roots
// whose marker verifies are counted or deleted.
if skillBackupMarkerValid(filepath.Join(root, e.Name())) {
names = append(names, e.Name())
}
}
sort.Strings(names)
var firstErr error
for len(names) > skillBackupKeep {
old := filepath.Join(root, names[0])
names = names[1:]
if err := upgradeRemoveAll(old); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// resolveMultiBundle returns the multi skill root and its skill names when
// extractedDir is itself a bundle, or when a multi/ child of the parent
// extract root carries one (LocateSkillsRoot already prefers that child).
func resolveMultiBundle(extractedDir string) (string, []string) {
if skills := bundleSkillNames(extractedDir); len(skills) > 0 {
return extractedDir, skills
}
child := filepath.Join(extractedDir, "multi")
if skills := bundleSkillNames(child); len(skills) > 0 {
return child, skills
}
return "", nil
}
// resolveMonoSkillSrc finds a mono skill tree for the legacy mono-only
// package fallback: the path itself, a sibling mono/ next to a multi root,
// or the extract-root SKILL.md copy that release zips still ship.
func resolveMonoSkillSrc(extractedDir string) string {
if skillTreeHasRoot(extractedDir) {
return extractedDir
}
sibling := filepath.Join(filepath.Dir(extractedDir), "mono")
if skillTreeHasRoot(sibling) {
return sibling
}
parent := filepath.Dir(extractedDir)
if skillTreeHasRoot(parent) {
return parent
}
child := filepath.Join(extractedDir, "mono")
if skillTreeHasRoot(child) {
return child
}
return ""
}
type skillStageSpec struct {
src string
dest string
}
type stagedSkillDir struct {
staged string
dest string
}
type backedUpSkillDir struct {
original string
backup string
}
// stageSkillSet builds a complete replacement next to its final destinations.
// Nothing Agent-visible is changed until every copy succeeds.
func stageSkillSet(destBase, prefix string, specs []skillStageSpec) (stageRoot string, staged []stagedSkillDir, err error) {
if err := upgradeMkdirAll(destBase, dirPermShared); err != nil {
return "", nil, fmt.Errorf("创建 Skill 目标目录失败 %s: %w", destBase, err)
}
stageRoot, err = upgradeMkdirTemp(destBase, prefix)
if err != nil {
return "", nil, fmt.Errorf("创建 Skill staging 失败 %s: %w", destBase, err)
}
defer func() {
if err == nil {
return
}
if cleanupErr := upgradeRemoveAll(stageRoot); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
}()
staged = make([]stagedSkillDir, 0, len(specs))
for _, spec := range specs {
stageDir := filepath.Join(stageRoot, filepath.Base(spec.dest))
if err := upgradeCopyDir(spec.src, stageDir); err != nil {
return stageRoot, nil, fmt.Errorf("拷贝 Skill staging 失败 %s: %w", stageDir, err)
}
staged = append(staged, stagedSkillDir{staged: stageDir, dest: spec.dest})
}
return stageRoot, staged, nil
}
func uniqueSkillDirs(paths []string) []string {
seen := make(map[string]bool, len(paths))
unique := make([]string, 0, len(paths))
for _, path := range paths {
path = filepath.Clean(path)
if seen[path] {
continue
}
seen[path] = true
unique = append(unique, path)
}
return unique
}
// restoreSkillSet removes any newly published directories, then restores all
// original directories in reverse backup order.
func restoreSkillSet(published []SkillPathPublication, backups []backedUpSkillDir) error {
restoreErr := upgradeRollbackPublishedSkillPaths(published)
for i := len(backups) - 1; i >= 0; i-- {
backup := backups[i]
if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err))
continue
}
if err := moveSkillPathRecoverably(backup.backup, backup.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err))
}
}
return restoreErr
}
// backupSkillSet moves every victim aside as one logical operation. If a
// later backup fails, earlier moves are restored before the error is returned.
func backupSkillSet(homeDir string, victims []string) ([]backedUpSkillDir, error) {
backups := make([]backedUpSkillDir, 0, len(victims))
for _, victim := range uniqueSkillDirs(victims) {
backup, err := backupAndRemoveSkillDir(homeDir, victim)
if err != nil {
if restoreErr := restoreSkillSet(nil, backups); restoreErr != nil {
return nil, errors.Join(err, fmt.Errorf("恢复已备份 Skill 失败: %w", restoreErr))
}
return nil, err
}
if backup != "" {
backups = append(backups, backedUpSkillDir{original: victim, backup: backup})
}
}
return backups, nil
}
// publishStagedSkillSet switches a fully staged set into place. Any publish
// failure removes the partial new set and restores every original directory.
func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []string) error {
backups, err := backupSkillSet(homeDir, victims)
if err != nil {
return err
}
published := make([]SkillPathPublication, 0, len(staged))
for _, skill := range staged {
publication, publishErr := upgradePublishSkillPath(skill.staged, skill.dest)
if publishErr != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, publishErr)
if restoreErr := restoreSkillSet(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr))
}
return publishErr
}
published = append(published, publication)
}
return nil
}
func monoUpgradeVictims(baseDir, destDir string, managed ...map[string]bool) ([]string, error) {
victims, err := managedMultiSkillVictims(baseDir, managed...)
if err != nil {
return nil, err
}
return append(victims, destDir), nil
}
func multiUpgradeVictims(destBase string, skillSet map[string]bool, skills []string, managed ...map[string]bool) ([]string, error) {
victims, err := oppositeModeSkillVictims(destBase, skillSet, managed...)
if err != nil {
return nil, err
}
for _, name := range skills {
victims = append(victims, filepath.Join(destBase, name))
}
return victims, nil
}
func publishMonoUpgradeTarget(homeDir, destBase, skillSrc string, managed ...map[string]bool) error {
destDir := filepath.Join(destBase, "dws")
victims, err := monoUpgradeVictims(destBase, destDir, managed...)
if err != nil {
return err
}
stageRoot, staged, err := stageSkillSet(destBase, ".dws-upgrade-mono-", []skillStageSpec{{src: skillSrc, dest: destDir}})
if err != nil {
return err
}
defer func() { _ = upgradeRemoveAll(stageRoot) }()
return publishStagedSkillSet(homeDir, staged, victims)
}
func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []string, skillSet map[string]bool, managed ...map[string]bool) error {
victims, err := multiUpgradeVictims(destBase, skillSet, skills, managed...)
if err != nil {
return err
}
specs := make([]skillStageSpec, 0, len(skills))
for _, name := range skills {
specs = append(specs, skillStageSpec{
src: filepath.Join(multiRoot, name),
dest: filepath.Join(destBase, name),
})
}
stageRoot, staged, err := stageSkillSet(destBase, ".dws-upgrade-multi-", specs)
if err != nil {
return err
}
defer func() { _ = upgradeRemoveAll(stageRoot) }()
return publishStagedSkillSet(homeDir, staged, victims)
}
func isGenericSkillRoot(agentDir string) bool {
return filepath.Clean(agentDir) == filepath.Clean(".agents/skills")
}
func isUniversalSkillRoot(agentDir string) bool {
return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))]
}
type resolvedSkillRoot struct {
base string
label string
universal bool
canonical bool
}
func resolveOpenClawSkillBase(homeDir string) string {
for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} {
base := filepath.Join(homeDir, name)
if info, err := upgradeStat(base); err == nil && info.IsDir() {
return filepath.Join(base, "skills")
}
}
return filepath.Join(homeDir, ".openclaw", "skills")
}
func configuredSkillRoots(homeDir string) []resolvedSkillRoot {
var roots []resolvedSkillRoot
seen := map[string]bool{}
configHome := strings.TrimSpace(upgradeGetenv("XDG_CONFIG_HOME"))
if configHome == "" {
configHome = filepath.Join(homeDir, ".config")
}
add := func(base, label string, universal, canonical bool) {
key := skillRootPathKey(base)
if seen[key] {
return
}
seen[key] = true
roots = append(roots, resolvedSkillRoot{base: base, label: label, universal: universal, canonical: canonical})
}
for _, agentDir := range knownSkillDirs {
base := filepath.Join(homeDir, agentDir)
switch filepath.ToSlash(filepath.Clean(agentDir)) {
case ".claude/skills":
if custom := strings.TrimSpace(upgradeGetenv("CLAUDE_CONFIG_DIR")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".codex/skills":
if custom := strings.TrimSpace(upgradeGetenv("CODEX_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".hermes/skills":
if custom := strings.TrimSpace(upgradeGetenv("HERMES_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".autohand/skills":
if custom := strings.TrimSpace(upgradeGetenv("AUTOHAND_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".grok/skills":
if custom := strings.TrimSpace(upgradeGetenv("GROK_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".vibe/skills":
if custom := strings.TrimSpace(upgradeGetenv("VIBE_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".openclaw/skills":
base = resolveOpenClawSkillBase(homeDir)
case ".config/agents/skills":
base = filepath.Join(configHome, "agents", "skills")
case ".config/opencode/skills":
base = filepath.Join(configHome, "opencode", "skills")
case ".config/crush/skills":
base = filepath.Join(configHome, "crush", "skills")
case ".config/devin/skills":
base = filepath.Join(configHome, "devin", "skills")
case ".config/goose/skills":
base = filepath.Join(configHome, "goose", "skills")
case ".config/kimchi/harness/skills":
base = filepath.Join(configHome, "kimchi", "harness", "skills")
}
add(base, agentDir, isUniversalSkillRoot(agentDir), isGenericSkillRoot(agentDir))
}
return roots
}
func skillRootDetectedBase(root resolvedSkillRoot) bool {
detectedDir := filepath.Dir(root.base)
switch filepath.ToSlash(filepath.Clean(root.label)) {
case ".config/kimchi/harness/skills":
detectedDir = filepath.Dir(filepath.Dir(root.base))
case ".tabnine/agent/skills":
detectedDir = filepath.Dir(filepath.Dir(root.base))
case ".zcode/skills":
// Application bundles are machine-scoped detection signals. Keep this
// independent of HOME so upgrade matches npm, Shell, and PowerShell.
if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() {
return true
}
case ".minimax/skills":
if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() {
return true
}
}
info, err := upgradeStat(detectedDir)
return err == nil && info.IsDir()
}
func samePhysicalSkillRoot(left, right string) bool {
leftReal, leftErr := upgradeEvalSymlinks(left)
rightReal, rightErr := upgradeEvalSymlinks(right)
if leftErr != nil || rightErr != nil {
return false
}
return skillRootPathKey(leftReal) == skillRootPathKey(rightReal)
}
func skillRootPathKey(path string) string {
clean := filepath.Clean(path)
if upgradeFoldPathCase {
clean = strings.ToLower(clean)
}
return clean
}
func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error {
victims, err := managedMultiSkillVictims(base, managed)
if err != nil {
return err
}
victims = append(victims, filepath.Join(base, "dws"))
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("迁移 Agent Skill 旧副本失败: %w", err)
}
return nil
}
func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) (stageRoot string, staged []stagedSkillDir, err error) {
if err := upgradeMkdirAll(destBase, dirPermShared); err != nil {
return "", nil, fmt.Errorf("创建 Agent Skill 目录失败 %s: %w", destBase, err)
}
stageRoot, err = upgradeMkdirTemp(destBase, prefix)
if err != nil {
return "", nil, fmt.Errorf("创建 Skill 链接 staging 失败 %s: %w", destBase, err)
}
defer func() {
if err != nil {
_ = upgradeRemoveAll(stageRoot)
}
}()
realDestBase, realErr := upgradeEvalSymlinks(destBase)
if realErr != nil {
return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", destBase, realErr)
}
for _, name := range names {
target := filepath.Join(canonicalBase, name)
realTarget, targetErr := upgradeEvalSymlinks(target)
if targetErr != nil {
return stageRoot, nil, fmt.Errorf("解析 canonical Skill 失败 %s: %w", target, targetErr)
}
relTarget, relErr := upgradeRel(realDestBase, realTarget)
if relErr != nil {
return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr)
}
stagedPath := filepath.Join(stageRoot, name)
if linkErr := upgradeSymlink(relTarget, stagedPath); linkErr != nil {
return stageRoot, nil, fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedPath, relTarget, linkErr)
}
staged = append(staged, stagedSkillDir{staged: stagedPath, dest: filepath.Join(destBase, name)})
}
return stageRoot, staged, nil
}
func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names []string, victims []string) error {
needed := make([]string, 0, len(names))
correct := make(map[string]bool, len(names))
for _, name := range names {
dest := filepath.Join(destBase, name)
canonical := filepath.Join(canonicalBase, name)
if samePhysicalSkillRoot(dest, canonical) {
correct[filepath.Clean(dest)] = true
continue
}
needed = append(needed, name)
}
filteredVictims := make([]string, 0, len(victims))
for _, victim := range victims {
if !correct[filepath.Clean(victim)] {
filteredVictims = append(filteredVictims, victim)
}
}
stageRoot, staged, err := stageLinkedSkillSet(destBase, canonicalBase, ".dws-link-set-", needed)
if err != nil {
return err
}
defer func() { _ = upgradeRemoveAll(stageRoot) }()
return publishStagedSkillSet(homeDir, staged, filteredVictims)
}
// upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory
// per agent home.
func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) {
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
canonicalBase := filepath.Join(homeDir, ".agents", "skills")
canonicalDest := filepath.Join(canonicalBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err})
// Canonical is the mandatory store: universal agents read it directly, so a
// failed canonical publish means nobody received the upgrade. Always fail
// loud — including universal-only machines — instead of reporting success
// with nothing installed.
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK})
for _, root := range configuredSkillRoots(homeDir) {
if root.canonical {
continue
}
destBase := root.base
destDir := filepath.Join(destBase, "dws")
if isBlacklisted(root.label) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
continue
}
if !skillRootDetectedBase(root) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
if samePhysicalSkillRoot(destBase, canonicalBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
if root.universal {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
// Nothing is installed below a universal root, so a stale copy that
// resists retirement is a warning, not an install failure.
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirRetireWarning, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
}
continue
}
victims, victimErr := monoUpgradeVictims(destBase, destDir, managedNames)
if victimErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: victimErr})
continue
}
if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, []string{"dws"}, victims); err != nil {
// An uncertain destination may belong to a concurrent writer.
// The copy fallback below would back that object aside and
// replace it — exactly what the sentinel forbids — so surface
// the uncertain state instead of retrying over it.
if errors.Is(err, ErrSkillPathPublicationUncertain) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
// Match the upstream installer: platforms/filesystems that reject
// links receive a direct copy rather than losing Agent support.
if copyErr := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); copyErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: errors.Join(err, copyErr)})
continue
}
}
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
}
// Best-effort: refresh the user-level mono cache so that
// `dws skill setup --mode mono` fallbacks stay on the upgraded version
// (symmetric with the multi cache refresh in upgradeMultiSkillLocations).
_ = refreshSkillCache(homeDir, "mono", skillSrc)
return result, nil
}
// upgradeMultiSkillLocations installs every skill of the multi bundle into
// each agent home as sibling directories and backs up + removes the
// opposite-mode leftovers. A home is marked failed (and multi is NOT
// installed into it) when leftover backup/removal fails, so mono and multi
// never co-exist.
func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*SkillUpgradeResult, error) {
skillSet := make(map[string]bool, len(skills))
for _, s := range skills {
skillSet[s] = true
}
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
canonicalBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err})
// Canonical is the mandatory store (see the mono branch): fail loud even
// on universal-only machines instead of reporting success with nothing
// installed.
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK})
for _, root := range configuredSkillRoots(homeDir) {
if root.canonical {
continue
}
destBase := root.base
if isBlacklisted(root.label) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
continue
}
if !skillRootDetectedBase(root) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
if samePhysicalSkillRoot(destBase, canonicalBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
if root.universal {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
// Nothing is installed below a universal root, so a stale copy that
// resists retirement is a warning, not an install failure.
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirRetireWarning, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
}
continue
}
victims, victimErr := multiUpgradeVictims(destBase, skillSet, skills, managedNames)
if victimErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: victimErr})
continue
}
if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, skills, victims); err != nil {
// An uncertain destination may belong to a concurrent writer;
// the copy fallback must not displace it (see the mono branch).
if errors.Is(err, ErrSkillPathPublicationUncertain) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
continue
}
if copyErr := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); copyErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: errors.Join(err, copyErr)})
continue
}
}
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
// Best-effort: refresh the user-level caches so that `dws skill setup`
// fallbacks stay on the upgraded version. The release zip ships both
// trees, so when the sibling mono/ tree is present the mono cache is
// refreshed as well.
_ = refreshSkillCache(homeDir, "multi", multiRoot)
if monoSrc := filepath.Join(filepath.Dir(multiRoot), "mono"); skillTreeHasRoot(monoSrc) {
_ = refreshSkillCache(homeDir, "mono", monoSrc)
}
return result, nil
}
// refreshSkillCache mirrors src into ~/.dws/skills/<name>/ through a staged
// sibling directory. The existing cache is moved aside only after the staged
// copy is complete, and is restored if publishing the new cache fails.
func refreshSkillCache(homeDir, name, src string) error {
cacheDir := filepath.Join(homeDir, ".dws", "skills", name)
cacheParent := filepath.Dir(cacheDir)
if err := upgradeMkdirAll(cacheParent, dirPermShared); err != nil {
return fmt.Errorf("创建 Skill 缓存目录失败 %s: %w", cacheParent, err)
}
stagedDir, err := upgradeMkdirTemp(cacheParent, "."+name+".tmp-")
if err != nil {
return fmt.Errorf("创建 Skill 缓存临时目录失败 %s: %w", cacheParent, err)
}
stagedPublished := false
defer func() {
if !stagedPublished {
_ = upgradeRemoveAll(stagedDir)
}
}()
if err := upgradeCopyDir(src, stagedDir); err != nil {
return fmt.Errorf("暂存 Skill 缓存失败 %s: %w", stagedDir, err)
}
if _, err := upgradeStat(cacheDir); os.IsNotExist(err) {
if err := upgradeRename(stagedDir, cacheDir); err != nil {
return fmt.Errorf("发布 Skill 缓存失败 %s: %w", cacheDir, err)
}
stagedPublished = true
return nil
} else if err != nil {
return fmt.Errorf("检查 Skill 缓存失败 %s: %w", cacheDir, err)
}
rollbackDir, err := upgradeMkdirTemp(cacheParent, "."+name+".old-")
if err != nil {
return fmt.Errorf("创建 Skill 缓存回滚目录失败 %s: %w", cacheParent, err)
}
if err := upgradeRemoveAll(rollbackDir); err != nil {
return fmt.Errorf("准备 Skill 缓存回滚目录失败 %s: %w", rollbackDir, err)
}
if err := upgradeRename(cacheDir, rollbackDir); err != nil {
return fmt.Errorf("暂存原 Skill 缓存失败 %s: %w", cacheDir, err)
}
if err := upgradeRename(stagedDir, cacheDir); err != nil {
if restoreErr := upgradeRename(rollbackDir, cacheDir); restoreErr != nil {
return fmt.Errorf("发布 Skill 缓存失败 %s: %w(恢复原缓存也失败: %v)", cacheDir, err, restoreErr)
}
return fmt.Errorf("发布 Skill 缓存失败 %s: %w", cacheDir, err)
}
stagedPublished = true
_ = upgradeRemoveAll(rollbackDir)
return nil
}
// skillTreeHasRoot reports whether dir carries a top-level SKILL.md.
func skillTreeHasRoot(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "SKILL.md"))
return err == nil && !info.IsDir()
}
func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) {
entries, err := upgradeReadDir(baseDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("读取技能目录失败 %s: %w", baseDir, err)
}
victims := make([]string, 0)
for _, e := range entries {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(baseDir, e.Name()))
}
return victims, nil
}
func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) {
victims := []string{filepath.Join(destBase, "dws")}
entries, err := upgradeReadDir(destBase)
if err != nil {
if os.IsNotExist(err) {
return victims, nil
}
return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err)
}
for _, e := range entries {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(destBase, e.Name()))
}
return victims, nil
}
// isManagedMultiSkillDir accepts only centralized metadata or the frozen exact
// official-name migration list. Files inside Skill directories are ignored.
func isManagedMultiSkillDir(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 readManagedSkillNames(homeDir string) map[string]bool {
state, readable, err := upgradeReadSkillState(homeDir)
if err != nil || !readable {
return map[string]bool{}
}
return skillstate.ManagedSkillNames(state)
}
// bundleSkillNames returns the sorted names of subdirectories of dir that
// contain a SKILL.md. It returns nil when dir itself carries a top-level
// SKILL.md (mono layout) so callers can distinguish the two layouts.
func bundleSkillNames(dir string) []string {
if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err == nil {
return nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var names []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(dir, e.Name(), "SKILL.md")); err == nil {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names
}
// LocateSkillsRoot resolves the skill root inside an extracted dws-skills.zip,
// preferring the multi bundle ({extractDir}/multi) over the legacy mono
// layouts handled by LocateSkillMD.
func LocateSkillsRoot(extractDir string) string {
multiRoot := filepath.Join(extractDir, "multi")
if skills := bundleSkillNames(multiRoot); len(skills) > 0 {
return multiRoot
}
return LocateSkillMD(extractDir)
}
// 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
}