fix(skills): use canonical global installation

This commit is contained in:
玉澜
2026-08-17 20:19:25 +08:00
parent 9e3a5d6fbd
commit 44d640bbae
16 changed files with 1020 additions and 714 deletions
+2 -2
View File
@@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents that natively support the universal root (including Codex) read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice.
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
@@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
> Installers use `$HOME/.agents/skills/` as the canonical global store, matching the mechanism used by lark-cli's `skills` installer. Universal Agents such as Codex read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
+159 -74
View File
@@ -29,6 +29,17 @@ const AGENT_DIRS = [
".hermes/skills",
];
// Same capability split as the `skills` CLI used by lark-cli. Universal
// Agents read ~/.agents/skills directly and must not receive a second copy.
const UNIVERSAL_AGENT_DIRS = new Set([
".cursor/skills",
".gemini/skills",
".codex/skills",
".github/skills",
".cline/skills",
".amp/skills",
]);
const PLATFORM_MAP = {
"darwin-x64": "dws-darwin-amd64.tar.gz",
"darwin-arm64": "dws-darwin-arm64.tar.gz",
@@ -65,7 +76,14 @@ function backupStamp() {
// and false is returned so callers skip that target rather than silently
// deleting data.
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
let info;
try {
info = fs.lstatSync(dir);
} catch (err) {
if (err && err.code === "ENOENT") return true;
throw err;
}
if (!info.isDirectory() && !info.isSymbolicLink()) {
return true;
}
const rel = path.relative(homeDir, dir);
@@ -234,15 +252,11 @@ function installSkillsToHomes(skillRoot) {
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
@@ -256,37 +270,44 @@ function installSkillsToHomes(skillRoot) {
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
const canonicalBase = path.join(homeDir, ".agents", "skills");
attempted += 1;
if (installToBase(canonicalBase)) {
installed += 1;
} else {
failed += 1;
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
if (installed > 0) {
for (const agentDir of AGENT_DIRS.slice(1)) {
const baseDir = path.join(homeDir, agentDir);
if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue;
attempted += 1;
if (UNIVERSAL_AGENT_DIRS.has(agentDir)) {
try {
retireManagedSkillRoot(homeDir, baseDir, managedNames);
} catch (err) {
console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`);
failed += 1;
}
continue;
}
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
}
try {
publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims);
installed += 1;
} catch (linkErr) {
console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`);
if (installToBase(baseDir)) installed += 1;
else failed += 1;
}
}
}
if (installed === 0) {
@@ -346,12 +367,11 @@ function isManagedMultiSkillDir(dir, managedNames) {
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
}
function retireGenericSkillRoot(homeDir, managedNames) {
const baseDir = path.join(homeDir, ".agents", "skills");
function retireManagedSkillRoot(homeDir, baseDir, managedNames) {
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
@@ -374,12 +394,74 @@ function retireGenericSkillRoot(homeDir, managedNames) {
}
}
if (restoreErrors.length > 0) {
throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`);
throw new Error(`${err.message}; Agent-root rollback failed: ${restoreErrors.join("; ")}`);
}
throw err;
}
}
function samePhysicalDir(left, right) {
try {
return fs.realpathSync(left) === fs.realpathSync(right);
} catch (_) {
return false;
}
}
function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) {
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-"));
const staged = [];
const backups = [];
const published = [];
const correctLinks = new Set();
const restore = () => {
for (let i = published.length - 1; i >= 0; i -= 1) {
fs.rmSync(published[i], { recursive: true, force: true });
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
fs.renameSync(backups[i].backup, backups[i].original);
}
};
try {
for (const name of names) {
const target = path.join(canonicalBase, name);
const dest = path.join(baseDir, name);
if (samePhysicalDir(dest, target)) {
correctLinks.add(path.resolve(dest));
continue;
}
const stagedPath = path.join(stageRoot, name);
const linkTarget = process.platform === "win32" ? target : path.relative(baseDir, target);
fs.symlinkSync(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir");
staged.push({ staged: stagedPath, dest: path.join(baseDir, name) });
}
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized) || correctLinks.has(normalized)) continue;
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
for (const item of staged) {
fs.renameSync(item.staged, item.dest);
published.push(item.dest);
}
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
fs.rmSync(stageRoot, { recursive: true, force: true });
}
}
function skillDirectoryDigest(dir) {
const files = [];
const visit = (current, prefix) => {
@@ -628,10 +710,6 @@ function installMultiSkillsToHomes(multiRoot) {
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
fs.mkdirSync(baseDir, { recursive: true });
const victims = [path.join(baseDir, "dws")];
@@ -639,7 +717,7 @@ function installMultiSkillsToHomes(multiRoot) {
// the same transaction as every replaced bundled skill.
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (
entry.isDirectory() &&
(entry.isDirectory() || entry.isSymbolicLink()) &&
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
!skillSet.has(entry.name)
) {
@@ -658,37 +736,44 @@ function installMultiSkillsToHomes(multiRoot) {
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
const canonicalBase = path.join(homeDir, ".agents", "skills");
attempted += 1;
if (installToBase(canonicalBase)) installed += 1;
else failed += 1;
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
if (installed > 0) {
for (const agentDir of AGENT_DIRS.slice(1)) {
const baseDir = path.join(homeDir, agentDir);
if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue;
attempted += 1;
if (UNIVERSAL_AGENT_DIRS.has(agentDir)) {
try {
retireManagedSkillRoot(homeDir, baseDir, managedNames);
} catch (err) {
console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`);
failed += 1;
}
continue;
}
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (
(entry.isDirectory() || entry.isSymbolicLink()) &&
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
!skillSet.has(entry.name)
) victims.push(path.join(baseDir, entry.name));
}
}
for (const name of skills) victims.push(path.join(baseDir, name));
try {
publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims);
installed += 1;
} catch (linkErr) {
console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`);
if (installToBase(baseDir)) installed += 1;
else failed += 1;
}
}
}
if (installed === 0) {
+101 -29
View File
@@ -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...) {
@@ -0,0 +1,89 @@
package app
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
})
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
t.Fatal(err)
}
}
dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
canonical := filepath.Join(home, ".agents", "skills")
if len(dests) != 4 || dests[0] != canonical {
t.Fatalf("targets = %v", dests)
}
src := t.TempDir()
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
dir := filepath.Join(src, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err)
}
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
if err != nil {
t.Fatal(err)
}
installed, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{})
if err != nil || skipped != 0 || installed != 6 { // canonical + two linked Agents, two Skills each
t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err)
}
if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
t.Fatalf("Codex duplicate remains: %v", err)
}
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical %s missing: %v", name, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
link := filepath.Join(home, agent, "skills", name)
info, err := os.Lstat(link)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("link %s = %#v, %v", link, info, err)
}
}
}
// Re-running setup must recognize the existing links as already correct;
// canonical refreshes in place without turning links into copied trees.
plan, err = buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
if err != nil {
t.Fatal(err)
}
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
for _, agent := range []string{".claude", ".openclaw"} {
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("idempotent setup replaced %s link: %#v, %v", agent, info, err)
}
}
}
+15 -13
View File
@@ -149,31 +149,36 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".codex", "skills")
genericMono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(genericMono, 0o755); err != nil {
canonical := filepath.Join(home, ".agents", "skills")
oldCodex := filepath.Join(dest, "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
return "", errors.New("transient HOME failure")
})
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{canonical, dest}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
t.Fatalf("generic cleanup target = %#v", plan.Targets)
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest {
t.Fatalf("universal cleanup target = %#v", plan.Targets)
}
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex {
t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups)
}
var preview bytes.Buffer
renderSkillSetupPlan(&preview, plan)
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
t.Fatalf("generic cleanup preview missing: %s", preview.String())
if !strings.Contains(preview.String(), "universal Agent") {
t.Fatalf("universal cleanup preview missing: %s", preview.String())
}
t.Run("managed multi and scan failure", func(t *testing.T) {
genericMono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(genericMono, 0o755); err != nil {
t.Fatal(err)
}
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
@@ -187,9 +192,6 @@ func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTar
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
t.Fatalf("generic scan error = %v", targetErr)
}
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
t.Fatalf("generic cleanup plan error = %v", planErr)
}
})
}
@@ -210,7 +212,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") {
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
+14 -14
View File
@@ -297,12 +297,12 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
if len(got) != 2 {
t.Fatalf("expected canonical + Claude, got %d", len(got))
}
want := filepath.Join(home, ".claude", "skills", "dws")
if filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[0])
if filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[1])
}
}
@@ -321,12 +321,12 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
if len(got) != 2 {
t.Fatalf("expected canonical + Claude, got %d", len(got))
}
want := filepath.Join(home, ".claude", "skills")
if filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[0])
if filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[1])
}
}
@@ -343,8 +343,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t
t.Fatal(err)
}
want := filepath.Join(home, ".codex", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want canonical + %s", got, want)
}
}
@@ -360,8 +360,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
t.Fatal(err)
}
want := filepath.Join(home, ".zcode", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want canonical + %s", got, want)
}
explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono)
@@ -369,8 +369,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
t.Fatal(err)
}
wantMono := filepath.Join(want, "dws")
if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
if len(explicit) != 2 || filepath.Clean(explicit[1]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want canonical + %s", explicit, wantMono)
}
}
+91
View File
@@ -0,0 +1,91 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
})
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
t.Fatal(err)
}
}
// beta.6 wrote a physical copy into Codex. The canonical migration must
// remove it without treating a matching user/market name as DWS-owned.
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
canonical := filepath.Join(home, ".agents", "skills")
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical %s missing: %v", name, err)
}
if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", name)); !os.IsNotExist(err) {
t.Fatalf("universal Codex duplicate remains for %s: %v", name, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
link := filepath.Join(home, agent, "skills", name)
info, err := os.Lstat(link)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("%s link = %#v, %v", link, info, err)
}
if _, err := os.Stat(filepath.Join(link, "SKILL.md")); err != nil {
t.Fatalf("%s does not resolve canonical content: %v", link, err)
}
}
}
result, err = UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("idempotent upgrade = %#v, %v", result, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("idempotent upgrade replaced %s link: %#v, %v", agent, info, err)
}
}
}
func TestCanonicalSkillLinksFallBackToCopies(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("links unavailable") })
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat")
result, err := UpgradeSkillLocations(multiRoot)
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
dest := filepath.Join(home, ".claude", "skills", "dingtalk-chat")
info, err := os.Lstat(dest)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("copy fallback = %#v, %v", info, err)
}
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
t.Fatalf("copy fallback content missing: %v", err)
}
}
+3 -3
View File
@@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) {
}
knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"}
result, err := UpgradeSkillLocations(source)
if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 {
if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 {
t.Fatalf("skill upgrade = %#v, %v", result, err)
}
knownSkillDirs = []string{".real/skills"}
@@ -593,8 +593,8 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(source)
if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 {
t.Fatalf("replaced primary fallback = %#v, %v", result, err)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical failure = %#v, %v", result, err)
}
failure := errors.New("ensure failed")
upgradeEnsureDir = func(string, os.FileMode) error { return failure }
+168 -113
View File
@@ -35,10 +35,11 @@ const (
// - 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 a generic fallback. It is used only
// when no concrete Agent home is detected; otherwise publishing there would
// duplicate every Skill for Agents (including Codex) that scan both roots.
// Subsequent entries are updated when their parent directory already exists.
// The first entry (.agents/skills) is the canonical global Skill directory,
// matching the mechanism used by lark-cli's upstream `skills` installer.
// 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",
".claude/skills",
@@ -59,6 +60,20 @@ var knownSkillDirs = []string{
".hermes/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{
".cursor/skills": true,
".gemini/skills": true,
".codex/skills": true,
".github/skills": true,
".cline/skills": true,
".amp/skills": true,
}
var (
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
@@ -70,6 +85,8 @@ var (
upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir
upgradeStat = os.Stat
upgradeLstat = os.Lstat
upgradeSymlink = os.Symlink
upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
@@ -90,14 +107,14 @@ const skillBackupSubdir = ".dws/skill-backups"
// opposite layout next to it silently). Missing paths and regular files are
// no-ops ("", nil).
func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
info, err := upgradeStat(dir)
info, err := upgradeLstat(dir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err)
}
if !info.IsDir() {
if !info.IsDir() && info.Mode()&os.ModeSymlink == 0 {
return "", nil
}
rel, relErr := filepath.Rel(homeDir, dir)
@@ -204,11 +221,13 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
// `dws skill setup --mode mono` flow.
//
// Strategy (matches npm install.js installSkillsToHomes):
// - Concrete agent dirs (claude, cursor, codex, ...) are updated when their
// parent directory exists (e.g. ~/.codex/ exists => user has Codex)
// - ~/.agents/skills/ is used only when no concrete Agent is detected
// - ~/.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
// - If no location was updated at all, fall back to ~/.agents/skills/
// - 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
@@ -517,100 +536,146 @@ func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []str
return publishStagedSkillSet(homeDir, staged, victims)
}
func hasDetectedSpecificSkillRoot(homeDir string) bool {
for _, agentDir := range knownSkillDirs {
if isGenericSkillRoot(agentDir) || isBlacklisted(agentDir) {
continue
}
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if info, err := upgradeStat(parentGate); err == nil && info.IsDir() {
return true
}
}
return false
}
func isGenericSkillRoot(agentDir string) bool {
return filepath.Clean(agentDir) == filepath.Clean(".agents/skills")
}
func retireGenericSkillRoot(homeDir string, managed map[string]bool) error {
base := filepath.Join(homeDir, ".agents", "skills")
func isUniversalSkillRoot(agentDir string) bool {
return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))]
}
func skillRootDetected(homeDir, agentDir string) bool {
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
info, err := upgradeStat(parentGate)
return err == nil && info.IsDir()
}
func samePhysicalSkillRoot(left, right string) bool {
leftReal, leftErr := upgradeEvalSymlinks(left)
rightReal, rightErr := upgradeEvalSymlinks(right)
return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal)
}
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("迁移通用 Skill 根目录失败: %w", err)
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)
}
}()
for _, name := range names {
target := filepath.Join(canonicalBase, name)
relTarget, relErr := filepath.Rel(destBase, target)
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 := victims[:0]
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)
hasSpecificRoot := hasDetectedSpecificSkillRoot(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})
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK})
for _, agentDir := range knownSkillDirs {
destDir := filepath.Join(homeDir, agentDir, "dws")
if isGenericSkillRoot(agentDir) {
continue
}
destBase := filepath.Join(homeDir, agentDir)
destDir := filepath.Join(destBase, "dws")
if isBlacklisted(agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
continue
}
if isGenericSkillRoot(agentDir) && hasSpecificRoot {
if !skillRootDetected(homeDir, agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
if !isGenericSkillRoot(agentDir) {
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
if samePhysicalSkillRoot(destBase, canonicalBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
if isUniversalSkillRoot(agentDir) {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, 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 {
// 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
}
}
if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); 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})
}
if hasSpecificRoot && len(result.Succeeded()) > 0 {
genericBase := filepath.Join(homeDir, ".agents", "skills")
if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
}
}
// Fallback: if nothing succeeded, force the primary location. The multi
// leftovers under the primary base are the usual reason the primary
// install failed, so clean them first — failing loud like the multi
// fallback — instead of letting mono and multi co-exist marked OK.
if len(result.Succeeded()) == 0 && !hasSpecificRoot {
destBase := filepath.Join(homeDir, ".agents", "skills")
dest := filepath.Join(destBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); 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})
}
}
// Best-effort: refresh the user-level mono cache so that
// `dws skill setup --mode mono` fallbacks stay on the upgraded version
@@ -633,61 +698,51 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
hasSpecificRoot := hasDetectedSpecificSkillRoot(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})
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK})
for _, agentDir := range knownSkillDirs {
if isGenericSkillRoot(agentDir) {
continue
}
destBase := filepath.Join(homeDir, agentDir)
if isBlacklisted(agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
continue
}
if isGenericSkillRoot(agentDir) && hasSpecificRoot {
if !skillRootDetected(homeDir, agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
if !isGenericSkillRoot(agentDir) {
parentGate := filepath.Dir(destBase)
if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
if samePhysicalSkillRoot(destBase, canonicalBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
if isUniversalSkillRoot(agentDir) {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, 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 {
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
}
}
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
if hasSpecificRoot && len(result.Succeeded()) > 0 {
genericBase := filepath.Join(homeDir, ".agents", "skills")
if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
}
}
// Fallback: if nothing succeeded, force the primary location
if len(result.Succeeded()) == 0 && !hasSpecificRoot {
destBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); 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 == destBase {
result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
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
@@ -789,7 +844,7 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str
}
victims := make([]string, 0)
for _, e := range entries {
if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(baseDir, e.Name()))
@@ -825,7 +880,7 @@ func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed
return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err)
}
for _, e := range entries {
if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
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()))
+32 -213
View File
@@ -124,8 +124,7 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) {
func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
home := withFakeHome(t)
// A concrete Agent root wins over the generic .agents fallback; .claude
// installs and .cursor is skipped.
// .agents is canonical; detected Claude receives links and Cursor is skipped.
agentsBase := filepath.Join(home, ".agents", "skills")
claudeBase := filepath.Join(home, ".claude", "skills")
for _, base := range []string{agentsBase, claudeBase} {
@@ -193,17 +192,19 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
// Succeeded entries report the agent home base in multi mode.
succeeded := result.Succeeded()
if len(succeeded) != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results)
if len(succeeded) != 2 {
t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results)
}
wantDirs := map[string]bool{claudeBase: true}
wantDirs := map[string]bool{agentsBase: true, claudeBase: true}
for _, d := range succeeded {
if !wantDirs[d.Dir] {
t.Errorf("unexpected succeeded dir %q", d.Dir)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("generic mono duplicate still visible: %v", err)
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Fatalf("canonical Skill missing: %v", err)
}
}
// Multi cache refreshed under the fake home.
@@ -216,7 +217,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
}
}
func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) {
func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"})
@@ -241,12 +242,12 @@ func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicat
t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err)
}
want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md")
want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md")
if _, err := os.Stat(want); err != nil {
t.Fatalf("canonical Codex Skill missing at %s: %v", want, err)
t.Fatalf("canonical Skill missing at %s: %v", want, err)
}
for _, duplicate := range []string{
filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"),
filepath.Join(codexBase, "dingtalk-chat"),
filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"),
} {
if _, err := os.Stat(duplicate); !os.IsNotExist(err) {
@@ -526,21 +527,15 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(
}
result, err := UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("fallback should replace the failed entry with OK, got failed=%v", failed)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (%v)", got, result.Results)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical copy failure = %#v, %v", result, err)
}
data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md"))
if err != nil {
t.Fatalf("mono fallback install missing: %v", err)
}
if string(data) != "# mono" {
t.Errorf("mono content = %q, want refreshed package", data)
if string(data) != "old" {
t.Errorf("mono content = %q, want original preserved", data)
}
}
@@ -574,22 +569,22 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
}
result, err := UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err)
if err == nil {
t.Fatal("canonical read failure must fail the upgrade")
}
failed := result.Failed()
if len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
}
wantDir := agentsBase
wantDir := filepath.Join(agentsBase, "dws")
if failed[0].Dir != wantDir || failed[0].Err == nil {
t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir)
}
if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") {
t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
if got := len(result.Succeeded()); got != 0 {
t.Fatalf("Succeeded() len = %d, want 0", got)
}
// Mono must NOT have been laid down next to the unreadable multi state.
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
@@ -628,8 +623,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
if err == nil {
t.Fatal("multi fallback cleanup failure must return an error")
}
if !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("error should mention the fallback cleanup failure, got %v", err)
if !strings.Contains(err.Error(), "canonical Skill 安装失败") {
t.Fatalf("error should mention the canonical cleanup failure, got %v", err)
}
if failed := result.Failed(); len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
@@ -654,8 +649,8 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
// backup. Inject it through the path seam so this branch is portable to
// Windows, where chmod-based permission failures are not reliable.
statFailurePath := filepath.Join(home, "stat-failure")
origStat := upgradeStat
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
origStat := upgradeLstat
testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
if path == statFailurePath {
return nil, errors.New("injected stat failure")
}
@@ -888,17 +883,12 @@ func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
// TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono
// path's fail-loud backup semantics and every primary-fallback outcome.
func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
originalDirs := append([]string(nil), knownSkillDirs...)
t.Cleanup(func() { knownSkillDirs = originalDirs })
mono := t.TempDir()
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
// Per-home backup failure marks that home failed; another home still wins.
home := withFakeHome(t)
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755)
os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644)
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
@@ -909,67 +899,11 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
return os.Rename(src, dst)
})
result, err := UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want exactly 1 backup-failed home", failed)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical backup failure = %#v, %v", result, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatalf("failed home must keep its original dws/: %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback cleanup failure: only a blacklisted home in the main loop, then
// the primary cleanup hits a stale dir whose backup fails.
home2 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
knownSkillDirs = []string{".real/skills"}
stale := filepath.Join(home2, ".agents", "skills", "dingtalk-stale")
os.MkdirAll(stale, 0o755)
useUpgradeManagedNames(t, filepath.Base(stale))
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") })
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback cleanup error = %v", err)
}
if _, err := os.Stat(stale); err != nil {
t.Fatalf("stale dir must survive failed cleanup: %v", err)
}
// Fallback backup failure of the primary dws/ itself.
home3 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
os.MkdirAll(filepath.Join(home3, ".agents", "skills", "dws"), 0o755)
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback copy failure: everything fails loud.
home4 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback copy error = %v", err)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback append (no prior entry for the primary dir): a blacklisted-only
// main loop leaves no primary entry, so the fallback appends a fresh OK.
home5 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
result, err = UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("fallback append error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 appended primary", got)
}
if _, err := os.Stat(filepath.Join(home5, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatalf("fallback append install missing: %v", err)
t.Fatalf("failed canonical home must keep its original dws/: %v", err)
}
}
@@ -977,15 +911,10 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
// path's blacklisted branch, per-skill backup/copy failures, and the primary
// fallback outcomes (cleanup OK + backup/copy failure, fresh append).
func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
originalDirs := append([]string(nil), knownSkillDirs...)
t.Cleanup(func() { knownSkillDirs = originalDirs })
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
// Blacklisted entry is reported, non-blacklisted installs.
home := withFakeHome(t)
knownSkillDirs = []string{".real/skills", ".agents/skills"}
testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"})
os.MkdirAll(filepath.Join(home, ".agents"), 0o755)
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
@@ -1003,118 +932,8 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) {
t.Fatalf("blacklisted home must never be touched, stat err=%v", err)
}
// A detected concrete Agent root wins over .agents. A failure while retiring
// the old generic copy is surfaced after the concrete root succeeds.
home2 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
os.MkdirAll(filepath.Join(home2, ".agents", "skills", "dingtalk-chat"), 0o755)
os.MkdirAll(filepath.Join(home2, ".claude"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, ".agents") {
return errors.New("backup denied")
}
return os.Rename(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want 1 backup-failed home", failed)
}
if _, err := os.Stat(filepath.Join(home2, ".agents", "skills", "dingtalk-chat")); err != nil {
t.Fatalf("failed home must keep its original skill: %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Per-skill copy failure on the concrete home fails that home; with no
// successful concrete target, the generic copy is left untouched.
home3 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
os.MkdirAll(filepath.Join(home3, ".claude"), 0o755)
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
if strings.Contains(dst, ".claude") {
return errors.New("copy denied")
}
return copyDir(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want 1 copy-failed home", failed)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback backup failure: the same-name bundle skill refresh is what
// fails (cleanup succeeds, the per-skill backup does not).
home4 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
knownSkillDirs = []string{".real/skills"}
os.MkdirAll(filepath.Join(home4, ".agents", "skills", "dingtalk-chat"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, "dingtalk-chat") {
return errors.New("backup denied")
}
return os.Rename(src, dst)
})
if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback copy failure.
home5 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback copy error = %v", err)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback append: blacklisted-only main loop → fresh OK entry appended.
home6 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home6, nil })
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("fallback append error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 appended primary (%v)", got, result.Results)
}
if _, err := os.Stat(filepath.Join(home6, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("fallback append install missing: %v", err)
}
// Fallback replace: the main loop fails the primary home on a transient
// copy error, the fallback retry succeeds and replaces the failed entry.
home7 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home7, nil })
knownSkillDirs = []string{".agents/skills"}
origCopy := upgradeCopyDir
attempts := 0
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
attempts++
if attempts == 1 {
return errors.New("transient copy failure")
}
return origCopy(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("fallback replace error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 replaced primary (%v)", got, result.Results)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("failed entry must be replaced by OK, got %v", failed)
}
if _, err := os.Stat(filepath.Join(home7, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("fallback replace install missing: %v", err)
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("canonical install missing: %v", err)
}
}
+88 -76
View File
@@ -408,12 +408,27 @@ multi_tree_has_skills() {
return 1
}
# Move DWS-owned copies out of the generic root once a concrete Agent root is
# active. This prevents Agents such as Codex from discovering duplicates.
retire_generic_skill_root() {
# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the
# canonical store. Universal Agents read it directly; other Agents receive
# relative links and fall back to copies only when links are unavailable.
is_universal_agent_dir() {
case "$1" in
".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;;
*) return 1 ;;
esac
}
same_physical_skill_root() {
[ -d "$1" ] && [ -d "$2" ] || return 1
_sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1
_sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1
[ "$_sps_left" = "$_sps_right" ]
}
retire_agent_skill_root() {
_rgs_root="$1"
_rgs_base="$_rgs_root/.agents/skills"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1
_rgs_base="$2"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1
_rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
@@ -430,25 +445,62 @@ retire_generic_skill_root() {
rm -rf "$_rgs_stage"
}
link_canonical_skills_to_base() {
_lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"
_lcs_canonical="$_lcs_root/.agents/skills"
mkdir -p "$_lcs_base" || return 1
same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
_lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1
_lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published"
: > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; }
: > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; }
if [ "$_lcs_mode" = "mono" ]; then
_lcs_names="dws"
else
_lcs_names=""
for _lcs_skill in "$_lcs_canonical"/*/; do
[ -f "${_lcs_skill}SKILL.md" ] || continue
_lcs_names="$_lcs_names $(basename "$_lcs_skill")"
done
fi
_lcs_publish_names=""
for _lcs_name in $_lcs_names; do
if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi
ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_publish_names="$_lcs_publish_names $_lcs_name"
done
for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do
[ -d "$_lcs_victim" ] || continue
[ "$_lcs_victim" = "$_lcs_stage" ] && continue
same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue
if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then continue; fi
if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then
restore_multi_skill_set /dev/null "$_lcs_backups" || true
rm -rf "$_lcs_stage"; return 1
fi
done
for _lcs_name in $_lcs_publish_names; do
printf '%s\n' "$_lcs_base/$_lcs_name" >> "$_lcs_published" || {
restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1;
}
if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then
restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
printf ' ↪ Skills → %s\n' "$_lcs_base/$_lcs_name"
done
rm -rf "$_lcs_stage"
}
# Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
install_multi_skills_to_root() {
multi_src="$1"
root="$2"
installed=0
attempted=0
attempted=1
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
for agent_dir in \
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
@@ -466,31 +518,22 @@ install_multi_skills_to_root() {
".openclaw/skills" \
".hermes/skills"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
[ -e "$parent_gate" ] || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1))
continue
fi
attempted=$((attempted + 1))
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
if link_canonical_skills_to_base "$root" "$base_dir" multi; then
installed=$((installed + 1))
else
failed=$((failed + 1))
printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir"
printf ' ⚠️ %s 无法创建 Skill 链接,回退为直接复制\n' "$base_dir"
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
installed=$((installed + 1))
fi
if [ "$installed" -eq 0 ]; then
printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'
return 1
@@ -654,20 +697,11 @@ install_skills_to_root() {
skill_src="$1"
root="$2"
installed=0
attempted=0
attempted=1
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
for agent_dir in \
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
@@ -685,44 +719,22 @@ install_skills_to_root() {
".openclaw/skills" \
".hermes/skills"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
[ -e "$parent_gate" ] || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1))
continue
fi
attempted=$((attempted + 1))
if [ "$root" = "$HOME" ]; then
label="~/$agent_dir/$SKILL_NAME"
else
label="$root/$agent_dir/$SKILL_NAME"
fi
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
if link_canonical_skills_to_base "$root" "$base_dir" mono; then
installed=$((installed + 1))
else
failed=$((failed + 1))
printf ' ⚠️ %s 无法创建 Skill 链接,回退为直接复制\n' "$base_dir"
if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ]; then
if [ "$root" = "$HOME" ]; then
flabel="~/.agents/skills/$SKILL_NAME"
else
flabel="$root/.agents/skills/$SKILL_NAME"
fi
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
if [ "$installed" -eq 0 ]; then
printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'
return 1
+94 -58
View File
@@ -458,10 +458,20 @@ function Restore-MultiSkillSet {
return $ok
}
function Move-GenericSkillRootToBackup {
param([string]$Root)
function Test-UniversalAgentDir {
param([string]$AgentDir)
return @(".cursor\skills", ".gemini\skills", ".codex\skills", ".github\skills", ".cline\skills", ".amp\skills") -contains $AgentDir
}
function Test-SamePhysicalSkillRoot {
param([string]$Left, [string]$Right)
if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false }
try { return (Resolve-Path $Left).Path -eq (Resolve-Path $Right).Path } catch { return $false }
}
function Move-AgentSkillRootToBackup {
param([string]$Root, [string]$BaseDir)
$baseDir = Join-Path $Root ".agents\skills"
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $baseDir $SkillName))
foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) {
@@ -476,7 +486,7 @@ function Move-GenericSkillRootToBackup {
if (!$seen.Add($victim)) { continue }
$backupPath = ""
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
throw "通用 Skill 副本备份失败: $victim"
throw "Agent Skill 旧副本备份失败: $victim"
}
if ($backupPath) {
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
@@ -485,11 +495,64 @@ function Move-GenericSkillRootToBackup {
return $true
} catch {
Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null
Write-Say "⚠️ 通用 Skill 副本迁移失败,已回滚: $_"
Write-Say "⚠️ Agent Skill 旧副本迁移失败,已回滚: $_"
return $false
}
}
function Publish-CanonicalSkillLinks {
param([string]$Root, [string]$BaseDir, [string]$Mode)
$canonical = Join-Path $Root ".agents\skills"
if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null }
if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true }
$stageRoot = Join-Path $BaseDir (".dws-link-set-" + [guid]::NewGuid().ToString("N"))
$backups = @()
$published = @()
try {
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
if ($Mode -eq "mono") {
$names = @($SkillName)
} else {
$names = @(Get-ChildItem -Path $canonical -Directory -ErrorAction Stop | Where-Object {
Test-Path (Join-Path $_.FullName "SKILL.md")
} | ForEach-Object { $_.Name })
}
$publishNames = @()
foreach ($name in $names) {
if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue }
New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target (Join-Path $canonical $name) -ErrorAction Stop | Out-Null
$publishNames += $name
}
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $BaseDir $SkillName))
foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -Force -ErrorAction SilentlyContinue) {
if ($existing.FullName -eq $stageRoot) { continue }
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) }
}
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($victim in $victims) {
if (!$seen.Add($victim)) { continue }
if (Test-SamePhysicalSkillRoot -Left $victim -Right (Join-Path $canonical (Split-Path $victim -Leaf))) { continue }
$backupPath = ""
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { throw "Skill 备份失败: $victim" }
if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } }
}
foreach ($name in $publishNames) {
$dest = Join-Path $BaseDir $name
$published += $dest
Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest
Write-Say "↪ Skills → $dest"
}
return $true
} catch {
Restore-MultiSkillSet -Published $published -Backups $backups | Out-Null
Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)"
return $false
} finally {
Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Copy-SkillToDir {
param([string]$SkillSrc, [string]$Dest, [string]$Label)
@@ -811,46 +874,27 @@ function Install-SkillsToHomes {
)
$installed = 0
$attempted = 0
$attempted = 1
$failed = 0
$specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
Test-Path (Split-Path (Join-Path $Root $_) -Parent)
})
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
$canonical = Join-Path $Root ".agents\skills"
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ }
if ($installed -eq 0) { return $false }
for ($i = 1; $i -lt $AgentDirs.Count; $i++) {
$agentDir = $AgentDirs[$i]
$baseDir = Join-Path $Root $agentDir
$parentGate = Split-Path $baseDir -Parent
if ($i -gt 0 -and !(Test-Path $parentGate)) {
if (!(Test-Path $parentGate)) { continue }
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
$attempted++
if (Test-UniversalAgentDir -AgentDir $agentDir) {
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ }
continue
}
$attempted++
if ($Root -eq $HOME) {
$label = "~\$agentDir\$SkillName"
} else {
$label = Join-Path $Root (Join-Path $agentDir $SkillName)
}
$copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label
if ($copied) {
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") {
$installed++
} else {
$failed++
}
}
if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
}
if ($attempted -eq 0) {
$fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName
if ($Root -eq $HOME) {
$flabel = "~\.agents\skills\$SkillName"
} else {
$flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName)
}
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) {
$installed++
} else {
$failed++
Write-Say "⚠️ $baseDir 无法创建 Skill 链接,回退为直接复制"
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) { $installed++ } else { $failed++ }
}
}
if ($installed -eq 0) {
@@ -889,35 +933,27 @@ function Install-MultiSkillsToHomes {
)
$installed = 0
$attempted = 0
$attempted = 1
$failed = 0
$specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
Test-Path (Split-Path (Join-Path $Root $_) -Parent)
})
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
$canonical = Join-Path $Root ".agents\skills"
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ }
if ($installed -eq 0) { return $false }
for ($i = 1; $i -lt $AgentDirs.Count; $i++) {
$agentDir = $AgentDirs[$i]
$baseDir = Join-Path $Root $agentDir
$parentGate = Split-Path $baseDir -Parent
if ($i -gt 0 -and !(Test-Path $parentGate)) {
if (!(Test-Path $parentGate)) { continue }
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
$attempted++
if (Test-UniversalAgentDir -AgentDir $agentDir) {
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ }
continue
}
$attempted++
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) {
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi") {
$installed++
} else {
Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)"
$failed++
}
}
if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
}
if ($attempted -eq 0) {
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") {
$installed++
} else {
$failed++
Write-Say "⚠️ $baseDir 无法创建 Skill 链接,回退为直接复制"
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) { $installed++ } else { $failed++ }
}
}
if ($installed -eq 0) {
+100 -83
View File
@@ -643,13 +643,27 @@ _install_mono_to_base() {
say "✅ Skills → ${_mono_label} (${_mono_count} files)"
}
# Move DWS-owned copies out of the generic root once a concrete Agent root is
# active. This prevents Agents such as Codex from discovering the same Skill
# through both ~/.agents/skills and ~/.codex/skills.
retire_generic_skill_root() {
# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the
# canonical store. Universal Agents read it directly; other Agents receive
# relative links and fall back to copies only when links are unavailable.
is_universal_agent_dir() {
case "$1" in
".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;;
*) return 1 ;;
esac
}
same_physical_skill_root() {
[ -d "$1" ] && [ -d "$2" ] || return 1
_sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1
_sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1
[ "$_sps_left" = "$_sps_right" ]
}
retire_agent_skill_root() {
_rgs_root="$1"
_rgs_base="$_rgs_root/.agents/skills"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1
_rgs_base="$2"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1
_rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
@@ -666,6 +680,64 @@ retire_generic_skill_root() {
rm -rf "$_rgs_stage"
}
link_canonical_skills_to_base() {
_lcs_root="$1"
_lcs_base="$2"
_lcs_mode="$3"
_lcs_canonical="$_lcs_root/.agents/skills"
mkdir -p "$_lcs_base" || return 1
same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
_lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1
_lcs_backups="$_lcs_stage/.backups"
_lcs_published="$_lcs_stage/.published"
: > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; }
: > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; }
if [ "$_lcs_mode" = "mono" ]; then
_lcs_names="dws"
else
_lcs_names=""
for _lcs_skill in "$_lcs_canonical"/*/; do
[ -f "${_lcs_skill}SKILL.md" ] || continue
_lcs_names="$_lcs_names $(basename "$_lcs_skill")"
done
fi
_lcs_publish_names=""
for _lcs_name in $_lcs_names; do
if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then
continue
fi
ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_publish_names="$_lcs_publish_names $_lcs_name"
done
for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do
[ -d "$_lcs_victim" ] || continue
[ "$_lcs_victim" = "$_lcs_stage" ] && continue
same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue
if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then
continue
fi
if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then
restore_multi_skill_set /dev/null "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
done
for _lcs_name in $_lcs_publish_names; do
printf '%s\n' "$_lcs_base/$_lcs_name" >> "$_lcs_published" || {
restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
}
if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then
restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
say " ↪ Skills → $_lcs_base/$_lcs_name"
done
rm -rf "$_lcs_stage"
}
# Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
# Installing mono removes proven DWS-managed multi leftovers for mutual exclusion,
# mirroring `dws skill setup --mode mono`.
@@ -673,20 +745,11 @@ install_skills_to_homes() {
skill_src="$1"
root="${HOME}"
installed=0
attempted=0
attempted=1
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; }
for agent_dir in \
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
@@ -704,50 +767,22 @@ install_skills_to_homes() {
".openclaw/skills" \
".hermes/skills"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
[ -e "$parent_gate" ] || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1))
continue
fi
attempted=$((attempted + 1))
case "$root" in
"$HOME")
label="~/$agent_dir/$SKILL_NAME"
;;
*)
label="$root/$agent_dir/$SKILL_NAME"
;;
esac
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
if link_canonical_skills_to_base "$root" "$base_dir" mono; then
installed=$((installed + 1))
else
failed=$((failed + 1))
say " ⚠️ $base_dir 无法创建 Skill 链接,回退为直接复制"
if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ]; then
case "$root" in
"$HOME")
flabel="~/.agents/skills/$SKILL_NAME"
;;
*)
flabel="$root/.agents/skills/$SKILL_NAME"
;;
esac
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
if [ "$installed" -eq 0 ]; then
say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"
return 1
@@ -783,20 +818,11 @@ install_multi_skills_to_homes() {
multi_src="$1"
root="${HOME}"
installed=0
attempted=0
attempted=1
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; }
for agent_dir in \
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
@@ -814,31 +840,22 @@ install_multi_skills_to_homes() {
".openclaw/skills" \
".hermes/skills"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
[ -e "$parent_gate" ] || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1))
continue
fi
attempted=$((attempted + 1))
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
if link_canonical_skills_to_base "$root" "$base_dir" multi; then
installed=$((installed + 1))
else
failed=$((failed + 1))
say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)"
say " ⚠️ $base_dir 无法创建 Skill 链接,回退为直接复制"
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
installed=$((installed + 1))
fi
if [ "$installed" -eq 0 ]; then
say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"
return 1
+27 -14
View File
@@ -90,6 +90,14 @@ HOME_SPECIFIC_SKILL_BASES="
.openclaw/skills
.hermes/skills
"
HOME_UNIVERSAL_SKILL_BASES="
.cursor/skills
.gemini/skills
.codex/skills
.github/skills
.cline/skills
.amp/skills
"
cleanup() {
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
@@ -118,23 +126,28 @@ verify_skill_base() {
verify_skill_targets() {
home_root="$1"
found_specific=0
# lark-cli/skills-compatible canonical store is always present, regardless
# of which concrete Agent homes existed at install time.
verify_skill_base "$home_root" ".agents/skills"
for base in $HOME_SPECIFIC_SKILL_BASES; do
parent="${base%/skills}"
if [ -d "$home_root/$parent" ]; then
verify_skill_base "$home_root" "$base"
found_specific=1
[ -d "$home_root/$parent" ] || continue
universal=0
for universal_base in $HOME_UNIVERSAL_SKILL_BASES; do
[ "$base" = "$universal_base" ] && universal=1
done
if [ "$universal" -eq 1 ]; then
for name in dingtalk-shared dingtalk-misc dws; do
[ ! -e "$home_root/$base/$name" ] || \
err "unexpected duplicate Skill found in universal Agent root $home_root/$base/$name"
done
continue
fi
done
if [ "$found_specific" -eq 0 ]; then
verify_skill_base "$home_root" ".agents/skills"
return
fi
for name in dingtalk-shared dingtalk-misc dws; do
[ ! -e "$home_root/.agents/skills/$name" ] || \
err "unexpected generic Skill copy found in $home_root/.agents/skills/$name"
verify_skill_base "$home_root" "$base"
for name in dingtalk-shared dingtalk-misc; do
[ -L "$home_root/$base/$name" ] || \
err "expected canonical Skill link not found at $home_root/$base/$name"
done
done
}
+10 -7
View File
@@ -193,7 +193,7 @@ scenario("multi install lays out sibling skills and caches", () => {
}
});
scenario("Codex uses its canonical root without a generic duplicate", () => {
scenario("Codex reads the universal canonical root and beta.6 copies are migrated", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
@@ -201,6 +201,7 @@ scenario("Codex uses its canonical root without a generic duplicate", () => {
});
try {
writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n");
writeFile(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md"), "beta.6 copy\n");
writeFile(
path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"),
"old nested duplicate\n",
@@ -209,12 +210,12 @@ scenario("Codex uses its canonical root without a generic duplicate", () => {
const res = runInstall(pkg, home, "multi");
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.ok(
fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")),
"Codex canonical Skill installed",
fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")),
"universal canonical Skill installed",
);
assert.ok(
!fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")),
"generic flat duplicate not installed",
!fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat")),
"beta.6 Codex duplicate migrated",
);
assert.ok(
!fs.existsSync(path.join(home, ".agents", "skills", "dws")),
@@ -225,7 +226,7 @@ scenario("Codex uses its canonical root without a generic duplicate", () => {
}
});
scenario("ZCode uses its canonical root without a generic duplicate", () => {
scenario("ZCode links its Agent root to the canonical store", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
@@ -241,8 +242,10 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => {
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.ok(
fs.existsSync(path.join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")),
"ZCode canonical Skill installed",
"ZCode Skill resolves through canonical link",
);
assert.ok(fs.lstatSync(path.join(home, ".zcode", "skills", "dingtalk-chat")).isSymbolicLink());
assert.ok(fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")));
assert.ok(
!fs.existsSync(path.join(home, ".agents", "skills", "dws")),
"legacy generic duplicate retired",
+27 -15
View File
@@ -182,13 +182,13 @@ func TestInstallScriptSourceModeInstallsSkillsIntoAgentsDir(t *testing.T) {
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
}
skillPath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws", "SKILL.md")
skillPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws", "SKILL.md")
if _, err := os.Stat(skillPath); err != nil {
t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output))
}
genericPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws")
if _, err := os.Stat(genericPath); !os.IsNotExist(err) {
t.Fatalf("generic Skill root must not duplicate detected Cursor: Stat(%s) = %v\noutput:\n%s", genericPath, err, string(output))
duplicatePath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws")
if _, err := os.Lstat(duplicatePath); !os.IsNotExist(err) {
t.Fatalf("universal Cursor root must not duplicate canonical Skill: Lstat(%s) = %v\noutput:\n%s", duplicatePath, err, string(output))
}
}
@@ -1242,7 +1242,7 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT"
assertSkillProvenance(t, home, filepath.Join(base, "dingtalk-test"), "dingtalk-test", "install-skills.sh")
}
func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) {
func TestInstallerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) {
for _, scriptName := range []string{"install.sh", "install-skills.sh"} {
t.Run(scriptName, func(t *testing.T) {
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName))
@@ -1277,11 +1277,11 @@ func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) {
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s Codex-root harness failed: %v\n%s", scriptName, err, output)
}
if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("%s canonical Codex Skill missing: %v", scriptName, err)
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("%s canonical Skill missing: %v", scriptName, err)
}
for _, duplicate := range []string{
filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md"),
filepath.Join(home, ".codex", "skills", "dingtalk-chat"),
filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"),
} {
if _, err := os.Stat(duplicate); !os.IsNotExist(err) {
@@ -1292,7 +1292,7 @@ func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) {
}
}
func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) {
func TestInstallerShellLinksZCodeRootToCanonical(t *testing.T) {
for _, scriptName := range []string{"install.sh", "install-skills.sh"} {
t.Run(scriptName, func(t *testing.T) {
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName))
@@ -1328,7 +1328,13 @@ func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) {
t.Fatalf("%s ZCode-root harness failed: %v\n%s", scriptName, err, output)
}
if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("%s canonical ZCode Skill missing: %v", scriptName, err)
t.Fatalf("%s linked ZCode Skill missing: %v", scriptName, err)
}
if info, err := os.Lstat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat")); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("%s ZCode target is not a canonical link: %#v, %v", scriptName, info, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("%s canonical Skill missing: %v", scriptName, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) {
t.Fatalf("%s generic duplicate remains: %v", scriptName, err)
@@ -1337,7 +1343,7 @@ func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) {
}
}
func TestInstallPowerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) {
func TestInstallPowerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) {
pwsh, err := exec.LookPath("pwsh")
if err != nil {
if runtime.GOOS == "windows" {
@@ -1378,15 +1384,18 @@ exit 0
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("PowerShell Codex-root harness failed: %v\n%s", err, output)
}
if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("PowerShell canonical Codex Skill missing: %v", err)
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("PowerShell canonical Skill missing: %v", err)
}
if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) {
t.Fatalf("PowerShell Codex duplicate remains: %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) {
t.Fatalf("PowerShell generic duplicate remains: %v", err)
}
}
func TestInstallPowerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) {
func TestInstallPowerShellLinksZCodeRootToCanonical(t *testing.T) {
pwsh, err := exec.LookPath("pwsh")
if err != nil {
if runtime.GOOS == "windows" {
@@ -1428,7 +1437,10 @@ exit 0
t.Fatalf("PowerShell ZCode-root harness failed: %v\n%s", err, output)
}
if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("PowerShell canonical ZCode Skill missing: %v", err)
t.Fatalf("PowerShell linked ZCode Skill missing: %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("PowerShell canonical Skill missing: %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) {
t.Fatalf("PowerShell generic duplicate remains: %v", err)