From 79f7ee80e0e93edb069f3ef0c3a8313f67243a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 20:21:56 +0800 Subject: [PATCH] fix(skills): complete canonical agent compatibility --- .changes/996-canonical-agent-skills.md | 5 + build/npm/install.js | 174 +++++--- docs/rfc-skill-installation-and-upgrade.md | 24 +- internal/app/skill_command.go | 131 ++++-- internal/app/skill_command_test.go | 6 +- internal/app/skill_setup.go | 298 ++++++++++++-- .../skill_setup_canonical_failures_test.go | 115 ++++++ internal/app/skill_setup_canonical_test.go | 268 ++++++++++++- internal/app/skill_setup_plan_test.go | 4 +- internal/app/skill_setup_test.go | 3 +- internal/upgrade/canonical_skills_test.go | 374 +++++++++++++++++- internal/upgrade/coverage_edges_test.go | 5 +- internal/upgrade/paths.go | 258 ++++++++++-- internal/upgrade/paths_multi_test.go | 18 +- scripts/install-devapp.ps1 | 117 +++++- scripts/install-devapp.sh | 95 ++++- scripts/install-event.sh | 109 +++-- scripts/install-skills.sh | 132 ++++--- scripts/install.ps1 | 218 ++++++++-- scripts/install.sh | 136 ++++--- scripts/release/verify-package-managers.sh | 213 +++++++--- test/scripts/agent_registry_test.go | 258 ++++++++++++ test/scripts/install_js_smoke.mjs | 60 ++- test/scripts/install_script_test.go | 169 +++++++- test/scripts/package_script_test.go | 107 ++++- 25 files changed, 2875 insertions(+), 422 deletions(-) create mode 100644 .changes/996-canonical-agent-skills.md create mode 100644 internal/app/skill_setup_canonical_failures_test.go create mode 100644 test/scripts/agent_registry_test.go diff --git a/.changes/996-canonical-agent-skills.md b/.changes/996-canonical-agent-skills.md new file mode 100644 index 00000000..c42462e8 --- /dev/null +++ b/.changes/996-canonical-agent-skills.md @@ -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. diff --git a/build/npm/install.js b/build/npm/install.js index 8a7e94d4..e43d29fc 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -8,37 +8,106 @@ const os = require("os"); const path = require("path"); const childProcess = require("child_process"); -// Canonical list: keep scripts/install.sh, scripts/install.ps1, scripts/install-skills.sh in sync. -const AGENT_DIRS = [ - ".agents/skills", - ".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", +// Exact vercel-labs/skills registry at the reference revision: 76 Agent IDs, +// including canonical-direct and no-global-directory entries. The resolver +// below intentionally deduplicates shared effective roots only after this +// authoritative enumeration has been classified. +const UPSTREAM_AGENTS = [ + ["aider-desk", false, ".aider-desk/skills"], + ["amp", true, ".config/agents/skills"], + ["antigravity", true, ".gemini/antigravity/skills"], + ["antigravity-cli", true, ".gemini/antigravity-cli/skills"], + ["astrbot", false, ".astrbot/data/skills"], + ["autohand-code", false, ".autohand/skills"], + ["augment", false, ".augment/skills"], ["bob", false, ".bob/skills"], + ["claude-code", false, ".claude/skills"], ["openclaw", false, ".openclaw/skills"], + ["cline", true, ".agents/skills"], ["codearts-agent", false, ".codeartsdoer/skills"], + ["codebuddy", false, ".codebuddy/skills"], ["codemaker", false, ".codemaker/skills"], + ["codestudio", false, ".codestudio/skills"], ["codex", true, ".codex/skills"], + ["command-code", false, ".commandcode/skills"], ["continue", false, ".continue/skills"], + ["cortex", false, ".snowflake/cortex/skills"], ["crush", false, ".config/crush/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 -// Agents read ~/.agents/skills directly and must not receive a second copy. -const UNIVERSAL_AGENT_DIRS = new Set([ - ".cursor/skills", - ".gemini/skills", - ".codex/skills", - ".github/skills", - ".cline/skills", - ".amp/skills", -]); +function openClawSkillsDir(homeDir) { + for (const name of [".openclaw", ".clawdbot", ".moltbot"]) { + const root = path.join(homeDir, name); + if (fs.existsSync(root)) return path.join(root, "skills"); + } + return path.join(homeDir, ".openclaw", "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 = { "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; throw err; } - if (!info.isDirectory() && !info.isSymbolicLink()) { - return true; - } const rel = path.relative(homeDir, dir); const name = rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel) @@ -279,11 +345,11 @@ function installSkillsToHomes(skillRoot) { } if (installed > 0) { - for (const agentDir of AGENT_DIRS.slice(1)) { - const baseDir = path.join(homeDir, agentDir); + for (const target of resolvedAgentTargets(homeDir)) { + const { agentDir, baseDir, universal } = target; if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; - if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { + if (universal) { try { retireManagedSkillRoot(homeDir, baseDir, managedNames); } catch (err) { @@ -304,9 +370,10 @@ function installSkillsToHomes(skillRoot) { publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims); installed += 1; } catch (linkErr) { - console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); - if (installToBase(baseDir)) installed += 1; - else failed += 1; + if (installToBase(baseDir)) { + console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`); + installed += 1; + } else failed += 1; } } } @@ -317,6 +384,10 @@ function installSkillsToHomes(skillRoot) { throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`); } 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 @@ -402,7 +473,9 @@ function retireManagedSkillRoot(homeDir, baseDir, managedNames) { function samePhysicalDir(left, right) { 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 (_) { return false; } @@ -410,6 +483,7 @@ function samePhysicalDir(left, right) { function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims) { fs.mkdirSync(baseDir, { recursive: true }); + const realBaseDir = fs.realpathSync(baseDir); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); const staged = []; const backups = []; @@ -433,7 +507,8 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, continue; } 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"); staged.push({ staged: stagedPath, dest: path.join(baseDir, name) }); } @@ -742,11 +817,11 @@ function installMultiSkillsToHomes(multiRoot) { else failed += 1; if (installed > 0) { - for (const agentDir of AGENT_DIRS.slice(1)) { - const baseDir = path.join(homeDir, agentDir); + for (const target of resolvedAgentTargets(homeDir)) { + const { agentDir, baseDir, universal } = target; if (!fs.existsSync(path.dirname(baseDir)) || samePhysicalDir(baseDir, canonicalBase)) continue; attempted += 1; - if (UNIVERSAL_AGENT_DIRS.has(agentDir)) { + if (universal) { try { retireManagedSkillRoot(homeDir, baseDir, managedNames); } catch (err) { @@ -770,9 +845,10 @@ function installMultiSkillsToHomes(multiRoot) { publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims); installed += 1; } catch (linkErr) { - console.warn(`⚠️ ${baseDir} 无法创建 Skill 链接,回退为直接复制: ${linkErr.message}`); - if (installToBase(baseDir)) installed += 1; - else failed += 1; + if (installToBase(baseDir)) { + console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`); + installed += 1; + } else failed += 1; } } } @@ -783,6 +859,10 @@ function installMultiSkillsToHomes(multiRoot) { throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`); } 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) @@ -897,6 +977,8 @@ if (require.main === module) { } module.exports = { + UPSTREAM_AGENTS, + resolvedAgentTargets, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index a33bc95f..bad9f3f9 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约 | 模式 | Agent 目录布局 | 选择方式 | |---|---|---| -| multi(默认) | `/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` | -| mono(兼容) | `/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in | +| multi(默认) | canonical `~/.agents/skills/dingtalk-*/`;非 universal Agent 使用链接或复制兼容层 | 默认;`dws skill setup --mode multi` | +| mono(兼容) | canonical `~/.agents/skills/dws/`;非 universal Agent 使用链接或复制兼容层 | `dws skill setup --mode mono` 或安装器的 mono opt-in | 模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装 mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、 @@ -140,10 +140,21 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen ## 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 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill; +Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts`(基准提交 +`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; 2. 在目标文件系统的 staging 中复制完整新集合; @@ -192,6 +203,7 @@ setup 在未显式指定 `--source` 时的本地回退缓存。 | `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 | | `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 | | npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 | +| `scripts/install-event.sh` / `install-devapp.*` | 产品 multi 子集 | 同样使用 canonical 与 Agent 兼容层 | Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。 @@ -209,6 +221,8 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行 - 复制失败不留下 Agent 可见的残缺官方目录; - 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill; - Windows、macOS、Linux 的路径和覆盖率门禁; +- symlinked parent、Windows junction、链接失败复制回退与 broken link 修复; +- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级; - npm、Shell、PowerShell 与包管理器安装冒烟。 ## 13. 后续演进 diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index 8db71d07..e43484f6 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -117,38 +117,114 @@ type CliSkillDTO struct { // (skill_setup.go) MUST have a matching path value here — enforced by // TestAgentSkillPathsCoversSetupHomes. var agentSkillPaths = map[string]string{ - // `agents` is the generic-agent sentinel: install scripts and `setup` - // special-case ~/.agents/skills as a no-checks-required fallback so a - // fresh machine without any IDE/agent registry still gets skills. - "agents": ".agents/skills", - "qoder": ".qoder/skills", - "qoderwork": ".qoderwork/skills", + // Universal agents. Those without an independent global directory map + // directly to the canonical ~/.agents/skills store. + "agents": ".agents/skills", + "amp": filepath.Join(".config", "agents", "skills"), + "antigravity": ".gemini/antigravity/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", - "cursor": ".cursor/skills", - "codex": ".codex/skills", - "zcode": ".zcode/skills", - "opencode": filepath.Join(".config", "opencode", "skills"), - // IDE / agent registries also probed by `dws skill setup --target all`. - "gemini": ".gemini/skills", - "github": ".github/skills", - "windsurf": ".windsurf/skills", - "augment": ".augment/skills", - "cline": ".cline/skills", - "amp": ".amp/skills", - "kiro": ".kiro/skills", - "trae": ".trae/skills", - "openclaw": ".openclaw/skills", - "hermes": ".hermes/skills", + "gemini": ".gemini/skills", + "github": ".copilot/skills", + "hermes": ".hermes/skills", + "kiro": ".kiro/skills", + "qoderwork": ".qoderwork/skills", +} + +// Eve has project-scoped Skill directories but no upstream globalSkillsDir. +// Keep it in the advertised enumeration while failing explicitly instead of +// pretending that a global install configured Eve. +var unsupportedGlobalAgentTargets = map[string]string{ + "eve": "Eve 不支持全局 Skill 安装,请在 Eve 项目内配置 agent/skills", + "promptscript": "PromptScript 不支持全局 Skill 安装,请在项目内使用 .agents/skills", } // supportedTargets returns a sorted, comma-separated list of supported // targets. Sorted so help text and error messages stay stable across runs // (Go map iteration order is intentionally randomized). func supportedTargets() string { - targets := make([]string, 0, len(agentSkillPaths)+1) + targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+1) for target := range agentSkillPaths { targets = append(targets, target) } + for target := range unsupportedGlobalAgentTargets { + targets = append(targets, target) + } sort.Strings(targets) targets = append(targets, ".") return strings.Join(targets, ", ") @@ -485,8 +561,13 @@ func resolveSkillTargetPath(target string) (string, error) { return os.Getwd() } - // Look up predefined agent paths - relPath, ok := agentSkillPaths[strings.ToLower(target)] + target = strings.ToLower(target) + if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported { + return "", errors.New(reason) + } + + // Look up predefined agent paths. + _, ok := agentSkillPaths[target] if !ok { 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 filepath.Join(homeDir, relPath), nil + return resolveSkillSetupBase(homeDir, target), nil } // fetchSkillDownloadInfo calls the download API to get the skill download URL. diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 381c87bc..4fcdb38d 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -477,8 +477,12 @@ func TestAgentSkillPathsCoversSetupHomes(t *testing.T) { for _, p := range agentSkillPaths { paths[p] = true } + legacyCleanupOnly := map[string]bool{ + ".github/skills": true, ".windsurf/skills": true, + ".cline/skills": true, ".amp/skills": true, + } for _, home := range skillSetupAgentHomes { - if !paths[home] { + if !paths[home] && !legacyCleanupOnly[home] { t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+ "add it to agentSkillPaths so users can address it via --target ", home) } diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 7f5fe2fb..94c8cbf5 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "runtime" "sort" "strings" "time" @@ -23,22 +24,77 @@ import ( // agree on the install footprint. var skillSetupAgentHomes = []string{ ".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", - ".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-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", + // beta.6 compatibility roots: cleanup only. + ".cursor/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", } const ( @@ -65,6 +121,8 @@ var ( skillSetupInteractive = isInteractiveTerminal skillSetupReadDir = os.ReadDir skillSetupStat = os.Stat + skillSetupLstat = os.Lstat + skillSetupGetenv = os.Getenv skillSetupSymlink = os.Symlink skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd @@ -85,6 +143,7 @@ var ( skillSetupWriteState = skillstate.Write skillSetupRemoveState = skillstate.Remove skillSetupNow = time.Now + skillSetupFoldPathCase = runtime.GOOS == "windows" ) type skillSetupBackup struct { @@ -153,9 +212,9 @@ multi 模式支持按产品挑选: 备份失败时保留原目录并跳过该目标,绝不静默删除。 · 所有将被移除的目录都会在确认前逐条列出。 -不带 --mode 时进入交互式询问;Skill 始终安装到 canonical 目录 ~/.agents/skills。 -原生支持该目录的 Agent 直接读取;其他已检测到的 Agent 目录创建相对链接, -链接不可用时回退为直接复制,避免同一 Agent 扫描两份 Skill。 +不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。 +DWS 会自动适配本机上检测到的 Agent;共享安装方式不可用时会自动改用兼容安装, +无需用户手动处理,也不会让同一个 Skill 重复出现。 skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`, Example: ` dws skill setup --mode multi --target claude --dry-run dws skill setup --mode multi --target claude`, @@ -248,7 +307,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } } } - // filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude // 时保持 additive(不动未列出的 sibling);全量安装与 install.sh / // 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.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。") + fmt.Fprintln(out, " 统一安装位置:~/.agents/skills") + fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent") + fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。") return nil } @@ -934,17 +994,85 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) { 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 { return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets()) } - dest := agentHomeForMode(filepath.Join(home, rel), mode) - if filepath.Clean(dest) == filepath.Clean(canonical) { + dest := agentHomeForMode(resolveSkillSetupBase(home, target), mode) + if sameSkillSetupPath(dest, canonical) { return []string{canonical}, 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. func agentHomeForMode(base, mode string) string { if mode == skillSetupModeMulti { @@ -956,16 +1084,57 @@ func agentHomeForMode(base, mode string) string { func detectExistingAgentHomes(home, mode string) []string { canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) 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 { if i == 0 { continue } base := filepath.Join(home, rel) - parent := filepath.Dir(base) - if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { - continue + switch filepath.ToSlash(filepath.Clean(rel)) { + case ".claude/skills": + 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 } @@ -978,10 +1147,26 @@ func skillSetupBaseForMode(dest, mode string) string { } 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{ - ".cursor/skills": true, ".gemini/skills": true, ".codex/skills": true, - ".github/skills": true, ".cline/skills": true, ".amp/skills": true, + ".config/agents/skills": true, ".gemini/antigravity/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) { return true @@ -990,6 +1175,18 @@ func isUniversalSkillSetupBase(base string) bool { 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 { for _, dest := range dests { 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 { leftReal, leftErr := skillSetupEvalSymlinks(left) 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) { @@ -1075,7 +1272,14 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } sort.Strings(plan.MultiSkillNames) 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() canonicalBase := canonicalSkillSetupBase(sortedDests, mode) 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))) { continue } - info, statErr := skillSetupStat(path) + _, statErr := skillSetupLstat(path) if statErr != nil { if errors.Is(statErr, os.ErrNotExist) { continue } 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 }) 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.Fprintln(out, " destinations:") + fmt.Fprintln(out, " 安装与适配位置:") for _, target := range plan.Targets { 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 { - fmt.Fprintf(out, " - %s (链接到 %s,失败时回退复制)\n", target.Destination, target.CanonicalBase) + fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination) } else { - fmt.Fprintf(out, " - %s\n", target.Destination) + fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination) } } 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)) } }() + realStageParent, realParentErr := skillSetupEvalSymlinks(stageParent) + if realParentErr != nil { + return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", stageParent, realParentErr) + } stageOne := func(src, dest string) error { stagedDir := filepath.Join(stageRoot, filepath.Base(dest)) @@ -1708,7 +1914,11 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s if samePhysicalSkillSetupPath(dest, canonicalTarget) { 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 { 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 { 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 { + isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase) if target.CleanupOnly { if homeErr != nil { 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 } 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 { fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr) } else { @@ -1844,12 +2065,15 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target) if stageErr != nil && target.LinkCanonical { - fmt.Fprintf(errOut, " ⚠️ %s 无法创建 Skill 链接,回退为直接复制: %v\n", target.Destination, stageErr) + fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination) fallback := target fallback.LinkCanonical = false stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback) } 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) skipped += perTarget continue @@ -1859,6 +2083,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil { 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) skipped += perTarget continue @@ -1869,6 +2096,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if cleanupErr != nil { 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) skipped += perTarget continue diff --git a/internal/app/skill_setup_canonical_failures_test.go b/internal/app/skill_setup_canonical_failures_test.go new file mode 100644 index 00000000..c1b82a19 --- /dev/null +++ b/internal/app/skill_setup_canonical_failures_test.go @@ -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 +} diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go index 83a43d93..85f823a3 100644 --- a/internal/app/skill_setup_canonical_test.go +++ b/internal/app/skill_setup_canonical_test.go @@ -2,14 +2,16 @@ package app import ( "bytes" + "errors" "os" "path/filepath" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) -func TestSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { +func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { home := t.TempDir() testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) 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 { 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) 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") + } +} diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index e46339f2..8330d6e9 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -170,7 +170,7 @@ func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTar } var preview bytes.Buffer renderSkillSetupPlan(&preview, plan) - if !strings.Contains(preview.String(), "universal Agent") { + if !strings.Contains(preview.String(), "改用统一安装位置") { t.Fatalf("universal cleanup preview missing: %s", preview.String()) } @@ -249,11 +249,13 @@ func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testin t.Fatal(err) } 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") { t.Fatalf("replacement stat error = %v", err) } 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 }) 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) diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 1a37371e..f5e3eb28 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -1055,12 +1055,11 @@ func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets( if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil { t.Fatal(err) } - stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes") if err != nil { 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) } diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go index 163598ad..c08aa96a 100644 --- a/internal/upgrade/canonical_skills_test.go +++ b/internal/upgrade/canonical_skills_test.go @@ -4,12 +4,13 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) -func TestCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { +func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { home := withFakeHome(t) testseam.Swap(t, &knownSkillDirs, []string{ ".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) testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) 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) } } + +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) + } +} diff --git a/internal/upgrade/coverage_edges_test.go b/internal/upgrade/coverage_edges_test.go index 9794f290..061d7d68 100644 --- a/internal/upgrade/coverage_edges_test.go +++ b/internal/upgrade/coverage_edges_test.go @@ -579,7 +579,10 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) { }) home := t.TempDir() 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 upgradeCopyDir = func(src, dst string) error { calls++ diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index 12d89118..c922a428 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -42,22 +42,78 @@ const ( // copy (with a direct-copy fallback when links are unavailable). var knownSkillDirs = []string{ ".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", - ".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-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", + // beta.6 compatibility roots: cleanup only, never publish new copies. + ".cursor/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", } // universalSkillDirs are concrete Agent directories whose current clients @@ -66,12 +122,21 @@ var knownSkillDirs = []string{ // with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally // non-universal because it is a DWS-specific integration absent upstream. var universalSkillDirs = map[string]bool{ - ".cursor/skills": true, - ".gemini/skills": true, - ".codex/skills": true, - ".github/skills": true, - ".cline/skills": true, - ".amp/skills": true, + ".config/agents/skills": true, + ".gemini/antigravity/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 as well. + ".github/skills": true, + ".windsurf/skills": true, + ".cline/skills": true, + ".amp/skills": true, } var ( @@ -87,11 +152,14 @@ var ( upgradeStat = os.Stat upgradeLstat = os.Lstat upgradeSymlink = os.Symlink + upgradeRel = filepath.Rel + upgradeGetenv = os.Getenv upgradeBuildProvenance = skillprovenance.Build upgradeReadSkillState = skillstate.Read upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } upgradeWriteSkillState = skillstate.Write upgradeNow = time.Now + upgradeFoldPathCase = runtime.GOOS == "windows" ) // skillBackupSubdir is the user-level directory where skill directories are @@ -102,21 +170,19 @@ const skillBackupSubdir = ".dws/skill-backups" // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / 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 -// opposite layout next to it silently). Missing paths and regular files are -// no-ops ("", nil). +// opposite layout next to it silently). Missing paths are no-ops. Directories, +// 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) { - info, err := upgradeLstat(dir) + _, err := upgradeLstat(dir) if err != nil { if os.IsNotExist(err) { return "", nil } return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err) } - if !info.IsDir() && info.Mode()&os.ModeSymlink == 0 { - return "", nil - } rel, relErr := filepath.Rel(homeDir, dir) if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") { rel = filepath.Base(dir) @@ -544,8 +610,87 @@ func isUniversalSkillRoot(agentDir string) bool { return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))] } -func skillRootDetected(homeDir, agentDir string) bool { - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) +type resolvedSkillRoot struct { + 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) return err == nil && info.IsDir() } @@ -553,7 +698,30 @@ func skillRootDetected(homeDir, agentDir string) bool { func samePhysicalSkillRoot(left, right string) bool { leftReal, leftErr := upgradeEvalSymlinks(left) rightReal, rightErr := upgradeEvalSymlinks(right) - return leftErr == nil && rightErr == nil && filepath.Clean(leftReal) == filepath.Clean(rightReal) + 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 { @@ -581,9 +749,17 @@ func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) _ = upgradeRemoveAll(stageRoot) } }() + realDestBase, realErr := upgradeEvalSymlinks(destBase) + if realErr != nil { + return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", destBase, realErr) + } for _, name := range names { 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 { 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") if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err}) - return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + 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}) - for _, agentDir := range knownSkillDirs { - if isGenericSkillRoot(agentDir) { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical { continue } - destBase := filepath.Join(homeDir, agentDir) + destBase := root.base destDir := filepath.Join(destBase, "dws") - if isBlacklisted(agentDir) { + if isBlacklisted(root.label) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if !skillRootDetected(homeDir, agentDir) { + if !skillRootDetectedBase(destBase) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } @@ -653,7 +832,7 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } - if isUniversalSkillRoot(agentDir) { + if root.universal { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) } else { @@ -701,20 +880,23 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk canonicalBase := filepath.Join(homeDir, ".agents", "skills") if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err}) - return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + 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}) - for _, agentDir := range knownSkillDirs { - if isGenericSkillRoot(agentDir) { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical { continue } - destBase := filepath.Join(homeDir, agentDir) - if isBlacklisted(agentDir) { + destBase := root.base + if isBlacklisted(root.label) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if !skillRootDetected(homeDir, agentDir) { + if !skillRootDetectedBase(destBase) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } @@ -722,7 +904,7 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } - if isUniversalSkillRoot(agentDir) { + if root.universal { if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) } else { diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index e9abb256..e238b0ca 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -503,6 +503,9 @@ func TestUpgradeMonoCleansPreStateOfficialAndPreservesCustom(t *testing.T) { // mono-only package path). func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) { home := withFakeHome(t) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } agentsBase := filepath.Join(home, ".agents", "skills") if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil { t.Fatal(err) @@ -598,6 +601,9 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t * // installing multi next to the stale skills. func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) { home := withFakeHome(t) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } agentsBase := filepath.Join(home, ".agents", "skills") staleDir := filepath.Join(agentsBase, "dingtalk-stale") if err := os.MkdirAll(staleDir, 0o755); err != nil { @@ -639,7 +645,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t } // 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 // collision) leaves the original directory untouched with an error. 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) } - // 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") if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { t.Fatal(err) } - if got, err := backupAndRemoveSkillDir(home, filePath); got != "" || err != nil { - t.Fatalf("regular file = (%q, %v), want no-op", got, err) + if got, err := backupAndRemoveSkillDir(home, filePath); got == "" || err != nil { + t.Fatalf("regular file = (%q, %v), want backup", got, err) } - if _, err := os.Stat(filePath); err != nil { - t.Fatalf("regular file must survive: %v", err) + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Fatalf("regular file must move into backup: %v", err) } // A skill directory outside home falls back to its basename instead of diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 894e4f5e..c5fdb3b4 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -22,9 +22,91 @@ $InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Pat $NoSkills = $env:DWS_NO_SKILLS -eq "1" $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 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 { if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH } try { @@ -99,22 +181,27 @@ if (-not $NoSkills) { New-Item -ItemType Directory -Path $cache -Force | Out-Null Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force - $agentDirs = @( - ".agents\skills", ".claude\skills", ".cursor\skills", ".qoder\skills", ".qoderwork\skills", - ".gemini\skills", ".codex\skills", ".github\skills", ".windsurf\skills", ".augment\skills", - ".cline\skills", ".amp\skills", ".kiro\skills", ".trae\skills", ".openclaw\skills", - ".hermes\skills", ".config\opencode\skills" - ) - $installed = 0; $idx = 0 - foreach ($d in $agentDirs) { - $base = Join-Path $HOME $d - $parent = Split-Path $base -Parent - if ($idx -gt 0 -and -not (Test-Path $parent)) { $idx++; continue } + $canonicalBase = Join-Path $HOME ".agents\skills" + $canonical = Join-Path $canonicalBase $SkillName + Backup-DevSkill $canonical + New-Item -ItemType Directory -Path $canonical -Force | Out-Null + Copy-Item -Path "$src\*" -Destination $canonical -Recurse -Force + $installed = 1 + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { + $parts = $row.Split('|'); $universal = $parts[1] -eq "1" + $base = Resolve-AgentBase $row + 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 - if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } - New-Item -ItemType Directory -Path $dest -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force - $installed++; $idx++ + if ($universal) { Backup-DevSkill $dest; continue } + New-Item -ItemType Directory -Path $base -Force | Out-Null + Backup-DevSkill $dest + 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)" } else { diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 1e7c5d7f..4a360638 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -26,6 +26,80 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ❌ %s\n' "$@" >&2; exit 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/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 detect_os() { @@ -83,17 +157,16 @@ install_skill() { cache="$HOME/.dws/skills/multi/$SKILL_NAME" rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/" - installed=0; idx=0 - for agent_dir in \ - .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ - .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ - .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ - .config/opencode/skills - do - base="$HOME/$agent_dir" - if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then idx=$((idx + 1)); continue; fi - dest="$base/$SKILL_NAME"; rm -rf "$dest"; mkdir -p "$dest"; cp -R "$src/." "$dest/" - installed=$((installed + 1)); idx=$((idx + 1)) + canonical="$HOME/.agents/skills/$SKILL_NAME" + copy_tree "$src" "$canonical" || return 1 + installed=1 + for agent_dir in $(agent_skill_dirs); do + base="$(resolve_agent_skill_base "$agent_dir")" + [ -e "$(dirname "$base")" ] || continue + same_physical_skill "$base" "$HOME/.agents/skills" && continue + if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$SKILL_NAME" >/dev/null || return 1; continue; fi + link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME" || return 1 + installed=$((installed + 1)) done say "✅ Skill dingtalk-misc → ${installed} agent dir(s)" } diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 3402a333..a45870e7 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -92,37 +92,98 @@ resolve_event_version() { copy_tree() { src="$1" dest="$2" - rm -rf "$dest" - mkdir -p "$dest" - cp -R "$src/." "$dest/" + 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/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() { src="$1" skill_name="$2" - - installed=0 - idx=0 - for agent_dir in \ - .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ - .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ - .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ - .config/opencode/skills - do - base="$HOME/$agent_dir" - if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then - idx=$((idx + 1)) - continue - fi - copy_tree "$src" "$base/$skill_name" + canonical="$HOME/.agents/skills/$skill_name" + copy_tree "$src" "$canonical" || return 1 + installed=1 + for agent_dir in $(agent_skill_dirs); do + base="$(resolve_agent_skill_base "$agent_dir")" + [ -e "$(dirname "$base")" ] || continue + same_physical_skill "$base" "$HOME/.agents/skills" && continue + if is_cleanup_only_agent_dir "$agent_dir"; then backup_skill_dir "$base/$skill_name" >/dev/null || return 1; continue; fi + link_or_copy_skill "$canonical" "$src" "$base/$skill_name" || return 1 installed=$((installed + 1)) - idx=$((idx + 1)) done - - if [ "$installed" -eq 0 ]; then - copy_tree "$src" "$HOME/.agents/skills/$skill_name" - installed=1 - fi printf '%s\n' "$installed" } diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 97a4b093..563582ec 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -53,13 +53,13 @@ DWS_LAST_SKILL_BACKUP="" backup_and_remove_skill_dir() { _bed_dir="$1" DWS_LAST_SKILL_BACKUP="" - [ -d "$_bed_dir" ] || return 0 + [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0 _bed_root="${HOME}/.dws/skill-backups" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_name="$(basename "$_bed_dir")" _bed_target="$_bed_root/$_bed_stamp/$_bed_name" _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_i=$((_bed_i + 1)) if [ "$_bed_i" -gt 1000 ]; then @@ -206,7 +206,7 @@ restore_multi_skill_set() { if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -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" _rms_ok=0 fi @@ -413,11 +413,62 @@ multi_tree_has_skills() { # relative links and fall back to copies only when links are unavailable. is_universal_agent_dir() { case "$1" in - ".cursor/skills"|".gemini/skills"|".codex/skills"|".github/skills"|".cline/skills"|".amp/skills") return 0 ;; + ".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 } +# 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() { [ -d "$1" ] && [ -d "$2" ] || 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" || { rm -rf "$_rgs_stage"; return 1; } 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 continue fi @@ -450,6 +501,7 @@ link_canonical_skills_to_base() { _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 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_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } @@ -466,11 +518,13 @@ link_canonical_skills_to_base() { _lcs_publish_names="" for _lcs_name in $_lcs_names; do if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi - ln -s "../../.agents/skills/$_lcs_name" "$_lcs_stage/$_lcs_name" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_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/dev/null && pwd)" || return 1 @@ -667,7 +722,7 @@ retire_agent_skill_root() { _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } 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 continue fi @@ -687,6 +742,7 @@ link_canonical_skills_to_base() { _lcs_canonical="$_lcs_root/.agents/skills" mkdir -p "$_lcs_base" || return 1 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_backups="$_lcs_stage/.backups" _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 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/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then 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() { home_root="$1" - for parent in $HOME_SPECIFIC_AGENT_PARENTS; do - mkdir -p "$home_root/$parent" + seen='' + 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 } +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() { home_root="$1" 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" } +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() { home_root="$1" # lark-cli/skills-compatible canonical store is always present, regardless # of which concrete Agent homes existed at install time. verify_skill_base "$home_root" ".agents/skills" - for base in $HOME_SPECIFIC_SKILL_BASES; do - parent="${base%/skills}" - [ -d "$home_root/$parent" ] || continue - universal=0 - for universal_base in $HOME_UNIVERSAL_SKILL_BASES; do - [ "$base" = "$universal_base" ] && universal=1 - done + seen='' + for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do + id=${row%%|*}; rest=${row#*|}; universal=${rest%%|*}; 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} +" + [ "$base" != "$home_root/.agents/skills" ] || continue + [ -d "${base%/skills}" ] || continue if [ "$universal" -eq 1 ]; then for name in dingtalk-shared dingtalk-misc dws; do - [ ! -e "$home_root/$base/$name" ] || \ - err "unexpected duplicate Skill found in universal Agent root $home_root/$base/$name" + { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/$name" done continue fi - verify_skill_base "$home_root" "$base" - for name in dingtalk-shared dingtalk-misc; do - [ -L "$home_root/$base/$name" ] || \ - err "expected canonical Skill link not found at $home_root/$base/$name" - done + verify_compatible_skill_base "$home_root" "$base" done } @@ -189,6 +281,7 @@ verify_npm() { need_cmd node need_cmd tar need_cmd unzip + need_cmd diff need_file "$NPM_STAGE_DIR/package.json" tarball_name="$( diff --git a/test/scripts/agent_registry_test.go b/test/scripts/agent_registry_test.go new file mode 100644 index 00000000..35a647b0 --- /dev/null +++ b/test/scripts/agent_registry_test.go @@ -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 +} diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 29be067a..945a8e61 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -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 require = createRequire(import.meta.url); const { + UPSTREAM_AGENTS, + resolvedAgentTargets, publishCacheAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, } = 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}`]; if (process.platform === "win32" || !assetName) { @@ -133,8 +143,8 @@ function stagePkg(zipEntries, emptyDirs = []) { return { tmp, pkg, home: path.join(tmp, "home") }; } -function runInstall(pkg, home, skillMode) { - const env = { ...process.env, HOME: home }; +function runInstall(pkg, home, skillMode, extraEnv = {}) { + const env = { ...process.env, HOME: home, ...extraEnv }; if (skillMode !== undefined) { env.DWS_SKILL_MODE = skillMode; } 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", () => { const { tmp, pkg, home } = stagePkg({ "SKILL.md": "# mono root copy\n", diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index ac685fb5..5ab08442 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -381,17 +381,23 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { } expectedSkills := map[string]string{ - ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".codex/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".config/opencode/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", - ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", - ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", - ".codex/skills/dws/SKILL.md": "user_im_message_receive_o2o", - ".dws/skills/multi/dingtalk-event/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", - ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", + ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-event/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", + ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + } + for _, duplicate := range []string{ + 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 { 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) { pwsh, err := exec.LookPath("pwsh") if err != nil { diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index fd3746ba..cc43e8cf 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -30,6 +30,10 @@ var expectedPackagedSkillTargets = []string{ ".augment/skills/dingtalk-shared", ".cline/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", ".trae/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") } for _, want := range []string{ - "HOME_SPECIFIC_SKILL_BASES=", - `$base/dingtalk-shared/SKILL.md`, - `$base/dingtalk-misc/SKILL.md`, + "UPSTREAM_AGENT_REGISTRY=", + "verify_compatible_skill_base", + `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", `verify_npm_install "$tarball_path" "specific-agent-roots"`, `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) { t.Helper() file, err := os.Create(path)