fix(skills): complete canonical agent compatibility
This commit is contained in:
1 parent
44d640bbae
commit
79f7ee80e0
25 files changed
+2875
-422
No files matched your search
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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="$(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user