fix(skills): complete canonical agent compatibility

This commit is contained in:
玉澜 committed 2026-08-17 20:19:28 +08:00
1 parent 44d640bbae
commit 79f7ee80e0
25 files changed
+2875 -422

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved.
+128 -46
View File
@@ -8,37 +8,106 @@ const os = require("os");
const path = require("path"); const path = require("path");
const childProcess = require("child_process"); const childProcess = require("child_process");
// Canonical list: keep scripts/install.sh, scripts/install.ps1, scripts/install-skills.sh in sync. // Exact vercel-labs/skills registry at the reference revision: 76 Agent IDs,
const AGENT_DIRS = [ // including canonical-direct and no-global-directory entries. The resolver
".agents/skills", // below intentionally deduplicates shared effective roots only after this
".claude/skills", // authoritative enumeration has been classified.
".cursor/skills", const UPSTREAM_AGENTS = [
".qoder/skills", ["aider-desk", false, ".aider-desk/skills"],
".qoderwork/skills", ["amp", true, ".config/agents/skills"],
".gemini/skills", ["antigravity", true, ".gemini/antigravity/skills"],
".codex/skills", ["antigravity-cli", true, ".gemini/antigravity-cli/skills"],
".zcode/skills", ["astrbot", false, ".astrbot/data/skills"],
".github/skills", ["autohand-code", false, ".autohand/skills"],
".windsurf/skills", ["augment", false, ".augment/skills"], ["bob", false, ".bob/skills"],
".augment/skills", ["claude-code", false, ".claude/skills"], ["openclaw", false, ".openclaw/skills"],
".cline/skills", ["cline", true, ".agents/skills"], ["codearts-agent", false, ".codeartsdoer/skills"],
".amp/skills", ["codebuddy", false, ".codebuddy/skills"], ["codemaker", false, ".codemaker/skills"],
".kiro/skills", ["codestudio", false, ".codestudio/skills"], ["codex", true, ".codex/skills"],
".trae/skills", ["command-code", false, ".commandcode/skills"], ["continue", false, ".continue/skills"],
".openclaw/skills", ["cortex", false, ".snowflake/cortex/skills"], ["crush", false, ".config/crush/skills"],
".hermes/skills", ["cursor", true, ".cursor/skills"], ["deepagents", true, ".deepagents/agent/skills"],
["devin", false, ".config/devin/skills"], ["dexto", true, ".agents/skills"],
["droid", false, ".factory/skills"], ["eve", false, null],
["firebender", true, ".firebender/skills"], ["forgecode", false, ".forge/skills"],
["gemini-cli", true, ".gemini/skills"], ["github-copilot", true, ".copilot/skills"],
["goose", false, ".config/goose/skills"], ["grok", false, ".grok/skills"],
["hermes-agent", false, ".hermes/skills"], ["inference-sh", false, ".inferencesh/skills"],
["jazz", false, ".jazz/skills"], ["junie", false, ".junie/skills"],
["iflow-cli", false, ".iflow/skills"], ["kilo", false, ".kilocode/skills"],
["kimchi", false, ".config/kimchi/harness/skills"], ["kimi-code-cli", true, ".agents/skills"],
["kiro-cli", false, ".kiro/skills"], ["kode", false, ".kode/skills"],
["lingma", false, ".lingma/skills"], ["loaf", true, ".agents/skills"],
["mcpjam", false, ".mcpjam/skills"], ["minimax-code", false, ".minimax/skills"],
["mistral-vibe", false, ".vibe/skills"], ["moxby", false, ".moxby/skills"],
["mux", false, ".mux/skills"], ["opencode", true, ".config/opencode/skills"],
["openhands", false, ".openhands/skills"], ["ona", false, ".ona/skills"],
["pi", false, ".pi/agent/skills"], ["qoder", false, ".qoder/skills"],
["qoder-cn", false, ".qoder-cn/skills"], ["qwen-code", false, ".qwen/skills"],
["replit", true, ".config/agents/skills"], ["reasonix", false, ".reasonix/skills"],
["rovodev", false, ".rovodev/skills"], ["roo", false, ".roo/skills"],
["tabnine-cli", false, ".tabnine/agent/skills"], ["terramind", false, ".terramind/skills"],
["tinycloud", false, ".tinycloud/skills"], ["trae", false, ".trae/skills"],
["trae-cn", false, ".trae-cn/skills"], ["warp", true, ".agents/skills"],
["windsurf", false, ".codeium/windsurf/skills"], ["zed", true, ".agents/skills"],
["zcode", false, ".zcode/skills"], ["zencoder", false, ".zencoder/skills"],
["zenflow", false, ".zencoder/skills"], ["neovate", false, ".neovate/skills"],
["pochi", false, ".pochi/skills"], ["promptscript", true, null],
["adal", false, ".adal/skills"], ["universal", true, ".config/agents/skills"],
].map(([id, universal, agentDir]) => ({ id, universal, agentDir }));
// Paths emitted by beta.6 or older DWS installers that do not correspond to
// the upstream registry. They are cleanup-only migration targets.
const LEGACY_UNIVERSAL_AGENT_DIRS = [
".github/skills", ".windsurf/skills", ".cline/skills", ".amp/skills",
]; ];
// Same capability split as the `skills` CLI used by lark-cli. Universal function openClawSkillsDir(homeDir) {
// Agents read ~/.agents/skills directly and must not receive a second copy. for (const name of [".openclaw", ".clawdbot", ".moltbot"]) {
const UNIVERSAL_AGENT_DIRS = new Set([ const root = path.join(homeDir, name);
".cursor/skills", if (fs.existsSync(root)) return path.join(root, "skills");
".gemini/skills", }
".codex/skills", return path.join(homeDir, ".openclaw", "skills");
".github/skills", }
".cline/skills",
".amp/skills", function resolvedAgentTargets(homeDir) {
]); const xdgConfigHome = (process.env.XDG_CONFIG_HOME || "").trim() || path.join(homeDir, ".config");
const targets = [
...UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir && agentDir !== ".agents/skills"),
// DWS compatibility target; qoderwork is not in upstream's registry.
{ id: "dws-qoderwork", agentDir: ".qoderwork/skills", universal: false, extension: true },
...LEGACY_UNIVERSAL_AGENT_DIRS.map((agentDir) => ({ agentDir, universal: true, legacy: true })),
].map((target) => {
const { agentDir } = target;
let baseDir = path.join(homeDir, agentDir);
if (agentDir === ".claude/skills" && (process.env.CLAUDE_CONFIG_DIR || "").trim()) {
baseDir = path.join(process.env.CLAUDE_CONFIG_DIR.trim(), "skills");
} else if (agentDir === ".codex/skills" && (process.env.CODEX_HOME || "").trim()) {
baseDir = path.join(process.env.CODEX_HOME.trim(), "skills");
} else if (agentDir === ".hermes/skills" && (process.env.HERMES_HOME || "").trim()) {
baseDir = path.join(process.env.HERMES_HOME.trim(), "skills");
} else if (agentDir === ".autohand/skills" && (process.env.AUTOHAND_HOME || "").trim()) {
baseDir = path.join(process.env.AUTOHAND_HOME.trim(), "skills");
} else if (agentDir === ".grok/skills" && (process.env.GROK_HOME || "").trim()) {
baseDir = path.join(process.env.GROK_HOME.trim(), "skills");
} else if (agentDir === ".vibe/skills" && (process.env.VIBE_HOME || "").trim()) {
baseDir = path.join(process.env.VIBE_HOME.trim(), "skills");
} else if (agentDir === ".openclaw/skills") {
baseDir = openClawSkillsDir(homeDir);
} else if (agentDir.startsWith(".config/")) {
baseDir = path.join(xdgConfigHome, agentDir.slice(".config/".length));
}
return { ...target, baseDir };
});
const seen = new Set();
return targets.filter((target) => {
let key = path.resolve(target.baseDir);
if (process.platform === "win32") key = key.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
const PLATFORM_MAP = { const PLATFORM_MAP = {
"darwin-x64": "dws-darwin-amd64.tar.gz", "darwin-x64": "dws-darwin-amd64.tar.gz",
@@ -83,9 +152,6 @@ function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.ren
if (err && err.code === "ENOENT") return true; if (err && err.code === "ENOENT") return true;
throw err; throw err;
} }
if (!info.isDirectory() && !info.isSymbolicLink()) {
return true;
}
const rel = path.relative(homeDir, dir); const rel = path.relative(homeDir, dir);
const name = const name =
rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel) rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel)
@@ -279,11 +345,11 @@ function installSkillsToHomes(skillRoot) {
} }
if (installed > 0) { if (installed > 0) {
for (const agentDir of AGENT_DIRS.slice(1)) { for (const target of resolvedAgentTargets(homeDir)) {
const baseDir = path.join(homeDir, agentDir); const { agentDir, baseDir, universal } = target;
if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue;
attempted += 1; attempted += 1;
if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { if (universal) {
try { try {
retireManagedSkillRoot(homeDir, baseDir, managedNames); retireManagedSkillRoot(homeDir, baseDir, managedNames);
} catch (err) { } catch (err) {
@@ -304,9 +370,10 @@ function installSkillsToHomes(skillRoot) {
publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims); publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims);
installed += 1; installed += 1;
} catch (linkErr) { } catch (linkErr) {
console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); if (installToBase(baseDir)) {
if (installToBase(baseDir)) installed += 1; console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`);
else failed += 1; installed += 1;
} else failed += 1;
} }
} }
} }
@@ -317,6 +384,10 @@ function installSkillsToHomes(skillRoot) {
throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`); throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`);
} }
fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true }); fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true });
console.log(`✅ DWS Skills 安装完成`);
console.log(` 统一安装位置:${canonicalBase}`);
console.log(` 已自动适配本机上检测到的 Agent`);
console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`);
} }
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and // multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
@@ -402,7 +473,9 @@ function retireManagedSkillRoot(homeDir, baseDir, managedNames) {
function samePhysicalDir(left, right) { function samePhysicalDir(left, right) {
try { try {
return fs.realpathSync(left) === fs.realpathSync(right); const leftReal = fs.realpathSync(left);
const rightReal = fs.realpathSync(right);
return process.platform === "win32" ? leftReal.toLowerCase() === rightReal.toLowerCase() : leftReal === rightReal;
} catch (_) { } catch (_) {
return false; return false;
} }
@@ -410,6 +483,7 @@ function samePhysicalDir(left, right) {
function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) { function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) {
fs.mkdirSync(baseDir, { recursive: true }); fs.mkdirSync(baseDir, { recursive: true });
const realBaseDir = fs.realpathSync(baseDir);
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-"));
const staged = []; const staged = [];
const backups = []; const backups = [];
@@ -433,7 +507,8 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names,
continue; continue;
} }
const stagedPath = path.join(stageRoot, name); const stagedPath = path.join(stageRoot, name);
const linkTarget = process.platform === "win32" ? target : path.relative(baseDir, target); const realTarget = fs.realpathSync(target);
const linkTarget = process.platform === "win32" ? realTarget : path.relative(realBaseDir, realTarget);
fs.symlinkSync(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); fs.symlinkSync(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir");
staged.push({ staged: stagedPath, dest: path.join(baseDir, name) }); staged.push({ staged: stagedPath, dest: path.join(baseDir, name) });
} }
@@ -742,11 +817,11 @@ function installMultiSkillsToHomes(multiRoot) {
else failed += 1; else failed += 1;
if (installed > 0) { if (installed > 0) {
for (const agentDir of AGENT_DIRS.slice(1)) { for (const target of resolvedAgentTargets(homeDir)) {
const baseDir = path.join(homeDir, agentDir); const { agentDir, baseDir, universal } = target;
if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue;
attempted += 1; attempted += 1;
if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { if (universal) {
try { try {
retireManagedSkillRoot(homeDir, baseDir, managedNames); retireManagedSkillRoot(homeDir, baseDir, managedNames);
} catch (err) { } catch (err) {
@@ -770,9 +845,10 @@ function installMultiSkillsToHomes(multiRoot) {
publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims); publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims);
installed += 1; installed += 1;
} catch (linkErr) { } catch (linkErr) {
console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); if (installToBase(baseDir)) {
if (installToBase(baseDir)) installed += 1; console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`);
else failed += 1; installed += 1;
} else failed += 1;
} }
} }
} }
@@ -783,6 +859,10 @@ function installMultiSkillsToHomes(multiRoot) {
throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`); throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`);
} }
writeSkillsState(homeDir, multiRoot, skills); writeSkillsState(homeDir, multiRoot, skills);
console.log(`✅ DWS Skills 安装完成`);
console.log(` 统一安装位置:${canonicalBase}`);
console.log(` 已自动适配本机上检测到的 Agent`);
console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`);
} }
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi) // resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
@@ -897,6 +977,8 @@ if (require.main === module) {
} }
module.exports = { module.exports = {
UPSTREAM_AGENTS,
resolvedAgentTargets,
publishCacheAtomically, publishCacheAtomically,
publishManagedMonoSkillSetAtomically, publishManagedMonoSkillSetAtomically,
publishManagedMultiSkillSetAtomically, publishManagedMultiSkillSetAtomically,
+19 -5
View File
@@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约
| 模式 | Agent 目录布局 | 选择方式 | | 模式 | Agent 目录布局 | 选择方式 |
|---|---|---| |---|---|---|
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` | | multi(默认) | canonical `~/.agents/skills/dingtalk-*/`;非 universal Agent 使用链接或复制兼容层 | 默认;`dws skill setup --mode multi` |
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in | | mono(兼容) | canonical `~/.agents/skills/dws/`;非 universal Agent 使用链接或复制兼容层 | `dws skill setup --mode mono` 或安装器的 mono opt-in |
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装 模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、 mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
@@ -140,10 +140,21 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
## 8. Upgrade 与恢复语义 ## 8. Upgrade 与恢复语义
升级器对每个 Agent 目标执行: 升级器始终先发布 `~/.agents/skills` canonical 集合。原生读取 universal 目录的
Agent(如 Codex、Cursor、Gemini、OpenCode)不再保留 Agent 私有副本;检测到的
非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical
的目录链接,Windows 使用 junction。链接不可用时回退为内容完整的直接复制。
自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、
`GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、
`.moltbot` 必须按 Agent 实际优先级解析。
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback; Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts`(基准提交
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill; `c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个
non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent
解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持
Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、
`.amp/skills`、`.cline/skills` 与
`.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill; 1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合; 2. 在目标文件系统的 staging 中复制完整新集合;
@@ -192,6 +203,7 @@ setup 在未显式指定 `--source` 时的本地回退缓存。
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 | | `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 | | `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 | | npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
| `scripts/install-event.sh` / `install-devapp.*` | 产品 multi 子集 | 同样使用 canonical 与 Agent 兼容层 |
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。 Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
@@ -209,6 +221,8 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行
- 复制失败不留下 Agent 可见的残缺官方目录; - 复制失败不留下 Agent 可见的残缺官方目录;
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill; - 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
- Windows、macOS、Linux 的路径和覆盖率门禁; - Windows、macOS、Linux 的路径和覆盖率门禁;
- symlinked parent、Windows junction、链接失败复制回退与 broken link 修复;
- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级;
- npm、Shell、PowerShell 与包管理器安装冒烟。 - npm、Shell、PowerShell 与包管理器安装冒烟。
## 13. 后续演进 ## 13. 后续演进
+106 -25
View File
@@ -117,38 +117,114 @@ type CliSkillDTO struct {
// (skill_setup.go) MUST have a matching path value here — enforced by // (skill_setup.go) MUST have a matching path value here — enforced by
// TestAgentSkillPathsCoversSetupHomes. // TestAgentSkillPathsCoversSetupHomes.
var agentSkillPaths = map[string]string{ var agentSkillPaths = map[string]string{
// `agents` is the generic-agent sentinel: install scripts and `setup` // Universal agents. Those without an independent global directory map
// special-case ~/.agents/skills as a no-checks-required fallback so a // directly to the canonical ~/.agents/skills store.
// fresh machine without any IDE/agent registry still gets skills. "agents": ".agents/skills",
"agents": ".agents/skills", "amp": filepath.Join(".config", "agents", "skills"),
"qoder": ".qoder/skills", "antigravity": ".gemini/antigravity/skills",
"qoderwork": ".qoderwork/skills", "antigravity-cli": ".gemini/antigravity-cli/skills",
"codex": ".codex/skills",
"cursor": ".cursor/skills",
"deepagents": ".deepagents/agent/skills",
"firebender": ".firebender/skills",
"gemini-cli": ".gemini/skills",
"github-copilot": ".copilot/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
"replit": filepath.Join(".config", "agents", "skills"),
"universal": filepath.Join(".config", "agents", "skills"),
"cline": ".agents/skills",
"dexto": ".agents/skills",
"kimi-code-cli": ".agents/skills",
"loaf": ".agents/skills",
"warp": ".agents/skills",
"zed": ".agents/skills",
// Non-universal agents with global Skill directories.
"aider-desk": ".aider-desk/skills",
"astrbot": ".astrbot/data/skills",
"autohand-code": ".autohand/skills",
"augment": ".augment/skills",
"bob": ".bob/skills",
"claude-code": ".claude/skills",
"openclaw": ".openclaw/skills",
"codearts-agent": ".codeartsdoer/skills",
"codebuddy": ".codebuddy/skills",
"codemaker": ".codemaker/skills",
"codestudio": ".codestudio/skills",
"command-code": ".commandcode/skills",
"continue": ".continue/skills",
"cortex": ".snowflake/cortex/skills",
"crush": filepath.Join(".config", "crush", "skills"),
"devin": filepath.Join(".config", "devin", "skills"),
"droid": ".factory/skills",
"forgecode": ".forge/skills",
"goose": filepath.Join(".config", "goose", "skills"),
"grok": ".grok/skills",
"hermes-agent": ".hermes/skills",
"inference-sh": ".inferencesh/skills",
"jazz": ".jazz/skills",
"junie": ".junie/skills",
"iflow-cli": ".iflow/skills",
"kilo": ".kilocode/skills",
"kimchi": filepath.Join(".config", "kimchi", "harness", "skills"),
"kiro-cli": ".kiro/skills",
"kode": ".kode/skills",
"lingma": ".lingma/skills",
"mcpjam": ".mcpjam/skills",
"minimax-code": ".minimax/skills",
"mistral-vibe": ".vibe/skills",
"moxby": ".moxby/skills",
"mux": ".mux/skills",
"openhands": ".openhands/skills",
"ona": ".ona/skills",
"pi": ".pi/agent/skills",
"qoder": ".qoder/skills",
"qoder-cn": ".qoder-cn/skills",
"qwen-code": ".qwen/skills",
"reasonix": ".reasonix/skills",
"rovodev": ".rovodev/skills",
"roo": ".roo/skills",
"tabnine-cli": ".tabnine/agent/skills",
"terramind": ".terramind/skills",
"tinycloud": ".tinycloud/skills",
"trae": ".trae/skills",
"trae-cn": ".trae-cn/skills",
"windsurf": ".codeium/windsurf/skills",
"zcode": ".zcode/skills",
"zencoder": ".zencoder/skills",
"zenflow": ".zencoder/skills",
"neovate": ".neovate/skills",
"pochi": ".pochi/skills",
"adal": ".adal/skills",
// DWS compatibility aliases and DWS-only integrations.
"claude": ".claude/skills", "claude": ".claude/skills",
"cursor": ".cursor/skills", "gemini": ".gemini/skills",
"codex": ".codex/skills", "github": ".copilot/skills",
"zcode": ".zcode/skills", "hermes": ".hermes/skills",
"opencode": filepath.Join(".config", "opencode", "skills"), "kiro": ".kiro/skills",
// IDE / agent registries also probed by `dws skill setup --target all`. "qoderwork": ".qoderwork/skills",
"gemini": ".gemini/skills", }
"github": ".github/skills",
"windsurf": ".windsurf/skills", // Eve has project-scoped Skill directories but no upstream globalSkillsDir.
"augment": ".augment/skills", // Keep it in the advertised enumeration while failing explicitly instead of
"cline": ".cline/skills", // pretending that a global install configured Eve.
"amp": ".amp/skills", var unsupportedGlobalAgentTargets = map[string]string{
"kiro": ".kiro/skills", "eve": "Eve 不支持全局 Skill 安装,请在 Eve 项目内配置 agent/skills",
"trae": ".trae/skills", "promptscript": "PromptScript 不支持全局 Skill 安装,请在项目内使用 .agents/skills",
"openclaw": ".openclaw/skills",
"hermes": ".hermes/skills",
} }
// supportedTargets returns a sorted, comma-separated list of supported // supportedTargets returns a sorted, comma-separated list of supported
// targets. Sorted so help text and error messages stay stable across runs // targets. Sorted so help text and error messages stay stable across runs
// (Go map iteration order is intentionally randomized). // (Go map iteration order is intentionally randomized).
func supportedTargets() string { func supportedTargets() string {
targets := make([]string, 0, len(agentSkillPaths)+1) targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+1)
for target := range agentSkillPaths { for target := range agentSkillPaths {
targets = append(targets, target) targets = append(targets, target)
} }
for target := range unsupportedGlobalAgentTargets {
targets = append(targets, target)
}
sort.Strings(targets) sort.Strings(targets)
targets = append(targets, ".") targets = append(targets, ".")
return strings.Join(targets, ", ") return strings.Join(targets, ", ")
@@ -485,8 +561,13 @@ func resolveSkillTargetPath(target string) (string, error) {
return os.Getwd() return os.Getwd()
} }
// Look up predefined agent paths target = strings.ToLower(target)
relPath, ok := agentSkillPaths[strings.ToLower(target)] if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
return "", errors.New(reason)
}
// Look up predefined agent paths.
_, ok := agentSkillPaths[target]
if !ok { if !ok {
return "", fmt.Errorf("unsupported target") return "", fmt.Errorf("unsupported target")
} }
@@ -496,7 +577,7 @@ func resolveSkillTargetPath(target string) (string, error) {
return "", fmt.Errorf("failed to get home directory: %w", err) return "", fmt.Errorf("failed to get home directory: %w", err)
} }
return filepath.Join(homeDir, relPath), nil return resolveSkillSetupBase(homeDir, target), nil
} }
// fetchSkillDownloadInfo calls the download API to get the skill download URL. // fetchSkillDownloadInfo calls the download API to get the skill download URL.
+5 -1
View File
@@ -477,8 +477,12 @@ func TestAgentSkillPathsCoversSetupHomes(t *testing.T) {
for _, p := range agentSkillPaths { for _, p := range agentSkillPaths {
paths[p] = true paths[p] = true
} }
legacyCleanupOnly := map[string]bool{
".github/skills": true, ".windsurf/skills": true,
".cline/skills": true, ".amp/skills": true,
}
for _, home := range skillSetupAgentHomes { for _, home := range skillSetupAgentHomes {
if !paths[home] { if !paths[home] && !legacyCleanupOnly[home] {
t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+ t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+
"add it to agentSkillPaths so users can address it via --target <name>", home) "add it to agentSkillPaths so users can address it via --target <name>", home)
} }
+264 -34
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -23,22 +24,77 @@ import (
// agree on the install footprint. // agree on the install footprint.
var skillSetupAgentHomes = []string{ var skillSetupAgentHomes = []string{
".agents/skills", ".agents/skills",
".config/agents/skills",
".gemini/antigravity/skills",
".gemini/antigravity-cli/skills",
".deepagents/agent/skills",
".firebender/skills",
".copilot/skills",
".config/opencode/skills",
".aider-desk/skills",
".astrbot/data/skills",
".autohand/skills",
".augment/skills",
".bob/skills",
".claude/skills", ".claude/skills",
".cursor/skills", ".openclaw/skills",
".codeartsdoer/skills",
".codebuddy/skills",
".codemaker/skills",
".codestudio/skills",
".commandcode/skills",
".continue/skills",
".snowflake/cortex/skills",
".config/crush/skills",
".config/devin/skills",
".factory/skills",
".forge/skills",
".config/goose/skills",
".grok/skills",
".hermes/skills",
".inferencesh/skills",
".jazz/skills",
".junie/skills",
".iflow/skills",
".kilocode/skills",
".config/kimchi/harness/skills",
".kiro/skills",
".kode/skills",
".lingma/skills",
".mcpjam/skills",
".minimax/skills",
".vibe/skills",
".moxby/skills",
".mux/skills",
".openhands/skills",
".ona/skills",
".pi/agent/skills",
".qoder/skills", ".qoder/skills",
".qoder-cn/skills",
".qwen/skills",
".reasonix/skills",
".rovodev/skills",
".roo/skills",
".tabnine/agent/skills",
".terramind/skills",
".tinycloud/skills",
".trae/skills",
".trae-cn/skills",
".codeium/windsurf/skills",
".zcode/skills",
".zencoder/skills",
".neovate/skills",
".pochi/skills",
".adal/skills",
".qoderwork/skills", ".qoderwork/skills",
// beta.6 compatibility roots: cleanup only.
".cursor/skills",
".gemini/skills", ".gemini/skills",
".codex/skills", ".codex/skills",
".zcode/skills",
".github/skills", ".github/skills",
".windsurf/skills", ".windsurf/skills",
".augment/skills",
".cline/skills", ".cline/skills",
".amp/skills", ".amp/skills",
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
} }
const ( const (
@@ -65,6 +121,8 @@ var (
skillSetupInteractive = isInteractiveTerminal skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir skillSetupReadDir = os.ReadDir
skillSetupStat = os.Stat skillSetupStat = os.Stat
skillSetupLstat = os.Lstat
skillSetupGetenv = os.Getenv
skillSetupSymlink = os.Symlink skillSetupSymlink = os.Symlink
skillSetupExecutable = os.Executable skillSetupExecutable = os.Executable
skillSetupGetwd = os.Getwd skillSetupGetwd = os.Getwd
@@ -85,6 +143,7 @@ var (
skillSetupWriteState = skillstate.Write skillSetupWriteState = skillstate.Write
skillSetupRemoveState = skillstate.Remove skillSetupRemoveState = skillstate.Remove
skillSetupNow = time.Now skillSetupNow = time.Now
skillSetupFoldPathCase = runtime.GOOS == "windows"
) )
type skillSetupBackup struct { type skillSetupBackup struct {
@@ -153,9 +212,9 @@ multi 模式支持按产品挑选:
备份失败时保留原目录并跳过该目标,绝不静默删除。 备份失败时保留原目录并跳过该目标,绝不静默删除。
· 所有将被移除的目录都会在确认前逐条列出。 · 所有将被移除的目录都会在确认前逐条列出。
不带 --mode 时进入交互式询问;Skill 始终安装到 canonical 目录 ~/.agents/skills。 不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。
原生支持该目录的 Agent 直接读取;其他已检测到的 Agent 目录创建相对链接, DWS 会自动适配本机上检测到的 Agent;共享安装方式不可用时会自动改用兼容安装,
链接不可用时回退为直接复制,避免同一 Agent 扫描两份 Skill。 无需用户手动处理,也不会让同一个 Skill 重复出现。
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`, skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
Example: ` dws skill setup --mode multi --target claude --dry-run Example: ` dws skill setup --mode multi --target claude --dry-run
dws skill setup --mode multi --target claude`, dws skill setup --mode multi --target claude`,
@@ -248,7 +307,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
} }
} }
} }
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude // filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh / // 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
// install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。 // install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。
@@ -355,7 +413,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
} }
} }
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped) fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。") fmt.Fprintln(out, " 统一安装位置:~/.agents/skills")
fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent")
fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。")
return nil return nil
} }
@@ -934,17 +994,85 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) {
return detectExistingAgentHomes(home, mode), nil return detectExistingAgentHomes(home, mode), nil
} }
rel, ok := agentSkillPaths[target] if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
return nil, errors.New(reason)
}
_, ok := agentSkillPaths[target]
if !ok { if !ok {
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets()) return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
} }
dest := agentHomeForMode(filepath.Join(home, rel), mode) dest := agentHomeForMode(resolveSkillSetupBase(home, target), mode)
if filepath.Clean(dest) == filepath.Clean(canonical) { if sameSkillSetupPath(dest, canonical) {
return []string{canonical}, nil return []string{canonical}, nil
} }
return []string{canonical, dest}, nil return []string{canonical, dest}, nil
} }
func resolveOpenClawSetupBase(home string) string {
for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} {
base := filepath.Join(home, name)
if info, err := skillSetupStat(base); err == nil && info.IsDir() {
return filepath.Join(base, "skills")
}
}
return filepath.Join(home, ".openclaw", "skills")
}
func resolveSkillSetupBase(home, target string) string {
switch target {
case "claude", "claude-code":
if custom := strings.TrimSpace(skillSetupGetenv("CLAUDE_CONFIG_DIR")); custom != "" {
return filepath.Join(custom, "skills")
}
case "codex":
if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "hermes", "hermes-agent":
if custom := strings.TrimSpace(skillSetupGetenv("HERMES_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "autohand-code":
if custom := strings.TrimSpace(skillSetupGetenv("AUTOHAND_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "grok":
if custom := strings.TrimSpace(skillSetupGetenv("GROK_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "mistral-vibe":
if custom := strings.TrimSpace(skillSetupGetenv("VIBE_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "openclaw":
return resolveOpenClawSetupBase(home)
case "opencode", "amp", "replit", "universal", "crush", "devin", "goose", "kimchi":
configHome := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME"))
if configHome == "" {
configHome = filepath.Join(home, ".config")
}
switch target {
case "opencode":
return filepath.Join(configHome, "opencode", "skills")
case "amp", "replit", "universal":
return filepath.Join(configHome, "agents", "skills")
case "crush":
return filepath.Join(configHome, "crush", "skills")
case "devin":
return filepath.Join(configHome, "devin", "skills")
case "goose":
return filepath.Join(configHome, "goose", "skills")
case "kimchi":
return filepath.Join(configHome, "kimchi", "harness", "skills")
}
case "github", "github-copilot":
return filepath.Join(home, ".copilot", "skills")
case "windsurf":
return filepath.Join(home, ".codeium", "windsurf", "skills")
}
return filepath.Join(home, agentSkillPaths[target])
}
// agentHomeForMode appends the mode-specific tail segment to an agent home base. // agentHomeForMode appends the mode-specific tail segment to an agent home base.
func agentHomeForMode(base, mode string) string { func agentHomeForMode(base, mode string) string {
if mode == skillSetupModeMulti { if mode == skillSetupModeMulti {
@@ -956,16 +1084,57 @@ func agentHomeForMode(base, mode string) string {
func detectExistingAgentHomes(home, mode string) []string { func detectExistingAgentHomes(home, mode string) []string {
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
dests := []string{canonical} dests := []string{canonical}
canonicalKey := skillSetupPathKey(canonical)
seen := map[string]bool{canonicalKey: true}
addDetected := func(base string) {
parent := filepath.Dir(base)
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() {
return
}
dest := agentHomeForMode(base, mode)
key := skillSetupPathKey(dest)
if !seen[key] {
seen[key] = true
dests = append(dests, dest)
}
}
for i, rel := range skillSetupAgentHomes { for i, rel := range skillSetupAgentHomes {
if i == 0 { if i == 0 {
continue continue
} }
base := filepath.Join(home, rel) base := filepath.Join(home, rel)
parent := filepath.Dir(base) switch filepath.ToSlash(filepath.Clean(rel)) {
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { case ".claude/skills":
continue base = resolveSkillSetupBase(home, "claude-code")
case ".codex/skills":
base = resolveSkillSetupBase(home, "codex")
case ".hermes/skills":
base = resolveSkillSetupBase(home, "hermes-agent")
case ".autohand/skills":
base = resolveSkillSetupBase(home, "autohand-code")
case ".grok/skills":
base = resolveSkillSetupBase(home, "grok")
case ".vibe/skills":
base = resolveSkillSetupBase(home, "mistral-vibe")
case ".openclaw/skills":
base = resolveSkillSetupBase(home, "openclaw")
case ".config/opencode/skills":
base = resolveSkillSetupBase(home, "opencode")
case ".config/agents/skills":
base = resolveSkillSetupBase(home, "amp")
case ".config/crush/skills":
base = resolveSkillSetupBase(home, "crush")
case ".config/devin/skills":
base = resolveSkillSetupBase(home, "devin")
case ".config/goose/skills":
base = resolveSkillSetupBase(home, "goose")
case ".config/kimchi/harness/skills":
base = resolveSkillSetupBase(home, "kimchi")
} }
dests = append(dests, agentHomeForMode(base, mode)) addDetected(base)
}
for _, target := range []string{"github-copilot", "windsurf"} {
addDetected(resolveSkillSetupBase(home, target))
} }
return dests return dests
} }
@@ -978,10 +1147,26 @@ func skillSetupBaseForMode(dest, mode string) string {
} }
func isUniversalSkillSetupBase(base string) bool { func isUniversalSkillSetupBase(base string) bool {
base = filepath.ToSlash(filepath.Clean(base)) cleanBase := filepath.Clean(base)
if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" && sameSkillSetupPath(cleanBase, filepath.Join(custom, "skills")) {
return true
}
if custom := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")); custom != "" {
if sameSkillSetupPath(cleanBase, filepath.Join(custom, "agents", "skills")) ||
sameSkillSetupPath(cleanBase, filepath.Join(custom, "opencode", "skills")) {
return true
}
}
base = filepath.ToSlash(cleanBase)
for rel := range map[string]bool{ for rel := range map[string]bool{
".cursor/skills": true, ".gemini/skills": true, ".codex/skills": true, ".config/agents/skills": true, ".gemini/antigravity/skills": true,
".github/skills": true, ".cline/skills": true, ".amp/skills": true, ".gemini/antigravity-cli/skills": true, ".codex/skills": true,
".cursor/skills": true, ".deepagents/agent/skills": true,
".firebender/skills": true, ".gemini/skills": true,
".copilot/skills": true, ".config/opencode/skills": true,
// beta.6 compatibility roots are cleanup-only.
".github/skills": true, ".windsurf/skills": true,
".cline/skills": true, ".amp/skills": true,
} { } {
if strings.HasSuffix(base, "/"+rel) { if strings.HasSuffix(base, "/"+rel) {
return true return true
@@ -990,6 +1175,18 @@ func isUniversalSkillSetupBase(base string) bool {
return false return false
} }
func sameSkillSetupPath(left, right string) bool {
return skillSetupPathKey(left) == skillSetupPathKey(right)
}
func skillSetupPathKey(path string) string {
clean := filepath.Clean(path)
if skillSetupFoldPathCase {
clean = strings.ToLower(clean)
}
return clean
}
func canonicalSkillSetupBase(dests []string, mode string) string { func canonicalSkillSetupBase(dests []string, mode string) string {
for _, dest := range dests { for _, dest := range dests {
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode))) base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode)))
@@ -1003,7 +1200,7 @@ func canonicalSkillSetupBase(dests []string, mode string) string {
func samePhysicalSkillSetupPath(left, right string) bool { func samePhysicalSkillSetupPath(left, right string) bool {
leftReal, leftErr := skillSetupEvalSymlinks(left) leftReal, leftErr := skillSetupEvalSymlinks(left)
rightReal, rightErr := skillSetupEvalSymlinks(right) rightReal, rightErr := skillSetupEvalSymlinks(right)
return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal)
} }
func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) { func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) {
@@ -1075,7 +1272,14 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
} }
sort.Strings(plan.MultiSkillNames) sort.Strings(plan.MultiSkillNames)
sortedDests := append([]string(nil), dests...) sortedDests := append([]string(nil), dests...)
sort.Strings(sortedDests) sort.Slice(sortedDests, func(i, j int) bool {
leftCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[i], mode))), "/.agents/skills")
rightCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[j], mode))), "/.agents/skills")
if leftCanonical != rightCanonical {
return leftCanonical
}
return sortedDests[i] < sortedDests[j]
})
managedNames := currentManagedSkillNames() managedNames := currentManagedSkillNames()
canonicalBase := canonicalSkillSetupBase(sortedDests, mode) canonicalBase := canonicalSkillSetupBase(sortedDests, mode)
for _, dest := range sortedDests { for _, dest := range sortedDests {
@@ -1134,16 +1338,14 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) { if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) {
continue continue
} }
info, statErr := skillSetupStat(path) _, statErr := skillSetupLstat(path)
if statErr != nil { if statErr != nil {
if errors.Is(statErr, os.ErrNotExist) { if errors.Is(statErr, os.ErrNotExist) {
continue continue
} }
return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr) return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr)
} }
if info.IsDir() { add(path, skillSetupBackupReplace)
add(path, skillSetupBackupReplace)
}
} }
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
if !target.CleanupOnly || len(target.Backups) > 0 { if !target.CleanupOnly || len(target.Backups) > 0 {
@@ -1200,14 +1402,14 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
fmt.Fprintf(out, " · %s\n", name) fmt.Fprintf(out, " · %s\n", name)
} }
} }
fmt.Fprintln(out, " destinations:") fmt.Fprintln(out, " 安装与适配位置:")
for _, target := range plan.Targets { for _, target := range plan.Targets {
if target.CleanupOnly { if target.CleanupOnly {
fmt.Fprintf(out, " - %s (仅迁移 universal Agent 中的旧 DWS 副本)\n", target.Destination) fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination)
} else if target.LinkCanonical { } else if target.LinkCanonical {
fmt.Fprintf(out, " - %s (链接到 %s,失败时回退复制)\n", target.Destination, target.CanonicalBase) fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination)
} else { } else {
fmt.Fprintf(out, " - %s\n", target.Destination) fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination)
} }
} }
fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):") fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):")
@@ -1700,6 +1902,10 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
} }
}() }()
realStageParent, realParentErr := skillSetupEvalSymlinks(stageParent)
if realParentErr != nil {
return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", stageParent, realParentErr)
}
stageOne := func(src, dest string) error { stageOne := func(src, dest string) error {
stagedDir := filepath.Join(stageRoot, filepath.Base(dest)) stagedDir := filepath.Join(stageRoot, filepath.Base(dest))
@@ -1708,7 +1914,11 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
if samePhysicalSkillSetupPath(dest, canonicalTarget) { if samePhysicalSkillSetupPath(dest, canonicalTarget) {
return nil return nil
} }
relTarget, relErr := filepath.Rel(filepath.Dir(dest), canonicalTarget) realCanonicalTarget, realTargetErr := skillSetupEvalSymlinks(canonicalTarget)
if realTargetErr != nil {
return fmt.Errorf("解析 canonical Skill 失败 %s: %w", canonicalTarget, realTargetErr)
}
relTarget, relErr := skillSetupRel(realStageParent, realCanonicalTarget)
if relErr != nil { if relErr != nil {
return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr) return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr)
} }
@@ -1819,7 +2029,15 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
if plan.Mode == skillSetupModeMulti { if plan.Mode == skillSetupModeMulti {
perTarget = len(plan.MultiSkillNames) perTarget = len(plan.MultiSkillNames)
} }
hasCanonicalDependents := false
for _, candidate := range plan.Targets {
if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) {
hasCanonicalDependents = true
break
}
}
for _, target := range plan.Targets { for _, target := range plan.Targets {
isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase)
if target.CleanupOnly { if target.CleanupOnly {
if homeErr != nil { if homeErr != nil {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr)
@@ -1833,6 +2051,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
continue continue
} }
if len(target.Backups) > 0 && homeErr != nil { if len(target.Backups) > 0 && homeErr != nil {
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("无法解析 HOME,canonical Skill 刷新中止 %s: %w", target.Destination, homeErr)
}
if plan.Mode == skillSetupModeMono { if plan.Mode == skillSetupModeMono {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr) fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr)
} else { } else {
@@ -1844,12 +2065,15 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target) stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target)
if stageErr != nil && target.LinkCanonical { if stageErr != nil && target.LinkCanonical {
fmt.Fprintf(errOut, " ⚠️ %s 无法创建 Skill 链接,回退为直接复制: %v\n", target.Destination, stageErr) fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination)
fallback := target fallback := target
fallback.LinkCanonical = false fallback.LinkCanonical = false
stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback) stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback)
} }
if stageErr != nil { if stageErr != nil {
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr)
}
fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr) fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr)
skipped += perTarget skipped += perTarget
continue continue
@@ -1859,6 +2083,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil { if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
} }
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr)
}
fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr) fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr)
skipped += perTarget skipped += perTarget
continue continue
@@ -1869,6 +2096,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
if cleanupErr != nil { if cleanupErr != nil {
publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
} }
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr)
}
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr) fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
skipped += perTarget skipped += perTarget
continue continue
@@ -0,0 +1,115 @@
package app
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) {
t.Helper()
home := t.TempDir()
src := t.TempDir()
skill := filepath.Join(src, "dingtalk-chat")
if err := os.MkdirAll(skill, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skill, "SKILL.md"), []byte("chat"), 0o644); err != nil {
t.Fatal(err)
}
canonical := filepath.Join(home, ".agents", "skills")
dependent := filepath.Join(home, ".claude", "skills")
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, dependent}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
return home, plan
}
func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t *testing.T) {
t.Run("home", func(t *testing.T) {
_, plan := canonicalFailurePlan(t)
canonical := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
if err := os.MkdirAll(canonical, 0o755); err != nil {
t.Fatal(err)
}
plan.Targets[0].Backups = []skillSetupBackup{{Path: canonical, Reason: skillSetupBackupReplace}}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home denied") })
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 刷新中止") {
t.Fatalf("home failure = %v", err)
}
})
t.Run("backup", func(t *testing.T) {
home, plan := canonicalFailurePlan(t)
victim := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
plan.Targets[0].Backups = []skillSetupBackup{{Path: victim, Reason: skillSetupBackupReplace}}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup denied") })
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 备份失败") {
t.Fatalf("backup failure = %v", err)
}
})
t.Run("publish", func(t *testing.T) {
home, plan := canonicalFailurePlan(t)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return errors.New("publish denied") })
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") {
t.Fatalf("publish failure = %v", err)
}
})
}
func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) {
home, plan := canonicalFailurePlan(t)
canonicalTarget := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
if err := os.MkdirAll(canonicalTarget, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(canonicalTarget, "SKILL.md"), []byte("chat"), 0o644); err != nil {
t.Fatal(err)
}
linked := plan.Targets[1]
t.Run("physical-parent", func(t *testing.T) {
testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) {
if path == linked.Destination {
return "", errors.New("parent denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "物理目录") {
t.Fatalf("physical parent error = %v", err)
}
})
t.Run("canonical-target", func(t *testing.T) {
testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) {
if path == canonicalTarget {
return "", errors.New("canonical denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "解析 canonical") {
t.Fatalf("canonical target error = %v", err)
}
})
t.Run("relative-path", func(t *testing.T) {
testseam.Swap(t, &skillSetupRel, func(string, string) (string, error) { return "", errors.New("relative denied") })
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "相对链接") {
t.Fatalf("relative path error = %v", err)
}
})
_ = home
}
+267 -1
View File
@@ -2,14 +2,16 @@ package app
import ( import (
"bytes" "bytes"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
) )
func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) {
home := t.TempDir() home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{ testseam.Swap(t, &skillSetupAgentHomes, []string{
@@ -47,6 +49,16 @@ func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) {
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
claudeSkills := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claudeSkills, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(claudeSkills, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("missing-target", filepath.Join(claudeSkills, "dingtalk-shared")); err != nil {
t.Fatal(err)
}
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
if err != nil { if err != nil {
@@ -87,3 +99,257 @@ func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) {
} }
} }
} }
func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) {
home := t.TempDir()
customClaude := filepath.Join(t.TempDir(), "claude")
customCodex := filepath.Join(t.TempDir(), "codex")
customHermes := filepath.Join(t.TempDir(), "hermes")
for _, root := range []string{customClaude, customCodex, customHermes, filepath.Join(home, ".moltbot"), filepath.Join(home, ".copilot"), filepath.Join(home, ".config", "opencode"), filepath.Join(home, ".config", "agents"), filepath.Join(home, ".codeium", "windsurf")} {
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupGetenv, func(name string) string {
switch name {
case "CLAUDE_CONFIG_DIR":
return customClaude
case "CODEX_HOME":
return customCodex
case "HERMES_HOME":
return customHermes
default:
return ""
}
})
dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
filepath.Join(home, ".agents", "skills"),
filepath.Join(customClaude, "skills"),
filepath.Join(customCodex, "skills"),
filepath.Join(customHermes, "skills"),
filepath.Join(home, ".moltbot", "skills"),
filepath.Join(home, ".copilot", "skills"),
filepath.Join(home, ".config", "opencode", "skills"),
filepath.Join(home, ".config", "agents", "skills"),
filepath.Join(home, ".codeium", "windsurf", "skills"),
} {
found := false
for _, got := range dests {
if sameSkillSetupPath(got, want) {
found = true
break
}
}
if !found {
t.Fatalf("resolved targets %v missing %s", dests, want)
}
}
if !isUniversalSkillSetupBase(filepath.Join(customCodex, "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "opencode", "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "agents", "skills")) {
t.Fatal("custom Codex, OpenCode, and Amp must be universal cleanup-only targets")
}
}
func TestCrossPlatformCoverageSkillSetupCanonicalFailureStopsDependentTargets(t *testing.T) {
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
claude := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claude, 0o755); err != nil {
t.Fatal(err)
}
oldClaude := filepath.Join(claude, "dingtalk-chat")
if err := os.MkdirAll(oldClaude, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldClaude, "SKILL.md"), []byte("old remains"), 0o644); err != nil {
t.Fatal(err)
}
src := t.TempDir()
if err := os.MkdirAll(filepath.Join(src, "dingtalk-chat"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "dingtalk-chat", "SKILL.md"), []byte("new"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return errors.New("canonical copy denied") })
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical") {
t.Fatalf("canonical failure = %v", err)
}
body, readErr := os.ReadFile(filepath.Join(oldClaude, "SKILL.md"))
if readErr != nil || string(body) != "old remains" {
t.Fatalf("dependent Claude target changed: %q, %v", body, readErr)
}
}
func TestCrossPlatformCoverageSkillSetupCanonicalCopyFallbackMessage(t *testing.T) {
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
claude := filepath.Join(home, ".claude", "skills")
src := t.TempDir()
skillSrc := filepath.Join(src, "dingtalk-chat")
if err := os.MkdirAll(skillSrc, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupSymlink, func(string, string) error { return errors.New("links unavailable") })
plan, err := buildSkillSetupPlan(
skillSetupModeMulti,
src,
[]string{canonical, claude},
[]string{"dingtalk-chat"},
false,
)
if err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
if err != nil || installed != 2 || skipped != 0 {
t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "自动改用兼容安装") {
t.Fatalf("human-readable fallback message missing: %s", errOut.String())
}
info, err := os.Lstat(filepath.Join(claude, "dingtalk-chat"))
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("copy fallback = %#v, %v", info, err)
}
}
func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" })
expected := map[string]string{
"aider-desk": ".aider-desk/skills", "amp": ".config/agents/skills",
"antigravity": ".gemini/antigravity/skills", "antigravity-cli": ".gemini/antigravity-cli/skills",
"astrbot": ".astrbot/data/skills", "autohand-code": ".autohand/skills",
"augment": ".augment/skills", "bob": ".bob/skills", "claude-code": ".claude/skills",
"openclaw": ".openclaw/skills", "cline": ".agents/skills", "codearts-agent": ".codeartsdoer/skills",
"codebuddy": ".codebuddy/skills", "codemaker": ".codemaker/skills", "codestudio": ".codestudio/skills",
"codex": ".codex/skills", "command-code": ".commandcode/skills", "continue": ".continue/skills",
"cortex": ".snowflake/cortex/skills", "crush": ".config/crush/skills", "cursor": ".cursor/skills",
"deepagents": ".deepagents/agent/skills", "devin": ".config/devin/skills", "dexto": ".agents/skills",
"droid": ".factory/skills", "firebender": ".firebender/skills", "forgecode": ".forge/skills",
"gemini-cli": ".gemini/skills", "github-copilot": ".copilot/skills", "goose": ".config/goose/skills",
"grok": ".grok/skills", "hermes-agent": ".hermes/skills", "inference-sh": ".inferencesh/skills",
"jazz": ".jazz/skills", "junie": ".junie/skills", "iflow-cli": ".iflow/skills",
"kilo": ".kilocode/skills", "kimchi": ".config/kimchi/harness/skills", "kimi-code-cli": ".agents/skills",
"kiro-cli": ".kiro/skills", "kode": ".kode/skills", "lingma": ".lingma/skills", "loaf": ".agents/skills",
"mcpjam": ".mcpjam/skills", "minimax-code": ".minimax/skills", "mistral-vibe": ".vibe/skills",
"moxby": ".moxby/skills", "mux": ".mux/skills", "opencode": ".config/opencode/skills",
"openhands": ".openhands/skills", "ona": ".ona/skills", "pi": ".pi/agent/skills",
"qoder": ".qoder/skills", "qoder-cn": ".qoder-cn/skills", "qwen-code": ".qwen/skills",
"replit": ".config/agents/skills", "reasonix": ".reasonix/skills", "rovodev": ".rovodev/skills",
"roo": ".roo/skills", "tabnine-cli": ".tabnine/agent/skills", "terramind": ".terramind/skills",
"tinycloud": ".tinycloud/skills", "trae": ".trae/skills", "trae-cn": ".trae-cn/skills",
"universal": ".config/agents/skills", "warp": ".agents/skills", "windsurf": ".codeium/windsurf/skills",
"zed": ".agents/skills", "zcode": ".zcode/skills", "zencoder": ".zencoder/skills",
"zenflow": ".zencoder/skills", "neovate": ".neovate/skills", "pochi": ".pochi/skills", "adal": ".adal/skills",
}
if got := len(expected) + len(unsupportedGlobalAgentTargets); got != 76 {
t.Fatalf("upstream agent enumeration = %d, want 76", got)
}
for target, rel := range expected {
mapped, ok := agentSkillPaths[target]
if !ok || filepath.Clean(mapped) != filepath.Clean(rel) {
t.Errorf("agent %s map = %q, want %q", target, mapped, rel)
}
if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, filepath.Join(home, filepath.FromSlash(rel))) {
t.Errorf("agent %s effective root = %q, want %q", target, got, filepath.Join(home, rel))
}
}
for _, target := range []string{"eve", "promptscript"} {
if _, err := resolveSkillSetupTargets(target, skillSetupModeMulti); err == nil {
t.Errorf("%s unexpectedly resolved a global setup root", target)
}
if _, err := resolveSkillTargetPath(target); err == nil {
t.Errorf("%s unexpectedly resolved a marketplace install root", target)
}
}
if got := supportedTargets(); !strings.Contains(got, "eve") || !strings.Contains(got, "promptscript") {
t.Fatalf("supported targets omit no-global upstream agents: %s", got)
}
custom := map[string]string{
"AUTOHAND_HOME": filepath.Join(home, "autohand-home"), "CLAUDE_CONFIG_DIR": filepath.Join(home, "claude-home"),
"CODEX_HOME": filepath.Join(home, "codex-home"), "GROK_HOME": filepath.Join(home, "grok-home"),
"HERMES_HOME": filepath.Join(home, "hermes-home"), "VIBE_HOME": filepath.Join(home, "vibe-home"),
"XDG_CONFIG_HOME": filepath.Join(home, "xdg"),
}
testseam.Swap(t, &skillSetupGetenv, func(name string) string { return custom[name] })
customCases := map[string]string{
"autohand-code": filepath.Join(custom["AUTOHAND_HOME"], "skills"),
"claude-code": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"),
"codex": filepath.Join(custom["CODEX_HOME"], "skills"),
"grok": filepath.Join(custom["GROK_HOME"], "skills"),
"hermes-agent": filepath.Join(custom["HERMES_HOME"], "skills"),
"mistral-vibe": filepath.Join(custom["VIBE_HOME"], "skills"),
"amp": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
"replit": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
"universal": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
"crush": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"),
"devin": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"),
"goose": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"),
"kimchi": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"),
"opencode": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"),
}
for target, want := range customCases {
if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, want) {
t.Errorf("custom %s root = %q, want %q", target, got, want)
}
}
for _, target := range []string{"codex", "amp", "opencode"} {
if !isUniversalSkillSetupBase(resolveSkillSetupBase(home, target)) {
t.Errorf("custom %s root not classified universal", target)
}
}
}
func TestCrossPlatformCoverageOpenClawAliasPriority(t *testing.T) {
for _, tc := range []struct {
name string
dirs []string
want string
}{
{name: "default", want: ".openclaw"},
{name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"},
{name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"},
{name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
for _, dir := range tc.dirs {
if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil {
t.Fatal(err)
}
}
if got := resolveOpenClawSetupBase(home); got != filepath.Join(home, tc.want, "skills") {
t.Fatalf("OpenClaw root = %q", got)
}
})
}
}
func TestCrossPlatformCoverageSkillSetupWindowsPathNormalization(t *testing.T) {
testseam.Swap(t, &skillSetupFoldPathCase, true)
if !sameSkillSetupPath(filepath.Join("Root", "Skills"), filepath.Join("root", "skills")) {
t.Fatal("case-insensitive platform path normalization failed")
}
}
+3 -1
View File
@@ -170,7 +170,7 @@ func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTar
} }
var preview bytes.Buffer var preview bytes.Buffer
renderSkillSetupPlan(&preview, plan) renderSkillSetupPlan(&preview, plan)
if !strings.Contains(preview.String(), "universal Agent") { if !strings.Contains(preview.String(), "改用统一安装位置") {
t.Fatalf("universal cleanup preview missing: %s", preview.String()) t.Fatalf("universal cleanup preview missing: %s", preview.String())
} }
@@ -249,11 +249,13 @@ func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testin
t.Fatal(err) t.Fatal(err)
} }
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure }) testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") { if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") {
t.Fatalf("replacement stat error = %v", err) t.Fatalf("replacement stat error = %v", err)
} }
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") { if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") {
t.Fatalf("stale scan error = %v", err) t.Fatalf("stale scan error = %v", err)
+1 -2
View File
@@ -1055,12 +1055,11 @@ func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets(
if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes") stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
if err != nil { if err != nil {
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
} }
if !strings.Contains(stdout, "重新加载 Skills") { if !strings.Contains(stdout, "请重启已打开的 Agent") {
t.Fatalf("completion should tell the user to reload skills: %s", stdout) t.Fatalf("completion should tell the user to reload skills: %s", stdout)
} }
+372 -2
View File
@@ -4,12 +4,13 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
) )
func TestCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) {
home := withFakeHome(t) home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{ testseam.Swap(t, &knownSkillDirs, []string{
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
@@ -67,7 +68,77 @@ func TestCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testi
} }
} }
func TestCanonicalSkillLinksFallBackToCopies(t *testing.T) { func TestCrossPlatformCoverageCanonicalCustomRootsSymlinkedParentsAndLexicalVictims(t *testing.T) {
home := withFakeHome(t)
customCodex := filepath.Join(t.TempDir(), "codex")
customHermes := filepath.Join(t.TempDir(), "hermes")
externalClaude := filepath.Join(t.TempDir(), "claude")
for _, root := range []string{customCodex, customHermes, externalClaude, filepath.Join(home, ".clawdbot"), filepath.Join(home, ".codeium", "windsurf")} {
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(externalClaude, filepath.Join(home, ".claude")); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeGetenv, func(name string) string {
switch name {
case "CODEX_HOME":
return customCodex
case "HERMES_HOME":
return customHermes
default:
return ""
}
})
oldCodex := filepath.Join(customCodex, "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)
}
claudeBase := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claudeBase, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(claudeBase, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("missing-target", filepath.Join(claudeBase, "dingtalk-shared")); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
result, err := UpgradeSkillLocations(multiRoot)
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
t.Fatalf("custom CODEX_HOME duplicate remains: %v", err)
}
for _, base := range []string{
claudeBase,
filepath.Join(customHermes, "skills"),
filepath.Join(home, ".clawdbot", "skills"),
filepath.Join(home, ".codeium", "windsurf", "skills"),
} {
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
path := filepath.Join(base, name)
info, statErr := os.Lstat(path)
if statErr != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("expected canonical link %s: %#v, %v", path, info, statErr)
}
body, readErr := os.ReadFile(filepath.Join(path, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), name) {
t.Fatalf("link %s does not resolve: %q, %v", path, body, readErr)
}
}
}
}
func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) {
home := withFakeHome(t) home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
@@ -89,3 +160,302 @@ func TestCanonicalSkillLinksFallBackToCopies(t *testing.T) {
t.Fatalf("copy fallback content missing: %v", err) t.Fatalf("copy fallback content missing: %v", err)
} }
} }
func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) {
home := t.TempDir()
custom := map[string]string{
"AUTOHAND_HOME": filepath.Join(home, "custom-autohand"),
"CLAUDE_CONFIG_DIR": filepath.Join(home, "custom-claude"),
"CODEX_HOME": filepath.Join(home, "custom-codex"),
"GROK_HOME": filepath.Join(home, "custom-grok"),
"HERMES_HOME": filepath.Join(home, "custom-hermes"),
"VIBE_HOME": filepath.Join(home, "custom-vibe"),
"XDG_CONFIG_HOME": filepath.Join(home, "custom-xdg"),
}
testseam.Swap(t, &upgradeGetenv, func(name string) string { return custom[name] })
roots := configuredSkillRoots(home)
if len(roots) != len(knownSkillDirs) {
t.Fatalf("configured unique roots = %d, want %d", len(roots), len(knownSkillDirs))
}
byLabel := make(map[string]resolvedSkillRoot, len(roots))
for _, root := range roots {
if _, duplicate := byLabel[root.label]; duplicate {
t.Fatalf("duplicate configured label %s", root.label)
}
byLabel[root.label] = root
}
wants := map[string]string{
".autohand/skills": filepath.Join(custom["AUTOHAND_HOME"], "skills"),
".claude/skills": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"),
".codex/skills": filepath.Join(custom["CODEX_HOME"], "skills"),
".grok/skills": filepath.Join(custom["GROK_HOME"], "skills"),
".hermes/skills": filepath.Join(custom["HERMES_HOME"], "skills"),
".vibe/skills": filepath.Join(custom["VIBE_HOME"], "skills"),
".config/agents/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
".config/crush/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"),
".config/devin/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"),
".config/goose/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"),
".config/kimchi/harness/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"),
".config/opencode/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"),
}
for label, want := range wants {
if got := byLabel[label].base; filepath.Clean(got) != filepath.Clean(want) {
t.Errorf("configured %s = %q, want %q", label, got, want)
}
}
for _, label := range []string{
".config/agents/skills", ".gemini/antigravity/skills", ".gemini/antigravity-cli/skills",
".codex/skills", ".cursor/skills", ".deepagents/agent/skills", ".firebender/skills",
".gemini/skills", ".copilot/skills", ".config/opencode/skills",
} {
if !byLabel[label].universal {
t.Errorf("%s not classified universal", label)
}
}
if !byLabel[".agents/skills"].canonical || byLabel[".claude/skills"].universal {
t.Fatal("canonical/non-universal classification drift")
}
}
func TestCrossPlatformCoverageUpgradeCanonicalFailureOnlyFailsFastWithDependents(t *testing.T) {
for _, mode := range []string{"mono", "multi"} {
t.Run(mode, func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"})
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") })
src := t.TempDir()
if mode == "mono" {
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
} else {
src = writeMultiBundle(t, src, "dingtalk-chat")
}
result, err := UpgradeSkillLocations(src)
if err != nil {
t.Fatalf("standalone canonical failure should be reported per-target, not fail fast: %v", err)
}
if len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("result = %#v", result)
}
if hasDependentSkillRoot(home, filepath.Join(home, ".agents", "skills")) {
t.Fatal("canonical-only configuration unexpectedly has a dependent root")
}
})
}
}
func TestCrossPlatformCoverageUpgradeOpenClawAliasPriority(t *testing.T) {
for _, tc := range []struct {
name string
dirs []string
want string
}{
{name: "default", want: ".openclaw"},
{name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"},
{name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"},
{name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
for _, dir := range tc.dirs {
if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil {
t.Fatal(err)
}
}
if got := resolveOpenClawSkillBase(home); got != filepath.Join(home, tc.want, "skills") {
t.Fatalf("OpenClaw root = %q", got)
}
})
}
}
func TestCrossPlatformCoverageUpgradeLinkStagingFailureEdges(t *testing.T) {
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
dest := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil {
t.Fatal(err)
}
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") })
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "创建 Agent Skill 目录") {
t.Fatalf("mkdir failure = %v", err)
}
})
t.Run("temp", func(t *testing.T) {
testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", errors.New("temp denied") })
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "链接 staging") {
t.Fatalf("temp failure = %v", err)
}
})
t.Run("physical-parent", func(t *testing.T) {
testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) {
if path == dest {
return "", errors.New("physical denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "物理目录") {
t.Fatalf("physical failure = %v", err)
}
})
t.Run("canonical-target", func(t *testing.T) {
target := filepath.Join(canonical, "dingtalk-chat")
testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) {
if path == target {
return "", errors.New("canonical denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "解析 canonical") {
t.Fatalf("canonical failure = %v", err)
}
})
t.Run("relative-path", func(t *testing.T) {
testseam.Swap(t, &upgradeRel, func(string, string) (string, error) { return "", errors.New("relative denied") })
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "相对链接") {
t.Fatalf("relative failure = %v", err)
}
})
}
func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) {
for _, mode := range []string{"mono", "multi"} {
t.Run(mode, func(t *testing.T) {
makeSource := func(t *testing.T) string {
t.Helper()
src := t.TempDir()
if mode == "mono" {
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
return src
}
return writeMultiBundle(t, src, "dingtalk-chat")
}
t.Run("undetected-and-physical-alias", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".qoder/skills"})
if err := os.MkdirAll(filepath.Join(home, ".qoder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(home, ".agents", "skills"), filepath.Join(home, ".qoder", "skills")); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil {
t.Fatal(err)
}
if len(result.Results) < 3 || result.Results[1].Status != SkillDirSkipped || result.Results[2].Status != SkillDirSkipped {
t.Fatalf("skip branches = %#v", result.Results)
}
})
t.Run("universal-retire-failure", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"})
victimBase := filepath.Join(home, ".cursor", "skills")
victim := filepath.Join(victimBase, "dws")
if mode == "multi" {
victim = filepath.Join(victimBase, "dingtalk-chat")
}
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
if mode == "multi" {
useUpgradeManagedNames(t, "dingtalk-chat")
}
origRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if src == victim {
return errors.New("retire denied")
}
return origRename(src, dst)
})
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 1 {
t.Fatalf("retire failure = %#v, %v", result, err)
}
})
t.Run("universal-retire-success", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"})
victimBase := filepath.Join(home, ".cursor", "skills")
victim := filepath.Join(victimBase, "dws")
if mode == "multi" {
victim = filepath.Join(victimBase, "dingtalk-chat")
useUpgradeManagedNames(t, "dingtalk-chat")
}
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("retire success = %#v, %v", result, err)
}
if _, err := os.Lstat(victim); !os.IsNotExist(err) {
t.Fatalf("universal victim remains: %v", err)
}
})
t.Run("victim-scan-and-fallback-copy-failures", func(t *testing.T) {
t.Run("victim-scan", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
base := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
origReadDir := upgradeReadDir
testseam.Swap(t, &upgradeReadDir, func(path string) ([]os.DirEntry, error) {
if path == base {
return nil, errors.New("scan denied")
}
return origReadDir(path)
})
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 1 {
t.Fatalf("victim scan = %#v, %v", result, err)
}
})
t.Run("fallback-copy", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
base := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("link denied") })
origCopy := upgradeCopyDir
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
if strings.HasPrefix(filepath.Clean(dst), filepath.Clean(base)+string(filepath.Separator)) {
return errors.New("copy denied")
}
return origCopy(src, dst)
})
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 1 {
t.Fatalf("fallback copy = %#v, %v", result, err)
}
})
})
})
}
}
func TestCrossPlatformCoverageUpgradeWindowsPathNormalizationAndRootDedup(t *testing.T) {
testseam.Swap(t, &upgradeFoldPathCase, true)
if got := skillRootPathKey(filepath.Join("Root", "Skills")); got != strings.ToLower(got) {
t.Fatalf("case-folded path key = %q", got)
}
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".claude/skills"})
if roots := configuredSkillRoots(t.TempDir()); len(roots) != 2 {
t.Fatalf("deduplicated roots = %#v", roots)
}
}
+4 -1
View File
@@ -579,7 +579,10 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
}) })
home := t.TempDir() home := t.TempDir()
upgradeUserHomeDir = func() (string, error) { return home, nil } upgradeUserHomeDir = func() (string, error) { return home, nil }
knownSkillDirs = []string{".agents/skills"} knownSkillDirs = []string{".agents/skills", ".claude/skills"}
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
calls := 0 calls := 0
upgradeCopyDir = func(src, dst string) error { upgradeCopyDir = func(src, dst string) error {
calls++ calls++
+220 -38
View File
@@ -42,22 +42,78 @@ const (
// copy (with a direct-copy fallback when links are unavailable). // copy (with a direct-copy fallback when links are unavailable).
var knownSkillDirs = []string{ var knownSkillDirs = []string{
".agents/skills", ".agents/skills",
".config/agents/skills",
".gemini/antigravity/skills",
".gemini/antigravity-cli/skills",
".deepagents/agent/skills",
".firebender/skills",
".copilot/skills",
".config/opencode/skills",
".aider-desk/skills",
".astrbot/data/skills",
".autohand/skills",
".augment/skills",
".bob/skills",
".claude/skills", ".claude/skills",
".cursor/skills", ".openclaw/skills",
".codeartsdoer/skills",
".codebuddy/skills",
".codemaker/skills",
".codestudio/skills",
".commandcode/skills",
".continue/skills",
".snowflake/cortex/skills",
".config/crush/skills",
".config/devin/skills",
".factory/skills",
".forge/skills",
".config/goose/skills",
".grok/skills",
".hermes/skills",
".inferencesh/skills",
".jazz/skills",
".junie/skills",
".iflow/skills",
".kilocode/skills",
".config/kimchi/harness/skills",
".kiro/skills",
".kode/skills",
".lingma/skills",
".mcpjam/skills",
".minimax/skills",
".vibe/skills",
".moxby/skills",
".mux/skills",
".openhands/skills",
".ona/skills",
".pi/agent/skills",
".qoder/skills", ".qoder/skills",
".qoder-cn/skills",
".qwen/skills",
".reasonix/skills",
".rovodev/skills",
".roo/skills",
".tabnine/agent/skills",
".terramind/skills",
".tinycloud/skills",
".trae/skills",
".trae-cn/skills",
".codeium/windsurf/skills",
".zcode/skills",
".zencoder/skills",
".neovate/skills",
".pochi/skills",
".adal/skills",
// DWS-only target retained for product compatibility.
".qoderwork/skills", ".qoderwork/skills",
// beta.6 compatibility roots: cleanup only, never publish new copies.
".cursor/skills",
".gemini/skills", ".gemini/skills",
".codex/skills", ".codex/skills",
".zcode/skills",
".github/skills", ".github/skills",
".windsurf/skills", ".windsurf/skills",
".augment/skills",
".cline/skills", ".cline/skills",
".amp/skills", ".amp/skills",
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
} }
// universalSkillDirs are concrete Agent directories whose current clients // universalSkillDirs are concrete Agent directories whose current clients
@@ -66,12 +122,21 @@ var knownSkillDirs = []string{
// with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally // with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally
// non-universal because it is a DWS-specific integration absent upstream. // non-universal because it is a DWS-specific integration absent upstream.
var universalSkillDirs = map[string]bool{ var universalSkillDirs = map[string]bool{
".cursor/skills": true, ".config/agents/skills": true,
".gemini/skills": true, ".gemini/antigravity/skills": true,
".codex/skills": true, ".gemini/antigravity-cli/skills": true,
".github/skills": true, ".codex/skills": true,
".cline/skills": true, ".cursor/skills": true,
".amp/skills": true, ".deepagents/agent/skills": true,
".firebender/skills": true,
".gemini/skills": true,
".copilot/skills": true,
".config/opencode/skills": true,
// beta.6 compatibility roots are cleanup-only as well.
".github/skills": true,
".windsurf/skills": true,
".cline/skills": true,
".amp/skills": true,
} }
var ( var (
@@ -87,11 +152,14 @@ var (
upgradeStat = os.Stat upgradeStat = os.Stat
upgradeLstat = os.Lstat upgradeLstat = os.Lstat
upgradeSymlink = os.Symlink upgradeSymlink = os.Symlink
upgradeRel = filepath.Rel
upgradeGetenv = os.Getenv
upgradeBuildProvenance = skillprovenance.Build upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read upgradeReadSkillState = skillstate.Read
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
upgradeWriteSkillState = skillstate.Write upgradeWriteSkillState = skillstate.Write
upgradeNow = time.Now upgradeNow = time.Now
upgradeFoldPathCase = runtime.GOOS == "windows"
) )
// skillBackupSubdir is the user-level directory where skill directories are // skillBackupSubdir is the user-level directory where skill directories are
@@ -102,21 +170,19 @@ const skillBackupSubdir = ".dws/skill-backups"
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/ // backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel> instead of destroying it, and returns the backup path. It is // <stamp>/<rel> instead of destroying it, and returns the backup path. It is
// fail-safe: a directory that cannot be backed up is NOT removed and the // fail-safe: a path that cannot be backed up is NOT removed and the
// error is returned so the caller can surface it (and never install the // error is returned so the caller can surface it (and never install the
// opposite layout next to it silently). Missing paths and regular files are // opposite layout next to it silently). Missing paths are no-ops. Directories,
// no-ops ("", nil). // links (including dangling links), and ordinary files are all preserved:
// an unexpected file at a managed destination must never be overwritten.
func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
info, err := upgradeLstat(dir) _, err := upgradeLstat(dir)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return "", nil return "", nil
} }
return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err) return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err)
} }
if !info.IsDir() && info.Mode()&os.ModeSymlink == 0 {
return "", nil
}
rel, relErr := filepath.Rel(homeDir, dir) rel, relErr := filepath.Rel(homeDir, dir)
if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") { if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") {
rel = filepath.Base(dir) rel = filepath.Base(dir)
@@ -544,8 +610,87 @@ func isUniversalSkillRoot(agentDir string) bool {
return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))] return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))]
} }
func skillRootDetected(homeDir, agentDir string) bool { type resolvedSkillRoot struct {
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) base string
label string
universal bool
canonical bool
}
func resolveOpenClawSkillBase(homeDir string) string {
for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} {
base := filepath.Join(homeDir, name)
if info, err := upgradeStat(base); err == nil && info.IsDir() {
return filepath.Join(base, "skills")
}
}
return filepath.Join(homeDir, ".openclaw", "skills")
}
func configuredSkillRoots(homeDir string) []resolvedSkillRoot {
var roots []resolvedSkillRoot
seen := map[string]bool{}
configHome := strings.TrimSpace(upgradeGetenv("XDG_CONFIG_HOME"))
if configHome == "" {
configHome = filepath.Join(homeDir, ".config")
}
add := func(base, label string, universal, canonical bool) {
key := skillRootPathKey(base)
if seen[key] {
return
}
seen[key] = true
roots = append(roots, resolvedSkillRoot{base: base, label: label, universal: universal, canonical: canonical})
}
for _, agentDir := range knownSkillDirs {
base := filepath.Join(homeDir, agentDir)
switch filepath.ToSlash(filepath.Clean(agentDir)) {
case ".claude/skills":
if custom := strings.TrimSpace(upgradeGetenv("CLAUDE_CONFIG_DIR")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".codex/skills":
if custom := strings.TrimSpace(upgradeGetenv("CODEX_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".hermes/skills":
if custom := strings.TrimSpace(upgradeGetenv("HERMES_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".autohand/skills":
if custom := strings.TrimSpace(upgradeGetenv("AUTOHAND_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".grok/skills":
if custom := strings.TrimSpace(upgradeGetenv("GROK_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".vibe/skills":
if custom := strings.TrimSpace(upgradeGetenv("VIBE_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".openclaw/skills":
base = resolveOpenClawSkillBase(homeDir)
case ".config/agents/skills":
base = filepath.Join(configHome, "agents", "skills")
case ".config/opencode/skills":
base = filepath.Join(configHome, "opencode", "skills")
case ".config/crush/skills":
base = filepath.Join(configHome, "crush", "skills")
case ".config/devin/skills":
base = filepath.Join(configHome, "devin", "skills")
case ".config/goose/skills":
base = filepath.Join(configHome, "goose", "skills")
case ".config/kimchi/harness/skills":
base = filepath.Join(configHome, "kimchi", "harness", "skills")
}
add(base, agentDir, isUniversalSkillRoot(agentDir), isGenericSkillRoot(agentDir))
}
return roots
}
func skillRootDetectedBase(base string) bool {
parentGate := filepath.Dir(base)
info, err := upgradeStat(parentGate) info, err := upgradeStat(parentGate)
return err == nil && info.IsDir() return err == nil && info.IsDir()
} }
@@ -553,7 +698,30 @@ func skillRootDetected(homeDir, agentDir string) bool {
func samePhysicalSkillRoot(left, right string) bool { func samePhysicalSkillRoot(left, right string) bool {
leftReal, leftErr := upgradeEvalSymlinks(left) leftReal, leftErr := upgradeEvalSymlinks(left)
rightReal, rightErr := upgradeEvalSymlinks(right) rightReal, rightErr := upgradeEvalSymlinks(right)
return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) if leftErr != nil || rightErr != nil {
return false
}
return skillRootPathKey(leftReal) == skillRootPathKey(rightReal)
}
func skillRootPathKey(path string) string {
clean := filepath.Clean(path)
if upgradeFoldPathCase {
clean = strings.ToLower(clean)
}
return clean
}
func hasDependentSkillRoot(homeDir, canonicalBase string) bool {
for _, root := range configuredSkillRoots(homeDir) {
if root.canonical || root.universal || isBlacklisted(root.label) || !skillRootDetectedBase(root.base) {
continue
}
if !samePhysicalSkillRoot(root.base, canonicalBase) {
return true
}
}
return false
} }
func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error { func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error {
@@ -581,9 +749,17 @@ func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string)
_ = upgradeRemoveAll(stageRoot) _ = upgradeRemoveAll(stageRoot)
} }
}() }()
realDestBase, realErr := upgradeEvalSymlinks(destBase)
if realErr != nil {
return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", destBase, realErr)
}
for _, name := range names { for _, name := range names {
target := filepath.Join(canonicalBase, name) target := filepath.Join(canonicalBase, name)
relTarget, relErr := filepath.Rel(destBase, target) realTarget, targetErr := upgradeEvalSymlinks(target)
if targetErr != nil {
return stageRoot, nil, fmt.Errorf("解析 canonical Skill 失败 %s: %w", target, targetErr)
}
relTarget, relErr := upgradeRel(realDestBase, realTarget)
if relErr != nil { if relErr != nil {
return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr) return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr)
} }
@@ -631,21 +807,24 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e
canonicalDest := filepath.Join(canonicalBase, "dws") canonicalDest := filepath.Join(canonicalBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil { if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err}) result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err})
return result, fmt.Errorf("canonical Skill 安装失败: %w", err) if hasDependentSkillRoot(homeDir, canonicalBase) {
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
return result, nil
} }
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK}) result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK})
for _, agentDir := range knownSkillDirs { for _, root := range configuredSkillRoots(homeDir) {
if isGenericSkillRoot(agentDir) { if root.canonical {
continue continue
} }
destBase := filepath.Join(homeDir, agentDir) destBase := root.base
destDir := filepath.Join(destBase, "dws") destDir := filepath.Join(destBase, "dws")
if isBlacklisted(agentDir) { if isBlacklisted(root.label) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
continue continue
} }
if !skillRootDetected(homeDir, agentDir) { if !skillRootDetectedBase(destBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue continue
} }
@@ -653,7 +832,7 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue continue
} }
if isUniversalSkillRoot(agentDir) { if root.universal {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
} else { } else {
@@ -701,20 +880,23 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk
canonicalBase := filepath.Join(homeDir, ".agents", "skills") canonicalBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil { if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err}) result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err})
return result, fmt.Errorf("canonical Skill 安装失败: %w", err) if hasDependentSkillRoot(homeDir, canonicalBase) {
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
return result, nil
} }
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK}) result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK})
for _, agentDir := range knownSkillDirs { for _, root := range configuredSkillRoots(homeDir) {
if isGenericSkillRoot(agentDir) { if root.canonical {
continue continue
} }
destBase := filepath.Join(homeDir, agentDir) destBase := root.base
if isBlacklisted(agentDir) { if isBlacklisted(root.label) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
continue continue
} }
if !skillRootDetected(homeDir, agentDir) { if !skillRootDetectedBase(destBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue continue
} }
@@ -722,7 +904,7 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue continue
} }
if isUniversalSkillRoot(agentDir) { if root.universal {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
} else { } else {
+12 -6
View File
@@ -503,6 +503,9 @@ func TestUpgradeMonoCleansPreStateOfficialAndPreservesCustom(t *testing.T) {
// mono-only package path). // mono-only package path).
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) { func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) {
home := withFakeHome(t) home := withFakeHome(t)
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
agentsBase := filepath.Join(home, ".agents", "skills") agentsBase := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -598,6 +601,9 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
// installing multi next to the stale skills. // installing multi next to the stale skills.
func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) { func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) {
home := withFakeHome(t) home := withFakeHome(t)
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
agentsBase := filepath.Join(home, ".agents", "skills") agentsBase := filepath.Join(home, ".agents", "skills")
staleDir := filepath.Join(agentsBase, "dingtalk-stale") staleDir := filepath.Join(agentsBase, "dingtalk-stale")
if err := os.MkdirAll(staleDir, 0o755); err != nil { if err := os.MkdirAll(staleDir, 0o755); err != nil {
@@ -639,7 +645,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
} }
// TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe // TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe
// contract of the backup helper: non-dir paths are no-ops, a colliding backup // contract of the backup helper: all lexical paths are preserved, a colliding backup
// target gets a numbered stamp, and any failure (mkdir / rename / unresolvable // target gets a numbered stamp, and any failure (mkdir / rename / unresolvable
// collision) leaves the original directory untouched with an error. // collision) leaves the original directory untouched with an error.
func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
@@ -660,16 +666,16 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatalf("stat failure = (%q, %v), want wrapped error", got, err) t.Fatalf("stat failure = (%q, %v), want wrapped error", got, err)
} }
// Regular file: no-op, no backup. // Regular file: preserve it instead of allowing a later rename to overwrite it.
filePath := filepath.Join(home, "not-a-dir") filePath := filepath.Join(home, "not-a-dir")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got, err := backupAndRemoveSkillDir(home, filePath); got != "" || err != nil { if got, err := backupAndRemoveSkillDir(home, filePath); got == "" || err != nil {
t.Fatalf("regular file = (%q, %v), want no-op", got, err) t.Fatalf("regular file = (%q, %v), want backup", got, err)
} }
if _, err := os.Stat(filePath); err != nil { if _, err := os.Stat(filePath); !os.IsNotExist(err) {
t.Fatalf("regular file must survive: %v", err) t.Fatalf("regular file must move into backup: %v", err)
} }
// A skill directory outside home falls back to its basename instead of // A skill directory outside home falls back to its basename instead of
+102 -15
View File
@@ -22,9 +22,91 @@ $InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Pat
$NoSkills = $env:DWS_NO_SKILLS -eq "1" $NoSkills = $env:DWS_NO_SKILLS -eq "1"
$SkillName = "dingtalk-misc" $SkillName = "dingtalk-misc"
# vercel-labs/skills agents.ts (c6f69c6). Each row is
# id|universal|effective global directory; '-' means no global directory.
$AgentRegistryRows = @(
"aider-desk|0|.aider-desk\skills", "amp|1|.config\agents\skills",
"antigravity|1|.gemini\antigravity\skills", "antigravity-cli|1|.gemini\antigravity-cli\skills",
"astrbot|0|.astrbot\data\skills", "autohand-code|0|.autohand\skills", "augment|0|.augment\skills",
"bob|0|.bob\skills", "claude-code|0|.claude\skills", "openclaw|0|.openclaw\skills",
"cline|1|.agents\skills", "codearts-agent|0|.codeartsdoer\skills", "codebuddy|0|.codebuddy\skills",
"codemaker|0|.codemaker\skills", "codestudio|0|.codestudio\skills", "codex|1|.codex\skills",
"command-code|0|.commandcode\skills", "continue|0|.continue\skills", "cortex|0|.snowflake\cortex\skills",
"crush|0|.config\crush\skills", "cursor|1|.cursor\skills", "deepagents|1|.deepagents\agent\skills",
"devin|0|.config\devin\skills", "dexto|1|.agents\skills", "droid|0|.factory\skills", "eve|0|-",
"firebender|1|.firebender\skills", "forgecode|0|.forge\skills", "gemini-cli|1|.gemini\skills",
"github-copilot|1|.copilot\skills", "goose|0|.config\goose\skills", "grok|0|.grok\skills",
"hermes-agent|0|.hermes\skills", "inference-sh|0|.inferencesh\skills", "jazz|0|.jazz\skills",
"junie|0|.junie\skills", "iflow-cli|0|.iflow\skills", "kilo|0|.kilocode\skills",
"kimchi|0|.config\kimchi\harness\skills", "kimi-code-cli|1|.agents\skills", "kiro-cli|0|.kiro\skills",
"kode|0|.kode\skills", "lingma|0|.lingma\skills", "loaf|1|.agents\skills", "mcpjam|0|.mcpjam\skills",
"minimax-code|0|.minimax\skills", "mistral-vibe|0|.vibe\skills", "moxby|0|.moxby\skills",
"mux|0|.mux\skills", "opencode|1|.config\opencode\skills", "openhands|0|.openhands\skills",
"ona|0|.ona\skills", "pi|0|.pi\agent\skills", "qoder|0|.qoder\skills", "qoder-cn|0|.qoder-cn\skills",
"qwen-code|0|.qwen\skills", "replit|1|.config\agents\skills", "reasonix|0|.reasonix\skills",
"rovodev|0|.rovodev\skills", "roo|0|.roo\skills", "tabnine-cli|0|.tabnine\agent\skills",
"terramind|0|.terramind\skills", "tinycloud|0|.tinycloud\skills", "trae|0|.trae\skills",
"trae-cn|0|.trae-cn\skills", "warp|1|.agents\skills", "windsurf|0|.codeium\windsurf\skills",
"zed|1|.agents\skills", "zcode|0|.zcode\skills", "zencoder|0|.zencoder\skills",
"zenflow|0|.zencoder\skills", "neovate|0|.neovate\skills", "pochi|0|.pochi\skills",
"promptscript|1|-", "adal|0|.adal\skills", "universal|1|.config\agents\skills"
)
$LegacyAgentCleanupRows = @(
"dws-qoderwork|0|.qoderwork\skills", "dws-legacy-github|1|.github\skills",
"dws-legacy-amp|1|.amp\skills", "dws-legacy-cline|1|.cline\skills",
"dws-legacy-windsurf|1|.windsurf\skills"
)
function Say($m) { Write-Host " $m" } function Say($m) { Write-Host " $m" }
function Die($m) { Write-Host " X $m" -ForegroundColor Red; exit 1 } function Die($m) { Write-Host " X $m" -ForegroundColor Red; exit 1 }
function Resolve-AgentBase([string]$row) {
$parts = $row.Split('|')
$id = $parts[0]
if ($parts[2] -eq "-") { return $null }
switch ($id) {
"autohand-code" { if ($env:AUTOHAND_HOME) { return Join-Path $env:AUTOHAND_HOME "skills" } }
"claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return Join-Path $env:CLAUDE_CONFIG_DIR "skills" } }
"codex" { if ($env:CODEX_HOME) { return Join-Path $env:CODEX_HOME "skills" } }
"grok" { if ($env:GROK_HOME) { return Join-Path $env:GROK_HOME "skills" } }
"hermes-agent" { if ($env:HERMES_HOME) { return Join-Path $env:HERMES_HOME "skills" } }
"mistral-vibe" { if ($env:VIBE_HOME) { return Join-Path $env:VIBE_HOME "skills" } }
"openclaw" {
foreach ($legacy in @(".openclaw", ".clawdbot", ".moltbot")) {
$candidate = Join-Path $HOME $legacy
if (Test-Path $candidate -PathType Container) { return Join-Path $candidate "skills" }
}
}
{ $_ -in @("amp", "replit", "universal") } {
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" }
return Join-Path $xdg "agents\skills"
}
{ $_ -in @("crush", "devin", "goose", "kimchi", "opencode") } {
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" }
$child = switch ($id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; "kimchi" { "kimchi\harness\skills" }; default { "opencode\skills" } }
return Join-Path $xdg $child
}
}
return Join-Path $HOME $parts[2]
}
function Test-PathLexically([string]$path) {
try { Get-Item -LiteralPath $path -Force -ErrorAction Stop | Out-Null; return $true } catch {}
$parent = Split-Path $path -Parent; $leaf = Split-Path $path -Leaf
if (!(Test-Path $parent -PathType Container)) { return $false }
return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq $leaf } | Select-Object -First 1)
}
function Backup-DevSkill([string]$path) {
if (!(Test-PathLexically $path)) { return }
$backupRoot = Join-Path $HOME ".dws\skill-backups\$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))"
New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null
$target = Join-Path $backupRoot (Split-Path $path -Leaf)
while (Test-PathLexically $target) { $target += "-$([guid]::NewGuid().ToString('N'))" }
Move-Item -LiteralPath $path -Destination $target -ErrorAction Stop
}
function Get-Arch { function Get-Arch {
if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH } if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH }
try { try {
@@ -99,22 +181,27 @@ if (-not $NoSkills) {
New-Item -ItemType Directory -Path $cache -Force | Out-Null New-Item -ItemType Directory -Path $cache -Force | Out-Null
Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force
$agentDirs = @( $canonicalBase = Join-Path $HOME ".agents\skills"
".agents\skills", ".claude\skills", ".cursor\skills", ".qoder\skills", ".qoderwork\skills", $canonical = Join-Path $canonicalBase $SkillName
".gemini\skills", ".codex\skills", ".github\skills", ".windsurf\skills", ".augment\skills", Backup-DevSkill $canonical
".cline\skills", ".amp\skills", ".kiro\skills", ".trae\skills", ".openclaw\skills", New-Item -ItemType Directory -Path $canonical -Force | Out-Null
".hermes\skills", ".config\opencode\skills" Copy-Item -Path "$src\*" -Destination $canonical -Recurse -Force
) $installed = 1
$installed = 0; $idx = 0 $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($d in $agentDirs) { foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) {
$base = Join-Path $HOME $d $parts = $row.Split('|'); $universal = $parts[1] -eq "1"
$parent = Split-Path $base -Parent $base = Resolve-AgentBase $row
if ($idx -gt 0 -and -not (Test-Path $parent)) { $idx++; continue } if ([string]::IsNullOrWhiteSpace($base)) { continue }
$baseKey = [System.IO.Path]::GetFullPath($base).TrimEnd([char[]]@('\', '/'))
if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue }
if (-not (Test-Path (Split-Path $base -Parent))) { continue }
$dest = Join-Path $base $SkillName $dest = Join-Path $base $SkillName
if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } if ($universal) { Backup-DevSkill $dest; continue }
New-Item -ItemType Directory -Path $dest -Force | Out-Null New-Item -ItemType Directory -Path $base -Force | Out-Null
Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force Backup-DevSkill $dest
$installed++; $idx++ try { New-Item -ItemType Junction -Path $dest -Target ([System.IO.Path]::GetFullPath($canonical)) -ErrorAction Stop | Out-Null }
catch { New-Item -ItemType Directory -Path $dest -Force | Out-Null; Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force }
$installed++
} }
Say "Skill dingtalk-misc -> $installed agent dir(s)" Say "Skill dingtalk-misc -> $installed agent dir(s)"
} else { } else {
+84 -11
View File
@@ -26,6 +26,80 @@ say() { printf ' %s\n' "$@"; }
err() { printf ' ❌ %s\n' "$@" >&2; exit 1; } err() { printf ' ❌ %s\n' "$@" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; }
copy_tree() {
src="$1"; dest="$2"; parent="$(dirname "$dest")"
mkdir -p "$parent" || return 1
stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
if mv "$stage" "$dest"; then return 0; fi
rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true
return 1
}
backup_skill_dir() {
victim="$1"
[ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; }
stamp="$(date -u +%Y%m%d-%H%M%S)"
name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')"
backup_root="$HOME/.dws/skill-backups/$stamp"; backup="$backup_root/$name"; i=0
while [ -e "$backup" ] || [ -L "$backup" ]; do i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"; done
mkdir -p "$backup_root" || return 1
mv "$victim" "$backup" || return 1
printf '%s\n' "$backup"
}
same_physical_skill() {
[ -d "$1" ] && [ -d "$2" ] || return 1
left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
[ "$left" = "$right" ]
}
is_cleanup_only_agent_dir() {
case "$1" in
.config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;;
*) return 1 ;;
esac
}
agent_skill_dirs() {
printf '%s\n' \
.config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \
.aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \
.qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills
}
resolve_agent_skill_base() {
agent_dir="$1"; base="$HOME/$agent_dir"
case "$agent_dir" in
.claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;;
.codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;;
.hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;;
.autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;;
.grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;;
.vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;;
.openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;;
.config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;;
esac
printf '%s\n' "$base"
}
link_or_copy_skill() {
canonical="$1"; src="$2"; dest="$3"
same_physical_skill "$dest" "$canonical" && return 0
parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1
parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1
target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1
relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
stage="$(mktemp -d "$parent/.dws-link.tmp.XXXXXX")" || return 1
if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi
rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true
return 1
}
need_cmd curl need_cmd curl
detect_os() { detect_os() {
@@ -83,17 +157,16 @@ install_skill() {
cache="$HOME/.dws/skills/multi/$SKILL_NAME" cache="$HOME/.dws/skills/multi/$SKILL_NAME"
rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/" rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/"
installed=0; idx=0 canonical="$HOME/.agents/skills/$SKILL_NAME"
for agent_dir in \ copy_tree "$src" "$canonical" || return 1
.agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ installed=1
.gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ for agent_dir in $(agent_skill_dirs); do
.cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ base="$(resolve_agent_skill_base "$agent_dir")"
.config/opencode/skills [ -e "$(dirname "$base")" ] || continue
do same_physical_skill "$base" "$HOME/.agents/skills" && continue
base="$HOME/$agent_dir" if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$SKILL_NAME" >/dev/null || return 1; continue; fi
if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then idx=$((idx + 1)); continue; fi link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME" || return 1
dest="$base/$SKILL_NAME"; rm -rf "$dest"; mkdir -p "$dest"; cp -R "$src/." "$dest/" installed=$((installed + 1))
installed=$((installed + 1)); idx=$((idx + 1))
done done
say "✅ Skill dingtalk-misc → ${installed} agent dir(s)" say "✅ Skill dingtalk-misc → ${installed} agent dir(s)"
} }
+85 -24
View File
@@ -92,37 +92,98 @@ resolve_event_version() {
copy_tree() { copy_tree() {
src="$1" src="$1"
dest="$2" dest="$2"
rm -rf "$dest" parent="$(dirname "$dest")"
mkdir -p "$dest" mkdir -p "$parent" || return 1
cp -R "$src/." "$dest/" stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
if mv "$stage" "$dest"; then return 0; fi
rm -rf "$stage"
[ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true
return 1
}
backup_skill_dir() {
victim="$1"
[ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; }
stamp="$(date -u +%Y%m%d-%H%M%S)"
name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')"
backup_root="$HOME/.dws/skill-backups/$stamp"
backup="$backup_root/$name"
i=0
while [ -e "$backup" ] || [ -L "$backup" ]; do
i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"
done
mkdir -p "$backup_root" || return 1
mv "$victim" "$backup" || return 1
printf '%s\n' "$backup"
}
same_physical_skill() {
[ -d "$1" ] && [ -d "$2" ] || return 1
left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
[ "$left" = "$right" ]
}
is_cleanup_only_agent_dir() {
case "$1" in
.config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;;
*) return 1 ;;
esac
}
agent_skill_dirs() {
printf '%s\n' \
.config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \
.aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \
.qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills
}
resolve_agent_skill_base() {
agent_dir="$1"; base="$HOME/$agent_dir"
case "$agent_dir" in
.claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;;
.codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;;
.hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;;
.autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;;
.grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;;
.vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;;
.openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;;
.config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;;
esac
printf '%s\n' "$base"
}
link_or_copy_skill() {
canonical="$1"; src="$2"; dest="$3"
same_physical_skill "$dest" "$canonical" && return 0
parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1
parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1
target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1
relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
stage="$(mktemp -d "$parent/.dws-link.tmp.XXXXXX")" || return 1
if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then rm -rf "$stage"; copy_tree "$src" "$dest"; return; fi
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi
rm -rf "$stage"; [ -z "$backup" ] || mv "$backup" "$dest" 2>/dev/null || true
return 1
} }
install_skill_to_homes() { install_skill_to_homes() {
src="$1" src="$1"
skill_name="$2" skill_name="$2"
canonical="$HOME/.agents/skills/$skill_name"
installed=0 copy_tree "$src" "$canonical" || return 1
idx=0 installed=1
for agent_dir in \ for agent_dir in $(agent_skill_dirs); do
.agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ base="$(resolve_agent_skill_base "$agent_dir")"
.gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ [ -e "$(dirname "$base")" ] || continue
.cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ same_physical_skill "$base" "$HOME/.agents/skills" && continue
.config/opencode/skills if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$skill_name" >/dev/null || return 1; continue; fi
do link_or_copy_skill "$canonical" "$src" "$base/$skill_name" || return 1
base="$HOME/$agent_dir"
if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then
idx=$((idx + 1))
continue
fi
copy_tree "$src" "$base/$skill_name"
installed=$((installed + 1)) installed=$((installed + 1))
idx=$((idx + 1))
done done
if [ "$installed" -eq 0 ]; then
copy_tree "$src" "$HOME/.agents/skills/$skill_name"
installed=1
fi
printf '%s\n' "$installed" printf '%s\n' "$installed"
} }
+85 -47
View File
@@ -53,13 +53,13 @@ DWS_LAST_SKILL_BACKUP=""
backup_and_remove_skill_dir() { backup_and_remove_skill_dir() {
_bed_dir="$1" _bed_dir="$1"
DWS_LAST_SKILL_BACKUP="" DWS_LAST_SKILL_BACKUP=""
[ -d "$_bed_dir" ] || return 0 [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0
_bed_root="${HOME}/.dws/skill-backups" _bed_root="${HOME}/.dws/skill-backups"
_bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)"
_bed_name="$(basename "$_bed_dir")" _bed_name="$(basename "$_bed_dir")"
_bed_target="$_bed_root/$_bed_stamp/$_bed_name" _bed_target="$_bed_root/$_bed_stamp/$_bed_name"
_bed_i=1 _bed_i=1
while [ -e "$_bed_target" ]; do while [ -e "$_bed_target" ] || [ -L "$_bed_target" ]; do
_bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
_bed_i=$((_bed_i + 1)) _bed_i=$((_bed_i + 1))
if [ "$_bed_i" -gt 1000 ]; then if [ "$_bed_i" -gt 1000 ]; then
@@ -206,7 +206,7 @@ restore_multi_skill_set() {
if [ -f "$_rms_backups" ]; then if [ -f "$_rms_backups" ]; then
while IFS= read -r _rms_original && IFS= read -r _rms_backup; do while IFS= read -r _rms_original && IFS= read -r _rms_backup; do
[ -n "$_rms_backup" ] || continue [ -n "$_rms_backup" ] || continue
if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then if [ -e "$_rms_original" ] || [ -L "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup" printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup"
_rms_ok=0 _rms_ok=0
fi fi
@@ -413,11 +413,62 @@ multi_tree_has_skills() {
# relative links and fall back to copies only when links are unavailable. # relative links and fall back to copies only when links are unavailable.
is_universal_agent_dir() { is_universal_agent_dir() {
case "$1" in case "$1" in
".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;; ".config/agents/skills"|".gemini/antigravity/skills"|".gemini/antigravity-cli/skills"|".codex/skills"|".cursor/skills"|".deepagents/agent/skills"|".firebender/skills"|".gemini/skills"|".copilot/skills"|".config/opencode/skills"|".github/skills"|".windsurf/skills"|".cline/skills"|".amp/skills") return 0 ;;
*) return 1 ;; *) return 1 ;;
esac esac
} }
# Exact upstream registry (76 IDs): id|classification|effective-global-root.
# `-` means no global directory; `.agents/skills` means canonical-direct.
readonly DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills'
upstream_agent_registry() {
for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done
}
agent_skill_dirs() {
printf '%s\n' \
".config/agents/skills" ".gemini/antigravity/skills" ".gemini/antigravity-cli/skills" \
".codex/skills" ".cursor/skills" ".deepagents/agent/skills" ".firebender/skills" \
".gemini/skills" ".copilot/skills" ".config/opencode/skills" \
".aider-desk/skills" ".astrbot/data/skills" ".autohand/skills" ".augment/skills" \
".bob/skills" ".claude/skills" ".openclaw/skills" ".codeartsdoer/skills" \
".codebuddy/skills" ".codemaker/skills" ".codestudio/skills" ".commandcode/skills" \
".continue/skills" ".snowflake/cortex/skills" ".config/crush/skills" \
".config/devin/skills" ".factory/skills" ".forge/skills" ".config/goose/skills" \
".grok/skills" ".hermes/skills" ".inferencesh/skills" ".jazz/skills" ".junie/skills" \
".iflow/skills" ".kilocode/skills" ".config/kimchi/harness/skills" ".kiro/skills" \
".kode/skills" ".lingma/skills" ".mcpjam/skills" ".minimax/skills" ".vibe/skills" \
".moxby/skills" ".mux/skills" ".openhands/skills" ".ona/skills" ".pi/agent/skills" \
".qoder/skills" ".qoder-cn/skills" ".qwen/skills" ".reasonix/skills" \
".rovodev/skills" ".roo/skills" ".tabnine/agent/skills" ".terramind/skills" \
".tinycloud/skills" ".trae/skills" ".trae-cn/skills" ".codeium/windsurf/skills" \
".zcode/skills" ".zencoder/skills" ".neovate/skills" ".pochi/skills" ".adal/skills" \
".qoderwork/skills" ".github/skills" ".windsurf/skills" ".cline/skills" ".amp/skills"
}
resolve_agent_skill_base() {
_ras_root="$1"; _ras_agent="$2"
case "$_ras_agent" in
".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;;
".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;;
".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;;
".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;;
".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;;
".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;;
".openclaw/skills")
for _ras_name in .openclaw .clawdbot .moltbot; do
[ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; }
done ;;
".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;;
".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;;
".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;;
".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;;
".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;;
".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;;
esac
printf '%s\n' "$_ras_root/$_ras_agent"
}
same_physical_skill_root() { same_physical_skill_root() {
[ -d "$1" ] && [ -d "$2" ] || return 1 [ -d "$1" ] && [ -d "$2" ] || return 1
_sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1
@@ -432,7 +483,7 @@ retire_agent_skill_root() {
_rgs_backups="$_rgs_stage/backups" _rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
[ -d "$_rgs_victim" ] || continue [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue
if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then
continue continue
fi fi
@@ -450,6 +501,7 @@ link_canonical_skills_to_base() {
_lcs_canonical="$_lcs_root/.agents/skills" _lcs_canonical="$_lcs_root/.agents/skills"
mkdir -p "$_lcs_base" || return 1 mkdir -p "$_lcs_base" || return 1
same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
_lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1
_lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1
_lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published"
: > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; }
@@ -466,11 +518,13 @@ link_canonical_skills_to_base() {
_lcs_publish_names="" _lcs_publish_names=""
for _lcs_name in $_lcs_names; do for _lcs_name in $_lcs_names; do
if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi 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_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
ln -s "$_lcs_link_target" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_publish_names="$_lcs_publish_names $_lcs_name" _lcs_publish_names="$_lcs_publish_names $_lcs_name"
done done
for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do
[ -d "$_lcs_victim" ] || continue [ -e "$_lcs_victim" ] || [ -L "$_lcs_victim" ] || continue
[ "$_lcs_victim" = "$_lcs_stage" ] && continue [ "$_lcs_victim" = "$_lcs_stage" ] && continue
same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && 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 [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then continue; fi
@@ -500,25 +554,9 @@ install_multi_skills_to_root() {
failed=0 failed=0
if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi 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; } [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
for agent_dir in \ for agent_dir in $(agent_skill_dirs)
".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 do
base_dir="$root/$agent_dir" base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
parent_gate="$(dirname "$base_dir")" parent_gate="$(dirname "$base_dir")"
[ -e "$parent_gate" ] || continue [ -e "$parent_gate" ] || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
@@ -530,8 +568,12 @@ install_multi_skills_to_root() {
if link_canonical_skills_to_base "$root" "$base_dir" multi; then if link_canonical_skills_to_base "$root" "$base_dir" multi; then
installed=$((installed + 1)) installed=$((installed + 1))
else else
printf ' ⚠️ %s 无法创建 Skill 链接,回退为直接复制\n' "$base_dir" if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi fi
done done
if [ "$installed" -eq 0 ]; then if [ "$installed" -eq 0 ]; then
@@ -543,6 +585,10 @@ install_multi_skills_to_root() {
return 1 return 1
fi fi
write_skills_state "$multi_src" "install-skills.sh" || return 1 write_skills_state "$multi_src" "install-skills.sh" || return 1
printf ' ✅ DWS Skills 安装完成\n'
printf ' 统一安装位置:%s/.agents/skills\n' "$root"
printf ' 已自动适配本机上检测到的 Agent\n'
printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n'
} }
_install_multi_to_base() { _install_multi_to_base() {
@@ -701,25 +747,9 @@ install_skills_to_root() {
failed=0 failed=0
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi 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; } [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
for agent_dir in \ for agent_dir in $(agent_skill_dirs)
".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 do
base_dir="$root/$agent_dir" base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
parent_gate="$(dirname "$base_dir")" parent_gate="$(dirname "$base_dir")"
[ -e "$parent_gate" ] || continue [ -e "$parent_gate" ] || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
@@ -731,8 +761,12 @@ install_skills_to_root() {
if link_canonical_skills_to_base "$root" "$base_dir" mono; then if link_canonical_skills_to_base "$root" "$base_dir" mono; then
installed=$((installed + 1)) installed=$((installed + 1))
else else
printf ' ⚠️ %s 无法创建 Skill 链接,回退为直接复制\n' "$base_dir" if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then
if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi fi
done done
if [ "$installed" -eq 0 ]; then if [ "$installed" -eq 0 ]; then
@@ -744,6 +778,10 @@ install_skills_to_root() {
return 1 return 1
fi fi
rm -f "$DWS_STATE_ROOT/skills-state.json" rm -f "$DWS_STATE_ROOT/skills-state.json"
printf ' ✅ DWS Skills 安装完成\n'
printf ' 统一安装位置:%s/.agents/skills\n' "$root"
printf ' 已自动适配本机上检测到的 Agent\n'
printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n'
} }
# ── Main ───────────────────────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────────────────────
+178 -40
View File
@@ -52,27 +52,103 @@ $LegacyOfficialMultiSkills = @(
"dingtalk-todo", "dingtalk-wiki", "dws-shared" "dingtalk-todo", "dingtalk-wiki", "dws-shared"
) )
# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS). # Agent registry synchronized with vercel-labs/skills agents.ts (c6f69c6).
$AgentDirs = @( # Universal agents read the canonical store directly; their separate historical
".agents\skills", # global directories are cleanup-only. Non-universal agents get junctions to
".claude\skills", # canonical (or complete copies when junction creation is unavailable).
".cursor\skills", $AgentRegistry = @(
".qoder\skills", [pscustomobject]@{ Id = "aider-desk"; Universal = $false; Dir = ".aider-desk\skills" },
".qoderwork\skills", [pscustomobject]@{ Id = "amp"; Universal = $true; Dir = ".config\agents\skills" },
".gemini\skills", [pscustomobject]@{ Id = "antigravity"; Universal = $true; Dir = ".gemini\antigravity\skills" },
".codex\skills", [pscustomobject]@{ Id = "antigravity-cli"; Universal = $true; Dir = ".gemini\antigravity-cli\skills" },
".zcode\skills", [pscustomobject]@{ Id = "astrbot"; Universal = $false; Dir = ".astrbot\data\skills" },
".github\skills", [pscustomobject]@{ Id = "autohand-code"; Universal = $false; Dir = ".autohand\skills" },
".windsurf\skills", [pscustomobject]@{ Id = "augment"; Universal = $false; Dir = ".augment\skills" },
".augment\skills", [pscustomobject]@{ Id = "bob"; Universal = $false; Dir = ".bob\skills" },
".cline\skills", [pscustomobject]@{ Id = "claude-code"; Universal = $false; Dir = ".claude\skills" },
".amp\skills", [pscustomobject]@{ Id = "openclaw"; Universal = $false; Dir = ".openclaw\skills" },
".kiro\skills", [pscustomobject]@{ Id = "cline"; Universal = $true; Dir = ".agents\skills" },
".trae\skills", [pscustomobject]@{ Id = "codearts-agent"; Universal = $false; Dir = ".codeartsdoer\skills" },
".openclaw\skills", [pscustomobject]@{ Id = "codebuddy"; Universal = $false; Dir = ".codebuddy\skills" },
".hermes\skills" [pscustomobject]@{ Id = "codemaker"; Universal = $false; Dir = ".codemaker\skills" },
[pscustomobject]@{ Id = "codestudio"; Universal = $false; Dir = ".codestudio\skills" },
[pscustomobject]@{ Id = "codex"; Universal = $true; Dir = ".codex\skills" },
[pscustomobject]@{ Id = "command-code"; Universal = $false; Dir = ".commandcode\skills" },
[pscustomobject]@{ Id = "continue"; Universal = $false; Dir = ".continue\skills" },
[pscustomobject]@{ Id = "cortex"; Universal = $false; Dir = ".snowflake\cortex\skills" },
[pscustomobject]@{ Id = "crush"; Universal = $false; Dir = ".config\crush\skills" },
[pscustomobject]@{ Id = "cursor"; Universal = $true; Dir = ".cursor\skills" },
[pscustomobject]@{ Id = "deepagents"; Universal = $true; Dir = ".deepagents\agent\skills" },
[pscustomobject]@{ Id = "devin"; Universal = $false; Dir = ".config\devin\skills" },
[pscustomobject]@{ Id = "dexto"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "droid"; Universal = $false; Dir = ".factory\skills" },
[pscustomobject]@{ Id = "eve"; Universal = $false; Dir = $null },
[pscustomobject]@{ Id = "firebender"; Universal = $true; Dir = ".firebender\skills" },
[pscustomobject]@{ Id = "forgecode"; Universal = $false; Dir = ".forge\skills" },
[pscustomobject]@{ Id = "gemini-cli"; Universal = $true; Dir = ".gemini\skills" },
[pscustomobject]@{ Id = "github-copilot"; Universal = $true; Dir = ".copilot\skills" },
[pscustomobject]@{ Id = "goose"; Universal = $false; Dir = ".config\goose\skills" },
[pscustomobject]@{ Id = "grok"; Universal = $false; Dir = ".grok\skills" },
[pscustomobject]@{ Id = "hermes-agent"; Universal = $false; Dir = ".hermes\skills" },
[pscustomobject]@{ Id = "inference-sh"; Universal = $false; Dir = ".inferencesh\skills" },
[pscustomobject]@{ Id = "jazz"; Universal = $false; Dir = ".jazz\skills" },
[pscustomobject]@{ Id = "junie"; Universal = $false; Dir = ".junie\skills" },
[pscustomobject]@{ Id = "iflow-cli"; Universal = $false; Dir = ".iflow\skills" },
[pscustomobject]@{ Id = "kilo"; Universal = $false; Dir = ".kilocode\skills" },
[pscustomobject]@{ Id = "kimchi"; Universal = $false; Dir = ".config\kimchi\harness\skills" },
[pscustomobject]@{ Id = "kimi-code-cli"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "kiro-cli"; Universal = $false; Dir = ".kiro\skills" },
[pscustomobject]@{ Id = "kode"; Universal = $false; Dir = ".kode\skills" },
[pscustomobject]@{ Id = "lingma"; Universal = $false; Dir = ".lingma\skills" },
[pscustomobject]@{ Id = "loaf"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "mcpjam"; Universal = $false; Dir = ".mcpjam\skills" },
[pscustomobject]@{ Id = "minimax-code"; Universal = $false; Dir = ".minimax\skills" },
[pscustomobject]@{ Id = "mistral-vibe"; Universal = $false; Dir = ".vibe\skills" },
[pscustomobject]@{ Id = "moxby"; Universal = $false; Dir = ".moxby\skills" },
[pscustomobject]@{ Id = "mux"; Universal = $false; Dir = ".mux\skills" },
[pscustomobject]@{ Id = "opencode"; Universal = $true; Dir = ".config\opencode\skills" },
[pscustomobject]@{ Id = "openhands"; Universal = $false; Dir = ".openhands\skills" },
[pscustomobject]@{ Id = "ona"; Universal = $false; Dir = ".ona\skills" },
[pscustomobject]@{ Id = "pi"; Universal = $false; Dir = ".pi\agent\skills" },
[pscustomobject]@{ Id = "qoder"; Universal = $false; Dir = ".qoder\skills" },
[pscustomobject]@{ Id = "qoder-cn"; Universal = $false; Dir = ".qoder-cn\skills" },
[pscustomobject]@{ Id = "qwen-code"; Universal = $false; Dir = ".qwen\skills" },
[pscustomobject]@{ Id = "replit"; Universal = $true; Dir = ".config\agents\skills" },
[pscustomobject]@{ Id = "reasonix"; Universal = $false; Dir = ".reasonix\skills" },
[pscustomobject]@{ Id = "rovodev"; Universal = $false; Dir = ".rovodev\skills" },
[pscustomobject]@{ Id = "roo"; Universal = $false; Dir = ".roo\skills" },
[pscustomobject]@{ Id = "tabnine-cli"; Universal = $false; Dir = ".tabnine\agent\skills" },
[pscustomobject]@{ Id = "terramind"; Universal = $false; Dir = ".terramind\skills" },
[pscustomobject]@{ Id = "tinycloud"; Universal = $false; Dir = ".tinycloud\skills" },
[pscustomobject]@{ Id = "trae"; Universal = $false; Dir = ".trae\skills" },
[pscustomobject]@{ Id = "trae-cn"; Universal = $false; Dir = ".trae-cn\skills" },
[pscustomobject]@{ Id = "warp"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "windsurf"; Universal = $false; Dir = ".codeium\windsurf\skills" },
[pscustomobject]@{ Id = "zed"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "zcode"; Universal = $false; Dir = ".zcode\skills" },
[pscustomobject]@{ Id = "zencoder"; Universal = $false; Dir = ".zencoder\skills" },
[pscustomobject]@{ Id = "zenflow"; Universal = $false; Dir = ".zencoder\skills" },
[pscustomobject]@{ Id = "neovate"; Universal = $false; Dir = ".neovate\skills" },
[pscustomobject]@{ Id = "pochi"; Universal = $false; Dir = ".pochi\skills" },
[pscustomobject]@{ Id = "promptscript"; Universal = $true; Dir = $null },
[pscustomobject]@{ Id = "adal"; Universal = $false; Dir = ".adal\skills" },
[pscustomobject]@{ Id = "universal"; Universal = $true; Dir = ".config\agents\skills" }
) )
# DWS compatibility targets that are not part of the upstream registry.
# Qoderwork remains a non-universal install target; the other entries are
# migration cleanup targets only and intentionally do not count as agents.
$LegacyAgentCleanupTargets = @(
[pscustomobject]@{ Id = "dws-qoderwork"; Universal = $false; Dir = ".qoderwork\skills" },
[pscustomobject]@{ Id = "dws-legacy-github"; Universal = $true; Dir = ".github\skills" },
[pscustomobject]@{ Id = "dws-legacy-amp"; Universal = $true; Dir = ".amp\skills" },
[pscustomobject]@{ Id = "dws-legacy-cline"; Universal = $true; Dir = ".cline\skills" },
[pscustomobject]@{ Id = "dws-legacy-windsurf"; Universal = $true; Dir = ".windsurf\skills" }
)
# Kept as a compatibility surface for policy tests and downstream packagers.
$AgentDirs = @($AgentRegistry | Where-Object { $null -ne $_.Dir } | ForEach-Object { $_.Dir })
# ── Helpers ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────
function Write-Say { function Write-Say {
@@ -400,7 +476,7 @@ function Backup-SkillDir {
[ref]$BackupPath [ref]$BackupPath
) )
if ($null -ne $BackupPath) { $BackupPath.Value = "" } if ($null -ne $BackupPath) { $BackupPath.Value = "" }
if (!(Test-Path $Dir -PathType Container)) { return $true } if (!(Test-SkillPathLexically -Path $Dir)) { return $true }
$backupRoot = Join-Path $HOME ".dws\skill-backups" $backupRoot = Join-Path $HOME ".dws\skill-backups"
$stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss") $stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss")
$name = Split-Path $Dir -Leaf $name = Split-Path $Dir -Leaf
@@ -434,7 +510,7 @@ function Restore-MultiSkillSet {
$ok = $true $ok = $true
for ($i = $Published.Count - 1; $i -ge 0; $i--) { for ($i = $Published.Count - 1; $i -ge 0; $i--) {
try { try {
if (Test-Path $Published[$i]) { if (Test-SkillPathLexically -Path $Published[$i]) {
Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop
} }
} catch { } catch {
@@ -445,7 +521,7 @@ function Restore-MultiSkillSet {
for ($i = $Backups.Count - 1; $i -ge 0; $i--) { for ($i = $Backups.Count - 1; $i -ge 0; $i--) {
$item = $Backups[$i] $item = $Backups[$i]
try { try {
if (Test-Path $item.Original) { if (Test-SkillPathLexically -Path $item.Original) {
throw "恢复目标仍存在" throw "恢复目标仍存在"
} }
New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null
@@ -458,9 +534,52 @@ function Restore-MultiSkillSet {
return $ok return $ok
} }
function Test-UniversalAgentDir { function Test-SkillPathLexically {
param([string]$AgentDir) param([string]$Path)
return @(".cursor\skills", ".gemini\skills", ".codex\skills", ".github\skills", ".cline\skills", ".amp\skills") -contains $AgentDir if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
try {
Get-Item -LiteralPath $Path -Force -ErrorAction Stop | Out-Null
return $true
} catch {
$parent = Split-Path $Path -Parent
$leaf = Split-Path $Path -Leaf
if ([string]::IsNullOrWhiteSpace($parent) -or !(Test-Path $parent -PathType Container)) { return $false }
return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq $leaf } | Select-Object -First 1)
}
}
function Resolve-AgentSkillBase {
param([string]$Root, $Agent)
if ($null -eq $Agent.Dir) { return $null }
switch ($Agent.Id) {
"autohand-code" { if ($env:AUTOHAND_HOME) { return (Join-Path $env:AUTOHAND_HOME "skills") } }
"claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return (Join-Path $env:CLAUDE_CONFIG_DIR "skills") } }
"codex" { if ($env:CODEX_HOME) { return (Join-Path $env:CODEX_HOME "skills") } }
"grok" { if ($env:GROK_HOME) { return (Join-Path $env:GROK_HOME "skills") } }
"hermes-agent" { if ($env:HERMES_HOME) { return (Join-Path $env:HERMES_HOME "skills") } }
"mistral-vibe" { if ($env:VIBE_HOME) { return (Join-Path $env:VIBE_HOME "skills") } }
"openclaw" {
foreach ($name in @(".openclaw", ".clawdbot", ".moltbot")) {
$candidate = Join-Path $Root $name
if (Test-Path $candidate -PathType Container) { return (Join-Path $candidate "skills") }
}
}
{ $_ -in @("amp", "replit", "universal") } {
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
return (Join-Path $configHome "agents\skills")
}
{ $_ -in @("crush", "devin", "goose", "opencode") } {
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
$child = switch ($Agent.Id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; default { "opencode\skills" } }
return (Join-Path $configHome $child)
}
"kimchi" {
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
return (Join-Path $configHome "kimchi\harness\skills")
}
}
return (Join-Path $Root $Agent.Dir)
} }
function Test-SamePhysicalSkillRoot { function Test-SamePhysicalSkillRoot {
@@ -474,7 +593,7 @@ function Move-AgentSkillRootToBackup {
$victims = [System.Collections.Generic.List[string]]::new() $victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $baseDir $SkillName)) $victims.Add((Join-Path $baseDir $SkillName))
foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) { foreach ($existing in Get-ChildItem -Path $baseDir -Force -ErrorAction SilentlyContinue) {
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { if (Test-ManagedMultiSkillDir -Dir $existing.FullName) {
$victims.Add($existing.FullName) $victims.Add($existing.FullName)
} }
@@ -520,12 +639,13 @@ function Publish-CanonicalSkillLinks {
$publishNames = @() $publishNames = @()
foreach ($name in $names) { foreach ($name in $names) {
if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue } 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 $absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))
New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget -ErrorAction Stop | Out-Null
$publishNames += $name $publishNames += $name
} }
$victims = [System.Collections.Generic.List[string]]::new() $victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $BaseDir $SkillName)) $victims.Add((Join-Path $BaseDir $SkillName))
foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -Force -ErrorAction SilentlyContinue) { foreach ($existing in Get-ChildItem -Path $BaseDir -Force -ErrorAction SilentlyContinue) {
if ($existing.FullName -eq $stageRoot) { continue } if ($existing.FullName -eq $stageRoot) { continue }
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) } if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) }
} }
@@ -879,22 +999,27 @@ function Install-SkillsToHomes {
$canonical = Join-Path $Root ".agents\skills" $canonical = Join-Path $Root ".agents\skills"
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ } if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ }
if ($installed -eq 0) { return $false } if ($installed -eq 0) { return $false }
for ($i = 1; $i -lt $AgentDirs.Count; $i++) { $seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$agentDir = $AgentDirs[$i] foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) {
$baseDir = Join-Path $Root $agentDir $baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent
if ([string]::IsNullOrWhiteSpace($baseDir)) { continue }
$baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/'))
if (!$seenBases.Add($baseKey)) { continue }
$parentGate = Split-Path $baseDir -Parent $parentGate = Split-Path $baseDir -Parent
if (!(Test-Path $parentGate)) { continue } if (!(Test-Path $parentGate)) { continue }
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
$attempted++ $attempted++
if (Test-UniversalAgentDir -AgentDir $agentDir) { if ($agent.Universal) {
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ } if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ }
continue continue
} }
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") { if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") {
$installed++ $installed++
} else { } else {
Write-Say "⚠️ $baseDir 无法创建 Skill 链接,回退为直接复制" if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) {
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) { $installed++ } else { $failed++ } Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用"
$installed++
} else { $failed++ }
} }
} }
if ($installed -eq 0) { if ($installed -eq 0) {
@@ -906,6 +1031,10 @@ function Install-SkillsToHomes {
return $false return $false
} }
Remove-Item -LiteralPath (Join-Path $SkillStateRoot "skills-state.json") -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path $SkillStateRoot "skills-state.json") -Force -ErrorAction SilentlyContinue
Write-Say "✅ DWS Skills 安装完成"
Write-Say " 统一安装位置:$canonical"
Write-Say " 已自动适配本机上检测到的 Agent"
Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
return $true return $true
} }
@@ -938,22 +1067,27 @@ function Install-MultiSkillsToHomes {
$canonical = Join-Path $Root ".agents\skills" $canonical = Join-Path $Root ".agents\skills"
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ } if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ }
if ($installed -eq 0) { return $false } if ($installed -eq 0) { return $false }
for ($i = 1; $i -lt $AgentDirs.Count; $i++) { $seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$agentDir = $AgentDirs[$i] foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) {
$baseDir = Join-Path $Root $agentDir $baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent
if ([string]::IsNullOrWhiteSpace($baseDir)) { continue }
$baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/'))
if (!$seenBases.Add($baseKey)) { continue }
$parentGate = Split-Path $baseDir -Parent $parentGate = Split-Path $baseDir -Parent
if (!(Test-Path $parentGate)) { continue } if (!(Test-Path $parentGate)) { continue }
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
$attempted++ $attempted++
if (Test-UniversalAgentDir -AgentDir $agentDir) { if ($agent.Universal) {
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ } if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { $failed++ }
continue continue
} }
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi") { if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi") {
$installed++ $installed++
} else { } else {
Write-Say "⚠️ $baseDir 无法创建 Skill 链接,回退为直接复制" if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agent.Dir) {
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) { $installed++ } else { $failed++ } Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用"
$installed++
} else { $failed++ }
} }
} }
if ($installed -eq 0) { if ($installed -eq 0) {
@@ -965,6 +1099,10 @@ function Install-MultiSkillsToHomes {
return $false return $false
} }
Write-SkillsState -MultiSrc $MultiSrc Write-SkillsState -MultiSrc $MultiSrc
Write-Say "✅ DWS Skills 安装完成"
Write-Say " 统一安装位置:$canonical"
Write-Say " 已自动适配本机上检测到的 Agent"
Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
return $true return $true
} }
+89 -47
View File
@@ -71,13 +71,13 @@ DWS_LAST_SKILL_BACKUP=""
backup_and_remove_skill_dir() { backup_and_remove_skill_dir() {
_bed_dir="$1" _bed_dir="$1"
DWS_LAST_SKILL_BACKUP="" DWS_LAST_SKILL_BACKUP=""
[ -d "$_bed_dir" ] || return 0 [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0
_bed_root="${HOME}/.dws/skill-backups" _bed_root="${HOME}/.dws/skill-backups"
_bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)"
_bed_name="$(basename "$_bed_dir")" _bed_name="$(basename "$_bed_dir")"
_bed_target="$_bed_root/$_bed_stamp/$_bed_name" _bed_target="$_bed_root/$_bed_stamp/$_bed_name"
_bed_i=1 _bed_i=1
while [ -e "$_bed_target" ]; do while [ -e "$_bed_target" ] || [ -L "$_bed_target" ]; do
_bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
_bed_i=$((_bed_i + 1)) _bed_i=$((_bed_i + 1))
if [ "$_bed_i" -gt 1000 ]; then if [ "$_bed_i" -gt 1000 ]; then
@@ -231,7 +231,7 @@ restore_multi_skill_set() {
if [ -f "$_rms_backups" ]; then if [ -f "$_rms_backups" ]; then
while IFS= read -r _rms_original && IFS= read -r _rms_backup; do while IFS= read -r _rms_original && IFS= read -r _rms_backup; do
[ -n "$_rms_backup" ] || continue [ -n "$_rms_backup" ] || continue
if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then if [ -e "$_rms_original" ] || [ -L "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)" say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)"
_rms_ok=0 _rms_ok=0
fi fi
@@ -648,11 +648,66 @@ _install_mono_to_base() {
# relative links and fall back to copies only when links are unavailable. # relative links and fall back to copies only when links are unavailable.
is_universal_agent_dir() { is_universal_agent_dir() {
case "$1" in case "$1" in
".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;; ".config/agents/skills"|".gemini/antigravity/skills"|".gemini/antigravity-cli/skills"|".codex/skills"|".cursor/skills"|".deepagents/agent/skills"|".firebender/skills"|".gemini/skills"|".copilot/skills"|".config/opencode/skills"|".github/skills"|".windsurf/skills"|".cline/skills"|".amp/skills") return 0 ;;
*) return 1 ;; *) return 1 ;;
esac esac
} }
# Exact upstream registry (76 IDs): id|classification|effective-global-root.
# `-` means no global directory; `.agents/skills` means canonical-direct.
readonly DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills'
upstream_agent_registry() {
for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done
}
# Effective global roots from vercel-labs/skills. Duplicate upstream roots
# (amp/replit/universal and zencoder/zenflow) deliberately appear once.
# Qoderwork is a DWS-only non-universal target. The final four entries are
# cleanup-only compatibility paths from older DWS installers.
agent_skill_dirs() {
printf '%s\n' \
".config/agents/skills" ".gemini/antigravity/skills" ".gemini/antigravity-cli/skills" \
".codex/skills" ".cursor/skills" ".deepagents/agent/skills" ".firebender/skills" \
".gemini/skills" ".copilot/skills" ".config/opencode/skills" \
".aider-desk/skills" ".astrbot/data/skills" ".autohand/skills" ".augment/skills" \
".bob/skills" ".claude/skills" ".openclaw/skills" ".codeartsdoer/skills" \
".codebuddy/skills" ".codemaker/skills" ".codestudio/skills" ".commandcode/skills" \
".continue/skills" ".snowflake/cortex/skills" ".config/crush/skills" \
".config/devin/skills" ".factory/skills" ".forge/skills" ".config/goose/skills" \
".grok/skills" ".hermes/skills" ".inferencesh/skills" ".jazz/skills" ".junie/skills" \
".iflow/skills" ".kilocode/skills" ".config/kimchi/harness/skills" ".kiro/skills" \
".kode/skills" ".lingma/skills" ".mcpjam/skills" ".minimax/skills" ".vibe/skills" \
".moxby/skills" ".mux/skills" ".openhands/skills" ".ona/skills" ".pi/agent/skills" \
".qoder/skills" ".qoder-cn/skills" ".qwen/skills" ".reasonix/skills" \
".rovodev/skills" ".roo/skills" ".tabnine/agent/skills" ".terramind/skills" \
".tinycloud/skills" ".trae/skills" ".trae-cn/skills" ".codeium/windsurf/skills" \
".zcode/skills" ".zencoder/skills" ".neovate/skills" ".pochi/skills" ".adal/skills" \
".qoderwork/skills" ".github/skills" ".windsurf/skills" ".cline/skills" ".amp/skills"
}
resolve_agent_skill_base() {
_ras_root="$1"; _ras_agent="$2"
case "$_ras_agent" in
".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;;
".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;;
".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;;
".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;;
".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;;
".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;;
".openclaw/skills")
for _ras_name in .openclaw .clawdbot .moltbot; do
[ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; }
done ;;
".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;;
".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;;
".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;;
".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;;
".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;;
".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;;
esac
printf '%s\n' "$_ras_root/$_ras_agent"
}
same_physical_skill_root() { same_physical_skill_root() {
[ -d "$1" ] && [ -d "$2" ] || return 1 [ -d "$1" ] && [ -d "$2" ] || return 1
_sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1 _sps_left="$(cd -P "$1" 2>/dev/null && pwd)" || return 1
@@ -667,7 +722,7 @@ retire_agent_skill_root() {
_rgs_backups="$_rgs_stage/backups" _rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
[ -d "$_rgs_victim" ] || continue [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue
if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then
continue continue
fi fi
@@ -687,6 +742,7 @@ link_canonical_skills_to_base() {
_lcs_canonical="$_lcs_root/.agents/skills" _lcs_canonical="$_lcs_root/.agents/skills"
mkdir -p "$_lcs_base" || return 1 mkdir -p "$_lcs_base" || return 1
same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
_lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1
_lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1
_lcs_backups="$_lcs_stage/.backups" _lcs_backups="$_lcs_stage/.backups"
_lcs_published="$_lcs_stage/.published" _lcs_published="$_lcs_stage/.published"
@@ -706,11 +762,13 @@ link_canonical_skills_to_base() {
if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then
continue continue
fi fi
ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; } _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
ln -s "$_lcs_link_target" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_publish_names="$_lcs_publish_names $_lcs_name" _lcs_publish_names="$_lcs_publish_names $_lcs_name"
done done
for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do
[ -d "$_lcs_victim" ] || continue [ -e "$_lcs_victim" ] || [ -L "$_lcs_victim" ] || continue
[ "$_lcs_victim" = "$_lcs_stage" ] && continue [ "$_lcs_victim" = "$_lcs_stage" ] && continue
same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && 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 if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then
@@ -749,25 +807,9 @@ install_skills_to_homes() {
failed=0 failed=0
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi 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; } [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; }
for agent_dir in \ for agent_dir in $(agent_skill_dirs)
".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 do
base_dir="$root/$agent_dir" base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
parent_gate="$(dirname "$base_dir")" parent_gate="$(dirname "$base_dir")"
[ -e "$parent_gate" ] || continue [ -e "$parent_gate" ] || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
@@ -779,8 +821,12 @@ install_skills_to_homes() {
if link_canonical_skills_to_base "$root" "$base_dir" mono; then if link_canonical_skills_to_base "$root" "$base_dir" mono; then
installed=$((installed + 1)) installed=$((installed + 1))
else else
say " ⚠️ $base_dir 无法创建 Skill 链接,回退为直接复制" if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then
if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi fi
done done
if [ "$installed" -eq 0 ]; then if [ "$installed" -eq 0 ]; then
@@ -792,6 +838,10 @@ install_skills_to_homes() {
return 1 return 1
fi fi
rm -f "$SKILL_STATE_ROOT/skills-state.json" rm -f "$SKILL_STATE_ROOT/skills-state.json"
say "✅ DWS Skills 安装完成"
say " 统一安装位置:$root/.agents/skills"
say " 已自动适配本机上检测到的 Agent"
say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
} }
# multi_tree_has_skills returns 0 only when the given multi bundle directory # multi_tree_has_skills returns 0 only when the given multi bundle directory
@@ -822,25 +872,9 @@ install_multi_skills_to_homes() {
failed=0 failed=0
if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi 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; } [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; }
for agent_dir in \ for agent_dir in $(agent_skill_dirs)
".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 do
base_dir="$root/$agent_dir" base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
parent_gate="$(dirname "$base_dir")" parent_gate="$(dirname "$base_dir")"
[ -e "$parent_gate" ] || continue [ -e "$parent_gate" ] || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
@@ -852,8 +886,12 @@ install_multi_skills_to_homes() {
if link_canonical_skills_to_base "$root" "$base_dir" multi; then if link_canonical_skills_to_base "$root" "$base_dir" multi; then
installed=$((installed + 1)) installed=$((installed + 1))
else else
say " ⚠️ $base_dir 无法创建 Skill 链接,回退为直接复制" if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then installed=$((installed + 1)); else failed=$((failed + 1)); fi say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi fi
done done
if [ "$installed" -eq 0 ]; then if [ "$installed" -eq 0 ]; then
@@ -865,6 +903,10 @@ install_multi_skills_to_homes() {
return 1 return 1
fi fi
write_skills_state "$multi_src" "install.sh" || return 1 write_skills_state "$multi_src" "install.sh" || return 1
say "✅ DWS Skills 安装完成"
say " 统一安装位置:$root/.agents/skills"
say " 已自动适配本机上检测到的 Agent"
say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
} }
_install_multi_to_base() { _install_multi_to_base() {
+153 -60
View File
@@ -54,50 +54,92 @@ while [ "$#" -gt 0 ]; do
done done
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")" TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
HOME_SPECIFIC_AGENT_PARENTS=" # vercel-labs/skills agents.ts (c6f69c6), exactly 76 upstream IDs.
.claude # Format: id|universal(1/0)|effective default global root; '-' has no global root.
.cursor UPSTREAM_AGENT_REGISTRY='aider-desk|0|.aider-desk/skills
.qoder amp|1|.config/agents/skills
.qoderwork antigravity|1|.gemini/antigravity/skills
.gemini antigravity-cli|1|.gemini/antigravity-cli/skills
.codex astrbot|0|.astrbot/data/skills
.zcode autohand-code|0|.autohand/skills
.github augment|0|.augment/skills
.windsurf bob|0|.bob/skills
.augment claude-code|0|.claude/skills
.cline openclaw|0|.openclaw/skills
.amp cline|1|.agents/skills
.kiro codearts-agent|0|.codeartsdoer/skills
.trae codebuddy|0|.codebuddy/skills
.openclaw codemaker|0|.codemaker/skills
.hermes codestudio|0|.codestudio/skills
" codex|1|.codex/skills
HOME_SPECIFIC_SKILL_BASES=" command-code|0|.commandcode/skills
.claude/skills continue|0|.continue/skills
.cursor/skills cortex|0|.snowflake/cortex/skills
.qoder/skills crush|0|.config/crush/skills
.qoderwork/skills cursor|1|.cursor/skills
.gemini/skills deepagents|1|.deepagents/agent/skills
.codex/skills devin|0|.config/devin/skills
.zcode/skills dexto|1|.agents/skills
.github/skills droid|0|.factory/skills
.windsurf/skills eve|0|-
.augment/skills firebender|1|.firebender/skills
.cline/skills forgecode|0|.forge/skills
.amp/skills gemini-cli|1|.gemini/skills
.kiro/skills github-copilot|1|.copilot/skills
.trae/skills goose|0|.config/goose/skills
.openclaw/skills grok|0|.grok/skills
.hermes/skills hermes-agent|0|.hermes/skills
" inference-sh|0|.inferencesh/skills
HOME_UNIVERSAL_SKILL_BASES=" jazz|0|.jazz/skills
.cursor/skills junie|0|.junie/skills
.gemini/skills iflow-cli|0|.iflow/skills
.codex/skills kilo|0|.kilocode/skills
.github/skills kimchi|0|.config/kimchi/harness/skills
.cline/skills kimi-code-cli|1|.agents/skills
.amp/skills kiro-cli|0|.kiro/skills
" kode|0|.kode/skills
lingma|0|.lingma/skills
loaf|1|.agents/skills
mcpjam|0|.mcpjam/skills
minimax-code|0|.minimax/skills
mistral-vibe|0|.vibe/skills
moxby|0|.moxby/skills
mux|0|.mux/skills
opencode|1|.config/opencode/skills
openhands|0|.openhands/skills
ona|0|.ona/skills
pi|0|.pi/agent/skills
qoder|0|.qoder/skills
qoder-cn|0|.qoder-cn/skills
qwen-code|0|.qwen/skills
replit|1|.config/agents/skills
reasonix|0|.reasonix/skills
rovodev|0|.rovodev/skills
roo|0|.roo/skills
tabnine-cli|0|.tabnine/agent/skills
terramind|0|.terramind/skills
tinycloud|0|.tinycloud/skills
trae|0|.trae/skills
trae-cn|0|.trae-cn/skills
warp|1|.agents/skills
windsurf|0|.codeium/windsurf/skills
zed|1|.agents/skills
zcode|0|.zcode/skills
zencoder|0|.zencoder/skills
zenflow|0|.zencoder/skills
neovate|0|.neovate/skills
pochi|0|.pochi/skills
promptscript|1|-
adal|0|.adal/skills
universal|1|.config/agents/skills'
# DWS-only compatibility and old wrong global paths. Qoderwork remains a
# non-universal install target; the other entries verify migration cleanup.
LEGACY_AGENT_CLEANUP_REGISTRY='dws-qoderwork|0|.qoderwork/skills
dws-legacy-github|1|.github/skills
dws-legacy-amp|1|.amp/skills
dws-legacy-cline|1|.cline/skills
dws-legacy-windsurf|1|.windsurf/skills'
cleanup() { cleanup() {
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then 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 \ HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
@@ -111,11 +153,41 @@ trap cleanup EXIT INT TERM
seed_specific_agent_homes() { seed_specific_agent_homes() {
home_root="$1" home_root="$1"
for parent in $HOME_SPECIFIC_AGENT_PARENTS; do seen=''
mkdir -p "$home_root/$parent" for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do
id=${row%%|*}; rest=${row#*|}; path=${rest#*|}
[ "$path" != '-' ] || continue
base=$(resolve_agent_base "$home_root" "$id" "$path")
[ -n "$base" ] || continue
key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')
printf '%s\n' "$seen" | grep -Fqx "$key" && continue
seen="${seen}${key}
"
mkdir -p "${base%/skills}"
done done
} }
resolve_agent_base() {
home_root="$1"; id="$2"; default_path="$3"
case "$id" in
autohand-code) printf '%s\n' "${AUTOHAND_HOME:-$home_root/.autohand}/skills" ;;
claude-code) printf '%s\n' "${CLAUDE_CONFIG_DIR:-$home_root/.claude}/skills" ;;
codex) printf '%s\n' "${CODEX_HOME:-$home_root/.codex}/skills" ;;
grok) printf '%s\n' "${GROK_HOME:-$home_root/.grok}/skills" ;;
hermes-agent) printf '%s\n' "${HERMES_HOME:-$home_root/.hermes}/skills" ;;
mistral-vibe) printf '%s\n' "${VIBE_HOME:-$home_root/.vibe}/skills" ;;
amp|replit|universal) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/agents/skills" ;;
crush|devin|goose|kimchi|opencode) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/${default_path#.config/}" ;;
openclaw)
for alias in .openclaw .clawdbot .moltbot; do
if [ -d "$home_root/$alias" ]; then printf '%s\n' "$home_root/$alias/skills"; return; fi
done
printf '%s\n' "$home_root/.openclaw/skills"
;;
*) printf '%s\n' "$home_root/$default_path" ;;
esac
}
verify_skill_base() { verify_skill_base() {
home_root="$1" home_root="$1"
base="$2" base="$2"
@@ -124,30 +196,50 @@ verify_skill_base() {
[ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws" [ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws"
} }
verify_compatible_skill_base() {
home_root="$1"; base="$2"; canonical="$home_root/.agents/skills"
for skill_md in "$canonical"/*/SKILL.md; do
[ -f "$skill_md" ] || continue
name=$(basename "$(dirname "$skill_md")")
target="$base/$name"
if [ -L "$target" ]; then
linked_real="$(CDPATH= cd -- "$target" 2>/dev/null && pwd -P)" || err "broken canonical Skill link at $target"
canonical_real="$(CDPATH= cd -- "$canonical/$name" && pwd -P)" || err "canonical Skill missing at $canonical/$name"
[ "$linked_real" = "$canonical_real" ] || err "Skill link does not resolve to canonical source at $target"
elif [ -d "$target" ]; then
diff -qr "$canonical/$name" "$target" >/dev/null || err "Skill copy fallback differs from canonical source at $target"
else
err "canonical Skill compatibility target missing at $target"
fi
done
[ ! -e "$base/dws" ] && [ ! -L "$base/dws" ] || err "unexpected mono Skill layout found in $base/dws"
}
verify_skill_targets() { verify_skill_targets() {
home_root="$1" home_root="$1"
# lark-cli/skills-compatible canonical store is always present, regardless # lark-cli/skills-compatible canonical store is always present, regardless
# of which concrete Agent homes existed at install time. # of which concrete Agent homes existed at install time.
verify_skill_base "$home_root" ".agents/skills" verify_skill_base "$home_root" ".agents/skills"
for base in $HOME_SPECIFIC_SKILL_BASES; do seen=''
parent="${base%/skills}" for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do
[ -d "$home_root/$parent" ] || continue id=${row%%|*}; rest=${row#*|}; universal=${rest%%|*}; path=${rest#*|}
universal=0 [ "$path" != '-' ] || continue
for universal_base in $HOME_UNIVERSAL_SKILL_BASES; do base=$(resolve_agent_base "$home_root" "$id" "$path")
[ "$base" = "$universal_base" ] && universal=1 [ -n "$base" ] || continue
done key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')
printf '%s\n' "$seen" | grep -Fqx "$key" && continue
seen="${seen}${key}
"
[ "$base" != "$home_root/.agents/skills" ] || continue
[ -d "${base%/skills}" ] || continue
if [ "$universal" -eq 1 ]; then if [ "$universal" -eq 1 ]; then
for name in dingtalk-shared dingtalk-misc dws; do for name in dingtalk-shared dingtalk-misc dws; do
[ ! -e "$home_root/$base/$name" ] || \ { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \
err "unexpected duplicate Skill found in universal Agent root $home_root/$base/$name" err "unexpected duplicate Skill found in universal Agent root $base/$name"
done done
continue continue
fi fi
verify_skill_base "$home_root" "$base" verify_compatible_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 done
} }
@@ -189,6 +281,7 @@ verify_npm() {
need_cmd node need_cmd node
need_cmd tar need_cmd tar
need_cmd unzip need_cmd unzip
need_cmd diff
need_file "$NPM_STAGE_DIR/package.json" need_file "$NPM_STAGE_DIR/package.json"
tarball_name="$( tarball_name="$(
+258
View File
@@ -0,0 +1,258 @@
package scripts_test
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"testing"
)
type packagedAgent struct {
universal bool
path string
}
var upstreamAgentIDs = strings.Fields(`
aider-desk amp antigravity antigravity-cli astrbot autohand-code augment bob
claude-code openclaw cline codearts-agent codebuddy codemaker codestudio codex
command-code continue cortex crush cursor deepagents devin dexto droid eve
firebender forgecode gemini-cli github-copilot goose grok hermes-agent inference-sh
jazz junie iflow-cli kilo kimchi kimi-code-cli kiro-cli kode lingma loaf mcpjam
minimax-code mistral-vibe moxby mux opencode openhands ona pi qoder qoder-cn
qwen-code replit reasonix rovodev roo tabnine-cli terramind tinycloud trae trae-cn
warp windsurf zed zcode zencoder zenflow neovate pochi promptscript adal universal
`)
func TestAgentRegistriesMatchUpstream76(t *testing.T) {
t.Parallel()
root := filepath.Join("..", "..")
installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1"))
devappPS := readTestFile(t, filepath.Join(root, "scripts", "install-devapp.ps1"))
verifier := readTestFile(t, filepath.Join(root, "scripts", "release", "verify-package-managers.sh"))
wantIDs := append([]string(nil), upstreamAgentIDs...)
sort.Strings(wantIDs)
registries := map[string]map[string]packagedAgent{
"install.ps1": parseInstallPowerShellRegistry(t, installPS),
"install-devapp.ps1": parsePipeRegistry(t, between(t, devappPS, "$AgentRegistryRows = @(", ")\n$LegacyAgentCleanupRows")),
"verifier": parsePipeRegistry(t, between(t, verifier, "UPSTREAM_AGENT_REGISTRY='", "'\n\n# DWS-only")),
}
for name, registry := range registries {
gotIDs := make([]string, 0, len(registry))
universal := 0
for id, agent := range registry {
gotIDs = append(gotIDs, id)
if agent.universal {
universal++
}
}
sort.Strings(gotIDs)
if strings.Join(gotIDs, "\n") != strings.Join(wantIDs, "\n") {
t.Fatalf("%s agent IDs differ from upstream 76\ngot: %v\nwant: %v", name, gotIDs, wantIDs)
}
if universal != 19 || len(registry)-universal != 57 {
t.Fatalf("%s classification = %d universal/%d non-universal, want 19/57", name, universal, len(registry)-universal)
}
}
canonical := registries["install.ps1"]
for name, got := range registries {
for id, want := range canonical {
if got[id] != want {
t.Errorf("%s[%s] = %+v, want %+v", name, id, got[id], want)
}
}
}
for id, wantPath := range map[string]string{
"amp": ".config/agents/skills",
"github-copilot": ".copilot/skills",
"opencode": ".config/opencode/skills",
"windsurf": ".codeium/windsurf/skills",
"cline": ".agents/skills",
"eve": "-",
"promptscript": "-",
} {
if got := canonical[id].path; got != wantPath {
t.Errorf("upstream path for %s = %q, want %q", id, got, wantPath)
}
}
}
func TestDWSCompatibilityAgentTargets(t *testing.T) {
t.Parallel()
root := filepath.Join("..", "..")
installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1"))
legacyBlock := between(t, installPS, "$LegacyAgentCleanupTargets = @(", ")\n\n# Kept as")
if !strings.Contains(legacyBlock, `Id = "dws-qoderwork"; Universal = $false`) {
t.Error("DWS Qoderwork compatibility target must remain non-universal")
}
for _, id := range []string{
"dws-legacy-github", "dws-legacy-amp", "dws-legacy-cline", "dws-legacy-windsurf",
} {
if !strings.Contains(legacyBlock, `Id = "`+id+`"; Universal = $true`) {
t.Errorf("legacy cleanup registry missing cleanup-only target %s", id)
}
}
for _, id := range upstreamAgentIDs {
if strings.HasPrefix(id, "dws-") {
t.Fatalf("DWS compatibility target %s leaked into upstream registry", id)
}
}
}
func TestInstallPowerShellUsesAbsoluteJunctionTargetsAndCopyFallback(t *testing.T) {
t.Parallel()
installPS := readTestFile(t, filepath.Join("..", "..", "scripts", "install.ps1"))
for _, want := range []string{
`$absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))`,
`New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget`,
`Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir`,
`Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir`,
} {
if !strings.Contains(installPS, want) {
t.Errorf("install.ps1 missing Windows junction/fallback contract %q", want)
}
}
}
func TestInstallPowerShellResolvesCustomAndLegacyAgentHomes(t *testing.T) {
pwsh, err := exec.LookPath("pwsh")
if err != nil {
if runtime.GOOS == "windows" {
pwsh, err = exec.LookPath("powershell")
}
if err != nil {
t.Skip("PowerShell is not available")
}
}
root := filepath.Join("..", "..")
installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1"))
cut := strings.LastIndex(installPS, "# ── Main")
if cut < 0 {
t.Fatal("install.ps1 main section not found")
}
home := t.TempDir()
custom := filepath.Join(t.TempDir(), "custom roots")
xdg := filepath.Join(custom, "xdg")
clawdbot := filepath.Join(home, ".clawdbot")
if err := os.MkdirAll(clawdbot, 0o755); err != nil {
t.Fatal(err)
}
prefix := strings.ReplaceAll(installPS[:cut], "$HOME", "$env:DWS_TEST_HOME")
prefix += `
$ids = @("amp", "autohand-code", "claude-code", "codex", "grok", "hermes-agent", "mistral-vibe", "openclaw", "goose", "kimchi", "opencode")
foreach ($id in $ids) {
$agent = $AgentRegistry | Where-Object { $_.Id -eq $id } | Select-Object -First 1
Write-Output ($id + "|" + (Resolve-AgentSkillBase -Root $env:DWS_TEST_HOME -Agent $agent))
}
exit 0
`
harness := filepath.Join(t.TempDir(), "resolve-agent-roots.ps1")
mustWriteFile(t, harness, []byte(prefix), 0o644)
envRoots := map[string]string{
"AUTOHAND_HOME": filepath.Join(custom, "autohand"),
"CLAUDE_CONFIG_DIR": filepath.Join(custom, "claude"),
"CODEX_HOME": filepath.Join(custom, "codex"),
"GROK_HOME": filepath.Join(custom, "grok"),
"HERMES_HOME": filepath.Join(custom, "hermes"),
"VIBE_HOME": filepath.Join(custom, "vibe"),
"XDG_CONFIG_HOME": xdg,
}
cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harness)
cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home)
for key, value := range envRoots {
cmd.Env = append(cmd.Env, key+"="+value)
}
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("PowerShell root resolver failed: %v\n%s", err, output)
}
want := map[string]string{
"amp": filepath.Join(xdg, "agents", "skills"),
"autohand-code": filepath.Join(envRoots["AUTOHAND_HOME"], "skills"),
"claude-code": filepath.Join(envRoots["CLAUDE_CONFIG_DIR"], "skills"),
"codex": filepath.Join(envRoots["CODEX_HOME"], "skills"),
"grok": filepath.Join(envRoots["GROK_HOME"], "skills"),
"hermes-agent": filepath.Join(envRoots["HERMES_HOME"], "skills"),
"mistral-vibe": filepath.Join(envRoots["VIBE_HOME"], "skills"),
"openclaw": filepath.Join(clawdbot, "skills"),
"goose": filepath.Join(xdg, "goose", "skills"),
"kimchi": filepath.Join(xdg, "kimchi", "harness", "skills"),
"opencode": filepath.Join(xdg, "opencode", "skills"),
}
got := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
parts := strings.SplitN(strings.TrimSpace(line), "|", 2)
if len(parts) == 2 {
got[parts[0]] = filepath.Clean(parts[1])
}
}
for id, path := range want {
if got[id] != filepath.Clean(path) {
t.Errorf("resolved %s = %q, want %q\noutput:\n%s", id, got[id], path, output)
}
}
}
func readTestFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
return string(b)
}
func between(t *testing.T, text, start, end string) string {
t.Helper()
i := strings.Index(text, start)
if i < 0 {
t.Fatalf("start marker %q missing", start)
}
i += len(start)
j := strings.Index(text[i:], end)
if j < 0 {
t.Fatalf("end marker %q missing", end)
}
return text[i : i+j]
}
func parseInstallPowerShellRegistry(t *testing.T, text string) map[string]packagedAgent {
t.Helper()
block := between(t, text, "$AgentRegistry = @(", ")\n\n# DWS compatibility targets")
re := regexp.MustCompile(`Id = "([^"]+)"; Universal = \$(true|false); Dir = (?:"([^"]+)"|\$null)`)
result := make(map[string]packagedAgent)
for _, match := range re.FindAllStringSubmatch(block, -1) {
path := strings.ReplaceAll(match[3], `\`, "/")
if path == "" {
path = "-"
}
result[match[1]] = packagedAgent{universal: match[2] == "true", path: path}
}
return result
}
func parsePipeRegistry(t *testing.T, block string) map[string]packagedAgent {
t.Helper()
re := regexp.MustCompile(`([a-z0-9-]+)\|([01])\|([.a-z0-9_\\/-]+)`)
result := make(map[string]packagedAgent)
for _, match := range re.FindAllStringSubmatch(block, -1) {
path := strings.ReplaceAll(match[3], `\`, "/")
if _, exists := result[match[1]]; exists {
t.Fatalf("duplicate agent %q in pipe registry", match[1])
}
result[match[1]] = packagedAgent{universal: match[2] == "1", path: path}
}
return result
}
+58 -2
View File
@@ -62,10 +62,20 @@ const repoRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)),
const installJsSource = path.join(repoRoot, "build", "npm", "install.js"); const installJsSource = path.join(repoRoot, "build", "npm", "install.js");
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { const {
UPSTREAM_AGENTS,
resolvedAgentTargets,
publishCacheAtomically, publishCacheAtomically,
publishManagedMonoSkillSetAtomically, publishManagedMonoSkillSetAtomically,
publishManagedMultiSkillSetAtomically, publishManagedMultiSkillSetAtomically,
} = require(installJsSource); } = require(installJsSource);
assert.equal(UPSTREAM_AGENTS.length, 76, "the complete upstream Agent registry is pinned");
assert.equal(new Set(UPSTREAM_AGENTS.map(({ id }) => id)).size, 76, "upstream Agent IDs are unique");
assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => universal).length, 19, "upstream universal classification is pinned");
assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => !universal).length, 57, "upstream non-universal classification is pinned");
assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === null).length, 2, "no-global Agents are retained in the registry");
assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === ".agents/skills").length, 6, "canonical-direct Agents need no target");
assert.equal(resolvedAgentTargets(path.join(os.tmpdir(), "dws-registry-home")).length, 70, "65 upstream roots, qoderwork, and 4 migration roots are deduplicated");
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`]; const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
if (process.platform === "win32" || !assetName) { if (process.platform === "win32" || !assetName) {
@@ -133,8 +143,8 @@ function stagePkg(zipEntries, emptyDirs = []) {
return { tmp, pkg, home: path.join(tmp, "home") }; return { tmp, pkg, home: path.join(tmp, "home") };
} }
function runInstall(pkg, home, skillMode) { function runInstall(pkg, home, skillMode, extraEnv = {}) {
const env = { ...process.env, HOME: home }; const env = { ...process.env, HOME: home, ...extraEnv };
if (skillMode !== undefined) { if (skillMode !== undefined) {
env.DWS_SKILL_MODE = skillMode; env.DWS_SKILL_MODE = skillMode;
} else { } else {
@@ -255,6 +265,52 @@ scenario("ZCode links its Agent root to the canonical store", () => {
} }
}); });
scenario("upstream Agent roots honor XDG and custom homes with relative links", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
});
const xdg = path.join(tmp, "xdg config");
const autohand = path.join(tmp, "autohand home");
const codex = path.join(tmp, "codex home");
try {
writeFile(path.join(xdg, "goose", "config.yaml"), "enabled: true\n");
writeFile(path.join(xdg, "agents", "detection.marker"), "amp\n");
writeFile(path.join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), "old amp copy\n");
writeFile(path.join(autohand, "config.json"), "{}\n");
writeFile(path.join(codex, "config.toml"), "model=test\n");
writeFile(path.join(codex, "skills", "dingtalk-chat", "SKILL.md"), "old codex copy\n");
writeFile(path.join(home, ".qoderwork", "config.json"), "{}\n");
writeFile(path.join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), "old DWS path\n");
const res = runInstall(pkg, home, "multi", {
XDG_CONFIG_HOME: xdg,
AUTOHAND_HOME: autohand,
CODEX_HOME: codex,
});
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
for (const linked of [
path.join(xdg, "goose", "skills", "dingtalk-chat"),
path.join(autohand, "skills", "dingtalk-chat"),
path.join(home, ".qoderwork", "skills", "dingtalk-chat"),
]) {
assert.ok(fs.lstatSync(linked).isSymbolicLink(), `${linked} is linked`);
assert.ok(!path.isAbsolute(fs.readlinkSync(linked)), `${linked} uses a relative link`);
assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n");
}
for (const retired of [
path.join(xdg, "agents", "skills", "dingtalk-chat"),
path.join(codex, "skills", "dingtalk-chat"),
path.join(home, ".amp", "skills", "dingtalk-chat"),
]) {
assert.ok(!fs.existsSync(retired), `legacy/universal duplicate retired: ${retired}`);
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("empty multi/ tree falls back to mono and keeps the old multi cache", () => { scenario("empty multi/ tree falls back to mono and keeps the old multi cache", () => {
const { tmp, pkg, home } = stagePkg({ const { tmp, pkg, home } = stagePkg({
"SKILL.md": "# mono root copy\n", "SKILL.md": "# mono root copy\n",
+158 -11
View File
@@ -381,17 +381,23 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) {
} }
expectedSkills := map[string]string{ expectedSkills := map[string]string{
".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o",
".codex/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite",
".config/opencode/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing",
".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o",
".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", ".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o",
".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", ".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite",
".codex/skills/dws/SKILL.md": "user_im_message_receive_o2o", ".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing",
".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o",
".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite", }
".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing", for _, duplicate := range []string{
".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", filepath.Join(fakeHome, ".codex", "skills", "dingtalk-event"),
filepath.Join(fakeHome, ".config", "opencode", "skills", "dingtalk-event"),
filepath.Join(fakeHome, ".codex", "skills", "dws"),
} {
if _, err := os.Lstat(duplicate); !os.IsNotExist(err) {
t.Fatalf("universal Agent duplicate remains at %s: %v", duplicate, err)
}
} }
for rel, marker := range expectedSkills { for rel, marker := range expectedSkills {
p := filepath.Join(fakeHome, filepath.FromSlash(rel)) p := filepath.Join(fakeHome, filepath.FromSlash(rel))
@@ -1343,6 +1349,147 @@ func TestInstallerShellLinksZCodeRootToCanonical(t *testing.T) {
} }
} }
func TestInstallerShellUsesUpstreamXDGAndCustomAgentRoots(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))
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
cut := strings.LastIndex(string(data), "\nmain\n")
if cut < 0 {
t.Fatalf("%s final main invocation not found", scriptName)
}
library := filepath.Join(t.TempDir(), scriptName+"-lib.sh")
mustWriteFile(t, library, data[:cut], 0o755)
home := t.TempDir()
tmp := t.TempDir()
xdg := filepath.Join(tmp, "xdg config")
autohand := filepath.Join(tmp, "autohand home")
codex := filepath.Join(tmp, "codex home")
source := filepath.Join(tmp, "multi")
mustWriteFile(t, filepath.Join(source, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644)
mustWriteFile(t, filepath.Join(xdg, "goose", "config.yaml"), []byte("enabled: true\n"), 0o644)
mustWriteFile(t, filepath.Join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), []byte("old amp copy\n"), 0o644)
mustWriteFile(t, filepath.Join(autohand, "config.json"), []byte("{}\n"), 0o644)
mustWriteFile(t, filepath.Join(codex, "config.toml"), []byte("model=test\n"), 0o644)
mustWriteFile(t, filepath.Join(codex, "skills", "dingtalk-chat", "SKILL.md"), []byte("old codex copy\n"), 0o644)
mustWriteFile(t, filepath.Join(home, ".qoderwork", "config.json"), []byte("{}\n"), 0o644)
mustWriteFile(t, filepath.Join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), []byte("old DWS path\n"), 0o644)
installCall := `install_multi_skills_to_homes "$DWS_TEST_SOURCE"`
if scriptName == "install-skills.sh" {
installCall = `install_multi_skills_to_root "$DWS_TEST_SOURCE" "$HOME"`
}
cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"
`+installCall+`
`)
cmd.Env = append(os.Environ(),
"HOME="+home,
"DWS_TEST_LIBRARY="+library,
"DWS_TEST_SOURCE="+source,
"XDG_CONFIG_HOME="+xdg,
"AUTOHAND_HOME="+autohand,
"CODEX_HOME="+codex,
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s upstream-root harness failed: %v\n%s", scriptName, err, output)
}
for _, linked := range []string{
filepath.Join(xdg, "goose", "skills", "dingtalk-chat"),
filepath.Join(autohand, "skills", "dingtalk-chat"),
filepath.Join(home, ".qoderwork", "skills", "dingtalk-chat"),
} {
info, err := os.Lstat(linked)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("%s is not a canonical symlink: %#v, %v", linked, info, err)
}
target, err := os.Readlink(linked)
if err != nil || filepath.IsAbs(target) {
t.Fatalf("%s link must be relative: target=%q err=%v", linked, target, err)
}
}
for _, retired := range []string{
filepath.Join(xdg, "agents", "skills", "dingtalk-chat"),
filepath.Join(codex, "skills", "dingtalk-chat"),
filepath.Join(home, ".amp", "skills", "dingtalk-chat"),
} {
if _, err := os.Lstat(retired); !os.IsNotExist(err) {
t.Fatalf("%s universal/legacy duplicate remains: %v", retired, err)
}
}
})
}
}
func TestInstallerShellPinsCompleteUpstreamAgentRegistry(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))
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
cut := strings.LastIndex(string(data), "\nmain\n")
if cut < 0 {
t.Fatalf("%s final main invocation not found", scriptName)
}
library := filepath.Join(t.TempDir(), scriptName+"-lib.sh")
mustWriteFile(t, library, data[:cut], 0o755)
cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"
upstream_agent_registry
`)
cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s registry failed: %v\n%s", scriptName, err, output)
}
lines := strings.Fields(strings.TrimSpace(string(output)))
if len(lines) != 76 {
t.Fatalf("%s registry has %d Agents, want 76", scriptName, len(lines))
}
ids := map[string]bool{}
roots := map[string]bool{}
universal, nonUniversal, noGlobal, canonicalDirect := 0, 0, 0, 0
for _, line := range lines {
parts := strings.Split(line, "|")
if len(parts) != 3 || ids[parts[0]] {
t.Fatalf("%s invalid or duplicate registry record %q", scriptName, line)
}
ids[parts[0]] = true
switch parts[1] {
case "U":
universal++
case "N":
nonUniversal++
default:
t.Fatalf("%s invalid classification in %q", scriptName, line)
}
switch parts[2] {
case "-":
noGlobal++
case ".agents/skills":
canonicalDirect++
default:
roots[parts[2]] = true
}
}
if universal != 19 || nonUniversal != 57 || noGlobal != 2 || canonicalDirect != 6 || len(roots) != 65 {
t.Fatalf("%s registry classification U=%d N=%d no-global=%d canonical=%d roots=%d", scriptName, universal, nonUniversal, noGlobal, canonicalDirect, len(roots))
}
})
}
}
func TestInstallPowerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { func TestInstallPowerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) {
pwsh, err := exec.LookPath("pwsh") pwsh, err := exec.LookPath("pwsh")
if err != nil { if err != nil {
+104 -3
View File
@@ -30,6 +30,10 @@ var expectedPackagedSkillTargets = []string{
".augment/skills/dingtalk-shared", ".augment/skills/dingtalk-shared",
".cline/skills/dingtalk-shared", ".cline/skills/dingtalk-shared",
".amp/skills/dingtalk-shared", ".amp/skills/dingtalk-shared",
".copilot/skills/dingtalk-shared",
".config/opencode/skills/dingtalk-shared",
".config/agents/skills/dingtalk-shared",
".codeium/windsurf/skills/dingtalk-shared",
".kiro/skills/dingtalk-shared", ".kiro/skills/dingtalk-shared",
".trae/skills/dingtalk-shared", ".trae/skills/dingtalk-shared",
".openclaw/skills/dingtalk-shared", ".openclaw/skills/dingtalk-shared",
@@ -70,9 +74,12 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line") t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line")
} }
for _, want := range []string{ for _, want := range []string{
"HOME_SPECIFIC_SKILL_BASES=", "UPSTREAM_AGENT_REGISTRY=",
`$base/dingtalk-shared/SKILL.md`, "verify_compatible_skill_base",
`$base/dingtalk-misc/SKILL.md`, `diff -qr "$canonical/$name" "$target"`,
"broken canonical Skill link",
"Skill link does not resolve to canonical source",
"Skill copy fallback differs from canonical source",
"unexpected mono Skill layout", "unexpected mono Skill layout",
`verify_npm_install "$tarball_path" "specific-agent-roots"`, `verify_npm_install "$tarball_path" "specific-agent-roots"`,
`verify_npm_install "$tarball_path" "generic-fallback"`, `verify_npm_install "$tarball_path" "generic-fallback"`,
@@ -140,6 +147,100 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T)
} }
} }
func TestPackageManagerVerifierAcceptsOnlyCorrectLinksOrCopies(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh"))
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
text := string(data)
cut := strings.Index(text, "\nverify_npm_install()")
if cut < 0 {
t.Fatal("verify-package-managers.sh helper boundary missing")
}
library := filepath.Join(t.TempDir(), "verifier-lib.sh")
mustWriteFile(t, library, data[:cut], 0o755)
for _, tc := range []struct {
name string
prepare func(t *testing.T, home, base string)
wantOK bool
}{
{
name: "correct-link",
prepare: func(t *testing.T, home, base string) {
t.Helper()
if err := os.Symlink(filepath.Join(home, ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil {
t.Fatal(err)
}
},
wantOK: true,
},
{
name: "complete-copy",
prepare: func(t *testing.T, _, base string) {
t.Helper()
mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644)
},
wantOK: true,
},
{
name: "wrong-link",
prepare: func(t *testing.T, home, base string) {
t.Helper()
wrong := filepath.Join(home, "wrong", "dingtalk-shared")
mustWriteFile(t, filepath.Join(wrong, "SKILL.md"), []byte("canonical\n"), 0o644)
if err := os.Symlink(wrong, filepath.Join(base, "dingtalk-shared")); err != nil {
t.Fatal(err)
}
},
wantOK: false,
},
{
name: "broken-link",
prepare: func(t *testing.T, home, base string) {
t.Helper()
if err := os.Symlink(filepath.Join(home, "missing"), filepath.Join(base, "dingtalk-shared")); err != nil {
t.Fatal(err)
}
},
wantOK: false,
},
{
name: "different-copy",
prepare: func(t *testing.T, _, base string) {
t.Helper()
mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("different\n"), 0o644)
},
wantOK: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".claude", "skills")
mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644)
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
tc.prepare(t, home, base)
cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"; verify_compatible_skill_base "$DWS_TEST_HOME" "$DWS_TEST_BASE"`)
cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library, "DWS_TEST_HOME="+home, "DWS_TEST_BASE="+base)
output, err := cmd.CombinedOutput()
if tc.wantOK && err != nil {
t.Fatalf("verifier rejected valid target: %v\n%s", err, output)
}
if !tc.wantOK && err == nil {
t.Fatalf("verifier accepted invalid target\n%s", output)
}
})
}
}
func seedDistArchive(t *testing.T, path string) { func seedDistArchive(t *testing.T, path string) {
t.Helper() t.Helper()
file, err := os.Create(path) file, err := os.Create(path)