fix(skills): use canonical global installation
This commit is contained in:
@@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
|
||||
The upgrade process follows a two-phase atomic flow to ensure consistency:
|
||||
|
||||
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents that natively support the universal root (including Codex) read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice.
|
||||
|
||||
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
|
||||
|
||||
@@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
|
||||
> Installers use `$HOME/.agents/skills/` as the canonical global store, matching the mechanism used by lark-cli's `skills` installer. Universal Agents such as Codex read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
|
||||
+159
-74
@@ -29,6 +29,17 @@ const AGENT_DIRS = [
|
||||
".hermes/skills",
|
||||
];
|
||||
|
||||
// Same capability split as the `skills` CLI used by lark-cli. Universal
|
||||
// Agents read ~/.agents/skills directly and must not receive a second copy.
|
||||
const UNIVERSAL_AGENT_DIRS = new Set([
|
||||
".cursor/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
".cline/skills",
|
||||
".amp/skills",
|
||||
]);
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
"darwin-x64": "dws-darwin-amd64.tar.gz",
|
||||
"darwin-arm64": "dws-darwin-arm64.tar.gz",
|
||||
@@ -65,7 +76,14 @@ function backupStamp() {
|
||||
// and false is returned so callers skip that target rather than silently
|
||||
// deleting data.
|
||||
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
let info;
|
||||
try {
|
||||
info = fs.lstatSync(dir);
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") return true;
|
||||
throw err;
|
||||
}
|
||||
if (!info.isDirectory() && !info.isSymbolicLink()) {
|
||||
return true;
|
||||
}
|
||||
const rel = path.relative(homeDir, dir);
|
||||
@@ -234,15 +252,11 @@ function installSkillsToHomes(skillRoot) {
|
||||
let attempted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
|
||||
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
|
||||
);
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
@@ -256,37 +270,44 @@ function installSkillsToHomes(skillRoot) {
|
||||
return true;
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
if (index === 0 && specificAgentDirs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
attempted += 1;
|
||||
if (installToBase(baseDir)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (specificAgentDirs.length > 0 && installed > 0) {
|
||||
try {
|
||||
retireGenericSkillRoot(homeDir, managedNames);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
const canonicalBase = path.join(homeDir, ".agents", "skills");
|
||||
attempted += 1;
|
||||
if (installToBase(canonicalBase)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
|
||||
if (attempted === 0) {
|
||||
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
if (installed > 0) {
|
||||
for (const agentDir of AGENT_DIRS.slice(1)) {
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue;
|
||||
attempted += 1;
|
||||
if (UNIVERSAL_AGENT_DIRS.has(agentDir)) {
|
||||
try {
|
||||
retireManagedSkillRoot(homeDir, baseDir, managedNames);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims);
|
||||
installed += 1;
|
||||
} catch (linkErr) {
|
||||
console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`);
|
||||
if (installToBase(baseDir)) installed += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (installed === 0) {
|
||||
@@ -346,12 +367,11 @@ function isManagedMultiSkillDir(dir, managedNames) {
|
||||
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
|
||||
}
|
||||
|
||||
function retireGenericSkillRoot(homeDir, managedNames) {
|
||||
const baseDir = path.join(homeDir, ".agents", "skills");
|
||||
function retireManagedSkillRoot(homeDir, baseDir, managedNames) {
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
@@ -374,12 +394,74 @@ function retireGenericSkillRoot(homeDir, managedNames) {
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`);
|
||||
throw new Error(`${err.message}; Agent-root rollback failed: ${restoreErrors.join("; ")}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function samePhysicalDir(left, right) {
|
||||
try {
|
||||
return fs.realpathSync(left) === fs.realpathSync(right);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-"));
|
||||
const staged = [];
|
||||
const backups = [];
|
||||
const published = [];
|
||||
const correctLinks = new Set();
|
||||
const restore = () => {
|
||||
for (let i = published.length - 1; i >= 0; i -= 1) {
|
||||
fs.rmSync(published[i], { recursive: true, force: true });
|
||||
}
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
|
||||
fs.renameSync(backups[i].backup, backups[i].original);
|
||||
}
|
||||
};
|
||||
try {
|
||||
for (const name of names) {
|
||||
const target = path.join(canonicalBase, name);
|
||||
const dest = path.join(baseDir, name);
|
||||
if (samePhysicalDir(dest, target)) {
|
||||
correctLinks.add(path.resolve(dest));
|
||||
continue;
|
||||
}
|
||||
const stagedPath = path.join(stageRoot, name);
|
||||
const linkTarget = process.platform === "win32" ? target : path.relative(baseDir, target);
|
||||
fs.symlinkSync(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir");
|
||||
staged.push({ staged: stagedPath, dest: path.join(baseDir, name) });
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const victim of victims) {
|
||||
const normalized = path.resolve(victim);
|
||||
if (seen.has(normalized) || correctLinks.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
for (const item of staged) {
|
||||
fs.renameSync(item.staged, item.dest);
|
||||
published.push(item.dest);
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
restore();
|
||||
} catch (restoreErr) {
|
||||
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
fs.rmSync(stageRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function skillDirectoryDigest(dir) {
|
||||
const files = [];
|
||||
const visit = (current, prefix) => {
|
||||
@@ -628,10 +710,6 @@ function installMultiSkillsToHomes(multiRoot) {
|
||||
let attempted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
|
||||
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
|
||||
);
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
@@ -639,7 +717,7 @@ function installMultiSkillsToHomes(multiRoot) {
|
||||
// the same transaction as every replaced bundled skill.
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
(entry.isDirectory() || entry.isSymbolicLink()) &&
|
||||
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
|
||||
!skillSet.has(entry.name)
|
||||
) {
|
||||
@@ -658,37 +736,44 @@ function installMultiSkillsToHomes(multiRoot) {
|
||||
return true;
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
if (index === 0 && specificAgentDirs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
attempted += 1;
|
||||
if (installToBase(baseDir)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
const canonicalBase = path.join(homeDir, ".agents", "skills");
|
||||
attempted += 1;
|
||||
if (installToBase(canonicalBase)) installed += 1;
|
||||
else failed += 1;
|
||||
|
||||
if (specificAgentDirs.length > 0 && installed > 0) {
|
||||
try {
|
||||
retireGenericSkillRoot(homeDir, managedNames);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempted === 0) {
|
||||
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
if (installed > 0) {
|
||||
for (const agentDir of AGENT_DIRS.slice(1)) {
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue;
|
||||
attempted += 1;
|
||||
if (UNIVERSAL_AGENT_DIRS.has(agentDir)) {
|
||||
try {
|
||||
retireManagedSkillRoot(homeDir, baseDir, managedNames);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Agent Skill 旧副本迁移失败 ${baseDir}: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (
|
||||
(entry.isDirectory() || entry.isSymbolicLink()) &&
|
||||
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
|
||||
!skillSet.has(entry.name)
|
||||
) victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
for (const name of skills) victims.push(path.join(baseDir, name));
|
||||
try {
|
||||
publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims);
|
||||
installed += 1;
|
||||
} catch (linkErr) {
|
||||
console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`);
|
||||
if (installToBase(baseDir)) installed += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (installed === 0) {
|
||||
|
||||
+101
-29
@@ -65,6 +65,7 @@ var (
|
||||
skillSetupInteractive = isInteractiveTerminal
|
||||
skillSetupReadDir = os.ReadDir
|
||||
skillSetupStat = os.Stat
|
||||
skillSetupSymlink = os.Symlink
|
||||
skillSetupExecutable = os.Executable
|
||||
skillSetupGetwd = os.Getwd
|
||||
skillSetupUserHomeDir = os.UserHomeDir
|
||||
@@ -74,6 +75,7 @@ var (
|
||||
skillSetupWalk = filepath.Walk
|
||||
skillSetupRel = filepath.Rel
|
||||
skillSetupReadlink = os.Readlink
|
||||
skillSetupEvalSymlinks = filepath.EvalSymlinks
|
||||
skillSetupOpen = os.Open
|
||||
skillSetupOpenFile = os.OpenFile
|
||||
skillSetupWriteFile = os.WriteFile
|
||||
@@ -91,9 +93,11 @@ type skillSetupBackup struct {
|
||||
}
|
||||
|
||||
type skillSetupTargetPlan struct {
|
||||
Destination string
|
||||
Backups []skillSetupBackup
|
||||
CleanupOnly bool
|
||||
Destination string
|
||||
CanonicalBase string
|
||||
Backups []skillSetupBackup
|
||||
CleanupOnly bool
|
||||
LinkCanonical bool
|
||||
}
|
||||
|
||||
type skillSetupPlan struct {
|
||||
@@ -149,8 +153,9 @@ multi 模式支持按产品挑选:
|
||||
备份失败时保留原目录并跳过该目标,绝不静默删除。
|
||||
· 所有将被移除的目录都会在确认前逐条列出。
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录;
|
||||
未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。
|
||||
不带 --mode 时进入交互式询问;Skill 始终安装到 canonical 目录 ~/.agents/skills。
|
||||
原生支持该目录的 Agent 直接读取;其他已检测到的 Agent 目录创建相对链接,
|
||||
链接不可用时回退为直接复制,避免同一 Agent 扫描两份 Skill。
|
||||
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
|
||||
Example: ` dws skill setup --mode multi --target claude --dry-run
|
||||
dws skill setup --mode multi --target claude`,
|
||||
@@ -910,8 +915,9 @@ func isSkillSourceRoot(path, mode string) bool {
|
||||
}
|
||||
|
||||
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
|
||||
// If target == "all", returns every agent home whose parent directory exists.
|
||||
// Otherwise returns the single matching home (whether or not it currently exists).
|
||||
// The canonical ~/.agents/skills destination is always first. If target ==
|
||||
// "all", detected concrete Agent roots follow it. A specific target follows
|
||||
// canonical as well so unknown/future Agents retain the universal copy.
|
||||
//
|
||||
// 末段约定:
|
||||
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
|
||||
@@ -923,6 +929,7 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) {
|
||||
}
|
||||
|
||||
target = strings.ToLower(strings.TrimSpace(target))
|
||||
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
|
||||
if target == "" || target == "all" {
|
||||
return detectExistingAgentHomes(home, mode), nil
|
||||
}
|
||||
@@ -931,7 +938,11 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
|
||||
}
|
||||
return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
|
||||
dest := agentHomeForMode(filepath.Join(home, rel), mode)
|
||||
if filepath.Clean(dest) == filepath.Clean(canonical) {
|
||||
return []string{canonical}, nil
|
||||
}
|
||||
return []string{canonical, dest}, nil
|
||||
}
|
||||
|
||||
// agentHomeForMode appends the mode-specific tail segment to an agent home base.
|
||||
@@ -943,7 +954,8 @@ func agentHomeForMode(base, mode string) string {
|
||||
}
|
||||
|
||||
func detectExistingAgentHomes(home, mode string) []string {
|
||||
var specific []string
|
||||
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
|
||||
dests := []string{canonical}
|
||||
for i, rel := range skillSetupAgentHomes {
|
||||
if i == 0 {
|
||||
continue
|
||||
@@ -953,12 +965,45 @@ func detectExistingAgentHomes(home, mode string) []string {
|
||||
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
specific = append(specific, agentHomeForMode(base, mode))
|
||||
dests = append(dests, agentHomeForMode(base, mode))
|
||||
}
|
||||
if len(specific) > 0 {
|
||||
return specific
|
||||
return dests
|
||||
}
|
||||
|
||||
func skillSetupBaseForMode(dest, mode string) string {
|
||||
if mode == skillSetupModeMono {
|
||||
return filepath.Dir(dest)
|
||||
}
|
||||
return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)}
|
||||
return dest
|
||||
}
|
||||
|
||||
func isUniversalSkillSetupBase(base string) bool {
|
||||
base = filepath.ToSlash(filepath.Clean(base))
|
||||
for rel := range map[string]bool{
|
||||
".cursor/skills": true, ".gemini/skills": true, ".codex/skills": true,
|
||||
".github/skills": true, ".cline/skills": true, ".amp/skills": true,
|
||||
} {
|
||||
if strings.HasSuffix(base, "/"+rel) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func canonicalSkillSetupBase(dests []string, mode string) string {
|
||||
for _, dest := range dests {
|
||||
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode)))
|
||||
if strings.HasSuffix(base, "/.agents/skills") {
|
||||
return skillSetupBaseForMode(dest, mode)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func samePhysicalSkillSetupPath(left, right string) bool {
|
||||
leftReal, leftErr := skillSetupEvalSymlinks(left)
|
||||
rightReal, rightErr := skillSetupEvalSymlinks(right)
|
||||
return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal)
|
||||
}
|
||||
|
||||
func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) {
|
||||
@@ -1032,8 +1077,17 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
sortedDests := append([]string(nil), dests...)
|
||||
sort.Strings(sortedDests)
|
||||
managedNames := currentManagedSkillNames()
|
||||
canonicalBase := canonicalSkillSetupBase(sortedDests, mode)
|
||||
for _, dest := range sortedDests {
|
||||
target := skillSetupTargetPlan{Destination: dest}
|
||||
base := skillSetupBaseForMode(dest, mode)
|
||||
target := skillSetupTargetPlan{Destination: dest, CanonicalBase: canonicalBase}
|
||||
if canonicalBase != "" && filepath.Clean(base) != filepath.Clean(canonicalBase) {
|
||||
if isUniversalSkillSetupBase(base) {
|
||||
target.CleanupOnly = true
|
||||
} else {
|
||||
target.LinkCanonical = true
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
add := func(path, reason string) {
|
||||
if seen[path] {
|
||||
@@ -1077,6 +1131,9 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
}
|
||||
}
|
||||
for _, path := range replacements {
|
||||
if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) {
|
||||
continue
|
||||
}
|
||||
info, statErr := skillSetupStat(path)
|
||||
if statErr != nil {
|
||||
if errors.Is(statErr, os.ErrNotExist) {
|
||||
@@ -1089,14 +1146,9 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
}
|
||||
}
|
||||
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
|
||||
plan.Targets = append(plan.Targets, target)
|
||||
}
|
||||
cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames)
|
||||
if cleanupErr != nil {
|
||||
return nil, cleanupErr
|
||||
}
|
||||
if cleanupTarget != nil {
|
||||
plan.Targets = append(plan.Targets, *cleanupTarget)
|
||||
if !target.CleanupOnly || len(target.Backups) > 0 {
|
||||
plan.Targets = append(plan.Targets, target)
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
@@ -1151,7 +1203,9 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
|
||||
fmt.Fprintln(out, " destinations:")
|
||||
for _, target := range plan.Targets {
|
||||
if target.CleanupOnly {
|
||||
fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination)
|
||||
fmt.Fprintf(out, " - %s (仅迁移 universal Agent 中的旧 DWS 副本)\n", target.Destination)
|
||||
} else if target.LinkCanonical {
|
||||
fmt.Fprintf(out, " - %s (链接到 %s,失败时回退复制)\n", target.Destination, target.CanonicalBase)
|
||||
} else {
|
||||
fmt.Fprintf(out, " - %s\n", target.Destination)
|
||||
}
|
||||
@@ -1649,6 +1703,21 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
|
||||
|
||||
stageOne := func(src, dest string) error {
|
||||
stagedDir := filepath.Join(stageRoot, filepath.Base(dest))
|
||||
if target.LinkCanonical {
|
||||
canonicalTarget := filepath.Join(target.CanonicalBase, filepath.Base(dest))
|
||||
if samePhysicalSkillSetupPath(dest, canonicalTarget) {
|
||||
return nil
|
||||
}
|
||||
relTarget, relErr := filepath.Rel(filepath.Dir(dest), canonicalTarget)
|
||||
if relErr != nil {
|
||||
return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr)
|
||||
}
|
||||
if linkErr := skillSetupSymlink(relTarget, stagedDir); linkErr != nil {
|
||||
return fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedDir, relTarget, linkErr)
|
||||
}
|
||||
staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest})
|
||||
return nil
|
||||
}
|
||||
if err := skillSetupMkdirAll(stagedDir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建 Skill staging 目录失败 %s: %w", stagedDir, err)
|
||||
}
|
||||
@@ -1752,16 +1821,13 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
}
|
||||
for _, target := range plan.Targets {
|
||||
if target.CleanupOnly {
|
||||
if skipped > 0 {
|
||||
continue
|
||||
}
|
||||
if homeErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr)
|
||||
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
|
||||
fmt.Fprintf(errOut, " ✗ universal Agent 旧副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
|
||||
skipped++
|
||||
}
|
||||
continue
|
||||
@@ -1777,6 +1843,12 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
}
|
||||
|
||||
stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target)
|
||||
if stageErr != nil && target.LinkCanonical {
|
||||
fmt.Fprintf(errOut, " ⚠️ %s 无法创建 Skill 链接,回退为直接复制: %v\n", target.Destination, stageErr)
|
||||
fallback := target
|
||||
fallback.LinkCanonical = false
|
||||
stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback)
|
||||
}
|
||||
if stageErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr)
|
||||
skipped += perTarget
|
||||
@@ -1836,7 +1908,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || keepSet[e.Name()] {
|
||||
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupAgentHomes, []string{
|
||||
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
|
||||
})
|
||||
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
|
||||
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := filepath.Join(home, ".agents", "skills")
|
||||
if len(dests) != 4 || dests[0] != canonical {
|
||||
t.Fatalf("targets = %v", dests)
|
||||
}
|
||||
|
||||
src := t.TempDir()
|
||||
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
|
||||
dir := filepath.Join(src, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
|
||||
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
if err != nil || skipped != 0 || installed != 6 { // canonical + two linked Agents, two Skills each
|
||||
t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err)
|
||||
}
|
||||
if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
|
||||
t.Fatalf("Codex duplicate remains: %v", err)
|
||||
}
|
||||
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("canonical %s missing: %v", name, err)
|
||||
}
|
||||
for _, agent := range []string{".claude", ".openclaw"} {
|
||||
link := filepath.Join(home, agent, "skills", name)
|
||||
info, err := os.Lstat(link)
|
||||
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("link %s = %#v, %v", link, info, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-running setup must recognize the existing links as already correct;
|
||||
// canonical refreshes in place without turning links into copied trees.
|
||||
plan, err = buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, agent := range []string{".claude", ".openclaw"} {
|
||||
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
|
||||
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("idempotent setup replaced %s link: %#v, %v", agent, info, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) {
|
||||
}
|
||||
knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"}
|
||||
result, err := UpgradeSkillLocations(source)
|
||||
if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 {
|
||||
if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 {
|
||||
t.Fatalf("skill upgrade = %#v, %v", result, err)
|
||||
}
|
||||
knownSkillDirs = []string{".real/skills"}
|
||||
@@ -593,8 +593,8 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := UpgradeSkillLocations(source)
|
||||
if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 {
|
||||
t.Fatalf("replaced primary fallback = %#v, %v", result, err)
|
||||
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
|
||||
t.Fatalf("canonical failure = %#v, %v", result, err)
|
||||
}
|
||||
failure := errors.New("ensure failed")
|
||||
upgradeEnsureDir = func(string, os.FileMode) error { return failure }
|
||||
|
||||
+168
-113
@@ -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()))
|
||||
|
||||
@@ -124,8 +124,7 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) {
|
||||
func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
|
||||
home := withFakeHome(t)
|
||||
|
||||
// A concrete Agent root wins over the generic .agents fallback; .claude
|
||||
// installs and .cursor is skipped.
|
||||
// .agents is canonical; detected Claude receives links and Cursor is skipped.
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
claudeBase := filepath.Join(home, ".claude", "skills")
|
||||
for _, base := range []string{agentsBase, claudeBase} {
|
||||
@@ -193,17 +192,19 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
|
||||
|
||||
// Succeeded entries report the agent home base in multi mode.
|
||||
succeeded := result.Succeeded()
|
||||
if len(succeeded) != 1 {
|
||||
t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results)
|
||||
if len(succeeded) != 2 {
|
||||
t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results)
|
||||
}
|
||||
wantDirs := map[string]bool{claudeBase: true}
|
||||
wantDirs := map[string]bool{agentsBase: true, claudeBase: true}
|
||||
for _, d := range succeeded {
|
||||
if !wantDirs[d.Dir] {
|
||||
t.Errorf("unexpected succeeded dir %q", d.Dir)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
t.Fatalf("generic mono duplicate still visible: %v", err)
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("canonical Skill missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Multi cache refreshed under the fake home.
|
||||
@@ -216,7 +217,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) {
|
||||
func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) {
|
||||
home := withFakeHome(t)
|
||||
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"})
|
||||
|
||||
@@ -241,12 +242,12 @@ func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicat
|
||||
t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md")
|
||||
want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("canonical Codex Skill missing at %s: %v", want, err)
|
||||
t.Fatalf("canonical Skill missing at %s: %v", want, err)
|
||||
}
|
||||
for _, duplicate := range []string{
|
||||
filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"),
|
||||
filepath.Join(codexBase, "dingtalk-chat"),
|
||||
filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"),
|
||||
} {
|
||||
if _, err := os.Stat(duplicate); !os.IsNotExist(err) {
|
||||
@@ -526,21 +527,15 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(
|
||||
}
|
||||
|
||||
result, err := UpgradeSkillLocations(mono)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 0 {
|
||||
t.Fatalf("fallback should replace the failed entry with OK, got failed=%v", failed)
|
||||
}
|
||||
if got := len(result.Succeeded()); got != 1 {
|
||||
t.Fatalf("Succeeded() len = %d, want 1 (%v)", got, result.Results)
|
||||
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
|
||||
t.Fatalf("canonical copy failure = %#v, %v", result, err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("mono fallback install missing: %v", err)
|
||||
}
|
||||
if string(data) != "# mono" {
|
||||
t.Errorf("mono content = %q, want refreshed package", data)
|
||||
if string(data) != "old" {
|
||||
t.Errorf("mono content = %q, want original preserved", data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,22 +569,22 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
|
||||
}
|
||||
|
||||
result, err := UpgradeSkillLocations(mono)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err)
|
||||
if err == nil {
|
||||
t.Fatal("canonical read failure must fail the upgrade")
|
||||
}
|
||||
failed := result.Failed()
|
||||
if len(failed) != 1 {
|
||||
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
|
||||
}
|
||||
wantDir := agentsBase
|
||||
wantDir := filepath.Join(agentsBase, "dws")
|
||||
if failed[0].Dir != wantDir || failed[0].Err == nil {
|
||||
t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir)
|
||||
}
|
||||
if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") {
|
||||
t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err)
|
||||
}
|
||||
if got := len(result.Succeeded()); got != 1 {
|
||||
t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
|
||||
if got := len(result.Succeeded()); got != 0 {
|
||||
t.Fatalf("Succeeded() len = %d, want 0", got)
|
||||
}
|
||||
// Mono must NOT have been laid down next to the unreadable multi state.
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
@@ -628,8 +623,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
|
||||
if err == nil {
|
||||
t.Fatal("multi fallback cleanup failure must return an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "回退到主目录也失败") {
|
||||
t.Fatalf("error should mention the fallback cleanup failure, got %v", err)
|
||||
if !strings.Contains(err.Error(), "canonical Skill 安装失败") {
|
||||
t.Fatalf("error should mention the canonical cleanup failure, got %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 1 {
|
||||
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
|
||||
@@ -654,8 +649,8 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
|
||||
// backup. Inject it through the path seam so this branch is portable to
|
||||
// Windows, where chmod-based permission failures are not reliable.
|
||||
statFailurePath := filepath.Join(home, "stat-failure")
|
||||
origStat := upgradeStat
|
||||
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
|
||||
origStat := upgradeLstat
|
||||
testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
|
||||
if path == statFailurePath {
|
||||
return nil, errors.New("injected stat failure")
|
||||
}
|
||||
@@ -888,17 +883,12 @@ func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
|
||||
// TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono
|
||||
// path's fail-loud backup semantics and every primary-fallback outcome.
|
||||
func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
|
||||
originalDirs := append([]string(nil), knownSkillDirs...)
|
||||
t.Cleanup(func() { knownSkillDirs = originalDirs })
|
||||
|
||||
mono := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Per-home backup failure marks that home failed; another home still wins.
|
||||
home := withFakeHome(t)
|
||||
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
|
||||
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
|
||||
os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755)
|
||||
os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644)
|
||||
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
|
||||
@@ -909,67 +899,11 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
|
||||
return os.Rename(src, dst)
|
||||
})
|
||||
result, err := UpgradeSkillLocations(mono)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
|
||||
t.Fatalf("failed = %v, want exactly 1 backup-failed home", failed)
|
||||
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
|
||||
t.Fatalf("canonical backup failure = %#v, %v", result, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||
t.Fatalf("failed home must keep its original dws/: %v", err)
|
||||
}
|
||||
if got := len(result.Succeeded()); got != 1 {
|
||||
t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
|
||||
}
|
||||
testseam.Swap(t, &upgradeRename, os.Rename)
|
||||
|
||||
// Fallback cleanup failure: only a blacklisted home in the main loop, then
|
||||
// the primary cleanup hits a stale dir whose backup fails.
|
||||
home2 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
|
||||
knownSkillDirs = []string{".real/skills"}
|
||||
stale := filepath.Join(home2, ".agents", "skills", "dingtalk-stale")
|
||||
os.MkdirAll(stale, 0o755)
|
||||
useUpgradeManagedNames(t, filepath.Base(stale))
|
||||
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") })
|
||||
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
|
||||
t.Fatalf("fallback cleanup error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(stale); err != nil {
|
||||
t.Fatalf("stale dir must survive failed cleanup: %v", err)
|
||||
}
|
||||
|
||||
// Fallback backup failure of the primary dws/ itself.
|
||||
home3 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
|
||||
os.MkdirAll(filepath.Join(home3, ".agents", "skills", "dws"), 0o755)
|
||||
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
|
||||
t.Fatalf("fallback backup error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &upgradeRename, os.Rename)
|
||||
|
||||
// Fallback copy failure: everything fails loud.
|
||||
home4 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
|
||||
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
|
||||
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
|
||||
t.Fatalf("fallback copy error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &upgradeCopyDir, copyDir)
|
||||
|
||||
// Fallback append (no prior entry for the primary dir): a blacklisted-only
|
||||
// main loop leaves no primary entry, so the fallback appends a fresh OK.
|
||||
home5 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
|
||||
result, err = UpgradeSkillLocations(mono)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback append error = %v", err)
|
||||
}
|
||||
if got := len(result.Succeeded()); got != 1 {
|
||||
t.Fatalf("Succeeded() len = %d, want 1 appended primary", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home5, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||
t.Fatalf("fallback append install missing: %v", err)
|
||||
t.Fatalf("failed canonical home must keep its original dws/: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,15 +911,10 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
|
||||
// path's blacklisted branch, per-skill backup/copy failures, and the primary
|
||||
// fallback outcomes (cleanup OK + backup/copy failure, fresh append).
|
||||
func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
|
||||
originalDirs := append([]string(nil), knownSkillDirs...)
|
||||
t.Cleanup(func() { knownSkillDirs = originalDirs })
|
||||
|
||||
extract := t.TempDir()
|
||||
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
|
||||
|
||||
// Blacklisted entry is reported, non-blacklisted installs.
|
||||
home := withFakeHome(t)
|
||||
knownSkillDirs = []string{".real/skills", ".agents/skills"}
|
||||
testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"})
|
||||
os.MkdirAll(filepath.Join(home, ".agents"), 0o755)
|
||||
result, err := UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
@@ -1003,118 +932,8 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) {
|
||||
t.Fatalf("blacklisted home must never be touched, stat err=%v", err)
|
||||
}
|
||||
|
||||
// A detected concrete Agent root wins over .agents. A failure while retiring
|
||||
// the old generic copy is surfaced after the concrete root succeeds.
|
||||
home2 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
|
||||
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
|
||||
os.MkdirAll(filepath.Join(home2, ".agents", "skills", "dingtalk-chat"), 0o755)
|
||||
os.MkdirAll(filepath.Join(home2, ".claude"), 0o755)
|
||||
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
|
||||
if strings.Contains(src, ".agents") {
|
||||
return errors.New("backup denied")
|
||||
}
|
||||
return os.Rename(src, dst)
|
||||
})
|
||||
result, err = UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
|
||||
t.Fatalf("failed = %v, want 1 backup-failed home", failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home2, ".agents", "skills", "dingtalk-chat")); err != nil {
|
||||
t.Fatalf("failed home must keep its original skill: %v", err)
|
||||
}
|
||||
testseam.Swap(t, &upgradeRename, os.Rename)
|
||||
|
||||
// Per-skill copy failure on the concrete home fails that home; with no
|
||||
// successful concrete target, the generic copy is left untouched.
|
||||
home3 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
|
||||
os.MkdirAll(filepath.Join(home3, ".claude"), 0o755)
|
||||
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
|
||||
if strings.Contains(dst, ".claude") {
|
||||
return errors.New("copy denied")
|
||||
}
|
||||
return copyDir(src, dst)
|
||||
})
|
||||
result, err = UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
|
||||
t.Fatalf("failed = %v, want 1 copy-failed home", failed)
|
||||
}
|
||||
testseam.Swap(t, &upgradeCopyDir, copyDir)
|
||||
|
||||
// Fallback backup failure: the same-name bundle skill refresh is what
|
||||
// fails (cleanup succeeds, the per-skill backup does not).
|
||||
home4 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
|
||||
knownSkillDirs = []string{".real/skills"}
|
||||
os.MkdirAll(filepath.Join(home4, ".agents", "skills", "dingtalk-chat"), 0o755)
|
||||
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
|
||||
if strings.Contains(src, "dingtalk-chat") {
|
||||
return errors.New("backup denied")
|
||||
}
|
||||
return os.Rename(src, dst)
|
||||
})
|
||||
if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
|
||||
t.Fatalf("fallback backup error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &upgradeRename, os.Rename)
|
||||
|
||||
// Fallback copy failure.
|
||||
home5 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
|
||||
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
|
||||
if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
|
||||
t.Fatalf("fallback copy error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &upgradeCopyDir, copyDir)
|
||||
|
||||
// Fallback append: blacklisted-only main loop → fresh OK entry appended.
|
||||
home6 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home6, nil })
|
||||
result, err = UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback append error = %v", err)
|
||||
}
|
||||
if got := len(result.Succeeded()); got != 1 {
|
||||
t.Fatalf("Succeeded() len = %d, want 1 appended primary (%v)", got, result.Results)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home6, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
|
||||
t.Fatalf("fallback append install missing: %v", err)
|
||||
}
|
||||
|
||||
// Fallback replace: the main loop fails the primary home on a transient
|
||||
// copy error, the fallback retry succeeds and replaces the failed entry.
|
||||
home7 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home7, nil })
|
||||
knownSkillDirs = []string{".agents/skills"}
|
||||
origCopy := upgradeCopyDir
|
||||
attempts := 0
|
||||
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return errors.New("transient copy failure")
|
||||
}
|
||||
return origCopy(src, dst)
|
||||
})
|
||||
result, err = UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback replace error = %v", err)
|
||||
}
|
||||
if got := len(result.Succeeded()); got != 1 {
|
||||
t.Fatalf("Succeeded() len = %d, want 1 replaced primary (%v)", got, result.Results)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 0 {
|
||||
t.Fatalf("failed entry must be replaced by OK, got %v", failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home7, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
|
||||
t.Fatalf("fallback replace install missing: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
|
||||
t.Fatalf("canonical install missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
-76
@@ -408,12 +408,27 @@ multi_tree_has_skills() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Move DWS-owned copies out of the generic root once a concrete Agent root is
|
||||
# active. This prevents Agents such as Codex from discovering duplicates.
|
||||
retire_generic_skill_root() {
|
||||
# Match the upstream `skills` CLI used by lark-cli: ~/.agents/skills is the
|
||||
# canonical store. Universal Agents read it directly; other Agents receive
|
||||
# relative links and fall back to copies only when links are unavailable.
|
||||
is_universal_agent_dir() {
|
||||
case "$1" in
|
||||
".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
same_physical_skill_root() {
|
||||
[ -d "$1" ] && [ -d "$2" ] || return 1
|
||||
_sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1
|
||||
_sps_right="$(cd -P "$2" 2>/dev/null && pwd)" || return 1
|
||||
[ "$_sps_left" = "$_sps_right" ]
|
||||
}
|
||||
|
||||
retire_agent_skill_root() {
|
||||
_rgs_root="$1"
|
||||
_rgs_base="$_rgs_root/.agents/skills"
|
||||
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1
|
||||
_rgs_base="$2"
|
||||
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1
|
||||
_rgs_backups="$_rgs_stage/backups"
|
||||
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
|
||||
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
|
||||
@@ -430,25 +445,62 @@ retire_generic_skill_root() {
|
||||
rm -rf "$_rgs_stage"
|
||||
}
|
||||
|
||||
link_canonical_skills_to_base() {
|
||||
_lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"
|
||||
_lcs_canonical="$_lcs_root/.agents/skills"
|
||||
mkdir -p "$_lcs_base" || return 1
|
||||
same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
|
||||
_lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1
|
||||
_lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published"
|
||||
: > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; }
|
||||
: > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; }
|
||||
if [ "$_lcs_mode" = "mono" ]; then
|
||||
_lcs_names="dws"
|
||||
else
|
||||
_lcs_names=""
|
||||
for _lcs_skill in "$_lcs_canonical"/*/; do
|
||||
[ -f "${_lcs_skill}SKILL.md" ] || continue
|
||||
_lcs_names="$_lcs_names $(basename "$_lcs_skill")"
|
||||
done
|
||||
fi
|
||||
_lcs_publish_names=""
|
||||
for _lcs_name in $_lcs_names; do
|
||||
if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi
|
||||
ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; }
|
||||
_lcs_publish_names="$_lcs_publish_names $_lcs_name"
|
||||
done
|
||||
for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do
|
||||
[ -d "$_lcs_victim" ] || continue
|
||||
[ "$_lcs_victim" = "$_lcs_stage" ] && continue
|
||||
same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue
|
||||
if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then continue; fi
|
||||
if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then
|
||||
restore_multi_skill_set /dev/null "$_lcs_backups" || true
|
||||
rm -rf "$_lcs_stage"; return 1
|
||||
fi
|
||||
done
|
||||
for _lcs_name in $_lcs_publish_names; do
|
||||
printf '%s\n' "$_lcs_base/$_lcs_name" >> "$_lcs_published" || {
|
||||
restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1;
|
||||
}
|
||||
if ! mv "$_lcs_stage/$_lcs_name" "$_lcs_base/$_lcs_name"; then
|
||||
restore_multi_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
|
||||
fi
|
||||
printf ' ↪ Skills → %s\n' "$_lcs_base/$_lcs_name"
|
||||
done
|
||||
rm -rf "$_lcs_stage"
|
||||
}
|
||||
|
||||
# Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
|
||||
install_multi_skills_to_root() {
|
||||
multi_src="$1"
|
||||
root="$2"
|
||||
installed=0
|
||||
attempted=0
|
||||
attempted=1
|
||||
failed=0
|
||||
idx=0
|
||||
specific_agents=0
|
||||
for specific_dir in \
|
||||
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
|
||||
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
|
||||
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
|
||||
".trae/skills" ".openclaw/skills" ".hermes/skills"
|
||||
do
|
||||
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
|
||||
done
|
||||
if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi
|
||||
[ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
|
||||
for agent_dir in \
|
||||
".agents/skills" \
|
||||
".claude/skills" \
|
||||
".cursor/skills" \
|
||||
".qoder/skills" \
|
||||
@@ -466,31 +518,22 @@ install_multi_skills_to_root() {
|
||||
".openclaw/skills" \
|
||||
".hermes/skills"
|
||||
do
|
||||
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
|
||||
idx=$((idx + 1))
|
||||
[ -e "$parent_gate" ] || continue
|
||||
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
|
||||
attempted=$((attempted + 1))
|
||||
if is_universal_agent_dir "$agent_dir"; then
|
||||
retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
attempted=$((attempted + 1))
|
||||
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
|
||||
if link_canonical_skills_to_base "$root" "$base_dir" multi; then
|
||||
installed=$((installed + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir"
|
||||
printf ' ⚠️ %s 无法创建 Skill 链接,回退为直接复制\n' "$base_dir"
|
||||
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi
|
||||
fi
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
|
||||
retire_generic_skill_root "$root" || failed=$((failed + 1))
|
||||
fi
|
||||
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
|
||||
installed=$((installed + 1))
|
||||
fi
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'
|
||||
return 1
|
||||
@@ -654,20 +697,11 @@ install_skills_to_root() {
|
||||
skill_src="$1"
|
||||
root="$2"
|
||||
installed=0
|
||||
attempted=0
|
||||
attempted=1
|
||||
failed=0
|
||||
idx=0
|
||||
specific_agents=0
|
||||
for specific_dir in \
|
||||
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
|
||||
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
|
||||
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
|
||||
".trae/skills" ".openclaw/skills" ".hermes/skills"
|
||||
do
|
||||
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
|
||||
done
|
||||
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi
|
||||
[ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
|
||||
for agent_dir in \
|
||||
".agents/skills" \
|
||||
".claude/skills" \
|
||||
".cursor/skills" \
|
||||
".qoder/skills" \
|
||||
@@ -685,44 +719,22 @@ install_skills_to_root() {
|
||||
".openclaw/skills" \
|
||||
".hermes/skills"
|
||||
do
|
||||
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
|
||||
idx=$((idx + 1))
|
||||
[ -e "$parent_gate" ] || continue
|
||||
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
|
||||
attempted=$((attempted + 1))
|
||||
if is_universal_agent_dir "$agent_dir"; then
|
||||
retire_agent_skill_root "$root" "$base_dir" || failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
attempted=$((attempted + 1))
|
||||
if [ "$root" = "$HOME" ]; then
|
||||
label="~/$agent_dir/$SKILL_NAME"
|
||||
else
|
||||
label="$root/$agent_dir/$SKILL_NAME"
|
||||
fi
|
||||
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
|
||||
if link_canonical_skills_to_base "$root" "$base_dir" mono; then
|
||||
installed=$((installed + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
printf ' ⚠️ %s 无法创建 Skill 链接,回退为直接复制\n' "$base_dir"
|
||||
if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi
|
||||
fi
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
|
||||
retire_generic_skill_root "$root" || failed=$((failed + 1))
|
||||
fi
|
||||
if [ "$attempted" -eq 0 ]; then
|
||||
if [ "$root" = "$HOME" ]; then
|
||||
flabel="~/.agents/skills/$SKILL_NAME"
|
||||
else
|
||||
flabel="$root/.agents/skills/$SKILL_NAME"
|
||||
fi
|
||||
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
|
||||
installed=$((installed + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
fi
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'
|
||||
return 1
|
||||
|
||||
+94
-58
@@ -458,10 +458,20 @@ function Restore-MultiSkillSet {
|
||||
return $ok
|
||||
}
|
||||
|
||||
function Move-GenericSkillRootToBackup {
|
||||
param([string]$Root)
|
||||
function Test-UniversalAgentDir {
|
||||
param([string]$AgentDir)
|
||||
return @(".cursor\skills", ".gemini\skills", ".codex\skills", ".github\skills", ".cline\skills", ".amp\skills") -contains $AgentDir
|
||||
}
|
||||
|
||||
function Test-SamePhysicalSkillRoot {
|
||||
param([string]$Left, [string]$Right)
|
||||
if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false }
|
||||
try { return (Resolve-Path $Left).Path -eq (Resolve-Path $Right).Path } catch { return $false }
|
||||
}
|
||||
|
||||
function Move-AgentSkillRootToBackup {
|
||||
param([string]$Root, [string]$BaseDir)
|
||||
|
||||
$baseDir = Join-Path $Root ".agents\skills"
|
||||
$victims = [System.Collections.Generic.List[string]]::new()
|
||||
$victims.Add((Join-Path $baseDir $SkillName))
|
||||
foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) {
|
||||
@@ -476,7 +486,7 @@ function Move-GenericSkillRootToBackup {
|
||||
if (!$seen.Add($victim)) { continue }
|
||||
$backupPath = ""
|
||||
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
|
||||
throw "通用 Skill 副本备份失败: $victim"
|
||||
throw "Agent Skill 旧副本备份失败: $victim"
|
||||
}
|
||||
if ($backupPath) {
|
||||
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
|
||||
@@ -485,11 +495,64 @@ function Move-GenericSkillRootToBackup {
|
||||
return $true
|
||||
} catch {
|
||||
Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null
|
||||
Write-Say "⚠️ 通用 Skill 副本迁移失败,已回滚: $_"
|
||||
Write-Say "⚠️ Agent Skill 旧副本迁移失败,已回滚: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Publish-CanonicalSkillLinks {
|
||||
param([string]$Root, [string]$BaseDir, [string]$Mode)
|
||||
$canonical = Join-Path $Root ".agents\skills"
|
||||
if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null }
|
||||
if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true }
|
||||
$stageRoot = Join-Path $BaseDir (".dws-link-set-" + [guid]::NewGuid().ToString("N"))
|
||||
$backups = @()
|
||||
$published = @()
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
|
||||
if ($Mode -eq "mono") {
|
||||
$names = @($SkillName)
|
||||
} else {
|
||||
$names = @(Get-ChildItem -Path $canonical -Directory -ErrorAction Stop | Where-Object {
|
||||
Test-Path (Join-Path $_.FullName "SKILL.md")
|
||||
} | ForEach-Object { $_.Name })
|
||||
}
|
||||
$publishNames = @()
|
||||
foreach ($name in $names) {
|
||||
if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue }
|
||||
New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target (Join-Path $canonical $name) -ErrorAction Stop | Out-Null
|
||||
$publishNames += $name
|
||||
}
|
||||
$victims = [System.Collections.Generic.List[string]]::new()
|
||||
$victims.Add((Join-Path $BaseDir $SkillName))
|
||||
foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -Force -ErrorAction SilentlyContinue) {
|
||||
if ($existing.FullName -eq $stageRoot) { continue }
|
||||
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) }
|
||||
}
|
||||
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($victim in $victims) {
|
||||
if (!$seen.Add($victim)) { continue }
|
||||
if (Test-SamePhysicalSkillRoot -Left $victim -Right (Join-Path $canonical (Split-Path $victim -Leaf))) { continue }
|
||||
$backupPath = ""
|
||||
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { throw "Skill 备份失败: $victim" }
|
||||
if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } }
|
||||
}
|
||||
foreach ($name in $publishNames) {
|
||||
$dest = Join-Path $BaseDir $name
|
||||
$published += $dest
|
||||
Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest
|
||||
Write-Say "↪ Skills → $dest"
|
||||
}
|
||||
return $true
|
||||
} catch {
|
||||
Restore-MultiSkillSet -Published $published -Backups $backups | Out-Null
|
||||
Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)"
|
||||
return $false
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Copy-SkillToDir {
|
||||
param([string]$SkillSrc, [string]$Dest, [string]$Label)
|
||||
|
||||
@@ -811,46 +874,27 @@ function Install-SkillsToHomes {
|
||||
)
|
||||
|
||||
$installed = 0
|
||||
$attempted = 0
|
||||
$attempted = 1
|
||||
$failed = 0
|
||||
$specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
|
||||
Test-Path (Split-Path (Join-Path $Root $_) -Parent)
|
||||
})
|
||||
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
|
||||
if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
|
||||
$canonical = Join-Path $Root ".agents\skills"
|
||||
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ }
|
||||
if ($installed -eq 0) { return $false }
|
||||
for ($i = 1; $i -lt $AgentDirs.Count; $i++) {
|
||||
$agentDir = $AgentDirs[$i]
|
||||
$baseDir = Join-Path $Root $agentDir
|
||||
$parentGate = Split-Path $baseDir -Parent
|
||||
if ($i -gt 0 -and !(Test-Path $parentGate)) {
|
||||
if (!(Test-Path $parentGate)) { continue }
|
||||
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
|
||||
$attempted++
|
||||
if (Test-UniversalAgentDir -AgentDir $agentDir) {
|
||||
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ }
|
||||
continue
|
||||
}
|
||||
$attempted++
|
||||
if ($Root -eq $HOME) {
|
||||
$label = "~\$agentDir\$SkillName"
|
||||
} else {
|
||||
$label = Join-Path $Root (Join-Path $agentDir $SkillName)
|
||||
}
|
||||
$copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label
|
||||
if ($copied) {
|
||||
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") {
|
||||
$installed++
|
||||
} else {
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
|
||||
if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
|
||||
}
|
||||
if ($attempted -eq 0) {
|
||||
$fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName
|
||||
if ($Root -eq $HOME) {
|
||||
$flabel = "~\.agents\skills\$SkillName"
|
||||
} else {
|
||||
$flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName)
|
||||
}
|
||||
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) {
|
||||
$installed++
|
||||
} else {
|
||||
$failed++
|
||||
Write-Say "⚠️ $baseDir 无法创建 Skill 链接,回退为直接复制"
|
||||
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) { $installed++ } else { $failed++ }
|
||||
}
|
||||
}
|
||||
if ($installed -eq 0) {
|
||||
@@ -889,35 +933,27 @@ function Install-MultiSkillsToHomes {
|
||||
)
|
||||
|
||||
$installed = 0
|
||||
$attempted = 0
|
||||
$attempted = 1
|
||||
$failed = 0
|
||||
$specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
|
||||
Test-Path (Split-Path (Join-Path $Root $_) -Parent)
|
||||
})
|
||||
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
|
||||
if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
|
||||
$canonical = Join-Path $Root ".agents\skills"
|
||||
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ }
|
||||
if ($installed -eq 0) { return $false }
|
||||
for ($i = 1; $i -lt $AgentDirs.Count; $i++) {
|
||||
$agentDir = $AgentDirs[$i]
|
||||
$baseDir = Join-Path $Root $agentDir
|
||||
$parentGate = Split-Path $baseDir -Parent
|
||||
if ($i -gt 0 -and !(Test-Path $parentGate)) {
|
||||
if (!(Test-Path $parentGate)) { continue }
|
||||
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
|
||||
$attempted++
|
||||
if (Test-UniversalAgentDir -AgentDir $agentDir) {
|
||||
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ }
|
||||
continue
|
||||
}
|
||||
$attempted++
|
||||
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) {
|
||||
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi") {
|
||||
$installed++
|
||||
} else {
|
||||
Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)"
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
|
||||
if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
|
||||
}
|
||||
if ($attempted -eq 0) {
|
||||
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") {
|
||||
$installed++
|
||||
} else {
|
||||
$failed++
|
||||
Write-Say "⚠️ $baseDir 无法创建 Skill 链接,回退为直接复制"
|
||||
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) { $installed++ } else { $failed++ }
|
||||
}
|
||||
}
|
||||
if ($installed -eq 0) {
|
||||
|
||||
+100
-83
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user