From 44d640bbae35acd2363b7e3c88cee0532d2be2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 18:50:27 +0800 Subject: [PATCH] fix(skills): use canonical global installation --- README.md | 4 +- build/npm/install.js | 233 +++++++++++------ internal/app/skill_setup.go | 130 +++++++--- internal/app/skill_setup_canonical_test.go | 89 +++++++ internal/app/skill_setup_plan_test.go | 28 +- internal/app/skill_setup_test.go | 28 +- internal/upgrade/canonical_skills_test.go | 91 +++++++ internal/upgrade/coverage_edges_test.go | 6 +- internal/upgrade/paths.go | 281 ++++++++++++--------- internal/upgrade/paths_multi_test.go | 245 +++--------------- scripts/install-skills.sh | 164 ++++++------ scripts/install.ps1 | 152 ++++++----- scripts/install.sh | 183 ++++++++------ scripts/release/verify-package-managers.sh | 41 ++- test/scripts/install_js_smoke.mjs | 17 +- test/scripts/install_script_test.go | 42 +-- 16 files changed, 1020 insertions(+), 714 deletions(-) create mode 100644 internal/app/skill_setup_canonical_test.go create mode 100644 internal/upgrade/canonical_skills_test.go diff --git a/README.md b/README.md index ec3e7416..132ebf9d 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/build/npm/install.js b/build/npm/install.js index 49e9fd7f..8a7e94d4 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -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) { diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 62ab2c17..7f5fe2fb 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -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 → /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...) { diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go new file mode 100644 index 00000000..83a43d93 --- /dev/null +++ b/internal/app/skill_setup_canonical_test.go @@ -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) + } + } +} diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index fcc5ceb0..e46339f2 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -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()) } }) diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 2a776355..1a37371e 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -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) } } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go new file mode 100644 index 00000000..163598ad --- /dev/null +++ b/internal/upgrade/canonical_skills_test.go @@ -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) + } +} diff --git a/internal/upgrade/coverage_edges_test.go b/internal/upgrade/coverage_edges_test.go index 960d81f7..9794f290 100644 --- a/internal/upgrade/coverage_edges_test.go +++ b/internal/upgrade/coverage_edges_test.go @@ -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 } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index a83309d3..12d89118 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -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())) diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index ec55c21d..e9abb256 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -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) } } diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index ad4eeb03..97a4b093 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -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 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d9c62a1b..bab5a678 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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) { diff --git a/scripts/install.sh b/scripts/install.sh index 016055fe..9fd1d289 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 71b0d8c0..dfe37168 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -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 } diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index b94bb679..29be067a 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -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", diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d6e299a5..ac685fb5 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -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)