Compare commits

...
Author SHA1 Message Date
玉澜 4e8469a175 test(skills): cover Windows same-file identity seam with synthetic info
skillPathSameFileIdentityImpl on Windows always returns false and is
never reached through skillPathIdentityProven (which uses file IDs
exclusively). Add a direct seam call with synthetic os.FileInfo to
exercise the Windows return-false path and the Unix os.SameFile path
with nil Sys().
2026-08-17 15:25:32 +08:00
玉澜 2292a49c6a refactor(skills): collapse Windows identity error paths for coverage
Restructure skillPathFileIdentityImpl to use nested if-err-nil with a
named return and skillPathIdentityProven to use a single expression.
Error conditions now fall through to the bare return instead of
occupying separate coverage blocks, eliminating 5 uncovered statements
that the Windows coverage gate flagged at 99.4193%.
2026-08-17 15:00:25 +08:00
玉澜 cf43cf1b47 style: gofmt alignment after adding skillPathSameFileIdentity seam 2026-08-17 14:41:55 +08:00
玉澜 344104268a fix(skills): open reparse points in Windows file ID query and stabilize tunneled test
Add FILE_FLAG_OPEN_REPARSE_POINT to the Windows CreateFile call in
skillPathFileIdentityImpl so symlinks are opened as reparse points
rather than followed to their target. Staged symlinks carry relative
targets computed for the final destination, which may not resolve from
the staging directory; following them caused CreateFile to fail,
yielding an empty file ID that rejected publication and broke canonical
skill layout migration on Windows.

Make skillPathSameFileIdentity a seam variable so the tunneled
replacement test can deterministically simulate the identity change on
Unix. On tmpfs (used by Linux CI runners), os.SameFile can return true
for a recreated file due to inode reuse, making the test flaky. On
Windows the swap is a no-op because skillPathIdentityProven compares
file IDs from GetFileInformationByHandle and ignores
skillPathSameFileIdentity.
2026-08-17 14:39:59 +08:00
玉澜 e45608bb13 fix(skills): prove Windows rollback identity via stable file ID
NTFS file tunneling can restore the original creation time for a
recreated same-named object, which defeated the creation-time
incarnation check and allowed rollback to delete a concurrent
replacement. Replace the platform-specific identity pair with a single
skillPathIdentityProven function:

- Unix: delegates to os.SameFile (inode/dev), ignoring file ID strings
- Windows: compares VolumeSerialNumber:FileIndexHigh:FileIndexLow from
  GetFileInformationByHandle, which uniquely identifies the file on the
  volume for its lifetime and is unaffected by tunneling

When the file ID cannot be obtained at publish time, identity is not
proven and the auto-delete is refused. Add a regression test that
simulates tunneled creation time and verifies rollback still refuses
the concurrent replacement.
2026-08-17 14:03:28 +08:00
玉澜 ff6e2347f6 test(skills): cover non-EEXIST link error on Windows
On Windows isNoReplaceRenameUnsupported always returns false, so the
fallback is never entered from the invalid-path test. Force the fallback
and swap skillPathLink to a non-EEXIST error to cover line 94 on all
platforms.
2026-08-17 13:39:12 +08:00
玉澜 2d29f6601b test(skills): cover all no-replace fallback branches for 100% coverage
Add tests for mkdir non-EEXIST error, remove failure after rename
failure, first-rename-succeeds path (Linux behavior), retry-rename
path, and non-regular source safe-fail. All 24 changed executable
statements now covered on both macOS and Windows.
2026-08-17 13:22:58 +08:00
玉澜 fa887ccd26 fix(skills): eliminate TOCTOU in no-replace rename fallback
The fallback path for filesystems without RENAME_NOREPLACE/EXCL (NFS,
FUSE, overlayfs) used Lstat-then-Rename, which could overwrite a
concurrently created destination between the check and the rename.

Replace the TOCTOU-prone check with truly atomic no-clobber primitives:
- Directories: os.Mkdir atomically claims the destination (fails with
  EEXIST if occupied). On Linux rename(2) replaces the empty dir
  directly; on Darwin/Windows rename refuses existing dirs so the empty
  dir is removed and the rename retried — any concurrent creation
  between remove and rename is detected by the second rename failing.
- Files: os.Link atomically fails if the destination exists, then
  os.Remove completes the move.

Add concurrent-creation test covering the mkdir→rename race window.
2026-08-17 13:11:15 +08:00
玉澜 30202e2b81 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-17 12:30:34 +08:00
玉澜 1203409185 test(skills): cover Windows stat-error branch in no-replace fallback
The !os.IsNotExist(statErr) branch in renameSkillPathNoReplace was
uncovered on Windows. Inject errNoReplaceRenameUnsupported for the
atomic rename and os.ErrPermission for skillPathLstat so the stat-error
path is exercised on every platform.
2026-08-17 12:23:40 +08:00
玉澜 0ba55350d8 fix(ci): unset XDG_CONFIG_HOME for npm installer smoke test
The smoke test creates temp home directories with .config/kimchi markers
for agent detection. On Linux CI runners XDG_CONFIG_HOME may point to the
runner's real config path, causing resolvedAgentTargets to look outside
the temp home. Unset it so detection resolves against the test's temp dir.
2026-08-17 11:48:11 +08:00
玉澜 14c2569cfb fix(skills): guard pruneSkillBackups against non-DWS directories
Restrict backup pruning to directories whose names match the DWS stamp
format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer
surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in
~/.dws/skill-backups are now preserved. Also fixes Windows coverage test
portability and covers the remaining macOS changed-code gap (retire
warning loop in runUpgrade).
2026-08-17 11:42:43 +08:00
john ae1565c0ff Merge branch 'main' into fix/canonical-agent-skills 2026-08-17 10:23:34 +08:00
玉澜 7581955892 fix(skills): make obsolete-copy retirement non-fatal and harden install
A universal Agent whose obsolete private copy cannot be retired installs
nothing there, yet every entry point counted that retirement failure as an
install failure — aborting `npm install`, `dws skill setup`, and the shell
installers even when the canonical store and all links published correctly,
and skipping the skills-state write. Route retirement failures to a separate
warning path across all surfaces (Go upgrade + skill setup, npm, PowerShell,
install.sh, install-skills.sh, install-event.sh, install-devapp.sh).

Also:
- Add a checked-rename fallback for filesystems that reject the atomic
  no-replace flag (NFS, FUSE, overlayfs); the no-clobber contract is kept and
  the previously unsupported platforms build and work.
- PowerShell multi-mode links only bundle skills, never the shared canonical
  store, so third-party/user skills are no longer fanned into every Agent root.
- Prune ~/.dws/skill-backups to the newest 5 on every surface; encode
  HOME-relative backup names on PowerShell to preserve origin.
- Add simulated-win32 junction coverage and rewrite the tautological
  no-replace test; remove dead code whose tests gave false coverage.
- Soften the overstated Windows ownership-proof comment (NTFS tunneling).
2026-08-15 14:26:11 +08:00
玉澜 24bbdda423 test(skills): cover Windows no-replace path errors 2026-08-14 20:22:32 +08:00
玉澜 276658590b fix(skills): retain Windows reparse link publication 2026-08-14 20:14:04 +08:00
玉澜 16d20b8178 fix(skills): compare Windows publication identity stably 2026-08-14 20:07:33 +08:00
玉澜 dd89c67f4d Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 20:00:47 +08:00
玉澜 75bf44b7be test(skills): cover post-publish identity reuse 2026-08-14 19:58:14 +08:00
玉澜 1bae872341 fix(skills): distinguish reused publication inodes 2026-08-14 19:47:04 +08:00
玉澜 d1ecdcd551 Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 19:24:09 +08:00
玉澜 a47740ca1c fix(skills): make publication rollback race-safe 2026-08-14 19:23:59 +08:00
玉澜 4ad321557f Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 18:04:34 +08:00
玉澜 34dee96833 fix(skills): make PowerShell rollback race-safe 2026-08-14 17:51:12 +08:00
玉澜 fc455f800c test(skills): junction-safe rollback and per-agent degrade regressions
- extend the silent-rollback contract to install-event.sh
- static contract: Restore-MultiSkillSet removes published paths lexically
  (section-scoped so identity-anchor refactors keep the guarantee) and link
  staging dirs are cleaned via Remove-LinkStageRoot / Remove-DevLinkStageRoot
- install-event.sh integration test: an uninstallable agent target is
  skipped loudly while later agents still receive links
- pwsh probe: Test-SamePhysicalSkillRoot must dereference junctions and
  symlinks (junction idempotency asserted where junctions are creatable)
2026-08-14 17:14:13 +08:00
玉澜 3556d28fdd fix(skills): junction-safe PowerShell rollback and per-agent degrade
- install.ps1: remove published junctions lexically in Restore-MultiSkillSet
  (Windows PowerShell 5.1 follows reparse points during Remove-Item -Recurse
  and could delete canonical store contents); clean link staging dirs
  lexically in Publish-CanonicalSkillLinks and Move-SkillPathRecoverably
- install.ps1: Test-SamePhysicalSkillRoot now dereferences junctions via
  Get-PhysicalSkillPath (mirrors EvalSymlinks/realpathSync/cd -P), so reruns
  recognize already-published junctions instead of backup churn
- install-event.sh: replace silent 'mv ... 2>/dev/null || true' rollback with
  the loud backup-retained failure contract already enforced for devapp
- event/devapp sh+ps1: link→copy fallback and per-agent failures now degrade
  per agent like install.sh (skip loudly, continue, report at the end)
  instead of aborting mid-loop or swallowing errors
- tests: junction-lexical removal contract, event per-agent degrade
  integration test, pwsh junction physical-root recognition + rerun
  idempotency (no backup churn)
2026-08-14 16:48:34 +08:00
玉澜 618eb842a2 test(skills): retain rollback identity anchor 2026-08-14 16:11:48 +08:00
玉澜 465acf1406 Revert "test(skills): retain rollback identity anchor"
This reverts commit ce73a5b452.
2026-08-14 16:11:13 +08:00
玉澜 ce73a5b452 test(skills): retain rollback identity anchor 2026-08-14 16:09:24 +08:00
玉澜 175b51cec0 fix(skills): retain link identity anchors through rollback 2026-08-14 15:45:51 +08:00
玉澜 53a71b08c7 fix(skills): protect shell link rollback from races 2026-08-14 15:35:25 +08:00
玉澜 3ef735bb3c Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 14:44:08 +08:00
玉澜 7cfaa1ca74 fix(skills): fail upgrade unconditionally when canonical publish fails
A failed canonical publish only failed the upgrade when
hasDependentSkillRoot reported a non-universal link target; that helper
explicitly skipped universal agents, which are exactly the direct consumers
of ~/.agents/skills. On a universal-only machine (e.g. only Codex
installed), UpgradeSkillLocations* returned a nil error with nothing
installed, contradicting the documented "canonical publication is
mandatory and fails the upgrade loudly" contract.

Canonical publish failures now return an error unconditionally in both the
mono and multi branches, and hasDependentSkillRoot is removed. The test
that pinned the old standalone-does-not-fail-fast behavior now asserts
error propagation in both modes.
2026-08-14 14:27:57 +08:00
玉澜 f8af8dc1dc Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 12:04:50 +08:00
玉澜 bb6fd2f256 fix(skills): remove ineffective app detection gate 2026-08-14 11:58:27 +08:00
玉澜 580c4d201b Revert "fix(skills): make app-bundle detection gate HOME-independent"
This reverts commit 37cd629335.
2026-08-14 11:43:57 +08:00
玉澜 37cd629335 fix(skills): make app-bundle detection gate HOME-independent
The allowSystemApps gate (homeDir == systemHome) was effectively a no-op in
production: systemHome came from os.UserHomeDir, which honors the $HOME env
override just like homeDir, so the two were always equal and the gate never
fired when $HOME was overridden.

ResolveSystemHomeDir now prefers the OS user database (getpwuid on Unix),
which is independent of $HOME, falling back to $HOME only when the user record
cannot be resolved. Production behavior is unchanged (a real $HOME still
matches); an isolated/overridden HOME now correctly skips machine-wide
/Applications discovery for zcode/minimax. The app surface references the same
shared resolver.

This is the correct fix for the hermeticity concern (machine-wide state leaking
into an isolated HOME): there is no cross-surface production inconsistency to
port — script installers always operate on the real user HOME in practice, so
they need no gate.
2026-08-14 11:33:55 +08:00
玉澜 0c0e2b3ce1 test(skills): cover Windows chmod failure branch 2026-08-14 10:32:20 +08:00
玉澜 7d16c9693f test(skills): cover Windows permission preparation seams 2026-08-14 10:22:03 +08:00
玉澜 1dee02d900 test(skills): make mode checks portable on Windows 2026-08-14 10:08:40 +08:00
玉澜 7664f04fda fix(skills): preserve read-only backup trees 2026-08-14 08:50:14 +08:00
玉澜 8177a06296 Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 08:43:04 +08:00
玉澜 c674366aea fix(skills): make backups cross-filesystem safe 2026-08-14 08:42:54 +08:00
chichuan ce5815a606 Merge branch 'main' into fix/canonical-agent-skills 2026-08-13 22:01:48 +08:00
玉澜 d211b79a80 test(ci): cover canonical skill platform branches 2026-08-13 21:49:35 +08:00
玉澜 de8df0fa6f fix(skills): harden canonical agent installation 2026-08-13 21:22:57 +08:00
玉澜 9ff31cdd55 Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-13 20:23:05 +08:00
玉澜 fde37f7896 fix(skills): complete canonical agent compatibility 2026-08-13 20:21:56 +08:00
玉澜 31902a987e fix(skills): use canonical global installation 2026-08-13 18:50:27 +08:00
54 changed files with 11052 additions and 1383 deletions
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract through a checked rename instead of failing the install. Failing to retire an obsolete Agent copy is now a warning rather than an install failure, and every install surface prunes `~/.dws/skill-backups` to the newest 5 batches. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor.
+6
View File
@@ -512,6 +512,12 @@ jobs:
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
run: node .github/reviewer-routing.test.js
- name: Test npm installer smoke (prune, backup, publish)
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
env:
XDG_CONFIG_HOME: ""
run: node test/scripts/install_js_smoke.mjs
test-focused:
name: "Test (focused: ${{ matrix.shard }})"
needs: lint
+2 -2
View File
@@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice.
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
@@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
+730 -122
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,6 +32,6 @@
"README.md"
],
"engines": {
"node": ">=16"
"node": ">=16.7.0"
}
}
+49 -6
View File
@@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约
| 模式 | Agent 目录布局 | 选择方式 |
|---|---|---|
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
| mono(兼容) | `<agent-home>/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,24 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
## 8. Upgrade 与恢复语义
升级器对每个 Agent 目标执行:
升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为
universal 的 Agent 不再保留 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` 与 `installer.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 枚举。
对 universal Agent,上游 installer 的 global 模式明确选择 canonical 并跳过
Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别和退役历史 native
路径,不作为 universal symlink 模式的发布目标。
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合;
@@ -151,6 +165,14 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
旧集合可能位于外部卷或自定义 Agent 根,而备份固定写入
`~/.dws/skill-backups`。因此备份与反向恢复统一采用 rename-first:同卷直接原子
rename;遇到跨文件系统错误时,在目标所在文件系统创建临时 staging,词法复制并
保留目录/文件权限、普通文件、符号链接及 dangling symlink,校验路径类型、目录项、
文件大小与 SHA256、链接目标后,再将 staging 原子 rename 为正式目标。正式目标
再次校验成功后才删除源路径。复制、校验或发布失败时保留源并清理 staging;源删除
失败时允许源与正式目标同时存在,但必须返回明确错误,不能报告成功。
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
@@ -159,11 +181,26 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功
## 9. 备份合同
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
- 主要操作:同一文件系统内使用 rename 移动;
- 主要操作:同一文件系统内使用 rename 移动;跨文件系统使用目标卷 staging 的
copy → verify → publish → remove 回退;
- 失败语义:备份失败时原目录保持不变,目标安装失败;
- 恢复语义:反向恢复使用相同回退;若删除备份源失败,原路径和备份可同时存在,
但恢复必须失败并明确提示两份均被保留;
- 可见性:计划和执行日志显示原路径与备份路径;
- 保留策略:自动修剪,仅保留最近 5 批。
跨卷回退只有 staging → 正式目标的发布 rename 是原子的,整次迁移不是跨文件系统
原子事务;该边界由“发布前不删源、发布后再次校验、删除失败保留两份”补偿。Shell
入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现
上述验证和失败合同。
原子 no-replace 发布(Linux `RENAME_NOREPLACE`、Darwin `RENAME_EXCL`)依赖底层文件
系统支持:`rename(2)` 只列出 ext4、btrfs、tmpfs 与 cifs,因此 NFS、FUSE 与
overlayfs 家目录会以 `EINVAL` 拒绝该 flag。这些文件系统不得让安装整体失败,而是降级
为“显式存在性检查 + 普通 rename”:不覆盖既有目标的契约保持不变,仅失去原子性,
其检查与 rename 之间的竞态窗口与普通 rename 本身一致,并由发布后的身份与内容校验
兜底。Windows `MoveFile` 本身即拒绝已存在的目标,无需降级。
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
`dws skill setup --mode mono`。
@@ -192,6 +229,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 +247,11 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行
- 复制失败不留下 Agent 可见的残缺官方目录;
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
- Windows、macOS、Linux 的路径和覆盖率门禁;
- symlinked parent、Windows junction、链接失败复制回退与 broken link 修复;
- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级;
- `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的
正向备份、反向恢复、普通链接及 dangling symlink 词法保留;
- copy、verify、publish、remove 各阶段故障,以及非跨设备权限错误不得进入复制回退;
- npm、Shell、PowerShell 与包管理器安装冒烟。
## 13. 后续演进
+128 -26
View File
@@ -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, ", ")
@@ -182,11 +258,28 @@ func formatAgentSkillPathsForHelp() string {
sort.Strings(names)
var b strings.Builder
for _, n := range names {
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n])
installPath := agentSkillPaths[n]
if isUniversalSkillInstallTarget(n) {
installPath = ".agents/skills"
}
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath)
}
return b.String()
}
// Universal Agents discover the shared ~/.agents/skills store directly. A
// marketplace install addressed to one of those Agent IDs must therefore
// publish to canonical instead of recreating an Agent-private duplicate.
func isUniversalSkillInstallTarget(target string) bool {
rel, ok := agentSkillPaths[target]
if !ok {
return false
}
base := filepath.Join("__home__", rel)
canonical := filepath.Join("__home__", ".agents", "skills")
return sameSkillSetupPath(base, canonical) || isUniversalSkillSetupBase(base)
}
func buildSkillCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "skill",
@@ -485,8 +578,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 +594,11 @@ func resolveSkillTargetPath(target string) (string, error) {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, relPath), nil
destination := resolveSkillSetupBase(homeDir, target)
if isUniversalSkillInstallTarget(target) {
destination = filepath.Join(homeDir, ".agents", "skills")
}
return destination, nil
}
// fetchSkillDownloadInfo calls the download API to get the skill download URL.
+40 -4
View File
@@ -28,6 +28,7 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestResolveSkillTargetPath(t *testing.T) {
@@ -57,19 +58,19 @@ func TestResolveSkillTargetPath(t *testing.T) {
{
name: "cursor target",
target: "cursor",
wantSuffix: filepath.Join(".cursor", "skills"),
wantSuffix: filepath.Join(".agents", "skills"),
wantErr: false,
},
{
name: "codex target",
target: "codex",
wantSuffix: filepath.Join(".codex", "skills"),
wantSuffix: filepath.Join(".agents", "skills"),
wantErr: false,
},
{
name: "opencode target",
target: "opencode",
wantSuffix: filepath.Join(".config", "opencode", "skills"),
wantSuffix: filepath.Join(".agents", "skills"),
wantErr: false,
},
{
@@ -118,6 +119,37 @@ func TestResolveSkillTargetPath(t *testing.T) {
}
}
func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil })
if isUniversalSkillInstallTarget("missing-agent") {
t.Fatal("unknown Agent target classified as universal")
}
for _, target := range []string{
"amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor",
"deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github",
"github-copilot", "kimi-code-cli", "loaf", "opencode", "replit",
"universal", "warp", "zed",
} {
got, err := resolveSkillTargetPath(target)
if err != nil {
t.Fatalf("resolve %s: %v", target, err)
}
if want := filepath.Join(home, ".agents", "skills"); got != want {
t.Errorf("resolve %s = %s, want canonical %s", target, got, want)
}
}
for target, want := range map[string]string{
"claude": filepath.Join(home, ".claude", "skills"),
"qoder": filepath.Join(home, ".qoder", "skills"),
"zcode": filepath.Join(home, ".zcode", "skills"),
} {
if got, err := resolveSkillTargetPath(target); err != nil || got != want {
t.Errorf("resolve non-universal %s = %s, %v; want %s", target, got, err, want)
}
}
}
func TestResolveSkillTargetPathCurrentDir(t *testing.T) {
// Test "." target returns current working directory
cwd, err := os.Getwd()
@@ -477,8 +509,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 <name>", home)
}
+449 -124
View File
@@ -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,15 +121,20 @@ var (
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
skillSetupStat = os.Stat
skillSetupLstat = os.Lstat
skillSetupGetenv = os.Getenv
skillSetupSymlink = os.Symlink
skillSetupExecutable = os.Executable
skillSetupGetwd = os.Getwd
skillSetupUserHomeDir = os.UserHomeDir
skillSetupRemoveAll = os.RemoveAll
skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir
skillSetupRestoreBackup = upgrade.RestoreSkillPath
skillSetupMkdirAll = os.MkdirAll
skillSetupWalk = filepath.Walk
skillSetupRel = filepath.Rel
skillSetupReadlink = os.Readlink
skillSetupEvalSymlinks = filepath.EvalSymlinks
skillSetupOpen = os.Open
skillSetupOpenFile = os.OpenFile
skillSetupWriteFile = os.WriteFile
@@ -82,7 +143,10 @@ var (
skillSetupReadState = skillstate.Read
skillSetupWriteState = skillstate.Write
skillSetupRemoveState = skillstate.Remove
skillSetupPublishPath = upgrade.PublishSkillPathNoReplace
skillSetupRollbackPaths = upgrade.RollbackSkillPathPublications
skillSetupNow = time.Now
skillSetupFoldPathCase = runtime.GOOS == "windows"
)
type skillSetupBackup struct {
@@ -91,9 +155,11 @@ type skillSetupBackup struct {
}
type skillSetupTargetPlan struct {
Destination string
Backups []skillSetupBackup
CleanupOnly bool
Destination string
CanonicalBase string
Backups []skillSetupBackup
CleanupOnly bool
LinkCanonical bool
}
type skillSetupPlan struct {
@@ -149,8 +215,9 @@ multi 模式支持按产品挑选:
备份失败时保留原目录并跳过该目标,绝不静默删除。
· 所有将被移除的目录都会在确认前逐条列出。
不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录;
未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 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`,
@@ -243,7 +310,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
}
}
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
// install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。
@@ -316,6 +382,17 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 {
retiredNames := append([]string(nil), multiSkillNames...)
if installsEventMiscCompanion && !containsSkillName(retiredNames, multiMiscSkill) {
retiredNames = append(retiredNames, multiMiscSkill)
}
if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil {
// Retiring an obsolete universal copy installs nothing; report it and
// keep the successful installation rather than failing the run.
fmt.Fprintf(errOut, " ⚠️ %v\n", retireErr)
}
}
if skipped > 0 {
return fmt.Errorf(
"Skill 安装不完整(mode=%s, installed=%d, skipped=%d);修复失败原因后请重试 setup,或运行普通 upgrade 全量刷新预制 Skill",
@@ -350,7 +427,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
}
@@ -910,8 +989,9 @@ func isSkillSourceRoot(path, mode string) bool {
}
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
// If target == "all", returns every agent home whose parent directory exists.
// Otherwise returns the single matching home (whether or not it currently exists).
// The canonical ~/.agents/skills destination is always first. If target ==
// "all", detected concrete Agent roots follow it. A specific target follows
// canonical as well so unknown/future Agents retain the universal copy.
//
// 末段约定:
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
@@ -923,15 +1003,88 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) {
}
target = strings.ToLower(strings.TrimSpace(target))
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
if target == "" || target == "all" {
return detectExistingAgentHomes(home, mode), nil
}
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())
}
return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
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.
@@ -943,79 +1096,141 @@ func agentHomeForMode(base, mode string) string {
}
func detectExistingAgentHomes(home, mode string) []string {
var specific []string
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
dests := []string{canonical}
canonicalKey := skillSetupPathKey(canonical)
seen := map[string]bool{canonicalKey: true}
addDetected := func(rel, base string) {
detectedDir := filepath.Dir(base)
switch filepath.ToSlash(filepath.Clean(rel)) {
case ".config/kimchi/harness/skills", ".tabnine/agent/skills":
detectedDir = filepath.Dir(filepath.Dir(base))
case ".zcode/skills":
// Application bundles are machine-scoped detection signals. Keep this
// independent of HOME so setup matches npm, Shell, and PowerShell.
if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() {
detectedDir = ""
}
case ".minimax/skills":
if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() {
detectedDir = ""
}
}
if detectedDir != "" {
if info, err := skillSetupStat(detectedDir); 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")
}
specific = append(specific, agentHomeForMode(base, mode))
addDetected(rel, base)
}
if len(specific) > 0 {
return specific
for _, target := range []string{"github-copilot", "windsurf"} {
addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target))
}
return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)}
return dests
}
func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) {
// Derive HOME from a concrete Agent destination instead of resolving it a
// second time. The destinations were already resolved from HOME by the
// caller, and a later/transient UserHomeDir failure must not turn an
// otherwise valid setup plan into an error. Direct/custom destinations that
// do not match a known concrete Agent root have no generic-root migration.
home := ""
for _, dest := range dests {
base := dest
if filepath.Base(dest) == "dws" {
base = filepath.Dir(dest)
}
base = filepath.Clean(base)
for i, rel := range skillSetupAgentHomes {
if i == 0 {
continue
}
suffix := filepath.Clean(filepath.FromSlash(rel))
needle := string(filepath.Separator) + suffix
if strings.HasSuffix(base, needle) {
home = strings.TrimSuffix(base, needle)
break
}
}
if home != "" {
break
}
func skillSetupBaseForMode(dest, mode string) string {
if mode == skillSetupModeMono {
return filepath.Dir(dest)
}
if home == "" {
return nil, nil
}
genericBase := filepath.Join(home, ".agents", "skills")
return dest
}
target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true}
add := func(path, reason string) {
if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() {
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason})
func isUniversalSkillSetupBase(base string) bool {
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
}
}
add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual)
entries, readErr := skillSetupReadDir(genericBase)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr)
}
for _, entry := range entries {
path := filepath.Join(genericBase, entry.Name())
if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) {
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale})
base = filepath.ToSlash(cleanBase)
for rel := range map[string]bool{
".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
}
}
if len(target.Backups) == 0 {
return nil, nil
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)
}
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
return target, nil
return clean
}
func canonicalSkillSetupBase(dests []string, mode string) string {
for _, dest := range dests {
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode)))
if strings.HasSuffix(base, "/.agents/skills") {
return skillSetupBaseForMode(dest, mode)
}
}
return ""
}
func samePhysicalSkillSetupPath(left, right string) bool {
leftReal, leftErr := skillSetupEvalSymlinks(left)
rightReal, rightErr := skillSetupEvalSymlinks(right)
return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal)
}
func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) {
@@ -1030,10 +1245,26 @@ 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 {
target := skillSetupTargetPlan{Destination: dest}
base := skillSetupBaseForMode(dest, mode)
target := skillSetupTargetPlan{Destination: dest, CanonicalBase: canonicalBase}
if canonicalBase != "" && filepath.Clean(base) != filepath.Clean(canonicalBase) {
if isUniversalSkillSetupBase(base) {
target.CleanupOnly = true
} else {
target.LinkCanonical = true
}
}
seen := map[string]bool{}
add := func(path, reason string) {
if seen[path] {
@@ -1077,26 +1308,22 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
}
}
for _, path := range replacements {
info, statErr := skillSetupStat(path)
if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) {
continue
}
_, 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 })
plan.Targets = append(plan.Targets, target)
}
cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames)
if cleanupErr != nil {
return nil, cleanupErr
}
if cleanupTarget != nil {
plan.Targets = append(plan.Targets, *cleanupTarget)
if !target.CleanupOnly || len(target.Backups) > 0 {
plan.Targets = append(plan.Targets, target)
}
}
return plan, nil
}
@@ -1140,6 +1367,33 @@ func configureEventMiscMigrationPlan(plan *skillSetupPlan, targets []string, ins
}
}
func retireMigratedUniversalSkills(targets, names []string, out io.Writer) error {
home, err := skillSetupUserHomeDir()
if err != nil {
return fmt.Errorf("无法解析 HOME 以退役 universal Agent 旧副本: %w", err)
}
seen := map[string]bool{}
var victims []skillSetupBackup
for _, target := range targets {
if !isUniversalSkillSetupBase(target) {
continue
}
for _, name := range names {
path := filepath.Join(target, name)
if seen[path] {
continue
}
seen[path] = true
victims = append(victims, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace})
}
}
sort.Slice(victims, func(i, j int) bool { return victims[i].Path < victims[j].Path })
if _, err := backupSkillSetupTarget(home, victims, out); err != nil {
return fmt.Errorf("退役 universal Agent Event/misc 旧副本失败,已回滚: %w", err)
}
return nil
}
func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
fmt.Fprintf(out, "📦 将安装 skill:\n mode: %s\n source: %s\n", plan.Mode, plan.Source)
if plan.Mode == skillSetupModeMulti {
@@ -1148,12 +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 (仅迁移旧的通用 DWS 副本)\n", target.Destination)
fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination)
} else if target.LinkCanonical {
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/):")
@@ -1324,8 +1580,12 @@ func installMultiSkillsWithEventMigration(
}
migrationSet := make(map[string]struct{}, len(migrationTargets))
physicalMigrationTargets := make([]string, 0, len(migrationTargets))
for _, dest := range migrationTargets {
migrationSet[dest] = struct{}{}
if !isUniversalSkillSetupBase(dest) {
physicalMigrationTargets = append(physicalMigrationTargets, dest)
}
}
var ordinaryTargets []string
for _, dest := range dests {
@@ -1334,23 +1594,47 @@ func installMultiSkillsWithEventMigration(
}
}
if len(ordinaryTargets) > 0 {
var canonicalTargets, otherOrdinaryTargets []string
for _, dest := range ordinaryTargets {
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, skillSetupModeMulti)))
if strings.HasSuffix(base, "/.agents/skills") {
canonicalTargets = append(canonicalTargets, dest)
} else {
otherOrdinaryTargets = append(otherOrdinaryTargets, dest)
}
}
installOrdinary := func(names, targets []string) error {
if len(targets) == 0 {
return nil
}
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut, filtered)
n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
return err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
return fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
}
return nil
}
canonicalNames := append([]string(nil), skillNames...)
if !containsSkillName(canonicalNames, multiMiscSkill) {
canonicalNames = append(canonicalNames, multiMiscSkill)
sort.Strings(canonicalNames)
}
if err := installOrdinary(canonicalNames, canonicalTargets); err != nil {
return installed, skipped, err
}
if err := installOrdinary(skillNames, otherOrdinaryTargets); err != nil {
return installed, skipped, err
}
// The folded pair is excluded from the ordinary best-effort installer. All
// other selected skills (especially dingtalk-shared) must succeed before the
// old Event route is touched.
for _, dest := range migrationTargets {
for _, dest := range physicalMigrationTargets {
if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil {
return installed, skipped + len(skillNames), cleanupErr
}
@@ -1361,9 +1645,9 @@ func installMultiSkillsWithEventMigration(
prerequisiteNames = append(prerequisiteNames, name)
}
}
if len(prerequisiteNames) > 0 {
if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut, true)
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true)
installed += n
skipped += nSkipped
if err != nil {
@@ -1374,7 +1658,7 @@ func installMultiSkillsWithEventMigration(
}
}
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut)
installed += migrated
if migrationErr != nil {
return installed, skipped, migrationErr
@@ -1646,9 +1930,32 @@ 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))
if target.LinkCanonical {
canonicalTarget := filepath.Join(target.CanonicalBase, filepath.Base(dest))
if samePhysicalSkillSetupPath(dest, canonicalTarget) {
return nil
}
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)
}
if linkErr := skillSetupSymlink(relTarget, stagedDir); linkErr != nil {
return fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedDir, relTarget, linkErr)
}
staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest})
return nil
}
if err := skillSetupMkdirAll(stagedDir, 0o755); err != nil {
return fmt.Errorf("创建 Skill staging 目录失败 %s: %w", stagedDir, err)
}
@@ -1675,16 +1982,11 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
// restoreSkillSetupTarget removes a partially published replacement and
// restores every original directory from its exact backup path.
func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir) error {
var restoreErr error
for i := len(published) - 1; i >= 0; i-- {
if err := skillSetupRemoveAll(published[i]); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
}
}
func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error {
restoreErr := skillSetupRollbackPaths(published)
for i := len(backups) - 1; i >= 0; i-- {
item := backups[i]
if _, err := skillSetupStat(item.original); err == nil {
if _, err := skillSetupLstat(item.original); err == nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup))
continue
} else if !errors.Is(err, os.ErrNotExist) {
@@ -1695,7 +1997,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup))
continue
}
if err := skillSetupPublishRename(item.backup, item.original); err != nil {
if err := skillSetupRestoreBackup(item.backup, item.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup))
}
}
@@ -1728,45 +2030,53 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ
}
func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error {
published := make([]string, 0, len(staged))
published := make([]upgrade.SkillPathPublication, 0, len(staged))
for _, item := range staged {
// Record before rename so rollback also removes a destination created by
// a platform-specific partial failure.
published = append(published, item.dest)
if err := skillSetupPublishRename(item.staged, item.dest); err != nil {
publication, err := skillSetupPublishPath(item.staged, item.dest)
if err != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err)
if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr))
}
return publishErr
}
published = append(published, publication)
}
return nil
}
func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) {
home, homeErr := skillSetupUserHomeDir()
perTarget := 1
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 {
perTarget := 1
if plan.Mode == skillSetupModeMulti {
perTarget = len(plan.MultiSkillNames)
}
isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase)
if target.CleanupOnly {
if skipped > 0 {
continue
}
// Nothing is installed below a universal root, so a stale copy that
// resists retirement must not count as a skipped install: any skipped
// count fails the whole setup, even when every real target succeeded.
if homeErr != nil {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr)
skipped++
fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr)
continue
}
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
skipped++
fmt.Fprintf(errOut, " ⚠️ universal Agent 旧副本迁移失败,已回滚,可手动删除 %s: %v\n", target.Destination, cleanupErr)
}
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 {
@@ -1777,7 +2087,16 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
}
stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target)
if stageErr != nil && target.LinkCanonical {
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
@@ -1787,6 +2106,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
@@ -1797,6 +2119,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
@@ -1836,7 +2161,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[
}
var victims []string
for _, e := range entries {
if !e.IsDir() || keepSet[e.Name()] {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] {
continue
}
if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) {
@@ -0,0 +1,118 @@
package app
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
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, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
return upgrade.SkillPathPublication{}, 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
}
+395
View File
@@ -0,0 +1,395 @@
package app
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
})
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
t.Fatal(err)
}
}
dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
canonical := filepath.Join(home, ".agents", "skills")
if len(dests) != 4 || dests[0] != canonical {
t.Fatalf("targets = %v", dests)
}
src := t.TempDir()
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
dir := filepath.Join(src, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err)
}
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 {
t.Fatal(err)
}
installed, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{})
if err != nil || skipped != 0 || installed != 6 { // canonical + two linked Agents, two Skills each
t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err)
}
if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
t.Fatalf("Codex duplicate remains: %v", err)
}
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical %s missing: %v", name, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
link := filepath.Join(home, agent, "skills", name)
info, err := os.Lstat(link)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("link %s = %#v, %v", link, info, err)
}
}
}
// Re-running setup must recognize the existing links as already correct;
// canonical refreshes in place without turning links into copied trees.
plan, err = buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
if err != nil {
t.Fatal(err)
}
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
for _, agent := range []string{".claude", ".openclaw"} {
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("idempotent setup replaced %s link: %#v, %v", agent, info, err)
}
}
}
func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) {
// Keep the destination HOME synthetic: app-bundle detection is deliberately
// machine-scoped and must not depend on the selected installation HOME.
home := t.TempDir()
testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" })
for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
sentinel := filepath.Join(home, "app-sentinel")
if err := os.MkdirAll(sentinel, 0o755); err != nil {
t.Fatal(err)
}
appInfo, err := os.Stat(sentinel)
if err != nil {
t.Fatal(err)
}
zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")
minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")
originalStat := skillSetupStat
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == zcodeApp || path == minimaxApp {
return appInfo, nil
}
return originalStat(path)
})
dests := detectExistingAgentHomes(home, skillSetupModeMulti)
for _, target := range []string{
filepath.Join(home, ".config", "kimchi", "harness", "skills"),
filepath.Join(home, ".tabnine", "agent", "skills"),
filepath.Join(home, ".zcode", "skills"),
filepath.Join(home, ".minimax", "skills"),
} {
if !containsSkillName(dests, target) {
t.Errorf("detected targets %v missing %s", dests, target)
}
}
}
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")
}
}
@@ -15,6 +15,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
func useManagedSkillNames(t *testing.T, names ...string) {
@@ -336,12 +337,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *test
return originalBackup(homeDir, dir)
})
} else {
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
originalPublish := skillSetupPublishPath
testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
return upgrade.SkillPathPublication{}, failure
}
return originalRename(oldPath, newPath)
return originalPublish(oldPath, newPath)
})
}
@@ -432,8 +433,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
t.Run("restore failure aggregation", func(t *testing.T) {
t.Run("remove published", func(t *testing.T) {
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure })
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
testseam.Swap(t, &skillSetupRollbackPaths, func([]upgrade.SkillPathPublication) error { return failure })
if err := restoreSkillSetupTarget([]upgrade.SkillPathPublication{{Destination: "published"}}, nil); !errors.Is(err, failure) {
t.Fatalf("remove published error = %v", err)
}
})
@@ -445,14 +446,14 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
}
})
t.Run("stat", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") {
t.Fatalf("restore stat error = %v", err)
}
})
t.Run("mkdir", func(t *testing.T) {
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, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") {
@@ -460,9 +461,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
}
})
t.Run("rename", func(t *testing.T) {
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, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") {
t.Fatalf("restore rename error = %v", err)
@@ -479,10 +480,10 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
}
return "", failure
})
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, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
restoreErr := errors.New("restore failure")
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return restoreErr })
_, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard)
if !errors.Is(err, failure) || !errors.Is(err, restoreErr) {
t.Fatalf("backup rollback error = %v", err)
@@ -490,8 +491,11 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
})
t.Run("publish rollback failure", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
return upgrade.SkillPathPublication{}, failure
})
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
err := publishSkillSetupTarget(
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
@@ -530,12 +534,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
t.Run("after publish failure", func(t *testing.T) {
plan := newPlan(t)
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
originalPublish := skillSetupPublishPath
testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
return upgrade.SkillPathPublication{}, failure
}
return originalRename(oldPath, newPath)
return originalPublish(oldPath, newPath)
})
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after publish failure")
@@ -0,0 +1,139 @@
package app
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies
// pins the P0 fix: when a beta.6 folded dingtalk-misc (carrying the personal
// Event route) exists only in a UNIVERSAL agent home (~/.codex/skills), the
// Event/misc migration must not leave physical duplicates there. Universal
// homes read ~/.agents/skills directly, so their old folded copies are retired
// (backed up) and the split standalone event + clean misc land in canonical.
func TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
codexSkills := filepath.Join(home, ".codex", "skills")
writeFoldedEventMisc(t, codexSkills)
// beta.6 physical copies alongside the folded misc.
for _, name := range []string{multiEventSkill, multiSharedSkill} {
if err := os.MkdirAll(filepath.Join(codexSkills, name), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(codexSkills, name, "SKILL.md"), []byte("beta6 "+name+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
canonical := filepath.Join(home, ".agents", "skills")
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
// P0: the universal home must not retain any physical dingtalk-* copy.
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} {
if _, err := os.Stat(filepath.Join(codexSkills, name)); !os.IsNotExist(err) {
t.Fatalf("universal .codex/skills still has physical %s (stat err=%v)", name, err)
}
}
// canonical owns the new standalone event + clean misc (+ shared).
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical missing %s: %v", name, err)
}
}
if err := validateCleanEventMiscRoot(filepath.Join(canonical, multiMiscSkill)); err != nil {
t.Fatalf("canonical misc still contains folded Event content: %v", err)
}
if _, _, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes"); err != nil {
t.Fatalf("idempotent rerun failed: %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFoldedPair(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
canonical := filepath.Join(home, ".agents", "skills")
codexSkills := filepath.Join(home, ".codex", "skills")
writeFoldedEventMisc(t, codexSkills)
writeOldStandaloneEvent(t, codexSkills)
chat := filepath.Join(codexSkills, "dingtalk-chat")
if err := os.MkdirAll(chat, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(chat, "SKILL.md"), []byte("keep chat\n"), 0o644); err != nil {
t.Fatal(err)
}
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc"})
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "doc", "--yes")
if err != nil {
t.Fatalf("selective doc install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if strings.Contains(stdout, "Event 原子迁移") {
t.Fatalf("unrelated selection migrated Event/misc: %s", stdout)
}
assertOldEventMiscPair(t, codexSkills)
if body, err := os.ReadFile(filepath.Join(chat, "SKILL.md")); err != nil || string(body) != "keep chat\n" {
t.Fatalf("unselected chat changed: body=%q err=%v", body, err)
}
}
func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T) {
failure := errors.New("retirement denied")
t.Run("home", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
codex := filepath.Join(t.TempDir(), ".codex", "skills")
if err := retireMigratedUniversalSkills([]string{codex}, []string{multiEventSkill}, io.Discard); !errors.Is(err, failure) {
t.Fatalf("home failure = %v, want %v", err, failure)
}
})
t.Run("deduplicate", func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
codex := filepath.Join(home, ".codex", "skills")
if err := retireMigratedUniversalSkills(
[]string{codex, codex},
[]string{multiEventSkill, multiEventSkill},
io.Discard,
); err != nil {
t.Fatalf("deduplicated retirement failed: %v", err)
}
})
// Retiring an obsolete universal copy installs nothing, so its failure is
// surfaced as a warning and the successful installation is kept.
t.Run("backup failure warns without failing the command", func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
codex := filepath.Join(home, ".codex", "skills")
writeFoldedEventMisc(t, codex)
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
return "", failure
})
_, stderr, err := executeMultiSkillSetupTest(
t,
src,
[]string{filepath.Join(home, ".agents", "skills"), codex},
"--skill", "event", "--yes",
)
if err != nil {
t.Fatalf("retirement backup failure must not fail the command: %v", err)
}
if !strings.Contains(stderr, "退役 universal Agent") {
t.Fatalf("retirement backup failure must be reported, stderr = %q", stderr)
}
})
}
+35 -5
View File
@@ -523,15 +523,45 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing
}
})
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
t.Run("ordinary install error", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return fail })
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration(src, []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "未执行迁移") {
t.Fatalf("ordinary install failure = %v", err)
}
if copyCalls == 0 {
t.Fatal("ordinary staging failure seam was not exercised")
}
})
t.Run("canonical ordinary install error", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
return 0, 0, fail
})
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
universalMigration := filepath.Join(home, ".codex", "skills")
if _, _, err := installMultiSkillsWithEventMigration(
"src",
[]string{multiEventSkill},
[]string{canonical, universalMigration},
[]string{universalMigration},
true,
io.Discard,
io.Discard,
); !errors.Is(err, fail) {
t.Fatalf("canonical ordinary install failure = %v, want %v", err, fail)
}
})
t.Run("prerequisite install error", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
return 0, 0, fail
})
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("ordinary install failure = %v", err)
}
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("prerequisite install failure = %v", err)
}
+24 -36
View File
@@ -48,16 +48,13 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t
backupCalls, copyCalls := []string{}, 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
backupCalls = append(backupCalls, path)
if err := os.RemoveAll(path); err != nil {
return "", err
}
return "backup", nil
})
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(src, dest)
})
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var dryRunOut bytes.Buffer
dryRunCmd.SetOut(&dryRunOut)
@@ -119,6 +116,9 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t
// A filtered multi plan replaces only selected same-name skills and leaves
// unselected siblings out of the backup set.
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
if err != nil {
t.Fatal(err)
@@ -149,55 +149,38 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".codex", "skills")
genericMono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(genericMono, 0o755); err != nil {
canonical := filepath.Join(home, ".agents", "skills")
oldCodex := filepath.Join(dest, "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
return "", errors.New("transient HOME failure")
})
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{canonical, dest}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
t.Fatalf("generic cleanup target = %#v", plan.Targets)
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest {
t.Fatalf("universal cleanup target = %#v", plan.Targets)
}
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex {
t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups)
}
var preview bytes.Buffer
renderSkillSetupPlan(&preview, plan)
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
t.Fatalf("generic cleanup preview missing: %s", preview.String())
if !strings.Contains(preview.String(), "改用统一安装位置") {
t.Fatalf("universal cleanup preview missing: %s", preview.String())
}
t.Run("managed multi and scan failure", func(t *testing.T) {
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
if targetErr != nil || target == nil || len(target.Backups) != 2 {
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
}
failure := errors.New("generic scan failure")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
t.Fatalf("generic scan error = %v", targetErr)
}
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
t.Fatalf("generic cleanup plan error = %v", planErr)
}
})
}
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
failure := errors.New("cleanup failure")
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
t.Run("cleanup runs even after an install skip", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
@@ -206,11 +189,14 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.
}
})
// A cleanup-only target installs nothing, so its failure is a warning and
// must never increment skipped — runSkillSetup turns any skipped count into
// a hard error and would fail an otherwise complete installation.
t.Run("home failure keeps generic copy", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") {
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
@@ -220,7 +206,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "迁移失败") {
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
@@ -247,11 +233,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)
@@ -0,0 +1,85 @@
package app
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
func TestCrossPlatformCoverageSkillSetupRollbackRetainsConcurrentReplacement(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
first := filepath.Join(base, "dingtalk-first")
second := filepath.Join(base, "dingtalk-second")
writeSkillSetupFile(t, first, "old first")
writeSkillSetupFile(t, second, "old second")
backups, err := backupSkillSetupTarget(home, []skillSetupBackup{{Path: first}, {Path: second}}, io.Discard)
if err != nil {
t.Fatal(err)
}
stageRoot := filepath.Join(base, ".stage")
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
writeSkillSetupFile(t, stagedFirst, "new first")
writeSkillSetupFile(t, stagedSecond, "new second")
failure := errors.New("injected second setup publication failure")
originalPublish := skillSetupPublishPath
calls := 0
testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) {
calls++
if calls == 2 {
if err := os.RemoveAll(first); err != nil {
t.Fatal(err)
}
writeSkillSetupFile(t, first, "concurrent")
return upgrade.SkillPathPublication{}, failure
}
return originalPublish(staged, destination)
})
err = publishSkillSetupTarget([]skillSetupStagedDir{
{staged: stagedFirst, dest: first},
{staged: stagedSecond, dest: second},
}, backups)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("setup transaction error = %v", err)
}
assertSkillSetupFile(t, first, "concurrent")
assertSkillSetupFile(t, second, "old second")
var firstBackup string
for _, item := range backups {
if item.original == first {
firstBackup = item.backup
break
}
}
if firstBackup == "" {
t.Fatal("first backup was not recorded")
}
assertSkillSetupFile(t, firstBackup, "old first")
}
func writeSkillSetupFile(t *testing.T, dir, content string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func assertSkillSetupFile(t *testing.T, dir, want string) {
t.Helper()
content, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
if err != nil || string(content) != want {
t.Fatalf("Skill content at %s = %q, %v; want %q", dir, content, err, want)
}
}
+15 -16
View File
@@ -297,12 +297,12 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
if len(got) != 2 {
t.Fatalf("expected canonical + Claude, got %d", len(got))
}
want := filepath.Join(home, ".claude", "skills", "dws")
if filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[0])
if filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[1])
}
}
@@ -321,12 +321,12 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
if len(got) != 2 {
t.Fatalf("expected canonical + Claude, got %d", len(got))
}
want := filepath.Join(home, ".claude", "skills")
if filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[0])
if filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[1])
}
}
@@ -343,8 +343,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t
t.Fatal(err)
}
want := filepath.Join(home, ".codex", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want canonical + %s", got, want)
}
}
@@ -360,8 +360,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
t.Fatal(err)
}
want := filepath.Join(home, ".zcode", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want canonical + %s", got, want)
}
explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono)
@@ -369,8 +369,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
t.Fatal(err)
}
wantMono := filepath.Join(want, "dws")
if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
if len(explicit) != 2 || filepath.Clean(explicit[1]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want canonical + %s", explicit, wantMono)
}
}
@@ -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)
}
+3
View File
@@ -620,6 +620,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
for _, d := range succeeded {
fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir)))
}
for _, d := range result.RetireWarnings() {
fmt.Printf(" %s %s %s\n", ugYellow("⚠"), shortenHome(d.Dir), ugDim("旧副本未能迁移,可手动删除: "+d.Err.Error()))
}
} else {
fmt.Printf(" %s\n", ugGreen("✓"))
}
+9 -3
View File
@@ -292,6 +292,12 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
if stage == "install-failed-dir" {
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil
}
if stage == "install-retire-warning" {
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{
{Dir: "/ok", Status: upgradepkg.SkillDirOK},
{Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")},
}}, nil
}
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil
}
}
@@ -300,7 +306,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
"ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both",
"backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills",
"extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir",
"success", "success-no-skills",
"success", "success-no-skills", "install-retire-warning",
} {
t.Run(stage, func(t *testing.T) {
configure(stage)
@@ -330,14 +336,14 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
opts.skipSkills = true
}
err := runUpgrade(context.Background(), opts)
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills"
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning"
if wantError && err == nil {
t.Fatalf("stage %s succeeded", stage)
}
if !wantError && err != nil {
t.Fatalf("stage %s failed: %v", stage, err)
}
if (stage == "success" || stage == "success-no-skills") && !rb.cleaned {
if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned {
t.Fatal("successful upgrade did not clean backups")
}
if stage == "success" {
+521
View File
@@ -0,0 +1,521 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
})
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
t.Fatal(err)
}
}
// beta.6 wrote a physical copy into Codex. The canonical migration must
// remove it without treating a matching user/market name as DWS-owned.
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
canonical := filepath.Join(home, ".agents", "skills")
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical %s missing: %v", name, err)
}
if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", name)); !os.IsNotExist(err) {
t.Fatalf("universal Codex duplicate remains for %s: %v", name, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
link := filepath.Join(home, agent, "skills", name)
info, err := os.Lstat(link)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("%s link = %#v, %v", link, info, err)
}
if _, err := os.Stat(filepath.Join(link, "SKILL.md")); err != nil {
t.Fatalf("%s does not resolve canonical content: %v", link, err)
}
}
}
result, err = UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("idempotent upgrade = %#v, %v", result, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("idempotent upgrade replaced %s link: %#v, %v", agent, info, err)
}
}
}
func 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 {
t.Fatal(err)
}
testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("links unavailable") })
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat")
result, err := UpgradeSkillLocations(multiRoot)
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
dest := filepath.Join(home, ".claude", "skills", "dingtalk-chat")
info, err := os.Lstat(dest)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("copy fallback = %#v, %v", info, err)
}
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
t.Fatalf("copy fallback content missing: %v", err)
}
}
func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) {
// Keep the destination HOME synthetic: app-bundle detection is deliberately
// machine-scoped and must not depend on the selected installation HOME.
home := t.TempDir()
testseam.Swap(t, &upgradeGetenv, func(string) string { return "" })
for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
sentinel := filepath.Join(home, "app-sentinel")
if err := os.MkdirAll(sentinel, 0o755); err != nil {
t.Fatal(err)
}
appInfo, err := os.Stat(sentinel)
if err != nil {
t.Fatal(err)
}
zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")
minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")
originalStat := upgradeStat
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
if path == zcodeApp || path == minimaxApp {
return appInfo, nil
}
return originalStat(path)
})
want := map[string]bool{
".config/kimchi/harness/skills": false,
".tabnine/agent/skills": false,
".zcode/skills": false,
".minimax/skills": false,
}
for _, root := range configuredSkillRoots(home) {
label := filepath.ToSlash(root.label)
if _, ok := want[label]; ok {
want[label] = skillRootDetectedBase(root)
}
}
for label, detected := range want {
if !detected {
t.Errorf("root %s was not detected", label)
}
}
}
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 TestCrossPlatformCoverageUpgradeCanonicalFailureFailsFastUnconditionally(t *testing.T) {
for _, mode := range []string{"mono", "multi"} {
t.Run(mode, func(t *testing.T) {
withFakeHome(t)
// Canonical-only configuration (no non-universal dependents): a failed
// canonical publish must still fail the whole upgrade. Universal agents
// read canonical directly, so silently succeeding here would leave the
// machine without any usable Skill while reporting success.
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 || !strings.Contains(err.Error(), "canonical Skill 安装失败") {
t.Fatalf("canonical publish failure must propagate an error, got (%v)", err)
}
if result == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("result = %#v", result)
}
})
}
}
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 := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, 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 {
t.Fatalf("retire failure must not fail the upgrade: %v", err)
}
if len(result.Failed()) != 0 {
t.Fatalf("retire failure must not count as an install failure: %#v", result.Failed())
}
warnings := result.RetireWarnings()
if len(warnings) != 1 || warnings[0].Err == nil {
t.Fatalf("retire warnings = %#v", warnings)
}
if len(result.Succeeded()) == 0 {
t.Fatalf("canonical must still be published: %#v", result)
}
if _, statErr := os.Lstat(victim); statErr != nil {
t.Fatalf("unretired copy must be preserved: %v", statErr)
}
})
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)
}
}
+7 -4
View File
@@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) {
}
knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"}
result, err := UpgradeSkillLocations(source)
if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 {
if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 {
t.Fatalf("skill upgrade = %#v, %v", result, err)
}
knownSkillDirs = []string{".real/skills"}
@@ -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++
@@ -593,8 +596,8 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(source)
if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 {
t.Fatalf("replaced primary fallback = %#v, %v", result, err)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical failure = %#v, %v", result, err)
}
failure := errors.New("ensure failed")
upgradeEnsureDir = func(string, os.FileMode) error { return failure }
+441 -199
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
@@ -35,46 +36,133 @@ const (
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
//
// The first entry (.agents/skills) is a generic fallback. It is used only
// when no concrete Agent home is detected; otherwise publishing there would
// duplicate every Skill for Agents (including Codex) that scan both roots.
// Subsequent entries are updated when their parent directory already exists.
// The first entry (.agents/skills) is the canonical global Skill directory,
// following the universal .agents/skills convention.
// Agents that understand the universal .agents convention read it directly;
// detected non-universal Agents receive relative links to this canonical
// copy (with a direct-copy fallback when links are unavailable).
var knownSkillDirs = []string{
".agents/skills",
".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
// natively scan ~/.agents/skills. Publishing another copy or link below these
// roots makes the same Skill appear twice. Keep this classification aligned
// with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally
// non-universal because it is a DWS-specific integration absent upstream.
var universalSkillDirs = map[string]bool{
".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 (
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeRemoveAll = os.RemoveAll
upgradeMkdirAll = os.MkdirAll
upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir
upgradeStat = os.Stat
upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
upgradeWriteSkillState = skillstate.Write
upgradeNow = time.Now
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeRemoveAll = os.RemoveAll
upgradeMkdirAll = os.MkdirAll
upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir
upgradeStat = os.Stat
upgradeLstat = os.Lstat
upgradeSymlink = os.Symlink
upgradeRel = filepath.Rel
upgradeGetenv = os.Getenv
upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read
upgradePublishSkillPath = PublishSkillPathNoReplace
upgradeRollbackPublishedSkillPaths = RollbackSkillPathPublications
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
@@ -85,21 +173,19 @@ const skillBackupSubdir = ".dws/skill-backups"
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel> instead of destroying it, and returns the backup path. It is
// fail-safe: a directory that cannot be backed up is NOT removed and the
// fail-safe: a path that cannot be backed up is NOT removed and the
// error is returned so the caller can surface it (and never install the
// 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 := upgradeStat(dir)
_, err := upgradeLstat(dir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err)
}
if !info.IsDir() {
return "", nil
}
rel, relErr := filepath.Rel(homeDir, dir)
if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") {
rel = filepath.Base(dir)
@@ -109,8 +195,11 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp)
target := filepath.Join(backupRoot, name)
for i := 1; ; i++ {
if _, err := os.Stat(target); os.IsNotExist(err) {
break
if _, err := upgradeLstat(target); err != nil {
if os.IsNotExist(err) {
break
}
return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err)
}
backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i))
target = filepath.Join(backupRoot, name)
@@ -121,7 +210,7 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil {
return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
}
if err := upgradeRename(dir, target); err != nil {
if err := moveSkillPathRecoverably(dir, target); err != nil {
return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err)
}
// Keep the backup directory bounded; a prune failure must not fail the
@@ -151,13 +240,17 @@ const (
SkillDirSkipped // agent not detected, directory skipped
SkillDirBlacklisted // blacklisted, never touched
SkillDirFailed // installation attempted but failed
// SkillDirRetireWarning marks a universal Agent whose obsolete private copy
// could not be retired. Nothing is installed below such a root, so the
// leftover is reported without counting as an install failure.
SkillDirRetireWarning
)
// SkillDirResult holds the per-directory install result.
type SkillDirResult struct {
Dir string // destination directory (e.g. ~/.claude/skills/dws)
Status SkillDirStatus // outcome
Err error // non-nil when Status == SkillDirFailed
Err error // non-nil when Status == SkillDirFailed or SkillDirRetireWarning
}
// SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call.
@@ -187,6 +280,18 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
return out
}
// RetireWarnings returns universal Agent roots that still hold an obsolete
// private copy because retiring it failed.
func (r *SkillUpgradeResult) RetireWarnings() []SkillDirResult {
var out []SkillDirResult
for _, d := range r.Results {
if d.Status == SkillDirRetireWarning {
out = append(out, d)
}
}
return out
}
// UpgradeSkillLocations refreshes skills from extractedDir into agent homes.
// extractedDir may be a multi-skill bundle root (subdirectories each containing
// SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that
@@ -204,11 +309,13 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
// `dws skill setup --mode mono` flow.
//
// Strategy (matches npm install.js installSkillsToHomes):
// - Concrete agent dirs (claude, cursor, codex, ...) are updated when their
// parent directory exists (e.g. ~/.codex/ exists => user has Codex)
// - ~/.agents/skills/ is used only when no concrete Agent is detected
// - ~/.agents/skills/ is always the canonical global store
// - universal Agents (Codex, Cursor, Gemini, Cline, Amp, Copilot) read the
// canonical store directly, so old agent-specific copies are retired
// - detected non-universal Agents receive relative links to canonical;
// filesystems that reject links receive a direct-copy fallback
// - ~/.real/ and other blacklisted paths are NEVER touched
// - If no location was updated at all, fall back to ~/.agents/skills/
// - canonical publication is mandatory and fails the upgrade loudly
//
// Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then
// removed so mono and multi never co-exist after an upgrade; a leftover that
@@ -277,9 +384,22 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string)
// backups are kept.
const skillBackupKeep = 5
// skillBackupStampRegexp matches only directory names DWS itself writes:
// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry
// in the backup root is foreign (user data, unrelated tooling) and must be
// left untouched, so pruning is restricted to names it can prove DWS created.
var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`)
// isSkillBackupStamp reports whether name is a DWS-created backup stamp.
func isSkillBackupStamp(name string) bool {
return skillBackupStampRegexp.MatchString(name)
}
// pruneSkillBackups removes the oldest backup directories when more than
// skillBackupKeep remain. Best-effort: a removal failure never aborts, but
// pruning failures are reported so callers can warn the user.
// skillBackupKeep remain. Only directories whose names match the DWS backup
// stamp format are candidates; unknown directories are preserved. Best-effort:
// a removal failure never aborts, but pruning failures are reported so
// callers can warn the user.
func pruneSkillBackups(homeDir string) error {
root := filepath.Join(homeDir, skillBackupSubdir)
entries, err := upgradeReadDir(root)
@@ -291,7 +411,7 @@ func pruneSkillBackups(homeDir string) error {
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
if e.IsDir() && isSkillBackupStamp(e.Name()) {
names = append(names, e.Name())
}
}
@@ -404,20 +524,15 @@ func uniqueSkillDirs(paths []string) []string {
// restoreSkillSet removes any newly published directories, then restores all
// original directories in reverse backup order.
func restoreSkillSet(published []string, backups []backedUpSkillDir) error {
var restoreErr error
for i := len(published) - 1; i >= 0; i-- {
if err := upgradeRemoveAll(published[i]); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
}
}
func restoreSkillSet(published []SkillPathPublication, backups []backedUpSkillDir) error {
restoreErr := upgradeRollbackPublishedSkillPaths(published)
for i := len(backups) - 1; i >= 0; i-- {
backup := backups[i]
if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err))
continue
}
if err := upgradeRename(backup.backup, backup.original); err != nil {
if err := moveSkillPathRecoverably(backup.backup, backup.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err))
}
}
@@ -450,16 +565,17 @@ func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []st
if err != nil {
return err
}
published := make([]string, 0, len(staged))
published := make([]SkillPathPublication, 0, len(staged))
for _, skill := range staged {
if err := upgradeRename(skill.staged, skill.dest); err != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err)
publication, publishErr := upgradePublishSkillPath(skill.staged, skill.dest)
if publishErr != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, publishErr)
if restoreErr := restoreSkillSet(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr))
}
return publishErr
}
published = append(published, skill.dest)
published = append(published, publication)
}
return nil
}
@@ -517,100 +633,266 @@ func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []str
return publishStagedSkillSet(homeDir, staged, victims)
}
func hasDetectedSpecificSkillRoot(homeDir string) bool {
for _, agentDir := range knownSkillDirs {
if isGenericSkillRoot(agentDir) || isBlacklisted(agentDir) {
continue
}
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if info, err := upgradeStat(parentGate); err == nil && info.IsDir() {
return true
}
}
return false
}
func isGenericSkillRoot(agentDir string) bool {
return filepath.Clean(agentDir) == filepath.Clean(".agents/skills")
}
func retireGenericSkillRoot(homeDir string, managed map[string]bool) error {
base := filepath.Join(homeDir, ".agents", "skills")
func isUniversalSkillRoot(agentDir string) bool {
return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))]
}
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(root resolvedSkillRoot) bool {
detectedDir := filepath.Dir(root.base)
switch filepath.ToSlash(filepath.Clean(root.label)) {
case ".config/kimchi/harness/skills":
detectedDir = filepath.Dir(filepath.Dir(root.base))
case ".tabnine/agent/skills":
detectedDir = filepath.Dir(filepath.Dir(root.base))
case ".zcode/skills":
// Application bundles are machine-scoped detection signals. Keep this
// independent of HOME so upgrade matches npm, Shell, and PowerShell.
if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() {
return true
}
case ".minimax/skills":
if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() {
return true
}
}
info, err := upgradeStat(detectedDir)
return err == nil && info.IsDir()
}
func samePhysicalSkillRoot(left, right string) bool {
leftReal, leftErr := upgradeEvalSymlinks(left)
rightReal, rightErr := upgradeEvalSymlinks(right)
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 retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error {
victims, err := managedMultiSkillVictims(base, managed)
if err != nil {
return err
}
victims = append(victims, filepath.Join(base, "dws"))
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("迁移通用 Skill 根目录失败: %w", err)
return fmt.Errorf("迁移 Agent Skill 旧副本失败: %w", err)
}
return nil
}
func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) (stageRoot string, staged []stagedSkillDir, err error) {
if err := upgradeMkdirAll(destBase, dirPermShared); err != nil {
return "", nil, fmt.Errorf("创建 Agent Skill 目录失败 %s: %w", destBase, err)
}
stageRoot, err = upgradeMkdirTemp(destBase, prefix)
if err != nil {
return "", nil, fmt.Errorf("创建 Skill 链接 staging 失败 %s: %w", destBase, err)
}
defer func() {
if err != nil {
_ = upgradeRemoveAll(stageRoot)
}
}()
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)
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)
}
stagedPath := filepath.Join(stageRoot, name)
if linkErr := upgradeSymlink(relTarget, stagedPath); linkErr != nil {
return stageRoot, nil, fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedPath, relTarget, linkErr)
}
staged = append(staged, stagedSkillDir{staged: stagedPath, dest: filepath.Join(destBase, name)})
}
return stageRoot, staged, nil
}
func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names []string, victims []string) error {
needed := make([]string, 0, len(names))
correct := make(map[string]bool, len(names))
for _, name := range names {
dest := filepath.Join(destBase, name)
canonical := filepath.Join(canonicalBase, name)
if samePhysicalSkillRoot(dest, canonical) {
correct[filepath.Clean(dest)] = true
continue
}
needed = append(needed, name)
}
filteredVictims := make([]string, 0, len(victims))
for _, victim := range victims {
if !correct[filepath.Clean(victim)] {
filteredVictims = append(filteredVictims, victim)
}
}
stageRoot, staged, err := stageLinkedSkillSet(destBase, canonicalBase, ".dws-link-set-", needed)
if err != nil {
return err
}
defer func() { _ = upgradeRemoveAll(stageRoot) }()
return publishStagedSkillSet(homeDir, staged, filteredVictims)
}
// upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory
// per agent home.
func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) {
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir)
canonicalBase := filepath.Join(homeDir, ".agents", "skills")
canonicalDest := filepath.Join(canonicalBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err})
// Canonical is the mandatory store: universal agents read it directly, so a
// failed canonical publish means nobody received the upgrade. Always fail
// loud — including universal-only machines — instead of reporting success
// with nothing installed.
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK})
for _, agentDir := range knownSkillDirs {
destDir := filepath.Join(homeDir, agentDir, "dws")
if isBlacklisted(agentDir) {
for _, root := range configuredSkillRoots(homeDir) {
if root.canonical {
continue
}
destBase := root.base
destDir := filepath.Join(destBase, "dws")
if isBlacklisted(root.label) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
continue
}
if isGenericSkillRoot(agentDir) && hasSpecificRoot {
if !skillRootDetectedBase(root) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
if !isGenericSkillRoot(agentDir) {
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
if samePhysicalSkillRoot(destBase, canonicalBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
if root.universal {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
// Nothing is installed below a universal root, so a stale copy that
// resists retirement is a warning, not an install failure.
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirRetireWarning, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
}
continue
}
victims, victimErr := monoUpgradeVictims(destBase, destDir, managedNames)
if victimErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: victimErr})
continue
}
if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, []string{"dws"}, victims); err != nil {
// Match the upstream installer: platforms/filesystems that reject
// links receive a direct copy rather than losing Agent support.
if copyErr := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); copyErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: errors.Join(err, copyErr)})
continue
}
}
if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
}
if hasSpecificRoot && len(result.Succeeded()) > 0 {
genericBase := filepath.Join(homeDir, ".agents", "skills")
if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
}
}
// Fallback: if nothing succeeded, force the primary location. The multi
// leftovers under the primary base are the usual reason the primary
// install failed, so clean them first — failing loud like the multi
// fallback — instead of letting mono and multi co-exist marked OK.
if len(result.Succeeded()) == 0 && !hasSpecificRoot {
destBase := filepath.Join(homeDir, ".agents", "skills")
dest := filepath.Join(destBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
replaced := false
for idx, r := range result.Results {
if r.Dir == dest {
result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK})
}
}
// Best-effort: refresh the user-level mono cache so that
// `dws skill setup --mode mono` fallbacks stay on the upgraded version
@@ -633,61 +915,56 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir)
canonicalBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err})
// Canonical is the mandatory store (see the mono branch): fail loud even
// on universal-only machines instead of reporting success with nothing
// installed.
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK})
for _, agentDir := range knownSkillDirs {
destBase := filepath.Join(homeDir, agentDir)
if isBlacklisted(agentDir) {
for _, root := range configuredSkillRoots(homeDir) {
if root.canonical {
continue
}
destBase := root.base
if isBlacklisted(root.label) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
continue
}
if isGenericSkillRoot(agentDir) && hasSpecificRoot {
if !skillRootDetectedBase(root) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
if !isGenericSkillRoot(agentDir) {
parentGate := filepath.Dir(destBase)
if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
if samePhysicalSkillRoot(destBase, canonicalBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
if root.universal {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
// Nothing is installed below a universal root, so a stale copy that
// resists retirement is a warning, not an install failure.
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirRetireWarning, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
}
continue
}
victims, victimErr := multiUpgradeVictims(destBase, skillSet, skills, managedNames)
if victimErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: victimErr})
continue
}
if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, skills, victims); err != nil {
if copyErr := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); copyErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: errors.Join(err, copyErr)})
continue
}
}
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
if hasSpecificRoot && len(result.Succeeded()) > 0 {
genericBase := filepath.Join(homeDir, ".agents", "skills")
if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
}
}
// Fallback: if nothing succeeded, force the primary location
if len(result.Succeeded()) == 0 && !hasSpecificRoot {
destBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
replaced := false
for idx, r := range result.Results {
if r.Dir == destBase {
result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
}
// Best-effort: refresh the user-level caches so that `dws skill setup`
// fallbacks stay on the upgraded version. The release zip ships both
@@ -762,23 +1039,6 @@ func skillTreeHasRoot(dir string) bool {
return err == nil && !info.IsDir()
}
// cleanupMultiLeftovers backs up + removes every proven DWS-managed
// multi-mode skill directory inside one agent home before mono is installed.
// A missing base directory simply means no leftovers; any other read failure
// is reported so mono never silently co-exists with multi. Removal is
// reversible: each leftover is preserved under ~/.dws/skill-backups/ and a
// backup failure aborts the removal for that home.
func cleanupMultiLeftovers(homeDir, baseDir string) error {
victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
}
return nil
}
func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) {
entries, err := upgradeReadDir(baseDir)
if err != nil {
@@ -789,7 +1049,7 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str
}
victims := make([]string, 0)
for _, e := range entries {
if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(baseDir, e.Name()))
@@ -797,24 +1057,6 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str
return victims, nil
}
// cleanupOppositeModeLeftovers backs up + removes, inside one agent home, the
// legacy mono directory (dws/) and every proven DWS-managed multi skill
// directory that is not part of the new bundle. A dingtalk-* prefix alone is
// not proof of ownership because market/user skills may use the same prefix.
// Removal is reversible: each
// directory is preserved under ~/.dws/skill-backups/ and a backup failure
// aborts the removal for that home.
func cleanupOppositeModeLeftovers(homeDir, destBase string, skillSet map[string]bool) error {
victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
}
return nil
}
func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) {
victims := []string{filepath.Join(destBase, "dws")}
entries, err := upgradeReadDir(destBase)
@@ -825,7 +1067,7 @@ func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed
return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err)
}
for _, e := range entries {
if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(destBase, e.Name()))
+186 -232
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
@@ -22,6 +23,16 @@ func withFakeHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil })
// Host-installed macOS apps must not make a fake HOME appear to have extra
// detected Agents. Tests that exercise application-based detection can
// override this seam explicitly.
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
applications := filepath.Join(string(filepath.Separator), "Applications")
if path == filepath.Join(applications, "ZCode.app") || path == filepath.Join(applications, "MiniMax Code.app") {
return nil, os.ErrNotExist
}
return os.Stat(path)
})
return home
}
@@ -124,8 +135,7 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) {
func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
home := withFakeHome(t)
// A concrete Agent root wins over the generic .agents fallback; .claude
// installs and .cursor is skipped.
// .agents is canonical; detected Claude receives links and Cursor is skipped.
agentsBase := filepath.Join(home, ".agents", "skills")
claudeBase := filepath.Join(home, ".claude", "skills")
for _, base := range []string{agentsBase, claudeBase} {
@@ -193,17 +203,19 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
// Succeeded entries report the agent home base in multi mode.
succeeded := result.Succeeded()
if len(succeeded) != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results)
if len(succeeded) != 2 {
t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results)
}
wantDirs := map[string]bool{claudeBase: true}
wantDirs := map[string]bool{agentsBase: true, claudeBase: true}
for _, d := range succeeded {
if !wantDirs[d.Dir] {
t.Errorf("unexpected succeeded dir %q", d.Dir)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("generic mono duplicate still visible: %v", err)
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Fatalf("canonical Skill missing: %v", err)
}
}
// Multi cache refreshed under the fake home.
@@ -216,7 +228,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
}
}
func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) {
func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"})
@@ -241,12 +253,12 @@ func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicat
t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err)
}
want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md")
want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md")
if _, err := os.Stat(want); err != nil {
t.Fatalf("canonical Codex Skill missing at %s: %v", want, err)
t.Fatalf("canonical Skill missing at %s: %v", want, err)
}
for _, duplicate := range []string{
filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"),
filepath.Join(codexBase, "dingtalk-chat"),
filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"),
} {
if _, err := os.Stat(duplicate); !os.IsNotExist(err) {
@@ -502,6 +514,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)
@@ -526,21 +541,15 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(
}
result, err := UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("fallback should replace the failed entry with OK, got failed=%v", failed)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (%v)", got, result.Results)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical copy failure = %#v, %v", result, err)
}
data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md"))
if err != nil {
t.Fatalf("mono fallback install missing: %v", err)
}
if string(data) != "# mono" {
t.Errorf("mono content = %q, want refreshed package", data)
if string(data) != "old" {
t.Errorf("mono content = %q, want original preserved", data)
}
}
@@ -574,22 +583,22 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
}
result, err := UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err)
if err == nil {
t.Fatal("canonical read failure must fail the upgrade")
}
failed := result.Failed()
if len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
}
wantDir := agentsBase
wantDir := filepath.Join(agentsBase, "dws")
if failed[0].Dir != wantDir || failed[0].Err == nil {
t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir)
}
if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") {
t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
if got := len(result.Succeeded()); got != 0 {
t.Fatalf("Succeeded() len = %d, want 0", got)
}
// Mono must NOT have been laid down next to the unreadable multi state.
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
@@ -603,6 +612,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 {
@@ -613,8 +625,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
}
useUpgradeManagedNames(t, filepath.Base(staleDir))
origRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
origRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error {
if strings.Contains(src, "dingtalk-stale") {
return errors.New("injected backup failure")
}
@@ -628,8 +640,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
if err == nil {
t.Fatal("multi fallback cleanup failure must return an error")
}
if !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("error should mention the fallback cleanup failure, got %v", err)
if !strings.Contains(err.Error(), "canonical Skill 安装失败") {
t.Fatalf("error should mention the canonical cleanup failure, got %v", err)
}
if failed := result.Failed(); len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
@@ -644,7 +656,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) {
@@ -654,8 +666,8 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
// backup. Inject it through the path seam so this branch is portable to
// Windows, where chmod-based permission failures are not reliable.
statFailurePath := filepath.Join(home, "stat-failure")
origStat := upgradeStat
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
origStat := upgradeLstat
testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
if path == statFailurePath {
return nil, errors.New("injected stat failure")
}
@@ -665,16 +677,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
@@ -722,6 +734,46 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatalf("victim must be gone after backup, stat err=%v", err)
}
// A dangling link at the first backup target is still an occupied lexical
// path and must select a numbered target rather than being overwritten.
danglingVictim := filepath.Join(home, ".agents", "skills", "dangling-collision")
if err := os.MkdirAll(danglingVictim, 0o755); err != nil {
t.Fatal(err)
}
danglingTaken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dangling-collision")
if err := os.Symlink("missing-backup", danglingTaken); err == nil {
got, err = backupAndRemoveSkillDir(home, danglingVictim)
if err != nil {
t.Fatalf("backup with dangling collision error = %v", err)
}
want = filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dangling-collision")
if got != want {
t.Fatalf("dangling collision backup path = %q, want %q", got, want)
}
} else if runtime.GOOS != "windows" {
t.Fatal(err)
}
// An unreadable target probe is not equivalent to a free path.
probeVictim := filepath.Join(home, "probe-victim")
if err := os.MkdirAll(probeVictim, 0o755); err != nil {
t.Fatal(err)
}
probeTarget := filepath.Join(home, skillBackupSubdir, "20260810-000000", "probe-victim")
testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
if path == probeTarget {
return nil, errors.New("target probe denied")
}
return os.Lstat(path)
})
if _, err := backupAndRemoveSkillDir(home, probeVictim); err == nil || !strings.Contains(err.Error(), "检查备份目标失败") {
t.Fatalf("target probe error = %v", err)
}
if _, err := os.Stat(probeVictim); err != nil {
t.Fatalf("probe victim must survive target check failure: %v", err)
}
testseam.Swap(t, &upgradeLstat, os.Lstat)
// Unresolvable collision (>1000 numbered stamps taken) fails and keeps dir.
victim2 := filepath.Join(home, "victim2")
if err := os.MkdirAll(victim2, 0o755); err != nil {
@@ -762,14 +814,14 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
if err := os.MkdirAll(victim4, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("rename denied") })
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("rename denied") })
if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") {
t.Fatalf("rename error = %v", err)
}
if _, err := os.Stat(victim4); err != nil {
t.Fatalf("victim4 must survive rename failure: %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
}
// TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention:
@@ -825,6 +877,60 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll)
}
// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that
// pruneSkillBackups only retires directories whose names match the DWS backup
// stamp format. Any foreign directory in the backup root must survive even when
// more than skillBackupKeep stamped backups exist.
func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) {
home := t.TempDir()
root := filepath.Join(home, skillBackupSubdir)
for i := 0; i < skillBackupKeep+2; i++ {
if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)), 0o755); err != nil {
t.Fatal(err)
}
}
// A collision-suffixed stamp is still a valid DWS backup and is eligible.
if err := os.MkdirAll(filepath.Join(root, "20260910-000000-7"), 0o755); err != nil {
t.Fatal(err)
}
unknown := []string{
"user-personal-backup",
"20260910-000000-abc", // stamp-shaped but non-numeric suffix
"20260910-00000", // too short
"notes",
}
for _, name := range unknown {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatal(err)
}
}
if err := pruneSkillBackups(home); err != nil {
t.Fatalf("pruneSkillBackups() error = %v", err)
}
for _, name := range unknown {
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err)
}
}
// Only stamped backups beyond skillBackupKeep are pruned; foreign entries
// are excluded from the count, so the stamp total stays at skillBackupKeep.
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
stamps := 0
for _, e := range entries {
if isSkillBackupStamp(e.Name()) {
stamps++
}
}
if stamps != skillBackupKeep {
t.Errorf("stamped backups after prune = %d, want %d", stamps, skillBackupKeep)
}
}
// TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of
// the mono-source resolver and the multi-bundle child lookup.
func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
@@ -888,88 +994,27 @@ func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
// TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono
// path's fail-loud backup semantics and every primary-fallback outcome.
func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
originalDirs := append([]string(nil), knownSkillDirs...)
t.Cleanup(func() { knownSkillDirs = originalDirs })
mono := t.TempDir()
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
// Per-home backup failure marks that home failed; another home still wins.
home := withFakeHome(t)
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755)
os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644)
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error {
if strings.Contains(src, ".agents") {
return errors.New("backup denied")
}
return os.Rename(src, dst)
return renameSkillPathNoReplace(src, dst)
})
result, err := UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want exactly 1 backup-failed home", failed)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical backup failure = %#v, %v", result, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatalf("failed home must keep its original dws/: %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback cleanup failure: only a blacklisted home in the main loop, then
// the primary cleanup hits a stale dir whose backup fails.
home2 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
knownSkillDirs = []string{".real/skills"}
stale := filepath.Join(home2, ".agents", "skills", "dingtalk-stale")
os.MkdirAll(stale, 0o755)
useUpgradeManagedNames(t, filepath.Base(stale))
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") })
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback cleanup error = %v", err)
}
if _, err := os.Stat(stale); err != nil {
t.Fatalf("stale dir must survive failed cleanup: %v", err)
}
// Fallback backup failure of the primary dws/ itself.
home3 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
os.MkdirAll(filepath.Join(home3, ".agents", "skills", "dws"), 0o755)
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback copy failure: everything fails loud.
home4 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback copy error = %v", err)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback append (no prior entry for the primary dir): a blacklisted-only
// main loop leaves no primary entry, so the fallback appends a fresh OK.
home5 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
result, err = UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("fallback append error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 appended primary", got)
}
if _, err := os.Stat(filepath.Join(home5, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatalf("fallback append install missing: %v", err)
t.Fatalf("failed canonical home must keep its original dws/: %v", err)
}
}
@@ -977,15 +1022,10 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
// path's blacklisted branch, per-skill backup/copy failures, and the primary
// fallback outcomes (cleanup OK + backup/copy failure, fresh append).
func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
originalDirs := append([]string(nil), knownSkillDirs...)
t.Cleanup(func() { knownSkillDirs = originalDirs })
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
// Blacklisted entry is reported, non-blacklisted installs.
home := withFakeHome(t)
knownSkillDirs = []string{".real/skills", ".agents/skills"}
testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"})
os.MkdirAll(filepath.Join(home, ".agents"), 0o755)
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
@@ -1003,119 +1043,33 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) {
t.Fatalf("blacklisted home must never be touched, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("canonical install missing: %v", err)
}
}
// A detected concrete Agent root wins over .agents. A failure while retiring
// the old generic copy is surfaced after the concrete root succeeds.
home2 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
os.MkdirAll(filepath.Join(home2, ".agents", "skills", "dingtalk-chat"), 0o755)
os.MkdirAll(filepath.Join(home2, ".claude"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, ".agents") {
return errors.New("backup denied")
}
return os.Rename(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
// The production wrappers that used to bundle these two steps were removed as
// dead code; the test keeps exercising the live building blocks they composed.
func cleanupMultiLeftoversForTest(homeDir, baseDir string) error {
victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
return err
}
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want 1 backup-failed home", failed)
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
}
if _, err := os.Stat(filepath.Join(home2, ".agents", "skills", "dingtalk-chat")); err != nil {
t.Fatalf("failed home must keep its original skill: %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
return nil
}
// Per-skill copy failure on the concrete home fails that home; with no
// successful concrete target, the generic copy is left untouched.
home3 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
os.MkdirAll(filepath.Join(home3, ".claude"), 0o755)
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
if strings.Contains(dst, ".claude") {
return errors.New("copy denied")
}
return copyDir(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
func cleanupOppositeModeLeftoversForTest(homeDir, destBase string, skillSet map[string]bool) error {
victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
return err
}
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want 1 copy-failed home", failed)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback backup failure: the same-name bundle skill refresh is what
// fails (cleanup succeeds, the per-skill backup does not).
home4 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
knownSkillDirs = []string{".real/skills"}
os.MkdirAll(filepath.Join(home4, ".agents", "skills", "dingtalk-chat"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, "dingtalk-chat") {
return errors.New("backup denied")
}
return os.Rename(src, dst)
})
if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback copy failure.
home5 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback copy error = %v", err)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback append: blacklisted-only main loop → fresh OK entry appended.
home6 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home6, nil })
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("fallback append error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 appended primary (%v)", got, result.Results)
}
if _, err := os.Stat(filepath.Join(home6, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("fallback append install missing: %v", err)
}
// Fallback replace: the main loop fails the primary home on a transient
// copy error, the fallback retry succeeds and replaces the failed entry.
home7 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home7, nil })
knownSkillDirs = []string{".agents/skills"}
origCopy := upgradeCopyDir
attempts := 0
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
attempts++
if attempts == 1 {
return errors.New("transient copy failure")
}
return origCopy(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("fallback replace error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 replaced primary (%v)", got, result.Results)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("failed entry must be replaced by OK, got %v", failed)
}
if _, err := os.Stat(filepath.Join(home7, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("fallback replace install missing: %v", err)
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
}
return nil
}
// TestCrossPlatformCoverageCleanupLeftoversEdges pins the cleanup helpers:
@@ -1127,40 +1081,40 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
// Read failure (non-ENOENT) surfaces from both cleanups.
testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("read denied") })
if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupMultiLeftovers read error = %v", err)
if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupMultiLeftoversForTest read error = %v", err)
}
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupOppositeModeLeftovers read error = %v", err)
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupOppositeModeLeftoversForTest read error = %v", err)
}
testseam.Swap(t, &upgradeReadDir, os.ReadDir)
// Backup failure of a multi leftover aborts cleanupMultiLeftovers.
os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755)
useUpgradeManagedNames(t, "dingtalk-stale")
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") })
if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") {
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("backup denied") })
if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") {
t.Fatalf("cleanupMultiLeftovers backup error = %v", err)
}
// Backup failure of the mono leftover aborts the opposite-mode cleanup.
os.MkdirAll(filepath.Join(base, "dws"), 0o755)
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup mono backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
// Backup failure of a stale (non-mono) skill aborts with its own message.
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error {
if strings.Contains(src, "dingtalk-stale") {
return errors.New("backup denied")
}
return os.Rename(src, dst)
return renameSkillPathNoReplace(src, dst)
})
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup stale backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
// Success matrix: mono leftover + stale skill removed into backups, bundle
// skill and regular file preserved.
@@ -1168,7 +1122,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
skillSet := map[string]bool{"dingtalk-keep": true}
os.MkdirAll(filepath.Join(base, "dingtalk-keep"), 0o755)
os.WriteFile(filepath.Join(base, "regular-file"), []byte("x"), 0o644)
if err := cleanupOppositeModeLeftovers(home, base, skillSet); err != nil {
if err := cleanupOppositeModeLeftoversForTest(home, base, skillSet); err != nil {
t.Fatalf("cleanupOppositeModeLeftovers() error = %v", err)
}
for _, gone := range []string{"dws", "dingtalk-stale"} {
@@ -1188,7 +1142,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
}
// cleanupMultiLeftovers on a missing base is a no-op.
if err := cleanupMultiLeftovers(home, filepath.Join(home, "missing")); err != nil {
if err := cleanupMultiLeftoversForTest(home, filepath.Join(home, "missing")); err != nil {
t.Fatalf("missing base must be a no-op, got %v", err)
}
}
+358
View File
@@ -0,0 +1,358 @@
package upgrade
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
)
var (
skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) }
skillPathRenameNoReplace = renameSkillPathNoReplace
skillPathCopy = copySkillPathLexically
skillPathVerify = verifySkillPathCopy
skillPathRemoveAll = os.RemoveAll
skillPathMkdirAll = os.MkdirAll
skillPathMkdirTemp = os.MkdirTemp
skillPathChmod = os.Chmod
skillPathLstat = os.Lstat
skillPathMkdir = os.Mkdir
skillPathLink = os.Link
skillPathRemove = os.Remove
skillPathReadDir = os.ReadDir
skillPathFileIdentity = skillPathFileIdentityImpl
skillPathFileIncarnation = skillPathFileIncarnationImpl
skillPathSameFileIdentity = skillPathSameFileIdentityImpl
skillPathReadlink = os.Readlink
skillPathSymlink = os.Symlink
skillPathOpen = os.Open
skillPathOpenFile = os.OpenFile
skillPathCopyBytes = io.Copy
skillPathSync = func(file *os.File) error { return file.Sync() }
skillPathWalkDir = filepath.WalkDir
)
// moveSkillPathRecoverably moves one managed Skill path without weakening the
// backup contract when source and destination are on different filesystems.
// The cross-device path publishes a fully copied and verified sibling staging
// path before removing the source. Any failure before source removal leaves the
// source intact; a removal failure deliberately leaves both verified copies.
func moveSkillPathRecoverably(src, dst string) (err error) {
if _, statErr := skillPathLstat(dst); statErr == nil {
return fmt.Errorf("目标已存在: %s", dst)
} else if !os.IsNotExist(statErr) {
return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr)
}
if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil {
return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err)
}
if renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil {
return nil
} else if !isCrossDeviceError(renameErr) {
return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr)
}
stageRoot, err := skillPathMkdirTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".cross-device-")
if err != nil {
return fmt.Errorf("创建跨设备 Skill staging 失败 %s: %w", dst, err)
}
stage := filepath.Join(stageRoot, "payload")
stageCleaned := false
defer func() {
if stageCleaned {
return
}
makeSkillPathTreeWritable(stageRoot)
if cleanupErr := skillPathRemoveAll(stageRoot); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("清理跨设备 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
}()
if err := skillPathCopy(src, stage); err != nil {
return fmt.Errorf("复制跨设备 Skill staging 失败 %s: %w", stage, err)
}
if err := skillPathVerify(src, stage); err != nil {
return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err)
}
stageInfo, err := skillPathLstat(stage)
if err != nil {
return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err)
}
if stageInfo.IsDir() {
// Darwin refuses to rename a read-only directory even when both parent
// directories are writable. The exact source mode was verified above;
// temporarily add owner access for publication, then restore it before
// the final verification and before the source is removed.
if err := skillPathChmod(stage, stageInfo.Mode().Perm()|0o700); err != nil {
return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err)
}
}
if err := skillPathRenameNoReplace(stage, dst); err != nil {
return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err)
}
if stageInfo.IsDir() {
if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil {
return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err)
}
}
if err := skillPathVerify(src, dst); err != nil {
return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err)
}
if err := skillPathRemoveAll(stageRoot); err != nil {
return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err)
}
stageCleaned = true
if err := removePublishedSkillSource(src); err != nil {
return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, err)
}
return nil
}
type skillPathDirMode struct {
path string
mode os.FileMode
}
func removePublishedSkillSource(src string) error {
dirModes, err := prepareSkillPathTreeRemoval(src)
if err != nil {
return err
}
removeErr := skillPathRemoveAll(src)
if removeErr == nil {
if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) {
return nil
} else if statErr == nil {
removeErr = errors.New("源路径仍存在")
} else {
removeErr = fmt.Errorf("无法确认源路径已删除: %w", statErr)
}
}
return errors.Join(removeErr, restoreSkillPathDirModes(dirModes))
}
func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) {
var modes []skillPathDirMode
err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
mode := info.Mode().Perm()
if mode&0o700 == 0o700 {
return nil
}
if err := skillPathChmod(path, mode|0o700); err != nil {
return err
}
modes = append(modes, skillPathDirMode{path: path, mode: mode})
return nil
})
if err != nil {
return nil, errors.Join(err, restoreSkillPathDirModes(modes))
}
return modes, nil
}
func restoreSkillPathDirModes(modes []skillPathDirMode) error {
var restoreErr error
for i := len(modes) - 1; i >= 0; i-- {
item := modes[i]
if _, err := skillPathLstat(item.path); os.IsNotExist(err) {
continue
} else if err != nil {
restoreErr = errors.Join(restoreErr, err)
continue
}
if err := skillPathChmod(item.path, item.mode); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复源目录权限失败 %s: %w", item.path, err))
}
}
return restoreErr
}
func makeSkillPathTreeWritable(root string) {
// Best effort only: RemoveAll below remains the source of truth and its
// error is returned. This preparation lets it traverse read-only staging
// directories left by a copy or verification failure.
_ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr == nil && entry.IsDir() {
_ = skillPathChmod(path, 0o700)
}
return nil
})
}
// RestoreSkillPath restores a previously recorded backup using the same
// rename-first, verified cross-device fallback as backup publication.
func RestoreSkillPath(backup, original string) error {
return moveSkillPathRecoverably(backup, original)
}
func copySkillPathLexically(src, dst string) error {
info, err := skillPathLstat(src)
if err != nil {
return err
}
switch {
case info.IsDir():
// Staging must remain writable while children are copied. Restore the
// source mode only after the directory is complete so read-only Skill
// directories (for example 0555) can still be migrated lexically.
if err := skillPathMkdir(dst, 0o700); err != nil {
return err
}
entries, err := skillPathReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
if err := copySkillPathLexically(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
return err
}
}
return os.Chmod(dst, info.Mode().Perm())
case info.Mode()&os.ModeSymlink != 0:
target, err := skillPathReadlink(src)
if err != nil {
return err
}
return skillPathSymlink(target, dst)
case info.Mode().IsRegular():
return copyRegularSkillFile(src, dst, info.Mode().Perm())
default:
return fmt.Errorf("不支持复制特殊 Skill 路径 %s(mode=%s)", src, info.Mode())
}
}
func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) {
in, err := skillPathOpen(src)
if err != nil {
return err
}
defer func() { err = errors.Join(err, in.Close()) }()
out, err := skillPathOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer func() { err = errors.Join(err, out.Close()) }()
if _, err := skillPathCopyBytes(out, in); err != nil {
return err
}
if err := skillPathSync(out); err != nil {
return err
}
return os.Chmod(dst, mode)
}
func verifySkillPathCopy(src, dst string) error {
srcInfo, err := skillPathLstat(src)
if err != nil {
return err
}
dstInfo, err := skillPathLstat(dst)
if err != nil {
return err
}
if srcInfo.Mode()&os.ModeType != dstInfo.Mode()&os.ModeType {
return fmt.Errorf("路径类型不一致: %s (%s) != %s (%s)", src, srcInfo.Mode(), dst, dstInfo.Mode())
}
switch {
case srcInfo.IsDir():
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
return fmt.Errorf("目录权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm())
}
srcNames, err := skillPathEntryNames(src)
if err != nil {
return err
}
dstNames, err := skillPathEntryNames(dst)
if err != nil {
return err
}
if len(srcNames) != len(dstNames) {
return fmt.Errorf("目录项数量不一致: %s (%d) != %s (%d)", src, len(srcNames), dst, len(dstNames))
}
for i, name := range srcNames {
if name != dstNames[i] {
return fmt.Errorf("目录项不一致: %s != %s", name, dstNames[i])
}
if err := verifySkillPathCopy(filepath.Join(src, name), filepath.Join(dst, name)); err != nil {
return err
}
}
return nil
case srcInfo.Mode()&os.ModeSymlink != 0:
srcTarget, err := skillPathReadlink(src)
if err != nil {
return err
}
dstTarget, err := skillPathReadlink(dst)
if err != nil {
return err
}
if srcTarget != dstTarget {
return fmt.Errorf("符号链接目标不一致: %q != %q", srcTarget, dstTarget)
}
return nil
case srcInfo.Mode().IsRegular():
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
return fmt.Errorf("文件权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm())
}
if srcInfo.Size() != dstInfo.Size() {
return fmt.Errorf("文件大小不一致: %s (%d) != %s (%d)", src, srcInfo.Size(), dst, dstInfo.Size())
}
srcDigest, err := skillPathFileDigest(src)
if err != nil {
return err
}
dstDigest, err := skillPathFileDigest(dst)
if err != nil {
return err
}
if srcDigest != dstDigest {
return fmt.Errorf("文件内容摘要不一致: %s != %s", src, dst)
}
return nil
default:
return fmt.Errorf("不支持校验特殊 Skill 路径 %s(mode=%s)", src, srcInfo.Mode())
}
}
func skillPathEntryNames(dir string) ([]string, error) {
entries, err := skillPathReadDir(dir)
if err != nil {
return nil, err
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
sort.Strings(names)
return names, nil
}
func skillPathFileDigest(path string) ([sha256.Size]byte, error) {
f, err := skillPathOpen(path)
if err != nil {
return [sha256.Size]byte{}, err
}
defer f.Close()
hash := sha256.New()
if _, err := skillPathCopyBytes(hash, f); err != nil {
return [sha256.Size]byte{}, err
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return digest, nil
}
@@ -0,0 +1,529 @@
package upgrade
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) {
t.Run("destination probe", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
original := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == dst {
return nil, os.ErrPermission
}
return original(path)
})
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("destination parent", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
testseam.Swap(t, &skillPathMkdirAll, func(string, os.FileMode) error { return os.ErrPermission })
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("staging creation", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", os.ErrPermission })
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("failed staging cleanup", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
testseam.Swap(t, &skillPathCopy, func(string, string) error { return errors.New("copy failed") })
testseam.Swap(t, &skillPathRemoveAll, func(string) error { return errors.New("cleanup failed") })
err := moveSkillPathRecoverably(src, dst)
if err == nil || !strings.Contains(err.Error(), "copy failed") || !strings.Contains(err.Error(), "cleanup failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("missing copied staging", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
testseam.Swap(t, &skillPathCopy, func(string, string) error { return nil })
testseam.Swap(t, &skillPathVerify, func(string, string) error { return nil })
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "检查跨设备") {
t.Fatalf("error = %v", err)
}
})
for _, tc := range []struct {
name string
path func(src, dst string) string
want string
}{
{name: "prepare staging mode", path: func(_, dst string) string { return filepath.Join(filepath.Dir(dst), "payload") }, want: "准备跨设备"},
{name: "restore published mode", path: func(_, dst string) string { return dst }, want: "恢复已发布"},
} {
t.Run(tc.name, func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
original := skillPathChmod
testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error {
if tc.name == "prepare staging mode" && strings.HasSuffix(path, string(filepath.Separator)+"payload") {
return errors.New("chmod failed")
}
if tc.name == "restore published mode" && path == dst {
return errors.New("chmod failed")
}
return original(path, mode)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v", err)
}
})
}
t.Run("published staging cleanup", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
original := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if strings.Contains(filepath.Base(path), ".cross-device-") {
return errors.New("cleanup failed")
}
return original(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") {
t.Fatalf("error = %v", err)
}
})
t.Run("source deletion cannot be confirmed", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
removalAttempted := false
originalLstat := skillPathLstat
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
removalAttempted = true
return nil
}
return originalRemove(path)
})
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == src && removalAttempted {
return nil, os.ErrPermission
}
return originalLstat(path)
})
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) {
t.Run("source preparation error", func(t *testing.T) {
testseam.Swap(t, &skillPathWalkDir, func(string, fs.WalkDirFunc) error {
return errors.New("prepare failed")
})
if err := removePublishedSkillSource("ignored"); err == nil || !strings.Contains(err.Error(), "prepare failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("walk error", func(t *testing.T) {
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, nil, errors.New("walk failed"))
})
if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "walk failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("writable mode preparation", func(t *testing.T) {
entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500}
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, entry, nil)
})
var chmodMode os.FileMode
testseam.Swap(t, &skillPathChmod, func(_ string, mode os.FileMode) error {
chmodMode = mode
return nil
})
modes, err := prepareSkillPathTreeRemoval("source")
if err != nil {
t.Fatal(err)
}
if chmodMode != 0o700 || len(modes) != 1 || modes[0].path != "source" || modes[0].mode != 0o500 {
t.Fatalf("prepared mode = %v, records = %#v", chmodMode, modes)
}
})
t.Run("writable mode preparation chmod error", func(t *testing.T) {
entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500}
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, entry, nil)
})
testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error {
return errors.New("chmod failed")
})
if _, err := prepareSkillPathTreeRemoval("source"); err == nil || !strings.Contains(err.Error(), "chmod failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("entry info error", func(t *testing.T) {
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, skillPathErrorDirEntry{}, nil)
})
if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "info failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("missing restored directory", func(t *testing.T) {
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: filepath.Join(t.TempDir(), "missing"), mode: 0o555}}); err != nil {
t.Fatal(err)
}
})
t.Run("restore probe error", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: "blocked", mode: 0o555}}); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("restore chmod error", func(t *testing.T) {
path := t.TempDir()
testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission })
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: path, mode: 0o555}}); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
func TestCrossPlatformCoverageSkillPathLexicalCopyErrors(t *testing.T) {
t.Run("source lstat", func(t *testing.T) {
if err := copySkillPathLexically(filepath.Join(t.TempDir(), "missing"), "unused"); !os.IsNotExist(err) {
t.Fatalf("error = %v", err)
}
})
t.Run("destination mkdir", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil {
t.Fatal(err)
}
if err := copySkillPathLexically(src, dst); err == nil {
t.Fatal("expected mkdir failure")
}
})
t.Run("source readdir", func(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "src")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission })
if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("nested copy", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
original := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if filepath.Base(path) == "child" {
return nil, os.ErrPermission
}
return original(path)
})
if err := copySkillPathLexically(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("readlink", func(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "link")
if err := os.Symlink("target", src); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission })
if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("special path", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
})
if err := copySkillPathLexically("special", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") {
t.Fatalf("error = %v", err)
}
})
t.Run("regular open", func(t *testing.T) {
if err := copyRegularSkillFile(filepath.Join(t.TempDir(), "missing"), "dst", 0o600); !os.IsNotExist(err) {
t.Fatalf("error = %v", err)
}
})
t.Run("regular destination open", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil {
t.Fatal(err)
}
if err := copyRegularSkillFile(src, dst, 0o600); err == nil {
t.Fatal("expected exclusive create failure")
}
})
for _, tc := range []struct {
name string
swap func(*testing.T)
}{
{name: "copy", swap: func(t *testing.T) {
testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy failed") })
}},
{name: "sync", swap: func(t *testing.T) {
testseam.Swap(t, &skillPathSync, func(*os.File) error { return errors.New("sync failed") })
}},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
tc.swap(t)
if err := copyRegularSkillFile(src, dst, 0o600); err == nil || !strings.Contains(err.Error(), tc.name) {
t.Fatalf("error = %v", err)
}
})
}
}
func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) {
root := t.TempDir()
t.Run("source and destination lstat", func(t *testing.T) {
if err := verifySkillPathCopy(filepath.Join(root, "missing-src"), "dst"); !os.IsNotExist(err) {
t.Fatalf("source error = %v", err)
}
src := filepath.Join(root, "source")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, filepath.Join(root, "missing-dst")); !os.IsNotExist(err) {
t.Fatalf("destination error = %v", err)
}
})
t.Run("directory and file modes", func(t *testing.T) {
for _, tc := range []struct {
name string
src os.FileMode
dst os.FileMode
}{
{name: "directory", src: os.ModeDir | 0o700, dst: os.ModeDir | 0o755},
{name: "file", src: 0o600, dst: 0o644},
} {
t.Run(tc.name, func(t *testing.T) {
src, dst := "src", "dst"
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == src {
return skillPathFakeInfo{mode: tc.src}, nil
}
return skillPathFakeInfo{mode: tc.dst}, nil
})
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("directory read errors", func(t *testing.T) {
for _, failDest := range []bool{false, true} {
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.Mkdir(src, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if (path == dst) == failDest {
return nil, os.ErrPermission
}
return original(path)
})
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("nested mismatch", func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.Mkdir(src, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dst, "child"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") {
t.Fatalf("error = %v", err)
}
})
t.Run("symlink reads", func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.Symlink("one", src); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if err := os.Symlink("one", dst); err != nil {
t.Fatal(err)
}
for _, failDest := range []bool{false, true} {
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
original := skillPathReadlink
testseam.Swap(t, &skillPathReadlink, func(path string) (string, error) {
if (path == dst) == failDest {
return "", os.ErrPermission
}
return original(path)
})
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("file digest opens", func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
for _, failDest := range []bool{false, true} {
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
original := skillPathOpen
testseam.Swap(t, &skillPathOpen, func(path string) (*os.File, error) {
if (path == dst) == failDest {
return nil, os.ErrPermission
}
return original(path)
})
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("special path", func(t *testing.T) {
fake := skillPathFakeInfo{mode: os.ModeNamedPipe}
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return fake, nil })
if err := verifySkillPathCopy("src", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") {
t.Fatalf("error = %v", err)
}
})
t.Run("entry names", func(t *testing.T) {
if _, err := skillPathEntryNames(filepath.Join(root, "missing-dir")); !os.IsNotExist(err) {
t.Fatalf("error = %v", err)
}
})
t.Run("digest open and read", func(t *testing.T) {
if _, err := skillPathFileDigest(filepath.Join(root, "missing-file")); !os.IsNotExist(err) {
t.Fatalf("open error = %v", err)
}
file := filepath.Join(root, "digest-file")
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("read failed") })
if _, err := skillPathFileDigest(file); err == nil || !strings.Contains(err.Error(), "read failed") {
t.Fatalf("read error = %v", err)
}
})
}
type skillPathFakeInfo struct {
mode os.FileMode
}
func (skillPathFakeInfo) Name() string { return "fake" }
func (skillPathFakeInfo) Size() int64 { return 0 }
func (info skillPathFakeInfo) Mode() os.FileMode { return info.mode }
func (skillPathFakeInfo) ModTime() time.Time { return time.Time{} }
func (info skillPathFakeInfo) IsDir() bool { return info.mode.IsDir() }
func (skillPathFakeInfo) Sys() any { return nil }
type skillPathErrorDirEntry struct{}
func (skillPathErrorDirEntry) Name() string { return "broken" }
func (skillPathErrorDirEntry) IsDir() bool { return true }
func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir }
func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") }
type skillPathModeDirEntry struct {
mode os.FileMode
}
func (skillPathModeDirEntry) Name() string { return "directory" }
func (skillPathModeDirEntry) IsDir() bool { return true }
func (entry skillPathModeDirEntry) Type() os.FileMode { return entry.mode.Type() }
func (entry skillPathModeDirEntry) Info() (os.FileInfo, error) {
return skillPathFakeInfo{mode: entry.mode}, nil
}
+451
View File
@@ -0,0 +1,451 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) {
t.Run("directory file and lexical links", func(t *testing.T) {
root := t.TempDir()
t.Cleanup(func() { makeSkillPathTreeWritable(root) })
src := filepath.Join(root, "external", "dingtalk-chat")
dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
if err := os.MkdirAll(filepath.Join(src, "references"), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "references", "guide.md"), []byte("guide\n"), 0o600); err != nil {
t.Fatal(err)
}
linksCreated := true
if err := os.Symlink("SKILL.md", filepath.Join(src, "skill-link")); err != nil {
if runtime.GOOS != "windows" {
t.Fatal(err)
}
linksCreated = false
}
if linksCreated {
if err := os.Symlink("missing.md", filepath.Join(src, "dangling-link")); err != nil {
t.Fatal(err)
}
}
forceCrossDeviceRename(t, src, dst)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("source must be removed: %v", err)
}
if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" {
t.Fatalf("copied file = %q, %v", got, err)
}
if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil {
t.Fatalf("copied file stat = %v, %v", info, err)
} else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 {
t.Fatalf("copied file mode = %v; want 0640", info.Mode().Perm())
}
if linksCreated {
for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} {
info, err := os.Lstat(filepath.Join(dst, name))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("%s must remain a symlink: %v, %v", name, info, err)
}
if got, err := os.Readlink(filepath.Join(dst, name)); err != nil || got != want {
t.Fatalf("%s target = %q, %v; want %q", name, got, err, want)
}
}
}
assertNoCrossDeviceStage(t, dst)
})
t.Run("regular file root", func(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source-file")
dst := filepath.Join(root, "backup", "source-file")
if err := os.WriteFile(src, []byte("state"), 0o600); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(dst); err != nil || string(got) != "state" {
t.Fatalf("backup file = %q, %v", got, err)
}
})
t.Run("read-only directory mode is restored after copying children", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory permission semantics are unavailable")
}
root := t.TempDir()
t.Cleanup(func() { makeSkillPathTreeWritable(root) })
src := filepath.Join(root, "external", "dingtalk-chat")
dst := filepath.Join(root, "backup", "dingtalk-chat")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("read only\n"), 0o444); err != nil {
t.Fatal(err)
}
if err := os.Chmod(src, 0o555); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if info, err := os.Stat(dst); err != nil || info.Mode().Perm() != 0o555 {
t.Fatalf("copied directory mode = %v, %v", info, err)
}
if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "read only\n" {
t.Fatalf("copied read-only file = %q, %v", got, err)
}
})
t.Run("rollback uses the same cross-device contract", func(t *testing.T) {
root := t.TempDir()
backup := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
original := filepath.Join(root, "external", "claude", "skills", "dingtalk-chat")
if err := os.MkdirAll(backup, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(backup, "SKILL.md"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, backup, original)
if err := RestoreSkillPath(backup, original); err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(filepath.Join(original, "SKILL.md")); err != nil || string(got) != "old" {
t.Fatalf("restored content = %q, %v", got, err)
}
if _, err := os.Lstat(backup); !os.IsNotExist(err) {
t.Fatalf("backup must be consumed after verified restore: %v", err)
}
})
}
func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) {
t.Run("non cross-device rename never copies", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
copied := false
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return os.ErrPermission })
testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil })
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
if copied {
t.Fatal("permission failure must not enter cross-device copy fallback")
}
assertSkillSourcePreserved(t, src)
})
for _, tc := range []struct {
name string
copy func(string, string) error
verify func(string, string) error
}{
{name: "copy failure", copy: func(string, string) error { return errors.New("copy failed") }},
{name: "verification failure", verify: func(string, string) error { return errors.New("verify failed") }},
} {
t.Run(tc.name, func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
if tc.copy != nil {
testseam.Swap(t, &skillPathCopy, tc.copy)
}
if tc.verify != nil {
testseam.Swap(t, &skillPathVerify, tc.verify)
}
if err := moveSkillPathRecoverably(src, dst); err == nil {
t.Fatal("expected failure")
}
assertSkillSourcePreserved(t, src)
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
t.Fatalf("formal backup must not publish: %v", err)
}
assertNoCrossDeviceStage(t, dst)
})
}
t.Run("staging publish failure", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error {
if from == src && to == dst {
return testCrossDeviceError()
}
if to == dst {
return errors.New("publish failed")
}
return originalRename(from, to)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "publish failed") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
assertNoCrossDeviceStage(t, dst)
})
t.Run("published verification failure preserves both", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
calls := 0
testseam.Swap(t, &skillPathVerify, func(string, string) error {
calls++
if calls == 2 {
return errors.New("published verify failed")
}
return nil
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "published verify failed") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
if _, err := os.Lstat(dst); err != nil {
t.Fatalf("verified backup candidate must remain: %v", err)
}
assertNoCrossDeviceStage(t, dst)
})
t.Run("source removal failure preserves both", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
return errors.New("remove failed")
}
return originalRemove(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
if _, err := os.Lstat(dst); err != nil {
t.Fatalf("formal backup must remain: %v", err)
}
})
t.Run("read-only source mode is restored after removal failure", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory permission semantics are unavailable")
}
src, dst := makeSkillMoveFixture(t)
if err := os.Chmod(src, 0o555); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
return errors.New("remove failed")
}
return originalRemove(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") {
t.Fatalf("error = %v", err)
}
if info, err := os.Stat(src); err != nil || info.Mode().Perm() != 0o555 {
t.Fatalf("source directory mode after failed removal = %v, %v", info, err)
}
if err := os.Chmod(src, 0o755); err != nil {
t.Fatal(err)
}
})
t.Run("source permission preparation failure preserves both", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory permission semantics are unavailable")
}
src, dst := makeSkillMoveFixture(t)
if err := os.Chmod(src, 0o555); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
originalChmod := skillPathChmod
testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error {
if path == src {
return errors.New("chmod failed")
}
return originalChmod(path, mode)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "chmod failed") {
t.Fatalf("error = %v", err)
}
if err := originalChmod(src, 0o755); err != nil {
t.Fatal(err)
}
})
t.Run("lying remover is detected", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
return nil
}
return originalRemove(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "仍存在") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
})
t.Run("same-filesystem fast path", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("source still exists: %v", err)
}
})
t.Run("existing destination is never overwritten", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "目标已存在") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
})
}
func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) {
t.Run("type", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") {
t.Fatalf("error = %v", err)
}
})
t.Run("directory entries", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Mkdir(src, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "one"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "数量") {
t.Fatalf("error = %v", err)
}
if err := os.WriteFile(filepath.Join(dst, "two"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "目录项") {
t.Fatalf("error = %v", err)
}
})
t.Run("file size and digest", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("same"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("longer"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "大小") {
t.Fatalf("error = %v", err)
}
if err := os.WriteFile(dst, []byte("diff"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "摘要") {
t.Fatalf("error = %v", err)
}
})
t.Run("symlink target", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Symlink("one", src); err != nil {
if runtime.GOOS == "windows" {
t.Skip("symlink creation unavailable")
}
t.Fatal(err)
}
if err := os.Symlink("two", dst); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "链接目标") {
t.Fatalf("error = %v", err)
}
})
}
func forceCrossDeviceRename(t *testing.T, src, dst string) {
t.Helper()
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error {
if from == src && to == dst {
return testCrossDeviceError()
}
return originalRename(from, to)
})
}
func makeSkillMoveFixture(t *testing.T) (string, string) {
t.Helper()
root := t.TempDir()
t.Cleanup(func() { makeSkillPathTreeWritable(root) })
src := filepath.Join(root, "external", "dingtalk-chat")
dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
return src, dst
}
func assertSkillSourcePreserved(t *testing.T, src string) {
t.Helper()
if got, err := os.ReadFile(filepath.Join(src, "SKILL.md")); err != nil || string(got) != "old" {
t.Fatalf("source content = %q, %v", got, err)
}
}
func assertNoCrossDeviceStage(t *testing.T, dst string) {
t.Helper()
entries, err := os.ReadDir(filepath.Dir(dst))
if err != nil {
if os.IsNotExist(err) {
return
}
t.Fatal(err)
}
prefix := "." + filepath.Base(dst) + ".cross-device-"
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), prefix) {
t.Fatalf("cross-device staging leaked: %s", entry.Name())
}
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package upgrade
import (
"errors"
"syscall"
)
func isCrossDeviceError(err error) bool {
return errors.Is(err, syscall.EXDEV)
}
@@ -0,0 +1,16 @@
//go:build !windows
package upgrade
import (
"os"
"syscall"
)
func testCrossDeviceError() error {
return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV}
}
func testNoReplaceUnsupportedErrors() []error {
return []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported}
}
@@ -0,0 +1,13 @@
//go:build windows
package upgrade
import (
"errors"
"golang.org/x/sys/windows"
)
func isCrossDeviceError(err error) bool {
return errors.Is(err, windows.ERROR_NOT_SAME_DEVICE)
}
@@ -0,0 +1,24 @@
//go:build windows
package upgrade
import (
"os"
"testing"
"golang.org/x/sys/windows"
)
func testCrossDeviceError() error {
return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE}
}
func testNoReplaceUnsupportedErrors() []error {
return []error{errNoReplaceRenameUnsupported}
}
func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) {
if !isCrossDeviceError(testCrossDeviceError()) {
t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback")
}
}
+197
View File
@@ -0,0 +1,197 @@
package upgrade
import (
"crypto/sha256"
"errors"
"fmt"
"hash"
"os"
"path/filepath"
"sort"
)
// SkillPathPublication records enough immutable identity to prove that a
// destination still belongs to the transaction that published it. The
// fingerprint is intentionally private so callers cannot forge records.
type SkillPathPublication struct {
Destination string
fingerprint [sha256.Size]byte
identity os.FileInfo
incarnation string
fileID string
}
// PublishSkillPathNoReplace atomically publishes a staged path without ever
// replacing a destination created after the backup phase.
func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication, error) {
identity, err := skillPathLstat(staged)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("读取待发布 Skill 身份失败 %s: %w", staged, err)
}
fingerprint, err := fingerprintSkillPath(staged)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err)
}
stagedFileID := skillPathFileIdentity(staged)
if err := skillPathRenameNoReplace(staged, destination); err != nil {
return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err)
}
publishedIdentity, err := skillPathLstat(destination)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err)
}
publishedFingerprint, err := fingerprintSkillPath(destination)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err)
}
publishedFileID := skillPathFileIdentity(destination)
if !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) || publishedFingerprint != fingerprint {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination)
}
return SkillPathPublication{
Destination: destination,
fingerprint: fingerprint,
identity: publishedIdentity,
incarnation: skillPathFileIncarnation(publishedIdentity),
fileID: publishedFileID,
}, nil
}
// RollbackSkillPathPublications removes only objects that can still be proven
// to have been published by this transaction. Each live path is first claimed
// into a private sibling quarantine. A concurrent replacement is restored when
// possible, otherwise retained in quarantine and reported explicitly.
func RollbackSkillPathPublications(publications []SkillPathPublication) error {
var rollbackErr error
for i := len(publications) - 1; i >= 0; i-- {
if err := rollbackSkillPathPublication(publications[i]); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func rollbackSkillPathPublication(publication SkillPathPublication) (err error) {
destination := publication.Destination
quarantineRoot, err := skillPathMkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".rollback-")
if err != nil {
return fmt.Errorf("创建 Skill 回滚隔离目录失败 %s: %w", destination, err)
}
quarantine := filepath.Join(quarantineRoot, "payload")
cleanupRoot := func() error {
if cleanupErr := skillPathRemoveAll(quarantineRoot); cleanupErr != nil {
return fmt.Errorf("清理 Skill 回滚隔离目录失败 %s: %w", quarantineRoot, cleanupErr)
}
return nil
}
liveIdentity, liveIdentityErr := skillPathLstat(destination)
if os.IsNotExist(liveIdentityErr) {
return cleanupRoot()
}
liveFingerprint, liveFingerprintErr := fingerprintSkillPath(destination)
liveVerificationErr := errors.Join(liveIdentityErr, liveFingerprintErr)
if liveVerificationErr != nil {
return errors.Join(
fmt.Errorf("拒绝删除无法验证的 Skill %s: %w", destination, liveVerificationErr),
cleanupRoot(),
)
}
if publication.identity == nil ||
!skillPathIdentityProven(publication.identity, liveIdentity, publication.fileID, skillPathFileIdentity(destination)) ||
publication.incarnation != skillPathFileIncarnation(liveIdentity) ||
liveFingerprint != publication.fingerprint {
return errors.Join(
fmt.Errorf("拒绝删除非本事务 Skill %s: 发布对象身份已变化", destination),
cleanupRoot(),
)
}
if err := skillPathRename(destination, quarantine); err != nil {
cleanupErr := cleanupRoot()
if os.IsNotExist(err) {
return cleanupErr
}
return errors.Join(fmt.Errorf("隔离待回滚 Skill 失败 %s: %w", destination, err), cleanupErr)
}
actualIdentity, identityErr := skillPathLstat(quarantine)
actual, fingerprintErr := fingerprintSkillPath(quarantine)
if identityErr == nil && fingerprintErr == nil &&
skillPathIdentityProven(publication.identity, actualIdentity, publication.fileID, skillPathFileIdentity(quarantine)) &&
actual == publication.fingerprint {
if removeErr := removePublishedSkillSource(quarantine); removeErr != nil {
return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr)
}
return cleanupRoot()
}
verificationErr := errors.Join(identityErr, fingerprintErr)
if verificationErr == nil {
verificationErr = errors.New("发布对象身份已变化")
}
if restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil {
return fmt.Errorf("拒绝删除非本事务 Skill %s: %v;并发对象保留于 %s,恢复原路径失败: %w", destination, verificationErr, quarantine, restoreErr)
}
return errors.Join(
fmt.Errorf("拒绝删除非本事务 Skill %s: %w", destination, verificationErr),
cleanupRoot(),
)
}
func fingerprintSkillPath(path string) ([sha256.Size]byte, error) {
h := sha256.New()
if err := fingerprintSkillPathInto(h, path); err != nil {
return [sha256.Size]byte{}, err
}
var result [sha256.Size]byte
copy(result[:], h.Sum(nil))
return result, nil
}
func fingerprintSkillPathInto(h hash.Hash, path string) error {
info, err := skillPathLstat(path)
if err != nil {
return err
}
writeFingerprintField(h, info.Mode().Type().String())
writeFingerprintField(h, info.Mode().Perm().String())
switch {
case info.IsDir():
entries, err := skillPathReadDir(path)
if err != nil {
return err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
writeFingerprintField(h, fmt.Sprintf("%d", len(entries)))
for _, entry := range entries {
writeFingerprintField(h, entry.Name())
if err := fingerprintSkillPathInto(h, filepath.Join(path, entry.Name())); err != nil {
return err
}
}
return nil
case info.Mode()&os.ModeSymlink != 0:
target, err := skillPathReadlink(path)
if err != nil {
return err
}
writeFingerprintField(h, target)
return nil
case info.Mode().IsRegular():
digest, err := skillPathFileDigest(path)
if err != nil {
return err
}
writeFingerprintField(h, fmt.Sprintf("%d", info.Size()))
_, err = h.Write(digest[:])
return err
default:
return fmt.Errorf("不支持识别特殊 Skill 路径 %s(mode=%s)", path, info.Mode())
}
}
func writeFingerprintField(h hash.Hash, value string) {
_, _ = fmt.Fprintf(h, "%d:", len(value))
_, _ = h.Write([]byte(value))
}
@@ -0,0 +1,30 @@
//go:build darwin
package upgrade
import (
"fmt"
"os"
"syscall"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
}
return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen,
stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec)
}
func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
func skillPathFileIdentityImpl(_ string) string {
return ""
}
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -0,0 +1,29 @@
//go:build linux
package upgrade
import (
"fmt"
"os"
"syscall"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
}
return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec)
}
func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
func skillPathFileIdentityImpl(_ string) string {
return ""
}
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -0,0 +1,24 @@
//go:build !linux && !darwin && !windows
package upgrade
import (
"fmt"
"os"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode())
}
func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
func skillPathFileIdentityImpl(_ string) string {
return ""
}
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -0,0 +1,64 @@
//go:build windows
package upgrade
import (
"fmt"
"os"
"syscall"
"golang.org/x/sys/windows"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Win32FileAttributeData)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
}
return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime)
}
// skillPathSameFileIdentity returns false because os.FileInfo on Windows does
// not expose the stable file ID (VolumeSerialNumber / FileIndex from
// GetFileInformationByHandle), which is the only primitive that distinguishes
// a recreated file from the original after NTFS file tunneling restores the
// creation time. Callers must use skillPathIdentityProven, which compares file
// IDs obtained via GetFileInformationByHandle.
func skillPathSameFileIdentityImpl(_, _ os.FileInfo) bool {
return false
}
// skillPathFileIdentityImpl queries the stable volume serial number and file
// index via GetFileInformationByHandle. Unlike creation time (which NTFS file
// tunneling can restore for a recreated same-named object), the file index
// uniquely identifies the file on the volume for its lifetime.
//
// FILE_FLAG_OPEN_REPARSE_POINT opens symlinks themselves rather than following
// them to the target. This matters at publish time because a staged symlink
// carries a relative target computed for the final destination, which may not
// resolve from the staging directory. Opening the reparse point yields a file
// ID that is stable across the rename and is the correct identity to compare.
func skillPathFileIdentityImpl(path string) (id string) {
if p, err := windows.UTF16PtrFromString(path); err == nil {
if handle, err := windows.CreateFile(
p, windows.FILE_READ_ATTRIBUTES,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil, windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0,
); err == nil {
defer windows.CloseHandle(handle)
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err == nil {
id = fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow)
}
}
}
return
}
// skillPathIdentityProven is the real identity proof on Windows. An empty
// expected value means the file ID could not be obtained at publish time, so
// identity cannot be proven and the caller must refuse the auto-delete.
func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool {
return expected != "" && expected == actual
}
+513
View File
@@ -0,0 +1,513 @@
package upgrade
import (
"crypto/sha256"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testing.T) {
t.Run("existing destination is never replaced", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
seedUpgradeSkill(t, destination, "concurrent", false)
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil {
t.Fatal("expected no-replace publication to fail")
}
assertUpgradeSkillContent(t, destination, "concurrent")
assertUpgradeSkillContent(t, staged, "new")
})
t.Run("owned publication is removed", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
t.Fatalf("owned publication remains: %v", err)
}
})
t.Run("concurrent replacement is quarantined instead of deleted", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
// Even a byte-for-byte identical replacement is not owned by this
// transaction: inode identity, not content alone, is authoritative.
seedUpgradeSkill(t, destination, "new", false)
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("replacement rollback error = %v", err)
}
assertUpgradeSkillContent(t, destination, "new")
})
t.Run("tunneled replacement with same creation time is still refused", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
// NTFS file tunneling can restore the original creation time for a
// same-named recreation, defeating the incarnation check. The file ID
// (or inode on Unix) must still differ and block the rollback.
testseam.Swap(t, &skillPathFileIncarnation, func(os.FileInfo) string { return "tunneled" })
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
// Simulate the identity change that a concurrent replacement causes.
// On Windows, skillPathIdentityProven compares file IDs from
// GetFileInformationByHandle and ignores this seam. On Unix, where the
// file ID is unavailable, this swap replaces the os.SameFile check
// (which can return true for a recreated file on tmpfs due to inode
// reuse) so the test is deterministic across filesystems.
testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false })
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, destination, "new", false)
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("tunneled replacement rollback error = %v", err)
}
assertUpgradeSkillContent(t, destination, "new")
})
}
func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) {
failure := errors.New("injected publication failure")
t.Run("platform no-replace path encoding", func(t *testing.T) {
if err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil {
t.Fatal("expected invalid source path failure")
}
source := filepath.Join(t.TempDir(), "source")
if err := os.WriteFile(source, []byte("value"), 0o600); err != nil {
t.Fatal(err)
}
if err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil {
t.Fatal("expected invalid destination path failure")
}
})
t.Run("publish identity", func(t *testing.T) {
if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil {
t.Fatal("expected identity failure")
}
})
t.Run("publish fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
seedUpgradeSkill(t, staged, "value", false)
originalLstat := skillPathLstat
calls := 0
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
calls++
if calls == 2 {
return nil, failure
}
return originalLstat(path)
})
if _, err := PublishSkillPathNoReplace(staged, filepath.Join(root, "dest")); !errors.Is(err, failure) {
t.Fatal("expected fingerprint failure")
}
})
t.Run("publish confirmation missing", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error {
if err := originalRename(source, target); err != nil {
return err
}
return os.RemoveAll(target)
})
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") {
t.Fatalf("publish confirmation error = %v", err)
}
})
t.Run("publish confirmation identity changed", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error {
if err := originalRename(source, target); err != nil {
return err
}
if err := os.RemoveAll(target); err != nil {
return err
}
seedUpgradeSkill(t, target, "replacement", false)
return nil
})
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") {
t.Fatalf("publish identity error = %v", err)
}
})
t.Run("publish confirmation fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
published := false
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error {
if err := originalRename(source, target); err != nil {
return err
}
published = true
return nil
})
originalReadDir := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if published && path == destination {
return nil, failure
}
return originalReadDir(path)
})
if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") {
t.Fatalf("publish fingerprint confirmation error = %v", err)
}
})
t.Run("rollback temp", func(t *testing.T) {
testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) {
t.Fatalf("temp error = %v", err)
}
})
t.Run("missing destination cleanup", func(t *testing.T) {
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); err != nil {
t.Fatal(err)
}
})
t.Run("missing destination cleanup failure", func(t *testing.T) {
testseam.Swap(t, &skillPathRemoveAll, func(string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); !errors.Is(err, failure) {
t.Fatalf("cleanup error = %v", err)
}
})
t.Run("quarantine rename", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRename, func(string, string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("quarantine error = %v", err)
}
})
t.Run("live verification", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalReadDir := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
return nil, failure
}
return originalReadDir(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("live verification error = %v", err)
}
})
t.Run("destination disappears before quarantine", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if err := os.RemoveAll(source); err != nil {
return err
}
return &os.PathError{Op: "rename", Path: source, Err: os.ErrNotExist}
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
t.Fatal(err)
}
})
t.Run("owned remove", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if filepath.Base(path) == "payload" {
return failure
}
return originalRemove(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("remove error = %v", err)
}
})
t.Run("quarantined fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if filepath.Base(path) == "payload" {
return nil, failure
}
return originalLstat(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("fingerprint error = %v", err)
}
})
t.Run("mismatch restore and cleanup", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "published", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if source == destination {
if err := os.RemoveAll(source); err != nil {
return err
}
seedUpgradeSkill(t, source, "replacement", false)
}
return originalRename(source, target)
})
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if strings.Contains(filepath.Base(path), ".destination.rollback-") {
return failure
}
return originalRemove(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("mismatch cleanup error = %v", err)
}
assertUpgradeSkillContent(t, destination, "replacement")
})
t.Run("mismatch restore failure", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "published", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if source == destination {
if err := os.RemoveAll(source); err != nil {
return err
}
seedUpgradeSkill(t, source, "replacement", false)
}
return originalRename(source, target)
})
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") {
t.Fatalf("mismatch restore error = %v", err)
}
})
t.Run("fingerprint read failures and lexical types", func(t *testing.T) {
t.Run("directory ordering", func(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"b", "a"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
if _, err := fingerprintSkillPath(dir); err != nil {
t.Fatal(err)
}
})
t.Run("directory read", func(t *testing.T) {
dir := t.TempDir()
testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
t.Fatalf("read dir error = %v", err)
}
})
t.Run("recursive child", func(t *testing.T) {
dir := t.TempDir()
child := filepath.Join(dir, "child")
if err := os.WriteFile(child, []byte("value"), 0o644); err != nil {
t.Fatal(err)
}
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == child {
return nil, failure
}
return originalLstat(path)
})
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
t.Fatalf("child error = %v", err)
}
})
t.Run("symlink read", func(t *testing.T) {
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink("missing", link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", failure })
if _, err := fingerprintSkillPath(link); !errors.Is(err, failure) {
t.Fatalf("readlink error = %v", err)
}
})
t.Run("symlink success", func(t *testing.T) {
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink("missing", link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := fingerprintSkillPath(link); err != nil {
t.Fatal(err)
}
})
t.Run("file digest", func(t *testing.T) {
file := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(file, []byte("value"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathOpen, func(string) (*os.File, error) { return nil, failure })
if _, err := fingerprintSkillPath(file); !errors.Is(err, failure) {
t.Fatalf("digest error = %v", err)
}
})
t.Run("special", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
})
if err := fingerprintSkillPathInto(sha256.New(), "special"); err == nil {
t.Fatal("expected special path error")
}
})
t.Run("unknown platform identity shape", func(t *testing.T) {
if got := skillPathFileIncarnation(skillPathFakeInfo{}); got == "" {
t.Fatal("empty incarnation")
}
})
t.Run("synthetic info does not match same-file identity", func(t *testing.T) {
fake := skillPathFakeInfo{}
if skillPathSameFileIdentity(fake, fake) {
t.Fatal("synthetic info should not match")
}
})
})
}
func TestCrossPlatformCoverageUpgradeRollbackRetainsConcurrentReplacement(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
first := filepath.Join(base, "dingtalk-first")
second := filepath.Join(base, "dingtalk-second")
seedUpgradeSkill(t, first, "old first", false)
seedUpgradeSkill(t, second, "old second", false)
stageRoot := filepath.Join(base, ".stage")
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
seedUpgradeSkill(t, stagedFirst, "new first", false)
seedUpgradeSkill(t, stagedSecond, "new second", false)
failure := errors.New("injected second publication failure")
originalPublish := upgradePublishSkillPath
calls := 0
testseam.Swap(t, &upgradePublishSkillPath, func(staged, destination string) (SkillPathPublication, error) {
calls++
if calls == 2 {
if err := os.RemoveAll(first); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, first, "concurrent", false)
return SkillPathPublication{}, failure
}
return originalPublish(staged, destination)
})
err := publishStagedSkillSet(home, []stagedSkillDir{
{staged: stagedFirst, dest: first},
{staged: stagedSecond, dest: second},
}, []string{first, second})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("transaction error = %v", err)
}
assertUpgradeSkillContent(t, first, "concurrent")
assertUpgradeSkillContent(t, second, "old second")
matches, globErr := filepath.Glob(filepath.Join(home, ".dws", "skill-backups", "*", ".agents-skills-dingtalk-first", "SKILL.md"))
if globErr != nil || len(matches) != 1 {
t.Fatalf("retained old backup = %v, %v", matches, globErr)
}
content, readErr := os.ReadFile(matches[0])
if readErr != nil || string(content) != "old first" {
t.Fatalf("retained backup content = %q, %v", content, readErr)
}
}
+97
View File
@@ -0,0 +1,97 @@
package upgrade
import (
"errors"
"fmt"
"os"
)
// errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace
// rename primitive at all.
var errNoReplaceRenameUnsupported = errors.New("平台不支持原子 no-replace rename")
var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic
// renameSkillPathNoReplace moves source onto destination and never replaces an
// object that already occupies destination.
//
// The atomic primitives (Linux RENAME_NOREPLACE, Darwin RENAME_EXCL) require
// support from the underlying filesystem and report EINVAL/ENOTSUP when it is
// missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and
// overlayfs homes reject the flag. Failing there would abort the whole install
// on those filesystems, so the fallback uses truly atomic no-clobber primitives
// that work on every POSIX filesystem: os.Mkdir for directories (fails with
// EEXIST if the path is occupied) and os.Link for regular files (fails with
// EEXIST if the path is occupied). Neither primitive has a TOCTOU window.
func renameSkillPathNoReplace(source, destination string) error {
err := skillPathRenameNoReplaceAtomic(source, destination)
if err == nil {
return nil
}
if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) {
return err
}
return renameSkillPathNoReplaceFallback(source, destination)
}
// renameSkillPathNoReplaceFallback is used when the kernel no-replace rename
// flag is unavailable. It dispatches on the source type to pick an atomic
// no-clobber primitive that does not require filesystem support.
func renameSkillPathNoReplaceFallback(source, destination string) error {
info, err := skillPathLstat(source)
if err != nil {
return fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err)
}
switch {
case info.IsDir():
return renameSkillDirNoReplaceFallback(source, destination)
case info.Mode().IsRegular():
return renameSkillFileNoReplaceFallback(source, destination)
default:
return &os.LinkError{
Op: "rename", Old: source, New: destination,
Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported),
}
}
}
// renameSkillDirNoReplaceFallback atomically claims the destination with
// mkdir (which fails with EEXIST if the path is already occupied) and then
// replaces the empty, self-owned directory with a plain rename. On Linux
// rename(2) replaces an empty directory directly. On Darwin and Windows
// rename(2) refuses to replace any existing directory, so the empty dir is
// removed and the rename retried. If someone populated the claimed directory
// between mkdir and remove, Remove fails (ENOTEMPTY) and no data is lost.
// If someone creates a new path at the destination between remove and rename,
// the second Rename detects it and fails. Neither step has a TOCTOU window
// that could silently clobber data.
func renameSkillDirNoReplaceFallback(source, destination string) error {
if err := skillPathMkdir(destination, 0o700); err != nil {
if os.IsExist(err) {
return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
}
return fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err)
}
if err := skillPathRename(source, destination); err == nil {
return nil
}
if rmErr := skillPathRemove(destination); rmErr != nil {
return fmt.Errorf("清理已声明 Skill 目标失败 %s: %w", destination, rmErr)
}
if err := skillPathRename(source, destination); err != nil {
return fmt.Errorf("发布 Skill 到已声明目标失败 %s: %w", destination, err)
}
return nil
}
// renameSkillFileNoReplaceFallback uses os.Link, which atomically fails if the
// destination exists, then removes the source to complete the move.
func renameSkillFileNoReplaceFallback(source, destination string) error {
if err := skillPathLink(source, destination); err != nil {
if os.IsExist(err) {
return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
}
return fmt.Errorf("发布 Skill 文件失败 %s: %w", destination, err)
}
return skillPathRemove(source)
}
@@ -0,0 +1,22 @@
//go:build darwin
package upgrade
import (
"errors"
"syscall"
"golang.org/x/sys/unix"
)
func renameSkillPathNoReplaceAtomic(source, destination string) error {
return unix.RenamexNp(source, destination, unix.RENAME_EXCL)
}
// Volumes whose filesystem does not implement renamex_np (several network and
// FUSE mounts) reject RENAME_EXCL instead of performing the rename.
func isNoReplaceRenameUnsupported(err error) bool {
return errors.Is(err, syscall.ENOTSUP) ||
errors.Is(err, syscall.EINVAL) ||
errors.Is(err, syscall.ENOSYS)
}
@@ -0,0 +1,23 @@
//go:build linux
package upgrade
import (
"errors"
"syscall"
"golang.org/x/sys/unix"
)
func renameSkillPathNoReplaceAtomic(source, destination string) error {
return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE)
}
// rename(2) documents EINVAL as "the filesystem does not support one of the
// flags", which is what NFS, FUSE and overlayfs return; ENOSYS covers kernels
// without renameat2 at all.
func isNoReplaceRenameUnsupported(err error) bool {
return errors.Is(err, syscall.EINVAL) ||
errors.Is(err, syscall.ENOSYS) ||
errors.Is(err, syscall.EOPNOTSUPP)
}
@@ -0,0 +1,9 @@
//go:build !linux && !darwin && !windows
package upgrade
func renameSkillPathNoReplaceAtomic(_, _ string) error {
return errNoReplaceRenameUnsupported
}
func isNoReplaceRenameUnsupported(error) bool { return false }
@@ -0,0 +1,307 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
type mockSpecialInfo struct{}
func (mockSpecialInfo) Name() string { return "source" }
func (mockSpecialInfo) Size() int64 { return 0 }
func (mockSpecialInfo) Mode() os.FileMode { return os.ModeNamedPipe }
func (mockSpecialInfo) ModTime() time.Time { return time.Time{} }
func (mockSpecialInfo) IsDir() bool { return false }
func (mockSpecialInfo) Sys() interface{} { return nil }
// TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for
// filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs).
// Those installs must still publish, and must still refuse to clobber an
// occupied destination. The fallback uses atomic no-clobber primitives
// (os.Mkdir for directories, os.Link for files) instead of a TOCTOU-prone
// check-then-rename.
func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) {
unsupported := testNoReplaceUnsupportedErrors()
t.Run("publishes a file when the filesystem rejects the flag", func(t *testing.T) {
for _, unsupportedErr := range unsupported {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr })
if err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err)
}
if data, err := os.ReadFile(destination); err != nil || string(data) != "payload" {
t.Fatalf("destination = %q, %v", data, err)
}
if _, err := os.Lstat(source); !os.IsNotExist(err) {
t.Fatalf("source must be gone, stat err=%v", err)
}
}
})
t.Run("publishes a directory when the filesystem rejects the flag", func(t *testing.T) {
for _, unsupportedErr := range unsupported {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr })
if err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err)
}
assertUpgradeSkillContent(t, destination, "payload")
if _, err := os.Lstat(source); !os.IsNotExist(err) {
t.Fatalf("source must be gone, stat err=%v", err)
}
}
})
t.Run("still refuses an occupied destination for files", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
for _, path := range []string{source, destination} {
if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o644); err != nil {
t.Fatal(err)
}
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrExist) {
t.Fatalf("occupied destination must report ErrExist, got %v", err)
}
if data, readErr := os.ReadFile(destination); readErr != nil || string(data) != "destination" {
t.Fatalf("destination must be untouched, got %q, %v", data, readErr)
}
if _, statErr := os.Lstat(source); statErr != nil {
t.Fatalf("source must be preserved: %v", statErr)
}
})
t.Run("file link failure with non-EEXIST error surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission })
err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("link error must surface, got %v", err)
}
if _, statErr := os.Lstat(source); statErr != nil {
t.Fatalf("source must be preserved: %v", statErr)
}
})
t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
seedUpgradeSkill(t, destination, "existing", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrExist) {
t.Fatalf("occupied destination must report ErrExist, got %v", err)
}
assertUpgradeSkillContent(t, destination, "existing")
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("concurrent creation between mkdir and rename is detected", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
renameErr := errors.New("simulated ENOTEMPTY: destination populated by another process")
testseam.Swap(t, &skillPathRename, func(string, string) error { return renameErr })
var removeCalled bool
testseam.Swap(t, &skillPathRemove, func(path string) error {
if path == destination {
removeCalled = true
}
return os.Remove(path)
})
err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, renameErr) {
t.Fatalf("rename failure must surface, got %v", err)
}
if !removeCalled {
t.Fatal("empty destination must be cleaned up after rename failure")
}
if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) {
t.Fatalf("destination must not exist after cleanup, stat err=%v", statErr)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("a genuine error is not retried", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
calls := 0
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error {
calls++
return os.ErrPermission
})
if err := renameSkillPathNoReplace(source, filepath.Join(dir, "destination")); !errors.Is(err, os.ErrPermission) {
t.Fatalf("permission error must surface, got %v", err)
}
if calls != 1 {
t.Fatalf("atomic attempts = %d, want 1", calls)
}
})
t.Run("source stat failure surfaces without rename", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
destination := filepath.Join(dir, "destination")
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("stat error must surface, got %v", err)
}
if _, statErr := os.Lstat(source); statErr != nil {
t.Fatalf("source must be preserved: %v", statErr)
}
})
t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) {
crossDevice := testCrossDeviceError()
if isNoReplaceRenameUnsupported(crossDevice) {
t.Fatal("cross-device error must not be treated as an unsupported flag")
}
dir := t.TempDir()
source := filepath.Join(dir, "source")
if err := os.MkdirAll(source, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
destination := filepath.Join(dir, "backup", "destination")
original := skillPathRenameNoReplaceAtomic
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error {
if from == source {
return crossDevice
}
return original(from, to)
})
if err := moveSkillPathRecoverably(source, destination); err != nil {
t.Fatalf("cross-device move = %v", err)
}
if data, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(data) != "payload" {
t.Fatalf("cross-device destination = %q, %v", data, err)
}
})
t.Run("mkdir failure with non-EEXIST error surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission })
err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("mkdir error must surface, got %v", err)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("remove failure after rename failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") })
removeErr := errors.New("simulated remove failure")
testseam.Swap(t, &skillPathRemove, func(string) error { return removeErr })
err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, removeErr) {
t.Fatalf("remove error must surface, got %v", err)
}
if _, statErr := os.Lstat(destination); statErr != nil {
t.Fatalf("destination must still exist after remove failure: %v", statErr)
}
assertUpgradeSkillContent(t, source, "payload")
_ = os.RemoveAll(destination)
})
t.Run("directory retry rename after removing claimed empty dir", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
calls := 0
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(src, dst string) error {
calls++
if calls == 1 {
return os.ErrExist
}
return originalRename(src, dst)
})
if err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("retry must publish, got %v", err)
}
assertUpgradeSkillContent(t, destination, "payload")
if _, err := os.Lstat(source); !os.IsNotExist(err) {
t.Fatalf("source must be gone, stat err=%v", err)
}
})
t.Run("directory publish when first rename replaces empty dir", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(src, dst string) error {
_ = os.Remove(dst)
return originalRename(src, dst)
})
if err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("publish must succeed when rename replaces empty dir, got %v", err)
}
assertUpgradeSkillContent(t, destination, "payload")
})
t.Run("non-regular non-directory source fails safely", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return mockSpecialInfo{}, nil })
err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, errNoReplaceRenameUnsupported) {
t.Fatalf("unsupported error must surface for special files, got %v", err)
}
})
}
@@ -0,0 +1,22 @@
//go:build windows
package upgrade
import "golang.org/x/sys/windows"
func renameSkillPathNoReplaceAtomic(source, destination string) error {
sourcePtr, err := windows.UTF16PtrFromString(source)
if err != nil {
return err
}
destinationPtr, err := windows.UTF16PtrFromString(destination)
if err != nil {
return err
}
return windows.MoveFile(sourcePtr, destinationPtr)
}
// MoveFile without MOVEFILE_REPLACE_EXISTING already refuses an occupied
// destination on every supported Windows version, so there is nothing to
// degrade to.
func isNoReplaceRenameUnsupported(error) bool { return false }
@@ -92,8 +92,8 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing.
t.Run("backup failure restores earlier victims", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
originalRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error {
if src == filepath.Join(base, "dingtalk-b") {
return failure
}
@@ -107,14 +107,14 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing.
t.Run("publish failure rolls back complete set", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
originalRename := upgradeRename
originalPublish := upgradePublishSkillPath
failed := false
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
if !failed && strings.Contains(src, ".dws-upgrade-multi-") && filepath.Base(dest) == "dingtalk-b" {
failed = true
return failure
return SkillPathPublication{}, failure
}
return originalRename(src, dest)
return originalPublish(src, dest)
})
if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) {
t.Fatalf("publish failure = %v, want injected failure", err)
@@ -156,14 +156,14 @@ func TestCrossPlatformCoverageMonoUpgradeTransactionPreservesOldSet(t *testing.T
t.Run("publish failure rolls back mono and multi", func(t *testing.T) {
home, base, monoRoot := seed(t)
originalRename := upgradeRename
originalPublish := upgradePublishSkillPath
failed := false
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
if !failed && strings.Contains(src, ".dws-upgrade-mono-") && filepath.Base(dest) == "dws" {
failed = true
return failure
return SkillPathPublication{}, failure
}
return originalRename(src, dest)
return originalPublish(src, dest)
})
if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) {
t.Fatalf("publish failure = %v, want injected failure", err)
@@ -211,10 +211,10 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
})
t.Run("restore reports removal and directory failures", func(t *testing.T) {
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure })
testseam.Swap(t, &upgradeRollbackPublishedSkillPaths, func([]SkillPathPublication) error { return failure })
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return restoreFailure })
err := restoreSkillSet(
[]string{filepath.Join(t.TempDir(), "published")},
[]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "published")}},
[]backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}},
)
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
@@ -223,7 +223,7 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
})
t.Run("restore reports rename failure", func(t *testing.T) {
testseam.Swap(t, &upgradeRename, func(string, string) error { return restoreFailure })
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return restoreFailure })
err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}})
if !errors.Is(err, restoreFailure) {
t.Fatalf("restore rename error = %v", err)
@@ -236,8 +236,8 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
second := filepath.Join(home, "second")
seedUpgradeSkill(t, first, "first", false)
seedUpgradeSkill(t, second, "second", false)
originalRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error {
switch {
case src == second:
return failure
@@ -259,16 +259,15 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
staged := filepath.Join(home, "skills", ".stage", "dws")
seedUpgradeSkill(t, old, "old", false)
seedUpgradeSkill(t, staged, "new", false)
originalRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
switch {
case src == staged:
return failure
case strings.Contains(filepath.ToSlash(src), skillBackupSubdir):
testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
return SkillPathPublication{}, failure
})
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error {
if strings.Contains(filepath.ToSlash(src), skillBackupSubdir) {
return restoreFailure
default:
return originalRename(src, dest)
}
return originalRename(src, dest)
})
err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old})
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
+496 -19
View File
@@ -22,9 +22,434 @@ $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-AgentBaseDetected([string]$id, [string]$base) {
$parent = Split-Path $base -Parent
switch ($id) {
"kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
"tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
"zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) }
"minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) }
default { return Test-Path $parent -PathType Container }
}
}
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 Move-DevSkillPath([string]$Source, [string]$Destination) {
if (Test-PathLexically $Destination) { throw "Skill move destination already exists: $Destination" }
$sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
if ($sourceItem.PSIsContainer) {
[System.IO.Directory]::Move($Source, $Destination)
} else {
[System.IO.File]::Move($Source, $Destination)
}
}
function Test-DevCrossDeviceMoveError([System.Management.Automation.ErrorRecord]$Record) {
$exception = $Record.Exception
while ($null -ne $exception) {
if (($exception.HResult -band 0xffff) -eq 17) { return $true }
$exception = $exception.InnerException
}
return $false
}
function Copy-DevSkillMetadata($SourceItem, [string]$Destination) {
(Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes
$nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
if ($nativeWindows) {
Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop
} else {
$mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName)
[System.IO.File]::SetUnixFileMode($Destination, $mode)
}
}
function Get-DevSkillPermissionFingerprint([string]$Path) {
$nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
if ($nativeWindows) { return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl }
return [string][System.IO.File]::GetUnixFileMode($Path)
}
function Copy-DevSkillPathLexically([string]$Source, [string]$Destination) {
$item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
if ($item.LinkType) {
$itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" }
New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null
return
}
if ($item.PSIsContainer) {
New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null
foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) {
Copy-DevSkillPathLexically $child.FullName (Join-Path $Destination $child.Name)
}
Copy-DevSkillMetadata $item $Destination
return
}
if ($item -isnot [System.IO.FileInfo]) { throw "unsupported special Skill path $Source" }
[System.IO.File]::Copy($Source, $Destination, $false)
Copy-DevSkillMetadata $item $Destination
}
function Assert-DevSkillPathCopy([string]$Source, [string]$Destination) {
$sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
$destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop
if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or
$sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) {
throw "Skill path type mismatch: $Source != $Destination"
}
if ($sourceItem.LinkType) {
if ($sourceItem.LinkType -ne $destinationItem.LinkType -or
($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) {
throw "Skill link target mismatch: $Source != $Destination"
}
return
}
if ((Get-DevSkillPermissionFingerprint $Source) -ne (Get-DevSkillPermissionFingerprint $Destination)) {
throw "Skill path permissions mismatch: $Source != $Destination"
}
if ($sourceItem.PSIsContainer) {
$sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name)
$destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name)
if ($sourceChildren.Count -ne $destinationChildren.Count) { throw "Skill directory entries mismatch: $Source != $Destination" }
for ($i = 0; $i -lt $sourceChildren.Count; $i++) {
if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { throw "Skill directory entries mismatch: $Source != $Destination" }
Assert-DevSkillPathCopy $sourceChildren[$i].FullName $destinationChildren[$i].FullName
}
return
}
if ($sourceItem.Length -ne $destinationItem.Length) { throw "Skill file size mismatch: $Source != $Destination" }
if ((Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash -ne
(Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash) {
throw "Skill file digest mismatch: $Source != $Destination"
}
}
function Remove-DevSkillPathLexically([string]$Path) {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($item.PSIsContainer -and !$item.LinkType) {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
} else {
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
}
}
# Removes a staging directory that may hold junction/symlink children. Link
# children must be removed non-recursively first: Windows PowerShell 5.1 can
# follow reparse points during Remove-Item -Recurse and would delete the
# canonical store's contents through the staged link.
function Remove-DevLinkStageRoot([string]$StageRoot) {
if (!(Test-Path -LiteralPath $StageRoot)) { return $true }
$ok = $true
foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) {
try { Remove-DevSkillPathLexically $child.FullName } catch { $ok = $false }
}
try { Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop } catch { $ok = $false }
return $ok
}
function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) {
if (Test-PathLexically $Destination) { throw "move destination already exists: $Destination" }
$destinationParent = Split-Path $Destination -Parent
New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null
try {
Move-DevSkillPath $Source $Destination
return
} catch {
if (!(Test-DevCrossDeviceMoveError $_)) { throw }
}
$stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N"))
$stage = Join-Path $stageRoot "payload"
New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
try {
Copy-DevSkillPathLexically $Source $stage
Assert-DevSkillPathCopy $Source $stage
Move-DevSkillPath $stage $Destination
Assert-DevSkillPathCopy $Source $Destination
if (!(Remove-DevLinkStageRoot $stageRoot)) {
throw "Skill staging cleanup failed (published content untouched): $stageRoot"
}
try { Remove-DevSkillPathLexically $Source } catch {
throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_"
}
if (Test-PathLexically $Source) { throw "Skill source still exists; both retained ($Source, $Destination)" }
} catch {
$failure = $_
if (Test-Path -LiteralPath $stageRoot) {
if (!(Remove-DevLinkStageRoot $stageRoot)) {
throw "$failure; cross-device Skill staging cleanup failed $stageRoot (backup and original retained)"
}
}
throw $failure
}
}
# Get-DevSkillBackupName encodes the HOME-relative path of a backed-up Skill
# directory ('.codex\skills\dingtalk-misc' → '.codex-skills-dingtalk-misc') so
# copies retired from different Agent roots stay distinguishable. Mirrors
# build/npm/install.js and internal/upgrade/paths.go; paths outside HOME fall
# back to the bare leaf.
function Get-DevSkillBackupName([string]$Dir) {
$leaf = Split-Path $Dir -Leaf
try {
$full = [System.IO.Path]::GetFullPath($Dir)
$root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/'))
} catch { return $leaf }
if ([string]::IsNullOrWhiteSpace($root)) { return $leaf }
foreach ($sep in @('\', '/')) {
$prefix = $root + $sep
if ($full.Length -gt $prefix.Length -and
$full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/'))
if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') }
}
}
return $leaf
}
# Keep $HOME\.dws\skill-backups bounded to the newest stamped roots, matching
# skillBackupKeep / pruneSkillBackups in internal/upgrade/paths.go. Roots this
# run created are never pruned: a rollback still needs them.
$DevSkillBackupKeep = 5
$script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
function Remove-OldDevSkillBackups {
$root = Join-Path $HOME ".dws\skill-backups"
if (!(Test-Path -LiteralPath $root -PathType Container)) { return }
# Only directories whose names match the DWS backup stamp format (UTC
# yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any
# other entry is foreign data and is preserved.
$dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } |
Sort-Object -Property Name)
$excess = $dirs.Count - $DevSkillBackupKeep
foreach ($dir in $dirs) {
if ($excess -le 0) { break }
if ($script:DevSkillBackupRootsThisRun.Contains($dir.FullName)) { continue }
Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
$excess--
}
}
function Backup-DevSkill([string]$path, [ref]$BackupPath) {
if ($null -ne $BackupPath) { $BackupPath.Value = "" }
if (!(Test-PathLexically $path)) { return }
$backupBase = Join-Path $HOME ".dws\skill-backups"
$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
$name = Get-DevSkillBackupName $path
$backupRoot = Join-Path $backupBase $stamp
$target = Join-Path $backupRoot $name
$i = 1
while (Test-PathLexically $target) {
$backupRoot = Join-Path $backupBase "$stamp-$i"
$target = Join-Path $backupRoot $name
$i++
if ($i -gt 1000) { throw "备份目录冲突无法解决,保留原目录: $path" }
}
New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null
Move-DevSkillPathRecoverably $path $target
if ($null -ne $BackupPath) { $BackupPath.Value = $target }
try {
$script:DevSkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($backupRoot)) | Out-Null
Remove-OldDevSkillBackups
} catch {
Say "⚠️ 旧备份清理失败(备份本身已成功): $($_.Exception.Message)"
}
}
function Get-DevSkillLinkSignature([string]$Path) {
try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { return $null }
if (!$item.LinkType) { return $null }
return ([string]$item.LinkType + "`0" + ((@($item.Target) | ForEach-Object { [string]$_ }) -join "`0"))
}
function New-DevRollbackFailure([string]$Message) {
$failure = [System.InvalidOperationException]::new($Message)
$failure.Data["DWSRollbackFailed"] = $true
return $failure
}
function Remove-VerifiedDevSkillPublication(
[string]$Destination,
[string]$PublishedSource,
[string]$PublishedLinkSignature
) {
$parent = Split-Path $Destination -Parent
$quarantine = Join-Path $parent (".dws-dev-rollback-" + [guid]::NewGuid().ToString("N"))
Move-DevSkillPath $Destination $quarantine
try {
if (![string]::IsNullOrWhiteSpace($PublishedLinkSignature)) {
if ((Get-DevSkillLinkSignature $quarantine) -ne $PublishedLinkSignature) {
throw "published Skill link was replaced by another process: $Destination"
}
} elseif (![string]::IsNullOrWhiteSpace($PublishedSource)) {
Assert-DevSkillPathCopy $PublishedSource $quarantine
} else {
throw "Skill restore destination is occupied by another process: $Destination"
}
Remove-DevSkillPathLexically $quarantine
} catch {
$failure = $_
if (Test-PathLexically $quarantine) {
try {
if (Test-PathLexically $Destination) { throw "original path is occupied" }
Move-DevSkillPath $quarantine $Destination
} catch {
throw "$failure; concurrent object retained at $quarantine`: $_"
}
}
throw $failure
}
}
function Restore-DevSkillBackup(
[string]$Destination,
[string]$Backup,
[string]$PublishedSource = "",
[string]$PublishedLinkSignature = ""
) {
if (Test-PathLexically $Destination) {
Remove-VerifiedDevSkillPublication $Destination $PublishedSource $PublishedLinkSignature
}
if (![string]::IsNullOrWhiteSpace($Backup)) {
Move-DevSkillPathRecoverably $Backup $Destination
}
}
function Publish-DevSkillCopy([string]$Source, [string]$Destination) {
$parent = Split-Path $Destination -Parent
New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null
$stageRoot = Join-Path $parent (".dws-dev-copy-" + [guid]::NewGuid().ToString("N"))
$stage = Join-Path $stageRoot "payload"
$backup = ""
$publishedSource = ""
New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
try {
Copy-DevSkillPathLexically $Source $stage
Assert-DevSkillPathCopy $Source $stage
Backup-DevSkill $Destination ([ref]$backup)
try {
Move-DevSkillPath $stage $Destination
$publishedSource = $Source
Assert-DevSkillPathCopy $Source $Destination
} catch {
$publishFailure = $_
try { Restore-DevSkillBackup $Destination $backup $publishedSource "" } catch {
throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_")
}
throw $publishFailure
}
} finally {
Remove-DevLinkStageRoot $stageRoot | Out-Null
}
}
function Publish-DevSkillJunction([string]$Target, [string]$Destination) {
$parent = Split-Path $Destination -Parent
New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null
$stageRoot = Join-Path $parent (".dws-dev-link-" + [guid]::NewGuid().ToString("N"))
$stage = Join-Path $stageRoot "payload"
$backup = ""
$publishedSignature = ""
New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
try {
New-Item -ItemType Junction -Path $stage -Target ([System.IO.Path]::GetFullPath($Target)) -ErrorAction Stop | Out-Null
Backup-DevSkill $Destination ([ref]$backup)
try {
Move-DevSkillPath $stage $Destination
$published = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop
if (!$published.LinkType) { throw "published Skill path is not a junction: $Destination" }
$publishedSignature = Get-DevSkillLinkSignature $Destination
if ([string]::IsNullOrEmpty($publishedSignature)) { throw "published Skill link identity is unavailable: $Destination" }
} catch {
$publishFailure = $_
try { Restore-DevSkillBackup $Destination $backup "" $publishedSignature } catch {
throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_")
}
throw $publishFailure
}
} finally {
# The staged payload is a junction to the canonical store; it must be
# removed lexically or Windows PowerShell 5.1 recursion could delete
# canonical content through the reparse point.
Remove-DevLinkStageRoot $stageRoot | Out-Null
}
}
function Get-Arch {
if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH }
try {
@@ -40,6 +465,26 @@ function Get-Arch {
}
}
function Assert-DevReleaseAssetChecksum([string]$AssetPath, [string]$AssetName, [string]$TempDir) {
$checksums = Join-Path $TempDir "checksums.txt"
if (!(Test-Path -LiteralPath $checksums -PathType Leaf)) {
try {
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/checksums.txt" `
-OutFile $checksums -UseBasicParsing -ErrorAction Stop
} catch {
Die "Could not download checksums.txt; refusing unverified release assets."
}
}
$line = Get-Content -LiteralPath $checksums | Where-Object {
$_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$"
} | Select-Object -First 1
if (-not $line) { Die "$AssetName is missing from checksums.txt." }
$expected = ($line -split '\s+')[0].ToLowerInvariant()
$actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
if ($actual -ne $expected) { Die "SHA256 checksum mismatch for $AssetName." }
Say "SHA256 checksum verified: $AssetName"
}
# Read the releases list (newest first) and take the top tag, so this also works
# if a release is ever published as a prerelease (which /releases/latest skips).
if (-not $Version) {
@@ -70,6 +515,7 @@ try {
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/$asset" `
-OutFile $zip -UseBasicParsing
} catch { Die "Binary download failed - does release $Version have $asset?" }
Assert-DevReleaseAssetChecksum $zip $asset $tmp
Expand-Archive -Path $zip -DestinationPath $tmp -Force
$exe = Get-ChildItem -Path $tmp -Recurse -Filter "dws.exe" | Select-Object -First 1
@@ -84,6 +530,7 @@ if (-not $NoSkills) {
$skzip = Join-Path $tmp "dws-skills.zip"
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" `
-OutFile $skzip -UseBasicParsing
Assert-DevReleaseAssetChecksum $skzip "dws-skills.zip" $tmp
$skdir = Join-Path $tmp "sk"
Expand-Archive -Path $skzip -DestinationPath $skdir -Force
@@ -95,33 +542,63 @@ if (-not $NoSkills) {
if ($src) {
# cache so `dws skill setup --mode multi` can find a source later
$cache = Join-Path $HOME ".dws\skills\multi\$SkillName"
if (Test-Path $cache) { Remove-Item -Recurse -Force $cache }
New-Item -ItemType Directory -Path $cache -Force | Out-Null
Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force
Publish-DevSkillCopy $src $cache
$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
Publish-DevSkillCopy $src $canonical
$installed = 1
$failed = 0
$retireFailed = 0
$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 (!(Test-AgentBaseDetected $parts[0] $base)) { 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) {
# Cleanup-only migration: universal Agents read the
# canonical store directly, so nothing is installed here. A
# retire failure only leaves an obsolete copy behind and
# must never fail an otherwise complete install.
try { Backup-DevSkill $dest } catch {
Say "⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): $dest ($($_.Exception.Message))"
$retireFailed++
}
continue
}
# Per-agent degrade like install.sh: a failed link→copy fallback
# skips that agent loudly instead of aborting the whole loop.
try {
New-Item -ItemType Directory -Path $base -Force -ErrorAction Stop | Out-Null
try { Publish-DevSkillJunction $canonical $dest }
catch {
# A failed rollback leaves the old backup as the only
# trusted copy. Do not run a second publisher over that
# state; surface the failure and continue other agents.
if ($_.Exception.Data["DWSRollbackFailed"]) { throw }
Publish-DevSkillCopy $canonical $dest
}
} catch {
Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))"
$failed++
continue
}
$installed++
}
if ($failed -gt 0) { Die "有 $failed 个 Agent 目标安装 $SkillName 失败(其余目标已安装)" }
if ($retireFailed -gt 0) {
Say "⚠️ 有 $retireFailed 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)"
}
Say "Skill dingtalk-misc -> $installed agent dir(s)"
} else {
Say "(dingtalk-misc not found in skills bundle; skipped)"
}
} catch {
Say "(skill install skipped: $($_.Exception.Message))"
Die "Skill install failed: $($_.Exception.Message)"
}
}
+246 -13
View File
@@ -26,6 +26,214 @@ 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"; }
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}'
else return 1
fi
}
verify_release_asset() {
name="$1"; file="$2"
checksums="$tmp/checksums.txt"
if [ ! -f "$checksums" ]; then
curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/checksums.txt" -o "$checksums" \
|| err "Could not download checksums.txt; refusing unverified release assets."
fi
expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")"
[ -n "$expected" ] || err "${name} is missing from checksums.txt."
actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}."
[ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}."
say "✅ SHA256 checksum verified: ${name}"
}
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"
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
return 1
}
# refresh_cache_tree <src> <dest>
# Ephemeral ~/.dws/skills cache refresh, identical to install-event.sh. The tree
# is byte-reproducible from the release asset, so it is never archived into
# ~/.dws/skill-backups; unlike the previous `rm -rf` + `cp` it only deletes the
# old cache after the replacement is fully staged, so an interrupted install can
# no longer leave a half-copied cache behind.
refresh_cache_tree() {
src="$1"; dest="$2"; parent="$(dirname "$dest")"
mkdir -p "$parent" || return 1
stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
old=""
if [ -e "$dest" ] || [ -L "$dest" ]; then
old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; }
rmdir "$old" || { rm -rf "$stage" "$old"; return 1; }
if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi
fi
if mv "$stage" "$dest"; then
if [ -n "$old" ] && ! rm -rf "$old"; then
printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2
fi
return 0
fi
rm -rf "$stage"
if [ -n "$old" ] && ! mv "$old" "$dest"; then
printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2
fi
return 1
}
backup_skill_dir() {
victim="$1"
[ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; }
# Prune before creating this run's archive, so the backup the caller is about
# to depend on for rollback can never be a prune candidate.
prune_skill_backups
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"
# Same bail-out as install-skills.sh: never spin forever on a pathological
# backup root, report it and keep the original directory instead.
if [ "$i" -gt 1000 ]; then
printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2
return 1
fi
done
mkdir -p "$backup_root" || return 1
mv "$victim" "$backup" || return 1
printf '%s\n' "$backup"
}
# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup
# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep /
# pruneSkillBackups. Glob order is lexicographic and the stamps are UTC
# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is
# reported and never fatal.
SKILL_BACKUP_KEEP=5
# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC
# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in
# the backup root is foreign (user data, unrelated tooling) and is preserved
# so pruning can never remove a directory it cannot prove DWS created.
is_skill_backup_stamp() {
case "$1" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
return 0 ;;
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
# those 16 chars and require the remainder to be all digits.
_isbs_suffix="${1#????????????????}"
case "$_isbs_suffix" in
""|*[!0-9]*) return 1 ;;
esac
return 0 ;;
esac
return 1
}
prune_skill_backups() {
prune_root="$HOME/.dws/skill-backups"
[ -d "$prune_root" ] || return 0
prune_total=0
for prune_entry in "$prune_root"/*; do
[ -d "$prune_entry" ] || continue
is_skill_backup_stamp "${prune_entry##*/}" || continue
prune_total=$((prune_total + 1))
done
[ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
prune_drop=$((prune_total - SKILL_BACKUP_KEEP))
prune_seen=0
for prune_entry in "$prune_root"/*; do
[ -d "$prune_entry" ] || continue
is_skill_backup_stamp "${prune_entry##*/}" || continue
prune_seen=$((prune_seen + 1))
[ "$prune_seen" -le "$prune_drop" ] || break
rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
done
}
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"
}
agent_skill_base_detected() {
agent_dir="$1"; base="$2"
case "$agent_dir" in
.config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;;
.zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;;
.minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
*) [ -d "$(dirname "$base")" ] ;;
esac
}
link_or_copy_skill() {
canonical="$1"; src="$2"; dest="$3"
same_physical_skill "$dest" "$canonical" && return 0
parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1
parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1
target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1
relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
stage="$(mktemp -d "$parent/.dws-link.tmp.XXXXXX")" || return 1
if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then
rm -rf "$stage"
if copy_tree "$src" "$dest"; then
printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2
return 0
fi
return 1
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"
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
return 1
}
need_cmd curl
detect_os() {
@@ -81,20 +289,43 @@ install_skill() {
# cache so `dws skill setup --mode multi` can find a source later
cache="$HOME/.dws/skills/multi/$SKILL_NAME"
rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/"
refresh_cache_tree "$src" "$cache" || { printf ' ⚠️ Skill 缓存刷新失败: %s\n' "$cache" >&2; return 1; }
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
failed=0
retire_failed=0
for agent_dir in $(agent_skill_dirs); do
base="$(resolve_agent_skill_base "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base" || continue
same_physical_skill "$base" "$HOME/.agents/skills" && continue
if is_cleanup_only_agent_dir "$agent_dir"; then
# Cleanup-only: universal Agents read the canonical store directly, so
# nothing is installed here. A retire failure only leaves an obsolete copy
# behind and must never fail an otherwise complete install.
if ! backup_skill_dir "$base/$SKILL_NAME" >/dev/null; then
printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$SKILL_NAME" >&2
retire_failed=$((retire_failed + 1))
fi
continue
fi
# Per-agent degrade like install.sh: a failed target is reported and
# skipped loudly instead of aborting the remaining agents mid-loop.
if ! link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME"; then
printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$SKILL_NAME" >&2
failed=$((failed + 1))
continue
fi
installed=$((installed + 1))
done
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2
fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$SKILL_NAME" >&2
return 1
fi
say "✅ Skill dingtalk-misc → ${installed} agent dir(s)"
}
@@ -119,6 +350,7 @@ main() {
say "⬇ Downloading ${asset} ..."
curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/${asset}" -o "$tmp/$asset" \
|| err "Binary download failed — does release ${DEVAPP_VERSION} have ${asset}?"
verify_release_asset "$asset" "$tmp/$asset"
if [ "$os" = "windows" ]; then
need_cmd unzip; unzip -q "$tmp/$asset" -d "$tmp"
else
@@ -132,10 +364,11 @@ main() {
# 2) dev skill from the release's skills bundle
if [ "$NO_SKILLS" != "1" ]; then
if curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/dws-skills.zip" -o "$tmp/skills.zip" 2>/dev/null; then
verify_release_asset "dws-skills.zip" "$tmp/skills.zip"
mkdir -p "$tmp/sk"
if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi
say ""
install_skill "$tmp/sk"
install_skill "$tmp/sk" || err "dingtalk-misc Skill 安装失败,详见上方告警"
else
say " (no dws-skills.zip in release ${DEVAPP_VERSION}; skill skipped)"
fi
+253 -29
View File
@@ -39,6 +39,28 @@ say() { printf ' %s\n' "$@"; }
err() { printf ' ERROR: %s\n' "$@" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; }
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}'
else return 1
fi
}
verify_release_asset() {
name="$1"; file="$2"
checksums="$tmp/checksums.txt"
if [ ! -f "$checksums" ]; then
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/checksums.txt" -o "$checksums" \
|| err "Could not download checksums.txt; refusing unverified release assets."
fi
expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")"
[ -n "$expected" ] || err "${name} is missing from checksums.txt."
actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}."
[ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}."
say "SHA256 checksum verified: ${name}"
}
detect_os() {
case "$(uname -s)" in
Linux*) echo linux ;;
@@ -92,36 +114,232 @@ 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"
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
return 1
}
# refresh_cache_tree <src> <dest>
# Same staged, atomic swap as copy_tree, but for the ephemeral ~/.dws/skills
# caches: those trees are byte-reproducible from the release asset, so archiving
# every stale generation into ~/.dws/skill-backups only grows that directory
# without bound. The previous cache is deleted, exactly like install-devapp.sh —
# but only after the replacement is fully staged and published.
refresh_cache_tree() {
src="$1"
dest="$2"
parent="$(dirname "$dest")"
mkdir -p "$parent" || return 1
stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
old=""
if [ -e "$dest" ] || [ -L "$dest" ]; then
old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; }
rmdir "$old" || { rm -rf "$stage" "$old"; return 1; }
if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi
fi
if mv "$stage" "$dest"; then
if [ -n "$old" ] && ! rm -rf "$old"; then
printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2
fi
return 0
fi
rm -rf "$stage"
if [ -n "$old" ] && ! mv "$old" "$dest"; then
printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2
fi
return 1
}
backup_skill_dir() {
victim="$1"
[ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; }
# Prune before creating this run's archive, so the backup the caller is about
# to depend on for rollback can never be a prune candidate.
prune_skill_backups
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"
# Same bail-out as install-skills.sh: never spin forever on a pathological
# backup root, report it and keep the original directory instead.
if [ "$i" -gt 1000 ]; then
printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2
return 1
fi
done
mkdir -p "$backup_root" || return 1
mv "$victim" "$backup" || return 1
printf '%s\n' "$backup"
}
# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup
# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep /
# pruneSkillBackups. Glob order is lexicographic and the stamps are UTC
# YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is
# reported and never fatal.
SKILL_BACKUP_KEEP=5
# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC
# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in
# the backup root is foreign (user data, unrelated tooling) and is preserved
# so pruning can never remove a directory it cannot prove DWS created.
is_skill_backup_stamp() {
case "$1" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
return 0 ;;
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
# those 16 chars and require the remainder to be all digits.
_isbs_suffix="${1#????????????????}"
case "$_isbs_suffix" in
""|*[!0-9]*) return 1 ;;
esac
return 0 ;;
esac
return 1
}
prune_skill_backups() {
prune_root="$HOME/.dws/skill-backups"
[ -d "$prune_root" ] || return 0
prune_total=0
for prune_entry in "$prune_root"/*; do
[ -d "$prune_entry" ] || continue
is_skill_backup_stamp "${prune_entry##*/}" || continue
prune_total=$((prune_total + 1))
done
[ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
prune_drop=$((prune_total - SKILL_BACKUP_KEEP))
prune_seen=0
for prune_entry in "$prune_root"/*; do
[ -d "$prune_entry" ] || continue
is_skill_backup_stamp "${prune_entry##*/}" || continue
prune_seen=$((prune_seen + 1))
[ "$prune_seen" -le "$prune_drop" ] || break
rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
done
}
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"
}
agent_skill_base_detected() {
agent_dir="$1"; base="$2"
case "$agent_dir" in
.config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;;
.zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;;
.minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
*) [ -d "$(dirname "$base")" ] ;;
esac
}
link_or_copy_skill() {
canonical="$1"; src="$2"; dest="$3"
same_physical_skill "$dest" "$canonical" && return 0
parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1
parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1
target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1
relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
stage="$(mktemp -d "$parent/.dws-link.tmp.XXXXXX")" || return 1
if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then
rm -rf "$stage"
if copy_tree "$src" "$dest"; then
printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2
return 0
fi
return 1
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"
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
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))
canonical="$HOME/.agents/skills/$skill_name"
copy_tree "$src" "$canonical" || return 1
installed=1
failed=0
retire_failed=0
for agent_dir in $(agent_skill_dirs); do
base="$(resolve_agent_skill_base "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base" || continue
same_physical_skill "$base" "$HOME/.agents/skills" && continue
if is_cleanup_only_agent_dir "$agent_dir"; then
# Cleanup-only: universal Agents read the canonical store directly, so
# nothing is installed here. A retire failure only leaves an obsolete copy
# behind and must never fail an otherwise complete install.
if ! backup_skill_dir "$base/$skill_name" >/dev/null; then
printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$skill_name" >&2
retire_failed=$((retire_failed + 1))
fi
continue
fi
# Per-agent degrade like install.sh: a failed target is reported and
# skipped loudly instead of aborting the remaining agents mid-loop.
if ! link_or_copy_skill "$canonical" "$src" "$base/$skill_name"; then
printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$skill_name" >&2
failed=$((failed + 1))
continue
fi
copy_tree "$src" "$base/$skill_name"
installed=$((installed + 1))
idx=$((idx + 1))
done
if [ "$installed" -eq 0 ]; then
copy_tree "$src" "$HOME/.agents/skills/$skill_name"
installed=1
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2
fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$skill_name" >&2
return 1
fi
printf '%s\n' "$installed"
}
@@ -176,17 +394,21 @@ install_skills_from_bundle() {
event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME"
shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME"
misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME"
copy_tree "$event_src" "$event_cache"
copy_tree "$shared_src" "$shared_cache"
copy_tree "$misc_src" "$misc_cache"
refresh_cache_tree "$event_src" "$event_cache" || err "${EVENT_SKILL_NAME} 缓存刷新失败: ${event_cache}"
refresh_cache_tree "$shared_src" "$shared_cache" || err "${SHARED_SKILL_NAME} 缓存刷新失败: ${shared_cache}"
refresh_cache_tree "$misc_src" "$misc_cache" || err "${MISC_SKILL_NAME} 缓存刷新失败: ${misc_cache}"
mono_cache="$HOME/.dws/skills/mono"
copy_tree "$mono_src" "$mono_cache"
refresh_cache_tree "$mono_src" "$mono_cache" || err "mono ${MONO_SKILL_NAME} 缓存刷新失败: ${mono_cache}"
event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")"
shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")"
misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")"
mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")"
event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" \
|| err "${EVENT_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" \
|| err "${SHARED_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" \
|| err "${MISC_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" \
|| err "mono ${MONO_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)"
say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)"
@@ -212,6 +434,7 @@ install_binary() {
say "Downloading ${asset} ..."
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/${asset}" -o "$tmp/$asset" \
|| err "Binary download failed. Does release ${EVENT_VERSION} have ${asset}?"
verify_release_asset "$asset" "$tmp/$asset"
if [ "$os" = "windows" ]; then
extract_zip "$tmp/$asset" "$tmp/bin"
@@ -240,6 +463,7 @@ install_skills() {
say "Downloading dws-skills.zip ..."
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/dws-skills.zip" -o "$tmp/dws-skills.zip" \
|| err "dws-skills.zip download failed for release ${EVENT_VERSION}"
verify_release_asset "dws-skills.zip" "$tmp/dws-skills.zip"
mkdir -p "$tmp/skills"
extract_zip "$tmp/dws-skills.zip" "$tmp/skills"
+368 -119
View File
@@ -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
@@ -80,6 +80,54 @@ backup_and_remove_skill_dir() {
return 1
}
# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup
# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep /
# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal.
# Only safe to call once every backup manifest of this run has been consumed,
# because a single transaction can span more than one timestamp.
DWS_SKILL_BACKUP_KEEP=5
# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC
# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in
# the backup root is foreign (user data, unrelated tooling) and is preserved
# so pruning can never remove a directory it cannot prove DWS created.
is_skill_backup_stamp() {
case "$1" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
return 0 ;;
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
# those 16 chars and require the remainder to be all digits.
_isbs_suffix="${1#????????????????}"
case "$_isbs_suffix" in
""|*[!0-9]*) return 1 ;;
esac
return 0 ;;
esac
return 1
}
prune_skill_backups() {
_psb_root="${HOME}/.dws/skill-backups"
[ -d "$_psb_root" ] || return 0
_psb_total=0
for _psb_entry in "$_psb_root"/*; do
[ -d "$_psb_entry" ] || continue
is_skill_backup_stamp "${_psb_entry##*/}" || continue
_psb_total=$((_psb_total + 1))
done
[ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0
_psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP))
_psb_seen=0
for _psb_entry in "$_psb_root"/*; do
[ -d "$_psb_entry" ] || continue
is_skill_backup_stamp "${_psb_entry##*/}" || continue
_psb_seen=$((_psb_seen + 1))
[ "$_psb_seen" -le "$_psb_drop" ] || break
rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry"
done
}
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
# use it too. Ownership comes from the centralized skills-state.json.
is_managed_multi_skill_dir() {
@@ -123,6 +171,29 @@ sha256_stdin() {
fi
}
verify_release_asset_checksum() {
_checksum_asset="$1"
_checksum_path="$2"
_checksum_dir="$3"
_checksum_url="$(asset_url checksums.txt)"
[ -n "$_checksum_url" ] || { printf '❌ Could not resolve checksums.txt for %s.\n' "$VERSION" >&2; return 1; }
curl -fsSL "$_checksum_url" -o "$_checksum_dir/checksums.txt" || {
printf '❌ Could not download checksums.txt for %s; refusing unverified %s.\n' "$VERSION" "$_checksum_asset" >&2
return 1
}
_checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")"
[ -n "$_checksum_expected" ] || { printf '❌ %s is missing from checksums.txt.\n' "$_checksum_asset" >&2; return 1; }
_checksum_actual="$(sha256_stdin < "$_checksum_path")" || {
printf '❌ Could not compute SHA256 for %s; install sha256sum, shasum, or openssl.\n' "$_checksum_asset" >&2
return 1
}
[ "$_checksum_actual" = "$_checksum_expected" ] || {
printf '❌ SHA256 checksum mismatch for %s. Expected %s, got %s.\n' "$_checksum_asset" "$_checksum_expected" "$_checksum_actual" >&2
return 1
}
printf ' ✅ SHA256 checksum verified: %s\n' "$_checksum_asset"
}
digest_skill_dir() {
_digest_dir="$1"
_digest="$({
@@ -206,7 +277,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
@@ -215,6 +286,50 @@ restore_multi_skill_set() {
[ "$_rms_ok" -eq 1 ]
}
# A link publication manifest stores destination/target/inode triples. Rollback
# only removes links that still have the identity created by this transaction;
# a path concurrently replaced with a file, directory, or new link is preserved.
skill_link_inode() {
_sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1
printf '%s\n' "$_sli_entry" | awk '{print $1}'
}
skill_link_matches() {
[ -L "$1" ] || return 1
_slm_target="$(readlink "$1" 2>/dev/null)" || return 1
[ "$_slm_target" = "$2" ] || return 1
_slm_expected_inode="${3-}"
[ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ]
}
restore_linked_skill_set() {
_rls_published="$1"
_rls_backups="$2"
_rls_ok=1
if [ -f "$_rls_published" ]; then
while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do
[ -n "$_rls_dest" ] || continue
if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then
rm -f "$_rls_dest" || _rls_ok=0
else
printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_rls_dest"
_rls_ok=0
fi
done < "$_rls_published"
fi
restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0
[ "$_rls_ok" -eq 1 ]
}
cleanup_nested_staged_link() {
_cnsl_nested="$1/$2"
_cnsl_target="$3"
_cnsl_inode="${4-}"
if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then
rm -f "$_cnsl_nested"
fi
}
# publish_skill_cache <source> <cache-dir>
# Stages a complete sibling cache before publishing it. Any copy or publish
# failure leaves the previous cache in place (or in the reported recovery dir
@@ -408,16 +523,99 @@ multi_tree_has_skills() {
return 1
}
# Move DWS-owned copies out of the generic root once a concrete Agent root is
# active. This prevents Agents such as Codex from discovering duplicates.
retire_generic_skill_root() {
# Exact upstream registry (76 IDs): id|classification|effective-global-root.
# `-` means no global directory; `.agents/skills` means canonical-direct.
# Not readonly: the library must stay re-sourceable inside one shell.
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
}
# DWS-only compatibility roots, same id|classification|root format. Qoderwork
# stays a real non-universal install target; the other four are global paths
# older DWS installers wrote by mistake, so they are retired like universal
# roots. Kept separate so the upstream pin above stays byte-comparable.
DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills'
legacy_agent_roots() {
for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done
}
# ~/.agents/skills is the canonical store under the universal convention.
# Universal Agents read it directly; other Agents receive relative links and
# fall back to copies only when links are unavailable.
#
# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned
# registries above, so a registry edit changes real install behaviour instead of
# only a test count, and pin/behaviour drift is impossible by construction.
is_universal_agent_dir() {
for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do
[ "${_iua_record##*|}" = "$1" ] || continue
case "$_iua_record" in
*"|U|"*) return 0 ;;
esac
done
return 1
}
# Effective global roots, in pinned registry order, de-duplicated. Records
# without a global root (`-`) and canonical-direct records (`.agents/skills`)
# are not per-Agent targets and are skipped.
agent_skill_dirs() {
{ upstream_agent_registry; legacy_agent_roots; } | awk -F'|' '
$3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }'
}
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"
}
agent_skill_base_detected() {
_asbd_agent="$1"; _asbd_base="$2"
case "$_asbd_agent" in
".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;;
".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;;
".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
*) [ -d "$(dirname "$_asbd_base")" ] ;;
esac
}
same_physical_skill_root() {
[ -d "$1" ] && [ -d "$2" ] || return 1
# CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved
# directory into the command substitution, which would silently report two
# identical roots as different.
_sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
_sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
[ "$_sps_left" = "$_sps_right" ]
}
retire_agent_skill_root() {
_rgs_root="$1"
_rgs_base="$_rgs_root/.agents/skills"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1
_rgs_base="$2"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1
_rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
[ -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
@@ -430,76 +628,158 @@ retire_generic_skill_root() {
rm -rf "$_rgs_stage"
}
# link_canonical_skills_to_base <root> <base> <mode> [bundle-src]
# <bundle-src> is the installed multi bundle and is REQUIRED in multi mode: the
# link set must come from the bundle, never from the whole canonical store.
# ~/.agents/skills is now shared, so third-party/user Skills live there too and
# must not be published into every detected Agent root (they are absent from
# skills-state.json, so nothing could ever reclaim those links). Go
# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list
# the same way.
link_canonical_skills_to_base() {
_lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"; _lcs_bundle="${4-}"
_lcs_canonical="$_lcs_root/.agents/skills"
mkdir -p "$_lcs_base" || return 1
same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
if [ "$_lcs_mode" = "mono" ]; then
_lcs_names="dws"
else
[ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1
_lcs_names=""
for _lcs_skill in "$_lcs_bundle"/*/; do
[ -f "${_lcs_skill}SKILL.md" ] || continue
_lcs_names="$_lcs_names $(basename "$_lcs_skill")"
done
[ -n "$_lcs_names" ] || return 1
fi
# Collision pre-flight: the victim loop below deliberately refuses to back up
# entries that are not DWS-managed, and publication must never replace them.
# Report the exact colliding paths before touching anything, so the fall back
# to the legacy full-copy layout is observable and actionable.
_lcs_collision=0
for _lcs_name in $_lcs_names; do
_lcs_dest="$_lcs_base/$_lcs_name"
{ [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue
same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue
[ "$_lcs_name" = "dws" ] && continue
is_managed_multi_skill_dir "$_lcs_dest" && continue
printf ' ⚠️ %s 已存在且不是 DWS 安装的 Skill,无法在此建立指向 %s 的共享链接\n' "$_lcs_dest" "$_lcs_canonical/$_lcs_name"
_lcs_collision=1
done
if [ "$_lcs_collision" -eq 1 ]; then
printf ' ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;\n'
printf ' 移走或改名后重新运行安装即可切换为共享 %s 布局。\n' "$_lcs_canonical"
return 1
fi
_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_stage_token="$(basename "$_lcs_stage")"
: > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; }
: > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; }
_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
_lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
_lcs_stage_name="${_lcs_stage_token}.${_lcs_name}"
ln -s "$_lcs_link_target" "$_lcs_stage/$_lcs_stage_name" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_publish_names="$_lcs_publish_names $_lcs_name"
done
for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do
[ -e "$_lcs_victim" ] || [ -L "$_lcs_victim" ] || continue
[ "$_lcs_victim" = "$_lcs_stage" ] && continue
same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue
if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then continue; fi
if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then
restore_multi_skill_set /dev/null "$_lcs_backups" || true
rm -rf "$_lcs_stage"; return 1
fi
done
for _lcs_name in $_lcs_publish_names; do
_lcs_dest="$_lcs_base/$_lcs_name"
_lcs_stage_name="${_lcs_stage_token}.${_lcs_name}"
_lcs_staged="$_lcs_stage/$_lcs_stage_name"
_lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
}
# The staged symlink keeps its inode across the rename below (the staging
# directory is a sibling inside the same Agent root), so the identity
# recorded in the publication manifest stays exact.
_lcs_inode="$(skill_link_inode "$_lcs_staged")" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
}
# Anything still present here appeared after the pre-flight scan, i.e. a
# concurrent writer. Never let `mv` clobber it.
if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
# Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is
# absent from BusyBox `ln`, which silently degraded every non-universal
# Agent to the copy layout on Alpine and most containers). install-event.sh
# and install-devapp.sh already publish this way. The staged basename stays
# unique, so a destination a concurrent process turned into a directory can
# only receive our link and post-validation removes exactly that.
if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then
if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
printf ' ↪ Skills → %s\n' "$_lcs_dest"
done
rm -rf "$_lcs_stage"
}
# Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
install_multi_skills_to_root() {
multi_src="$1"
root="$2"
installed=0
attempted=0
attempted=1
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
retire_failed=0
if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
for agent_dir in $(agent_skill_dirs)
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
for agent_dir in \
".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"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
continue
fi
base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base_dir" || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
continue
fi
if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir"
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
installed=$((installed + 1))
fi
if [ "$installed" -eq 0 ]; then
printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'
return 1
fi
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装失败\n' "$failed"
return 1
fi
write_skills_state "$multi_src" "install-skills.sh" || return 1
printf ' ✅ DWS Skills 安装完成\n'
printf ' 统一安装位置:%s/.agents/skills\n' "$root"
printf ' 已自动适配本机上检测到的 Agent\n'
printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n'
}
_install_multi_to_base() {
@@ -654,84 +934,48 @@ install_skills_to_root() {
skill_src="$1"
root="$2"
installed=0
attempted=0
attempted=1
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
retire_failed=0
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
for agent_dir in $(agent_skill_dirs)
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
for agent_dir in \
".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"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
continue
fi
base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base_dir" || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if [ "$root" = "$HOME" ]; then
label="~/$agent_dir/$SKILL_NAME"
else
label="$root/$agent_dir/$SKILL_NAME"
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
continue
fi
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
if link_canonical_skills_to_base "$root" "$base_dir" mono; then
installed=$((installed + 1))
else
failed=$((failed + 1))
if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then
printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ]; then
if [ "$root" = "$HOME" ]; then
flabel="~/.agents/skills/$SKILL_NAME"
else
flabel="$root/.agents/skills/$SKILL_NAME"
fi
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
if [ "$installed" -eq 0 ]; then
printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'
return 1
fi
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装 mono Skill 失败\n' "$failed"
return 1
fi
rm -f "$DWS_STATE_ROOT/skills-state.json"
printf ' ✅ DWS Skills 安装完成\n'
printf ' 统一安装位置:%s/.agents/skills\n' "$root"
printf ' 已自动适配本机上检测到的 Agent\n'
printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n'
}
# ── Main ─────────────────────────────────────────────────────────────────────
@@ -755,6 +999,7 @@ main() {
[ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; }
printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION"
curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip"
verify_release_asset_checksum "dws-skills.zip" "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK"
extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted"
# Prefer the explicit mono/ subtree; fall back to legacy nested or zip root.
@@ -803,6 +1048,10 @@ main() {
fi
fi
# Every transaction of this run has finished, so old stamped archives can no
# longer be needed for a rollback.
prune_skill_backups
printf '\n'
printf ' 📖 Skill includes:\n'
printf ' • SKILL.md — Main skill with product overview and intent routing\n'
+689 -119
View File
@@ -52,27 +52,103 @@ $LegacyOfficialMultiSkills = @(
"dingtalk-todo", "dingtalk-wiki", "dws-shared"
)
# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS).
$AgentDirs = @(
".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"
# Agent registry synchronized with vercel-labs/skills agents.ts (c6f69c6).
# Universal agents read the canonical store directly; their separate historical
# global directories are cleanup-only. Non-universal agents get junctions to
# canonical (or complete copies when junction creation is unavailable).
$AgentRegistry = @(
[pscustomobject]@{ Id = "aider-desk"; Universal = $false; Dir = ".aider-desk\skills" },
[pscustomobject]@{ Id = "amp"; Universal = $true; Dir = ".config\agents\skills" },
[pscustomobject]@{ Id = "antigravity"; Universal = $true; Dir = ".gemini\antigravity\skills" },
[pscustomobject]@{ Id = "antigravity-cli"; Universal = $true; Dir = ".gemini\antigravity-cli\skills" },
[pscustomobject]@{ Id = "astrbot"; Universal = $false; Dir = ".astrbot\data\skills" },
[pscustomobject]@{ Id = "autohand-code"; Universal = $false; Dir = ".autohand\skills" },
[pscustomobject]@{ Id = "augment"; Universal = $false; Dir = ".augment\skills" },
[pscustomobject]@{ Id = "bob"; Universal = $false; Dir = ".bob\skills" },
[pscustomobject]@{ Id = "claude-code"; Universal = $false; Dir = ".claude\skills" },
[pscustomobject]@{ Id = "openclaw"; Universal = $false; Dir = ".openclaw\skills" },
[pscustomobject]@{ Id = "cline"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "codearts-agent"; Universal = $false; Dir = ".codeartsdoer\skills" },
[pscustomobject]@{ Id = "codebuddy"; Universal = $false; Dir = ".codebuddy\skills" },
[pscustomobject]@{ Id = "codemaker"; Universal = $false; Dir = ".codemaker\skills" },
[pscustomobject]@{ Id = "codestudio"; Universal = $false; Dir = ".codestudio\skills" },
[pscustomobject]@{ Id = "codex"; Universal = $true; Dir = ".codex\skills" },
[pscustomobject]@{ Id = "command-code"; Universal = $false; Dir = ".commandcode\skills" },
[pscustomobject]@{ Id = "continue"; Universal = $false; Dir = ".continue\skills" },
[pscustomobject]@{ Id = "cortex"; Universal = $false; Dir = ".snowflake\cortex\skills" },
[pscustomobject]@{ Id = "crush"; Universal = $false; Dir = ".config\crush\skills" },
[pscustomobject]@{ Id = "cursor"; Universal = $true; Dir = ".cursor\skills" },
[pscustomobject]@{ Id = "deepagents"; Universal = $true; Dir = ".deepagents\agent\skills" },
[pscustomobject]@{ Id = "devin"; Universal = $false; Dir = ".config\devin\skills" },
[pscustomobject]@{ Id = "dexto"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "droid"; Universal = $false; Dir = ".factory\skills" },
[pscustomobject]@{ Id = "eve"; Universal = $false; Dir = $null },
[pscustomobject]@{ Id = "firebender"; Universal = $true; Dir = ".firebender\skills" },
[pscustomobject]@{ Id = "forgecode"; Universal = $false; Dir = ".forge\skills" },
[pscustomobject]@{ Id = "gemini-cli"; Universal = $true; Dir = ".gemini\skills" },
[pscustomobject]@{ Id = "github-copilot"; Universal = $true; Dir = ".copilot\skills" },
[pscustomobject]@{ Id = "goose"; Universal = $false; Dir = ".config\goose\skills" },
[pscustomobject]@{ Id = "grok"; Universal = $false; Dir = ".grok\skills" },
[pscustomobject]@{ Id = "hermes-agent"; Universal = $false; Dir = ".hermes\skills" },
[pscustomobject]@{ Id = "inference-sh"; Universal = $false; Dir = ".inferencesh\skills" },
[pscustomobject]@{ Id = "jazz"; Universal = $false; Dir = ".jazz\skills" },
[pscustomobject]@{ Id = "junie"; Universal = $false; Dir = ".junie\skills" },
[pscustomobject]@{ Id = "iflow-cli"; Universal = $false; Dir = ".iflow\skills" },
[pscustomobject]@{ Id = "kilo"; Universal = $false; Dir = ".kilocode\skills" },
[pscustomobject]@{ Id = "kimchi"; Universal = $false; Dir = ".config\kimchi\harness\skills" },
[pscustomobject]@{ Id = "kimi-code-cli"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "kiro-cli"; Universal = $false; Dir = ".kiro\skills" },
[pscustomobject]@{ Id = "kode"; Universal = $false; Dir = ".kode\skills" },
[pscustomobject]@{ Id = "lingma"; Universal = $false; Dir = ".lingma\skills" },
[pscustomobject]@{ Id = "loaf"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "mcpjam"; Universal = $false; Dir = ".mcpjam\skills" },
[pscustomobject]@{ Id = "minimax-code"; Universal = $false; Dir = ".minimax\skills" },
[pscustomobject]@{ Id = "mistral-vibe"; Universal = $false; Dir = ".vibe\skills" },
[pscustomobject]@{ Id = "moxby"; Universal = $false; Dir = ".moxby\skills" },
[pscustomobject]@{ Id = "mux"; Universal = $false; Dir = ".mux\skills" },
[pscustomobject]@{ Id = "opencode"; Universal = $true; Dir = ".config\opencode\skills" },
[pscustomobject]@{ Id = "openhands"; Universal = $false; Dir = ".openhands\skills" },
[pscustomobject]@{ Id = "ona"; Universal = $false; Dir = ".ona\skills" },
[pscustomobject]@{ Id = "pi"; Universal = $false; Dir = ".pi\agent\skills" },
[pscustomobject]@{ Id = "qoder"; Universal = $false; Dir = ".qoder\skills" },
[pscustomobject]@{ Id = "qoder-cn"; Universal = $false; Dir = ".qoder-cn\skills" },
[pscustomobject]@{ Id = "qwen-code"; Universal = $false; Dir = ".qwen\skills" },
[pscustomobject]@{ Id = "replit"; Universal = $true; Dir = ".config\agents\skills" },
[pscustomobject]@{ Id = "reasonix"; Universal = $false; Dir = ".reasonix\skills" },
[pscustomobject]@{ Id = "rovodev"; Universal = $false; Dir = ".rovodev\skills" },
[pscustomobject]@{ Id = "roo"; Universal = $false; Dir = ".roo\skills" },
[pscustomobject]@{ Id = "tabnine-cli"; Universal = $false; Dir = ".tabnine\agent\skills" },
[pscustomobject]@{ Id = "terramind"; Universal = $false; Dir = ".terramind\skills" },
[pscustomobject]@{ Id = "tinycloud"; Universal = $false; Dir = ".tinycloud\skills" },
[pscustomobject]@{ Id = "trae"; Universal = $false; Dir = ".trae\skills" },
[pscustomobject]@{ Id = "trae-cn"; Universal = $false; Dir = ".trae-cn\skills" },
[pscustomobject]@{ Id = "warp"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "windsurf"; Universal = $false; Dir = ".codeium\windsurf\skills" },
[pscustomobject]@{ Id = "zed"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "zcode"; Universal = $false; Dir = ".zcode\skills" },
[pscustomobject]@{ Id = "zencoder"; Universal = $false; Dir = ".zencoder\skills" },
[pscustomobject]@{ Id = "zenflow"; Universal = $false; Dir = ".zencoder\skills" },
[pscustomobject]@{ Id = "neovate"; Universal = $false; Dir = ".neovate\skills" },
[pscustomobject]@{ Id = "pochi"; Universal = $false; Dir = ".pochi\skills" },
[pscustomobject]@{ Id = "promptscript"; Universal = $true; Dir = $null },
[pscustomobject]@{ Id = "adal"; Universal = $false; Dir = ".adal\skills" },
[pscustomobject]@{ Id = "universal"; Universal = $true; Dir = ".config\agents\skills" }
)
# DWS compatibility targets that are not part of the upstream registry.
# Qoderwork remains a non-universal install target; the other entries are
# migration cleanup targets only and intentionally do not count as agents.
$LegacyAgentCleanupTargets = @(
[pscustomobject]@{ Id = "dws-qoderwork"; Universal = $false; Dir = ".qoderwork\skills" },
[pscustomobject]@{ Id = "dws-legacy-github"; Universal = $true; Dir = ".github\skills" },
[pscustomobject]@{ Id = "dws-legacy-amp"; Universal = $true; Dir = ".amp\skills" },
[pscustomobject]@{ Id = "dws-legacy-cline"; Universal = $true; Dir = ".cline\skills" },
[pscustomobject]@{ Id = "dws-legacy-windsurf"; Universal = $true; Dir = ".windsurf\skills" }
)
# Kept as a compatibility surface for policy tests and downstream packagers.
$AgentDirs = @($AgentRegistry | Where-Object { $null -ne $_.Dir } | ForEach-Object { $_.Dir })
# ── Helpers ──────────────────────────────────────────────────────────────────
function Write-Say {
@@ -182,7 +258,226 @@ function Move-SkillPath {
[string]$Source,
[string]$Destination
)
Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop
if (Test-SkillPathLexically -Path $Destination) {
throw "Skill move destination already exists: $Destination"
}
$sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
if ($sourceItem.PSIsContainer) {
# Move-Item treats an existing directory as a container and nests the
# source below it. Directory.Move has the exact rename semantics this
# transaction requires: an occupied destination fails without touching
# either path.
[System.IO.Directory]::Move($Source, $Destination)
} else {
[System.IO.File]::Move($Source, $Destination)
}
}
function Test-CrossDeviceMoveError {
param([System.Management.Automation.ErrorRecord]$Record)
$exception = $Record.Exception
while ($null -ne $exception) {
# Win32 ERROR_NOT_SAME_DEVICE is 17 (0x11). Move-Item surfaces it as
# the low word of an IOException HRESULT.
if (($exception.HResult -band 0xffff) -eq 17) { return $true }
$exception = $exception.InnerException
}
return $false
}
function Copy-SkillPathMetadata {
param($SourceItem, [string]$Destination)
(Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes
$nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
if ($nativeWindows) {
Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop
} else {
$mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName)
[System.IO.File]::SetUnixFileMode($Destination, $mode)
}
}
function Get-SkillPathPermissionFingerprint {
param([string]$Path)
$nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
if ($nativeWindows) {
return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl
}
return [string][System.IO.File]::GetUnixFileMode($Path)
}
function Copy-SkillPathLexically {
param([string]$Source, [string]$Destination)
$item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
if ($item.LinkType) {
$itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" }
New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null
return
}
if ($item.PSIsContainer) {
New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null
foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) {
Copy-SkillPathLexically -Source $child.FullName -Destination (Join-Path $Destination $child.Name)
}
Copy-SkillPathMetadata -SourceItem $item -Destination $Destination
return
}
if ($item -isnot [System.IO.FileInfo]) {
throw "不支持复制特殊 Skill 路径 $Source"
}
[System.IO.File]::Copy($Source, $Destination, $false)
Copy-SkillPathMetadata -SourceItem $item -Destination $Destination
}
function Assert-SkillPathCopy {
param([string]$Source, [string]$Destination)
$sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
$destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop
if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or
$sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) {
throw "Skill 路径类型不一致: $Source != $Destination"
}
if ($sourceItem.LinkType) {
if ($sourceItem.LinkType -ne $destinationItem.LinkType -or
($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) {
throw "Skill 链接目标不一致: $Source != $Destination"
}
return
}
if ((Get-SkillPathPermissionFingerprint -Path $Source) -ne
(Get-SkillPathPermissionFingerprint -Path $Destination)) {
throw "Skill 路径权限不一致: $Source != $Destination"
}
if ($sourceItem.PSIsContainer) {
$sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name)
$destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name)
if ($sourceChildren.Count -ne $destinationChildren.Count) {
throw "Skill 目录项数量不一致: $Source != $Destination"
}
for ($i = 0; $i -lt $sourceChildren.Count; $i++) {
if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) {
throw "Skill 目录项不一致: $Source != $Destination"
}
Assert-SkillPathCopy -Source $sourceChildren[$i].FullName -Destination $destinationChildren[$i].FullName
}
return
}
if ($sourceItem.Length -ne $destinationItem.Length) {
throw "Skill 文件大小不一致: $Source != $Destination"
}
$sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash
$destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash
if ($sourceHash -ne $destinationHash) {
throw "Skill 文件内容摘要不一致: $Source != $Destination"
}
}
function Remove-SkillPathLexically {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($item.PSIsContainer -and !$item.LinkType) {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
} else {
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
}
}
# Removes a staging directory that may hold unpublished junction/symlink
# children. Every link child must be removed non-recursively first: Windows
# PowerShell 5.1 (the advertised irm|iex surface) can follow reparse points
# during Remove-Item -Recurse and would delete the canonical store's contents
# through the staged link. CI runs pwsh 7, which masks this behavior.
function Remove-LinkStageRoot {
param([string]$StageRoot)
if (!(Test-Path -LiteralPath $StageRoot)) { return $true }
$ok = $true
foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) {
try { Remove-SkillPathLexically -Path $child.FullName } catch { $ok = $false }
}
try {
Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop
} catch {
$ok = $false
}
return $ok
}
# Resolves a path to its physical location, dereferencing junction/symlink
# reparse points at any depth. Resolve-Path alone is lexical and never
# recognizes DWS's own junctions, so every rerun would back them up and
# recreate them. Mirrors EvalSymlinks (Go), realpathSync (npm), and cd -P
# (shell) on the other installer surfaces.
function Get-PhysicalSkillPath {
param([string]$Path, [int]$Depth = 0)
if ($Depth -gt 40) { return $null }
$parent = Split-Path $Path -Parent
if ([string]::IsNullOrWhiteSpace($parent)) { return $Path }
$parentPhysical = Get-PhysicalSkillPath -Path $parent -Depth ($Depth + 1)
if ($null -eq $parentPhysical) { return $null }
$leaf = Split-Path $Path -Leaf
if ([string]::IsNullOrEmpty($leaf)) { return $parentPhysical }
$candidate = Join-Path $parentPhysical $leaf
try { $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop } catch { return $null }
$target = @($item.Target) | Where-Object { $_ } | Select-Object -First 1
if ($item.LinkType -and $target) {
$targetPath = ""
if ([System.IO.Path]::IsPathRooted([string]$target)) {
$targetPath = [string]$target
} else {
$targetPath = Join-Path $parentPhysical ([string]$target)
}
# Resolve the target recursively as well. A custom HOME or canonical
# root can itself sit below another junction, which must compare equal
# to the fully physical path just like EvalSymlinks/realpath do.
return Get-PhysicalSkillPath -Path ([System.IO.Path]::GetFullPath($targetPath)) -Depth ($Depth + 1)
}
return $candidate
}
# Same-volume moves remain atomic. For a cross-volume backup/restore, stage on
# the destination filesystem, copy links lexically, verify, publish, then
# remove the source. Before source removal every failure keeps the original;
# a removal failure deliberately leaves both verified copies and fails loud.
function Move-SkillPathRecoverably {
param([string]$Source, [string]$Destination)
if (Test-SkillPathLexically -Path $Destination) { throw "移动目标已存在: $Destination" }
$destinationParent = Split-Path $Destination -Parent
New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null
try {
Move-SkillPath -Source $Source -Destination $Destination
return
} catch {
if (!(Test-CrossDeviceMoveError -Record $_)) { throw }
}
$stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N"))
$stage = Join-Path $stageRoot "payload"
New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
try {
Copy-SkillPathLexically -Source $Source -Destination $stage
Assert-SkillPathCopy -Source $Source -Destination $stage
Move-SkillPath -Source $stage -Destination $Destination
Assert-SkillPathCopy -Source $Source -Destination $Destination
if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) {
throw "Skill staging 清理失败(已发布内容不受影响): $stageRoot"
}
try {
Remove-SkillPathLexically -Path $Source
} catch {
throw "Skill 目标已发布但源路径删除失败(源 $Source 与目标 $Destination 均保留): $_"
}
if (Test-SkillPathLexically -Path $Source) {
throw "Skill 目标已发布但源路径仍存在(源 $Source 与目标 $Destination 均保留)"
}
} catch {
$failure = $_
if (Test-Path -LiteralPath $stageRoot) {
if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) {
throw "$failure;跨设备 Skill staging 清理失败 $stageRoot(备份与原路径均保留)"
}
}
throw $failure
}
}
function Get-Arch {
@@ -264,6 +559,26 @@ function Get-GiteeAssetUrl {
return ""
}
function Assert-ReleaseAssetChecksum {
param([string]$AssetPath, [string]$AssetName, [string]$TempDir)
if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
if (-not $checksumUrl) { Write-Err "Could not resolve checksums.txt for $Version." }
$checksumPath = Join-Path $TempDir "checksums.txt"
try {
Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing -ErrorAction Stop
} catch {
Write-Err "Could not download checksums.txt for $Version; refusing unverified $AssetName."
}
$expectedLine = Get-Content -LiteralPath $checksumPath | Where-Object {
$_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$"
} | Select-Object -First 1
if (-not $expectedLine) { Write-Err "$AssetName is missing from checksums.txt." }
$expected = ($expectedLine -split '\s+')[0].ToLowerInvariant()
$actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
if ($actual -ne $expected) { Write-Err "SHA256 checksum mismatch for $AssetName. Expected $expected, got $actual." }
Write-Say "✅ SHA256 checksum verified: $AssetName"
}
function Resolve-Source {
# Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable.
if ($GiteeRepo -ne "") { return }
@@ -389,6 +704,61 @@ function Publish-SkillCache {
}
}
# Get-SkillBackupName encodes the HOME-relative path of a backed-up Skill
# directory ('.codex\skills\dingtalk-chat' → '.codex-skills-dingtalk-chat') so
# copies retired from different Agent roots stay distinguishable inside one
# stamp. Mirrors build/npm/install.js and internal/upgrade/paths.go. Paths
# outside HOME fall back to the bare leaf.
function Get-SkillBackupName {
param([string]$Dir)
$leaf = Split-Path $Dir -Leaf
try {
$full = [System.IO.Path]::GetFullPath($Dir)
$root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/'))
} catch {
return $leaf
}
if ([string]::IsNullOrWhiteSpace($root)) { return $leaf }
foreach ($sep in @('\', '/')) {
$prefix = $root + $sep
if ($full.Length -gt $prefix.Length -and
$full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/'))
if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') }
}
}
return $leaf
}
# SkillBackupKeep bounds $HOME\.dws\skill-backups growth: only the newest
# stamped backup directories are kept, matching skillBackupKeep and
# pruneSkillBackups in internal/upgrade/paths.go.
$SkillBackupKeep = 5
$script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
# Remove-OldSkillBackups deletes the oldest stamped backup directories once
# more than $SkillBackupKeep remain. Stamps sort lexicographically in
# chronological order, so name order is age order. Roots created during this
# run are never pruned: an in-flight transaction still needs them to roll
# back. Best-effort — a prune failure never fails the install.
function Remove-OldSkillBackups {
$root = Join-Path $HOME ".dws\skill-backups"
if (!(Test-Path -LiteralPath $root -PathType Container)) { return }
# Only directories whose names match the DWS backup stamp format (UTC
# yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any
# other entry is foreign data and is preserved.
$dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } |
Sort-Object -Property Name)
$excess = $dirs.Count - $SkillBackupKeep
foreach ($dir in $dirs) {
if ($excess -le 0) { break }
if ($script:SkillBackupRootsThisRun.Contains($dir.FullName)) { continue }
Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
$excess--
}
}
# Backup-SkillDir moves $Dir into $HOME\.dws\skill-backups\<stamp>\<name>
# instead of destroying it (non-interactive installs cannot confirm, so
# removals must stay reversible). Missing paths are a no-op success. On any
@@ -400,14 +770,16 @@ function Backup-SkillDir {
[ref]$BackupPath
)
if ($null -ne $BackupPath) { $BackupPath.Value = "" }
if (!(Test-Path $Dir -PathType Container)) { return $true }
if (!(Test-SkillPathLexically -Path $Dir)) { return $true }
$backupRoot = Join-Path $HOME ".dws\skill-backups"
$stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss")
$name = Split-Path $Dir -Leaf
$target = Join-Path (Join-Path $backupRoot $stamp) $name
$name = Get-SkillBackupName -Dir $Dir
$targetRoot = Join-Path $backupRoot $stamp
$target = Join-Path $targetRoot $name
$i = 1
while (Test-Path $target) {
$target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name
while (Test-SkillPathLexically -Path $target) {
$targetRoot = Join-Path $backupRoot "$stamp-$i"
$target = Join-Path $targetRoot $name
$i++
if ($i -gt 1000) {
Write-Say "⚠️ 备份目录冲突,保留原目录 $Dir"
@@ -416,16 +788,84 @@ function Backup-SkillDir {
}
try {
New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null
Move-SkillPath -Source $Dir -Destination $target
Move-SkillPathRecoverably -Source $Dir -Destination $target
} catch {
Write-Say "⚠️ 备份失败,保留原目录 $Dir"
Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_"
return $false
}
if ($null -ne $BackupPath) { $BackupPath.Value = $target }
try {
$script:SkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($targetRoot)) | Out-Null
Remove-OldSkillBackups
} catch {
Write-Say "⚠️ 旧备份清理失败(备份本身已成功): $_"
}
Write-Say " × 已备份并移除 $Dir → $target"
return $true
}
function Get-SkillLinkSignature {
param([string]$Path)
try {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
} catch {
return $null
}
if (!$item.LinkType) { return $null }
$targets = @($item.Target) | ForEach-Object { [string]$_ }
return ([string]$item.LinkType + "`0" + ($targets -join "`0"))
}
function New-PublishedSkillLinkRecord {
param([string]$Path)
$signature = Get-SkillLinkSignature -Path $Path
if ([string]::IsNullOrEmpty($signature)) {
throw "published Skill path is not a link: $Path"
}
return [pscustomobject]@{ Path = $Path; LinkSignature = $signature }
}
function New-PublishedSkillCopyRecord {
param([string]$Path, [string]$Source)
Assert-SkillPathCopy -Source $Source -Destination $Path
return [pscustomobject]@{ Path = $Path; Source = $Source }
}
function Remove-PublishedSkillPathSafely {
param($Record)
$path = [string]$Record.Path
$parent = Split-Path $path -Parent
$quarantine = Join-Path $parent (".dws-link-rollback-" + [guid]::NewGuid().ToString("N"))
# Claim the current directory entry with an exact same-filesystem rename.
# If another process replaced our link, verification below fails and that
# object is moved back instead of ever being recursively removed.
Move-SkillPath -Source $path -Destination $quarantine
try {
if ($Record.LinkSignature) {
if ((Get-SkillLinkSignature -Path $quarantine) -ne [string]$Record.LinkSignature) {
throw "发布链接已被其他进程替换,拒绝删除"
}
} elseif ($Record.Source) {
Assert-SkillPathCopy -Source ([string]$Record.Source) -Destination $quarantine
} else {
throw "发布路径缺少事务身份,拒绝删除"
}
Remove-SkillPathLexically -Path $quarantine
} catch {
$failure = $_
if (Test-SkillPathLexically -Path $quarantine) {
try {
if (Test-SkillPathLexically -Path $path) { throw "原路径已被占用" }
Move-SkillPath -Source $quarantine -Destination $path
} catch {
throw "$failure;并发对象保留于 $quarantine`: $_"
}
}
throw $failure
}
}
function Restore-MultiSkillSet {
param(
[array]$Published,
@@ -433,23 +873,29 @@ function Restore-MultiSkillSet {
)
$ok = $true
for ($i = $Published.Count - 1; $i -ge 0; $i--) {
$publishedItem = $Published[$i]
$publishedPath = if ($publishedItem -is [string]) { $publishedItem } else { [string]$publishedItem.Path }
try {
if (Test-Path $Published[$i]) {
Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop
if (Test-SkillPathLexically -Path $publishedPath) {
if (!($publishedItem -is [string])) {
Remove-PublishedSkillPathSafely -Record $publishedItem
} else {
Remove-SkillPathLexically -Path $publishedPath
}
}
} catch {
Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_"
Write-Say "⚠️ 无法移除失败发布目录 $publishedPath`: $_"
$ok = $false
}
}
for ($i = $Backups.Count - 1; $i -ge 0; $i--) {
$item = $Backups[$i]
try {
if (Test-Path $item.Original) {
if (Test-SkillPathLexically -Path $item.Original) {
throw "恢复目标仍存在"
}
New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null
Move-SkillPath -Source $item.Backup -Destination $item.Original
Move-SkillPathRecoverably -Source $item.Backup -Destination $item.Original
} catch {
Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_"
$ok = $false
@@ -458,13 +904,81 @@ function Restore-MultiSkillSet {
return $ok
}
function Move-GenericSkillRootToBackup {
param([string]$Root)
function Test-SkillPathLexically {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
try {
Get-Item -LiteralPath $Path -Force -ErrorAction Stop | Out-Null
return $true
} catch {
$parent = Split-Path $Path -Parent
$leaf = Split-Path $Path -Leaf
if ([string]::IsNullOrWhiteSpace($parent) -or !(Test-Path $parent -PathType Container)) { return $false }
return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq $leaf } | Select-Object -First 1)
}
}
function Resolve-AgentSkillBase {
param([string]$Root, $Agent)
if ($null -eq $Agent.Dir) { return $null }
switch ($Agent.Id) {
"autohand-code" { if ($env:AUTOHAND_HOME) { return (Join-Path $env:AUTOHAND_HOME "skills") } }
"claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return (Join-Path $env:CLAUDE_CONFIG_DIR "skills") } }
"codex" { if ($env:CODEX_HOME) { return (Join-Path $env:CODEX_HOME "skills") } }
"grok" { if ($env:GROK_HOME) { return (Join-Path $env:GROK_HOME "skills") } }
"hermes-agent" { if ($env:HERMES_HOME) { return (Join-Path $env:HERMES_HOME "skills") } }
"mistral-vibe" { if ($env:VIBE_HOME) { return (Join-Path $env:VIBE_HOME "skills") } }
"openclaw" {
foreach ($name in @(".openclaw", ".clawdbot", ".moltbot")) {
$candidate = Join-Path $Root $name
if (Test-Path $candidate -PathType Container) { return (Join-Path $candidate "skills") }
}
}
{ $_ -in @("amp", "replit", "universal") } {
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
return (Join-Path $configHome "agents\skills")
}
{ $_ -in @("crush", "devin", "goose", "opencode") } {
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
$child = switch ($Agent.Id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; default { "opencode\skills" } }
return (Join-Path $configHome $child)
}
"kimchi" {
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
return (Join-Path $configHome "kimchi\harness\skills")
}
}
return (Join-Path $Root $Agent.Dir)
}
function Test-AgentSkillBaseDetected {
param([string]$BaseDir, $Agent)
$parent = Split-Path $BaseDir -Parent
switch ($Agent.Id) {
"kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
"tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
"zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) }
"minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) }
default { return Test-Path $parent -PathType Container }
}
}
function Test-SamePhysicalSkillRoot {
param([string]$Left, [string]$Right)
if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false }
$leftPhysical = Get-PhysicalSkillPath -Path $Left
$rightPhysical = Get-PhysicalSkillPath -Path $Right
if ($null -eq $leftPhysical -or $null -eq $rightPhysical) { return $false }
return $leftPhysical.TrimEnd([char[]]@('\', '/')) -ieq $rightPhysical.TrimEnd([char[]]@('\', '/'))
}
function Move-AgentSkillRootToBackup {
param([string]$Root, [string]$BaseDir)
$baseDir = Join-Path $Root ".agents\skills"
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $baseDir $SkillName))
foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) {
foreach ($existing in Get-ChildItem -Path $baseDir -Force -ErrorAction SilentlyContinue) {
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) {
$victims.Add($existing.FullName)
}
@@ -476,7 +990,7 @@ function Move-GenericSkillRootToBackup {
if (!$seen.Add($victim)) { continue }
$backupPath = ""
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
throw "通用 Skill 副本备份失败: $victim"
throw "Agent Skill 旧副本备份失败: $victim"
}
if ($backupPath) {
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
@@ -485,11 +999,81 @@ function Move-GenericSkillRootToBackup {
return $true
} catch {
Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null
Write-Say "⚠️ 通用 Skill 副本迁移失败,已回滚: $_"
Write-Say "⚠️ Agent Skill 旧副本迁移失败,已回滚: $_"
return $false
}
}
function Publish-CanonicalSkillLinks {
param([string]$Root, [string]$BaseDir, [string]$Mode, [string[]]$BundleNames = @())
$canonical = Join-Path $Root ".agents\skills"
if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null }
if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true }
$stageRoot = Join-Path $BaseDir (".dws-link-set-" + [guid]::NewGuid().ToString("N"))
$backups = @()
$published = @()
try {
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
if ($Mode -eq "mono") {
$names = @($SkillName)
} elseif ($BundleNames.Count -gt 0) {
# The canonical store is SHARED: enumerate the link set from the
# installed bundle, never from ~\.agents\skills, or third-party and
# user skills would be republished into every Agent root.
$names = @($BundleNames)
} else {
# No bundle list means we cannot tell DWS skills apart from the
# user's own entries in the shared store. Fail this Agent (the
# caller degrades to a copy install) instead of guessing, matching
# link_canonical_skills_to_base in scripts/install-skills.sh.
throw "multi mode requires the installed bundle skill names"
}
$publishNames = @()
foreach ($name in $names) {
if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue }
$absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))
New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget -ErrorAction Stop | Out-Null
$publishNames += $name
}
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $BaseDir $SkillName))
foreach ($existing in Get-ChildItem -Path $BaseDir -Force -ErrorAction SilentlyContinue) {
if ($existing.FullName -eq $stageRoot) { continue }
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) }
}
# Every published name replaces whatever occupies its destination, even
# when that copy predates central ownership metadata; the loop below
# still skips destinations that are already correct links.
foreach ($name in $names) {
$victims.Add((Join-Path $BaseDir $name))
}
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($victim in $victims) {
if (!$seen.Add($victim)) { continue }
if (Test-SamePhysicalSkillRoot -Left $victim -Right (Join-Path $canonical (Split-Path $victim -Leaf))) { continue }
$backupPath = ""
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { throw "Skill 备份失败: $victim" }
if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } }
}
foreach ($name in $publishNames) {
$dest = Join-Path $BaseDir $name
Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest
# Only record a publication after the staged junction has occupied
# the exact destination. Rollback also checks this identity before
# deleting, so a concurrent user directory is never removed.
$published += New-PublishedSkillLinkRecord -Path $dest
Write-Say "↪ Skills → $dest"
}
return $true
} catch {
Restore-MultiSkillSet -Published $published -Backups $backups | Out-Null
Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)"
return $false
} finally {
Remove-LinkStageRoot -StageRoot $stageRoot | Out-Null
}
}
function Copy-SkillToDir {
param([string]$SkillSrc, [string]$Dest, [string]$Label)
@@ -616,26 +1200,7 @@ function Install-Binary {
$archivePath = Join-Path $tmpDir $archiveName
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing
# Download and verify SHA256 checksum
if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
try {
$checksumPath = Join-Path $tmpDir "checksums.txt"
Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing
$checksumContent = Get-Content $checksumPath
$expectedLine = $checksumContent | Where-Object { $_ -match [regex]::Escape($archiveName) }
if ($expectedLine) {
$expected = ($expectedLine -split '\s+')[0]
$actual = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected.ToLower()) {
Write-Err "SHA256 checksum mismatch! Expected $expected, got $actual. Aborting."
}
Write-Say "✅ SHA256 checksum verified"
} else {
Write-Say "⚠️ Archive not found in checksums.txt; skipping verification"
}
} catch {
Write-Say "⚠️ Could not download checksums.txt; skipping verification"
}
Assert-ReleaseAssetChecksum -AssetPath $archivePath -AssetName $archiveName -TempDir $tmpDir
Write-Say "📦 Extracting..."
Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force
@@ -784,8 +1349,8 @@ function Install-MonoToBase {
}
}
$published += $dest
Move-SkillPath -Source $stagedSkill -Destination $dest
$published += New-PublishedSkillCopyRecord -Path $dest -Source $SkillSrc
} catch {
$transactionError = $_
if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) {
@@ -811,46 +1376,37 @@ function Install-SkillsToHomes {
)
$installed = 0
$attempted = 0
$attempted = 1
$failed = 0
$specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
Test-Path (Split-Path (Join-Path $Root $_) -Parent)
})
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
$agentDir = $AgentDirs[$i]
$baseDir = Join-Path $Root $agentDir
$parentGate = Split-Path $baseDir -Parent
if ($i -gt 0 -and !(Test-Path $parentGate)) {
$canonical = Join-Path $Root ".agents\skills"
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ }
if ($installed -eq 0) { return $false }
$seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) {
$baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent
if ([string]::IsNullOrWhiteSpace($baseDir)) { continue }
$baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/'))
if (!$seenBases.Add($baseKey)) { continue }
if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue }
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
$attempted++
if ($agent.Universal) {
# Cleanup-only migration: universal Agents read the canonical store
# directly, so nothing is installed here. Leaving an obsolete copy
# behind is a warning, never a reason to fail an install whose
# canonical store and links all succeeded.
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) {
Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir"
}
continue
}
$attempted++
if ($Root -eq $HOME) {
$label = "~\$agentDir\$SkillName"
} else {
$label = Join-Path $Root (Join-Path $agentDir $SkillName)
}
$copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label
if ($copied) {
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") {
$installed++
} else {
$failed++
}
}
if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
}
if ($attempted -eq 0) {
$fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName
if ($Root -eq $HOME) {
$flabel = "~\.agents\skills\$SkillName"
} else {
$flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName)
}
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) {
$installed++
} else {
$failed++
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) {
Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用"
$installed++
} else { $failed++ }
}
}
if ($installed -eq 0) {
@@ -862,6 +1418,10 @@ function Install-SkillsToHomes {
return $false
}
Remove-Item -LiteralPath (Join-Path $SkillStateRoot "skills-state.json") -Force -ErrorAction SilentlyContinue
Write-Say "✅ DWS Skills 安装完成"
Write-Say " 统一安装位置:$canonical"
Write-Say " 已自动适配本机上检测到的 Agent"
Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
return $true
}
@@ -889,35 +1449,40 @@ function Install-MultiSkillsToHomes {
)
$installed = 0
$attempted = 0
$attempted = 1
$failed = 0
$specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
Test-Path (Split-Path (Join-Path $Root $_) -Parent)
})
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
$agentDir = $AgentDirs[$i]
$baseDir = Join-Path $Root $agentDir
$parentGate = Split-Path $baseDir -Parent
if ($i -gt 0 -and !(Test-Path $parentGate)) {
$canonical = Join-Path $Root ".agents\skills"
# The link set must come from the installed bundle, not from the shared
# canonical store (which may also hold user/third-party skills).
$bundleNames = @(Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue | Where-Object {
Test-Path (Join-Path $_.FullName "SKILL.md")
} | ForEach-Object { $_.Name })
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ }
if ($installed -eq 0) { return $false }
$seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) {
$baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent
if ([string]::IsNullOrWhiteSpace($baseDir)) { continue }
$baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/'))
if (!$seenBases.Add($baseKey)) { continue }
if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue }
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
$attempted++
if ($agent.Universal) {
# Cleanup-only migration (see Install-SkillsToHomes): a retire
# failure must not fail an otherwise complete install.
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) {
Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir"
}
continue
}
$attempted++
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) {
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi" -BundleNames $bundleNames) {
$installed++
} else {
Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)"
$failed++
}
}
if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
}
if ($attempted -eq 0) {
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") {
$installed++
} else {
$failed++
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agent.Dir) {
Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用"
$installed++
} else { $failed++ }
}
}
if ($installed -eq 0) {
@@ -929,6 +1494,10 @@ function Install-MultiSkillsToHomes {
return $false
}
Write-SkillsState -MultiSrc $MultiSrc
Write-Say "✅ DWS Skills 安装完成"
Write-Say " 统一安装位置:$canonical"
Write-Say " 已自动适配本机上检测到的 Agent"
Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
return $true
}
@@ -990,8 +1559,8 @@ function Install-MultiToBase {
foreach ($skillDir in $skillDirs) {
$dest = Join-Path $BaseDir $skillDir.Name
$published += $dest
Move-SkillPath -Source (Join-Path $stageRoot $skillDir.Name) -Destination $dest
$published += New-PublishedSkillCopyRecord -Path $dest -Source $skillDir.FullName
}
} catch {
$transactionError = $_
@@ -1072,6 +1641,7 @@ function Install-Skills {
Write-Err "Cannot download skills from GitHub and no local source checkout found."
}
}
Assert-ReleaseAssetChecksum -AssetPath $zipPath -AssetName "dws-skills.zip" -TempDir $tmpDir
$extractRoot = Join-Path $tmpDir "skills"
Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force
+385 -153
View File
@@ -71,13 +71,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
@@ -98,6 +98,54 @@ backup_and_remove_skill_dir() {
return 1
}
# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup
# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep /
# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal.
# Only safe to call once every backup manifest of this run has been consumed,
# because a single transaction can span more than one timestamp.
DWS_SKILL_BACKUP_KEEP=5
# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC
# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in
# the backup root is foreign (user data, unrelated tooling) and is preserved
# so pruning can never remove a directory it cannot prove DWS created.
is_skill_backup_stamp() {
case "$1" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
return 0 ;;
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
# those 16 chars and require the remainder to be all digits.
_isbs_suffix="${1#????????????????}"
case "$_isbs_suffix" in
""|*[!0-9]*) return 1 ;;
esac
return 0 ;;
esac
return 1
}
prune_skill_backups() {
_psb_root="${HOME}/.dws/skill-backups"
[ -d "$_psb_root" ] || return 0
_psb_total=0
for _psb_entry in "$_psb_root"/*; do
[ -d "$_psb_entry" ] || continue
is_skill_backup_stamp "${_psb_entry##*/}" || continue
_psb_total=$((_psb_total + 1))
done
[ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0
_psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP))
_psb_seen=0
for _psb_entry in "$_psb_root"/*; do
[ -d "$_psb_entry" ] || continue
is_skill_backup_stamp "${_psb_entry##*/}" || continue
_psb_seen=$((_psb_seen + 1))
[ "$_psb_seen" -le "$_psb_drop" ] || break
rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry"
done
}
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
# use it too. Ownership comes from the centralized skills-state.json.
is_managed_multi_skill_dir() {
@@ -141,6 +189,23 @@ sha256_stdin() {
fi
}
verify_release_asset_checksum() {
_checksum_asset="$1"
_checksum_path="$2"
_checksum_dir="$3"
_checksum_url="$(asset_url checksums.txt)"
[ -n "$_checksum_url" ] || err "Could not resolve checksums.txt for ${VERSION}."
download "$_checksum_url" "$_checksum_dir/checksums.txt" 2>/dev/null || \
err "Could not download checksums.txt for ${VERSION}; refusing unverified ${_checksum_asset}."
_checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")"
[ -n "$_checksum_expected" ] || err "${_checksum_asset} is missing from checksums.txt."
_checksum_actual="$(sha256_stdin < "$_checksum_path")" || \
err "Could not compute SHA256 for ${_checksum_asset}; install sha256sum, shasum, or openssl."
[ "$_checksum_actual" = "$_checksum_expected" ] || \
err "SHA256 checksum mismatch for ${_checksum_asset}. Expected ${_checksum_expected}, got ${_checksum_actual}."
say "✅ SHA256 checksum verified: ${_checksum_asset}"
}
digest_skill_dir() {
_digest_dir="$1"
_digest="$({
@@ -231,7 +296,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
say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)"
_rms_ok=0
fi
@@ -240,6 +305,50 @@ restore_multi_skill_set() {
[ "$_rms_ok" -eq 1 ]
}
# A link publication manifest stores destination/target/inode triples. Rollback
# only removes links that still have the identity created by this transaction;
# a path concurrently replaced with a file, directory, or new link is preserved.
skill_link_inode() {
_sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1
printf '%s\n' "$_sli_entry" | awk '{print $1}'
}
skill_link_matches() {
[ -L "$1" ] || return 1
_slm_target="$(readlink "$1" 2>/dev/null)" || return 1
[ "$_slm_target" = "$2" ] || return 1
_slm_expected_inode="${3-}"
[ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ]
}
restore_linked_skill_set() {
_rls_published="$1"
_rls_backups="$2"
_rls_ok=1
if [ -f "$_rls_published" ]; then
while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do
[ -n "$_rls_dest" ] || continue
if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then
rm -f "$_rls_dest" || _rls_ok=0
else
say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_rls_dest"
_rls_ok=0
fi
done < "$_rls_published"
fi
restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0
[ "$_rls_ok" -eq 1 ]
}
cleanup_nested_staged_link() {
_cnsl_nested="$1/$2"
_cnsl_target="$3"
_cnsl_inode="${4-}"
if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then
rm -f "$_cnsl_nested"
fi
}
# publish_skill_cache <source> <cache-dir>
# Copies a complete cache into a sibling staging directory, then publishes it
# with rename. Copy/publish failures retain the previous cache; if restoration
@@ -643,17 +752,100 @@ _install_mono_to_base() {
say "✅ Skills → ${_mono_label} (${_mono_count} files)"
}
# Move DWS-owned copies out of the generic root once a concrete Agent root is
# active. This prevents Agents such as Codex from discovering the same Skill
# through both ~/.agents/skills and ~/.codex/skills.
retire_generic_skill_root() {
# Exact upstream registry (76 IDs): id|classification|effective-global-root.
# `-` means no global directory; `.agents/skills` means canonical-direct.
# Not readonly: the library must stay re-sourceable inside one shell.
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
}
# DWS-only compatibility roots, same id|classification|root format. Qoderwork
# stays a real non-universal install target; the other four are global paths
# older DWS installers wrote by mistake, so they are retired like universal
# roots. Kept separate so the upstream pin above stays byte-comparable.
DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills'
legacy_agent_roots() {
for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done
}
# ~/.agents/skills is the canonical store under the universal convention.
# Universal Agents read it directly; other Agents receive relative links and
# fall back to copies only when links are unavailable.
#
# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned
# registries above, so a registry edit changes real install behaviour instead of
# only a test count, and pin/behaviour drift is impossible by construction.
is_universal_agent_dir() {
for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do
[ "${_iua_record##*|}" = "$1" ] || continue
case "$_iua_record" in
*"|U|"*) return 0 ;;
esac
done
return 1
}
# Effective global roots from vercel-labs/skills, in pinned registry order and
# de-duplicated (amp/replit/universal and zencoder/zenflow share a root).
# Records without a global root (`-`) and canonical-direct records
# (`.agents/skills`) are not per-Agent targets and are skipped.
agent_skill_dirs() {
{ upstream_agent_registry; legacy_agent_roots; } | awk -F'|' '
$3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }'
}
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"
}
agent_skill_base_detected() {
_asbd_agent="$1"; _asbd_base="$2"
case "$_asbd_agent" in
".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;;
".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;;
".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
*) [ -d "$(dirname "$_asbd_base")" ] ;;
esac
}
same_physical_skill_root() {
[ -d "$1" ] && [ -d "$2" ] || return 1
# CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved
# directory into the command substitution, which would silently report two
# identical roots as different.
_sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
_sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
[ "$_sps_left" = "$_sps_right" ]
}
retire_agent_skill_root() {
_rgs_root="$1"
_rgs_base="$_rgs_root/.agents/skills"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1
_rgs_base="$2"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1
_rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
[ -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
@@ -666,6 +858,130 @@ retire_generic_skill_root() {
rm -rf "$_rgs_stage"
}
# link_canonical_skills_to_base <root> <base> <mode> [bundle-src]
# <bundle-src> is the installed multi bundle and is REQUIRED in multi mode: the
# link set must come from the bundle, never from the whole canonical store.
# ~/.agents/skills is now shared, so third-party/user Skills live there too and
# must not be published into every detected Agent root (they are absent from
# skills-state.json, so nothing could ever reclaim those links). Go
# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list
# the same way.
link_canonical_skills_to_base() {
_lcs_root="$1"
_lcs_base="$2"
_lcs_mode="$3"
_lcs_bundle="${4-}"
_lcs_canonical="$_lcs_root/.agents/skills"
mkdir -p "$_lcs_base" || return 1
same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
if [ "$_lcs_mode" = "mono" ]; then
_lcs_names="dws"
else
[ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1
_lcs_names=""
for _lcs_skill in "$_lcs_bundle"/*/; do
[ -f "${_lcs_skill}SKILL.md" ] || continue
_lcs_names="$_lcs_names $(basename "$_lcs_skill")"
done
[ -n "$_lcs_names" ] || return 1
fi
# Collision pre-flight: the victim loop below deliberately refuses to back up
# entries that are not DWS-managed, and publication must never replace them.
# Report the exact colliding paths before touching anything, so the fall back
# to the legacy full-copy layout is observable and actionable.
_lcs_collision=0
for _lcs_name in $_lcs_names; do
_lcs_dest="$_lcs_base/$_lcs_name"
{ [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue
same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue
[ "$_lcs_name" = "dws" ] && continue
is_managed_multi_skill_dir "$_lcs_dest" && continue
say " ⚠️ $_lcs_dest 已存在且不是 DWS 安装的 Skill,无法在此建立指向 $_lcs_canonical/$_lcs_name 的共享链接"
_lcs_collision=1
done
if [ "$_lcs_collision" -eq 1 ]; then
say " ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;"
say " 移走或改名后重新运行安装即可切换为共享 $_lcs_canonical 布局。"
return 1
fi
_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_stage_token="$(basename "$_lcs_stage")"
: > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; }
: > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; }
_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
_lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
_lcs_stage_name="${_lcs_stage_token}.${_lcs_name}"
ln -s "$_lcs_link_target" "$_lcs_stage/$_lcs_stage_name" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_publish_names="$_lcs_publish_names $_lcs_name"
done
for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do
[ -e "$_lcs_victim" ] || [ -L "$_lcs_victim" ] || continue
[ "$_lcs_victim" = "$_lcs_stage" ] && continue
same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue
if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then
continue
fi
if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then
restore_multi_skill_set /dev/null "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
done
for _lcs_name in $_lcs_publish_names; do
_lcs_dest="$_lcs_base/$_lcs_name"
_lcs_stage_name="${_lcs_stage_token}.${_lcs_name}"
_lcs_staged="$_lcs_stage/$_lcs_stage_name"
_lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
}
# The staged symlink keeps its inode across the rename below (the staging
# directory is a sibling inside the same Agent root), so the identity
# recorded in the publication manifest stays exact.
_lcs_inode="$(skill_link_inode "$_lcs_staged")" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
}
# Anything still present here appeared after the pre-flight scan, i.e. a
# concurrent writer. Never let `mv` clobber it.
if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
# Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is
# absent from BusyBox `ln`, which silently degraded every non-universal
# Agent to the copy layout on Alpine and most containers). install-event.sh
# and install-devapp.sh already publish this way. The staged basename stays
# unique, so a destination a concurrent process turned into a directory can
# only receive our link and post-validation removes exactly that.
if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then
if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
say " ↪ Skills → $_lcs_dest"
done
rm -rf "$_lcs_stage"
}
# Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
# Installing mono removes proven DWS-managed multi leftovers for mutual exclusion,
# mirroring `dws skill setup --mode mono`.
@@ -673,90 +989,48 @@ install_skills_to_homes() {
skill_src="$1"
root="${HOME}"
installed=0
attempted=0
attempted=1
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
retire_failed=0
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; }
for agent_dir in $(agent_skill_dirs)
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
for agent_dir in \
".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"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
continue
fi
base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base_dir" || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
case "$root" in
"$HOME")
label="~/$agent_dir/$SKILL_NAME"
;;
*)
label="$root/$agent_dir/$SKILL_NAME"
;;
esac
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
continue
fi
if link_canonical_skills_to_base "$root" "$base_dir" mono; then
installed=$((installed + 1))
else
failed=$((failed + 1))
if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then
say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ]; then
case "$root" in
"$HOME")
flabel="~/.agents/skills/$SKILL_NAME"
;;
*)
flabel="$root/.agents/skills/$SKILL_NAME"
;;
esac
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
if [ "$installed" -eq 0 ]; then
say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"
return 1
fi
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
fi
if [ "$failed" -gt 0 ]; then
say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败"
return 1
fi
rm -f "$SKILL_STATE_ROOT/skills-state.json"
say "✅ DWS Skills 安装完成"
say " 统一安装位置:$root/.agents/skills"
say " 已自动适配本机上检测到的 Agent"
say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
}
# multi_tree_has_skills returns 0 only when the given multi bundle directory
@@ -783,71 +1057,48 @@ install_multi_skills_to_homes() {
multi_src="$1"
root="${HOME}"
installed=0
attempted=0
attempted=1
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
retire_failed=0
if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; }
for agent_dir in $(agent_skill_dirs)
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
for agent_dir in \
".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"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
continue
fi
base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base_dir" || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
continue
fi
if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)"
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
installed=$((installed + 1))
fi
if [ "$installed" -eq 0 ]; then
say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"
return 1
fi
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
fi
if [ "$failed" -gt 0 ]; then
say " ⚠️ 有 ${failed} 个 Agent 目标安装失败"
return 1
fi
write_skills_state "$multi_src" "install.sh" || return 1
say "✅ DWS Skills 安装完成"
say " 统一安装位置:$root/.agents/skills"
say " 已自动适配本机上检测到的 Agent"
say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
}
_install_multi_to_base() {
@@ -1018,32 +1269,7 @@ install_binary() {
download "$download_url" "$tmpdir/$archive_name"
# Download and verify SHA256 checksum
checksum_url="$(asset_url checksums.txt)"
if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then
expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")"
if [ -n "$expected" ]; then
if need_cmd sha256sum; then
actual="$(sha256sum "$tmpdir/$archive_name" | awk '{print $1}')"
elif need_cmd shasum; then
actual="$(shasum -a 256 "$tmpdir/$archive_name" | awk '{print $1}')"
else
actual=""
fi
if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then
err "SHA256 checksum mismatch! Expected ${expected}, got ${actual}. Aborting."
fi
if [ -n "$actual" ]; then
say "✅ SHA256 checksum verified"
else
say "⚠️ Could not compute checksum (sha256sum/shasum not found); skipping verification"
fi
else
say "⚠️ Archive not found in checksums.txt; skipping verification"
fi
else
say "⚠️ Could not download checksums.txt; skipping verification"
fi
verify_release_asset_checksum "$archive_name" "$tmpdir/$archive_name" "$tmpdir"
say "📦 Extracting..."
tar xzf "$tmpdir/$archive_name" -C "$tmpdir"
@@ -1108,6 +1334,8 @@ install_skills() {
fi
fi
verify_release_asset_checksum "$skills_archive" "$tmpdir_skills/$skills_archive" "$tmpdir_skills"
extract_root="$tmpdir_skills/skills"
mkdir -p "$extract_root"
if ! extract_zip "$tmpdir_skills/$skills_archive" "$extract_root" 2>/dev/null; then
@@ -1206,6 +1434,10 @@ main() {
install_skills
fi
# Every transaction of this run has finished, so old stamped archives can no
# longer be needed for a rollback.
prune_skill_backups
printf '\n'
say "🎉 Installation complete!"
say ""
+205 -58
View File
@@ -54,42 +54,92 @@ while [ "$#" -gt 0 ]; do
done
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
HOME_SPECIFIC_AGENT_PARENTS="
.claude
.cursor
.qoder
.qoderwork
.gemini
.codex
.zcode
.github
.windsurf
.augment
.cline
.amp
.kiro
.trae
.openclaw
.hermes
"
HOME_SPECIFIC_SKILL_BASES="
.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
"
# vercel-labs/skills agents.ts (c6f69c6), exactly 76 upstream IDs.
# Format: id|universal(1/0)|effective default global root; '-' has no global root.
UPSTREAM_AGENT_REGISTRY='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'
# DWS-only compatibility and old wrong global paths. Qoderwork remains a
# non-universal install target; the other entries verify migration cleanup.
LEGACY_AGENT_CLEANUP_REGISTRY='dws-qoderwork|0|.qoderwork/skills
dws-legacy-github|1|.github/skills
dws-legacy-amp|1|.amp/skills
dws-legacy-cline|1|.cline/skills
dws-legacy-windsurf|1|.windsurf/skills'
cleanup() {
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
@@ -103,11 +153,61 @@ 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
}
# Package verification must never inherit the runner's real Agent roots. In
# particular, hosted Linux runners can define XDG_CONFIG_HOME globally; two
# parallel verification jobs would then publish links into the same directory
# and make one another's links appear broken. Keep every supported override
# inside the scenario-specific temporary HOME instead.
with_isolated_agent_env() (
isolated_home=$1
shift
HOME="$isolated_home"
AUTOHAND_HOME="$isolated_home/.autohand"
CLAUDE_CONFIG_DIR="$isolated_home/.claude"
CODEX_HOME="$isolated_home/.codex"
GROK_HOME="$isolated_home/.grok"
HERMES_HOME="$isolated_home/.hermes"
VIBE_HOME="$isolated_home/.vibe"
XDG_CONFIG_HOME="$isolated_home/.config"
export HOME AUTOHAND_HOME CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME HERMES_HOME VIBE_HOME XDG_CONFIG_HOME
"$@"
)
verify_skill_base() {
home_root="$1"
base="$2"
@@ -116,25 +216,71 @@ verify_skill_base() {
[ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws"
}
verify_skill_targets() {
home_root="$1"
found_specific=0
for base in $HOME_SPECIFIC_SKILL_BASES; do
parent="${base%/skills}"
if [ -d "$home_root/$parent" ]; then
verify_skill_base "$home_root" "$base"
found_specific=1
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
link_target="$(readlink "$target")" || err "could not read canonical Skill link at $target"
# Relative link targets are the headline invariant of the canonical store:
# an absolute target breaks as soon as the home directory moves or the
# store is relocated, so reject it even though it resolves today.
case "$link_target" in
/*) err "canonical Skill link must be relative at $target (target=$link_target)" ;;
esac
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"
}
if [ "$found_specific" -eq 0 ]; then
verify_skill_base "$home_root" ".agents/skills"
return
fi
# Universal Agents read the canonical store directly, so they must own no copy
# of ANY canonical Skill. Iterate the real store instead of a hardcoded name
# list: a hardcoded list silently passed leftover copies of every other
# dingtalk-* Skill from earlier betas.
verify_universal_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")")
{ [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \
err "unexpected duplicate Skill found in universal Agent root $base/$name"
done
# The mono layout is absent from a multi canonical store, so gate it directly.
{ [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ]; } || \
err "unexpected duplicate Skill found in universal Agent root $base/dws"
}
for name in dingtalk-shared dingtalk-misc dws; do
[ ! -e "$home_root/.agents/skills/$name" ] || \
err "unexpected generic Skill copy found in $home_root/.agents/skills/$name"
verify_skill_targets() {
home_root="$1"
# The universal-convention canonical store is always present, regardless of
# which concrete Agent homes existed at install time.
verify_skill_base "$home_root" ".agents/skills"
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
verify_universal_skill_base "$home_root" "$base"
continue
fi
verify_compatible_skill_base "$home_root" "$base"
done
}
@@ -146,15 +292,15 @@ verify_npm_install() {
npm_cache="$TMP_ROOT/npm-cache-$scenario"
mkdir -p "$npm_home" "$npm_prefix" "$npm_cache"
if [ "$scenario" = "specific-agent-roots" ]; then
seed_specific_agent_homes "$npm_home"
with_isolated_agent_env "$npm_home" seed_specific_agent_homes "$npm_home"
fi
say "==> verifying npm package install ($scenario)"
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
npm install -g "$tarball_path" >/dev/null
[ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin"
HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null
with_isolated_agent_env "$npm_home" "$npm_prefix/bin/dws" --help >/dev/null
if [ -n "$EXPECTED_VERSION" ]; then
vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws"
need_file "$vendor_bin"
@@ -165,9 +311,9 @@ verify_npm_install() {
if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1);
' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch"
fi
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home"
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || with_isolated_agent_env "$npm_home" verify_skill_targets "$npm_home"
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
npm uninstall -g dingtalk-workspace-cli >/dev/null
}
@@ -176,6 +322,7 @@ verify_npm() {
need_cmd node
need_cmd tar
need_cmd unzip
need_cmd diff
need_file "$NPM_STAGE_DIR/package.json"
tarball_name="$(
+279
View File
@@ -0,0 +1,279 @@
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 TestAgentInstallersCarryUpstreamShallowAndApplicationDetection(t *testing.T) {
t.Parallel()
checks := map[string][]string{
"build/npm/install.js": {"agentTargetDetected", `case "kimchi"`, `case "tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
"scripts/install.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
"scripts/install-skills.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
"scripts/install-event.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
"scripts/install-devapp.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
"scripts/install.ps1": {"Test-AgentSkillBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
"scripts/install-devapp.ps1": {"Test-AgentBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
}
for rel, wants := range checks {
body := readTestFile(t, filepath.Join("..", "..", filepath.FromSlash(rel)))
for _, want := range wants {
if !strings.Contains(body, want) {
t.Errorf("%s missing detection contract %q", rel, 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
}
+683 -22
View File
@@ -16,22 +16,38 @@
* skills, mono leftover dws/ and stale
* dingtalk-* are removed, and the
* ~/.dws/skills/{multi,mono} caches fill.
* 2. empty multi/ tree — warns and falls back to mono instead of
* 2. universal topology — canonical store installed, pre-existing private
* copies retired and recoverable under
* ~/.dws/skill-backups/<stamp>/<home-rel-name>.
* 3. second run — idempotent: an already-correct link is neither
* re-backed-up nor rewritten.
* 4. unretireable universal root — a cleanup-only failure WARNS; canonical
* store, non-universal links and skills-state.json
* are all still written and the install exits 0.
* 5. empty multi/ tree — warns and falls back to mono instead of
* crashing postinstall; a previously good multi
* cache is NOT wiped.
* 3. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an
* 6. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an
* "invalid DWS_SKILL_MODE" error.
* 4. multi-only zip, mono — mono install is skipped with a warning; the
* 7. multi-only zip, mono — mono install is skipped with a warning; the
* staging root is NOT copied into a dws/ dir.
* 5. multi backup failure — preserves mono, writes no multi skill, and
* 8. multi backup failure — preserves mono, writes no multi skill, and
* reports postinstall failure.
* 6. mono backup failure — preserves multi, writes no mono skill, and
* 9. mono backup failure — preserves multi, writes no mono skill, and
* reports postinstall failure.
* 7. mono switch — migrates only centrally owned multi Skills.
* 8. cache copy failure — preserves the previous complete cache.
* 9. multi publish failure — restores the complete previous Skill set.
* 10. multi backup failure — restores every earlier successful backup.
* 11. mono transaction failure — restores every managed multi Skill after
* 10. mono switch — migrates only centrally owned multi Skills.
* 11. cache copy failure — preserves the previous complete cache.
* 12. no-replace publish — an occupied destination that is not a victim is
* refused, not replaced or linked into.
* 13. simulated win32 — junction type + ABSOLUTE canonical target and
* the rename publish path with its no-replace
* guard, exercised by swapping process.platform
* instead of requiring a Windows host.
* 14. backup retention — ~/.dws/skill-backups keeps the newest 5 stamps.
* 15. unknown preservation — prune never removes non-DWS directories in skill-backups.
* 16. multi publish failure — restores the complete previous Skill set.
* 17. multi backup failure — restores every earlier successful backup.
* 18. mono transaction failure — restores every managed multi Skill after
* later backup or mono publish failure.
*
* Requirements: unix host with tar/zip/unzip on PATH (the same tools
@@ -62,10 +78,34 @@ 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,
agentTargetDetected,
isSkillBackupStamp,
pruneSkillBackups,
backupAndRemoveSkillDir,
movePathRecoverablySync,
publishCacheAtomically,
publishCanonicalLinksAtomically,
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 detectionHome = fs.mkdtempSync(path.join(os.tmpdir(), "dws-agent-detect-"));
fs.mkdirSync(path.join(detectionHome, ".config", "kimchi"), { recursive: true });
fs.mkdirSync(path.join(detectionHome, ".tabnine"), { recursive: true });
const detectionTargets = resolvedAgentTargets(detectionHome);
for (const id of ["kimchi", "tabnine-cli"]) {
assert.equal(agentTargetDetected(detectionTargets.find((target) => target.id === id)), true, `${id} shallow install marker is detected`);
}
fs.rmSync(detectionHome, { recursive: true, force: true });
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
if (process.platform === "win32" || !assetName) {
@@ -107,6 +147,193 @@ function writeManagedState(home, names) {
writeFile(path.join(home, ".dws", "skills-state.json"), `${JSON.stringify(state, null, 2)}\n`);
}
function crossDeviceError() {
return Object.assign(new Error("injected cross-device rename"), { code: "EXDEV" });
}
// withSimulatedWin32 runs fn with process.platform === "win32" so the
// Windows-only publish branches (junction type, absolute link target, rename
// publish + its no-replace guard) get real execution coverage on a unix host.
// fs.symlinkSync ignores the type argument off Windows, so the same call is
// safe here. Create every temp path BEFORE calling: os.tmpdir() follows the
// simulated platform.
function withSimulatedWin32(fn) {
const original = process.platform;
const set = (value) =>
Object.defineProperty(process, "platform", { value, configurable: true, enumerable: true, writable: false });
set("win32");
try {
return fn();
} finally {
set(original);
}
}
function runCrossDeviceMoveContract() {
const roots = [];
const fixture = (name) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `dws-installjs-exdev-${name}-`));
roots.push(root);
const src = path.join(root, "external", "skill");
const dest = path.join(root, "home", ".dws", "skill-backups", "stamp", "skill");
writeFile(path.join(src, "SKILL.md"), "old skill\n", 0o640);
return { root, src, dest };
};
const injectedRename = (source, target) => {
if (path.basename(source) === "skill" && path.basename(target) === "skill") {
throw crossDeviceError();
}
fs.renameSync(source, target);
};
try {
{
const { src, dest } = fixture("success");
fs.symlinkSync("SKILL.md", path.join(src, "skill-link"));
fs.symlinkSync("missing.md", path.join(src, "dangling-link"));
movePathRecoverablySync(src, dest, { renameFn: injectedRename });
assert.equal(fs.lstatSync(dest).isDirectory(), true);
assert.equal(fs.statSync(path.join(dest, "SKILL.md")).mode & 0o777, 0o640);
assert.equal(fs.lstatSync(path.join(dest, "skill-link")).isSymbolicLink(), true);
assert.equal(fs.readlinkSync(path.join(dest, "skill-link")), "SKILL.md");
assert.equal(fs.lstatSync(path.join(dest, "dangling-link")).isSymbolicLink(), true);
assert.equal(fs.readlinkSync(path.join(dest, "dangling-link")), "missing.md");
assert.equal(fs.existsSync(src), false, "verified EXDEV move removes source last");
const restored = path.join(path.dirname(src), "restored-skill");
movePathRecoverablySync(dest, restored, {
renameFn(source, target) {
if (source === dest && target === restored) throw crossDeviceError();
fs.renameSync(source, target);
},
});
assert.equal(fs.readFileSync(path.join(restored, "SKILL.md"), "utf8"), "old skill\n");
assert.equal(fs.existsSync(dest), false, "EXDEV rollback consumes verified backup");
}
for (const [name, options] of [
["copy", { copyFn() { throw new Error("copy failed"); } }],
["verify", { verifyFn() { throw new Error("verify failed"); } }],
]) {
const { root, src, dest } = fixture(name);
assert.throws(
() => movePathRecoverablySync(src, dest, { renameFn: injectedRename, ...options }),
new RegExp(`${name} failed`),
);
assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n");
assert.equal(fs.existsSync(dest), false);
const backupParent = path.dirname(dest);
if (fs.existsSync(backupParent)) {
assert.equal(
fs.readdirSync(backupParent).some((entry) => entry.startsWith(".skill.cross-device-")),
false,
`${name} failure cleans destination-filesystem staging`,
);
}
assert.ok(root);
}
{
const { src, dest } = fixture("remove");
assert.throws(
() => movePathRecoverablySync(src, dest, {
renameFn: injectedRename,
removeFn(target) {
if (target === src) throw new Error("remove failed");
fs.rmSync(target, { recursive: true, force: true });
},
}),
/both copies retained .*remove failed/,
);
assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n");
assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n");
}
{
const { src, dest } = fixture("read-only");
fs.chmodSync(src, 0o555);
movePathRecoverablySync(src, dest, { renameFn: injectedRename });
assert.equal(fs.statSync(dest).mode & 0o777, 0o555, "read-only directory mode is restored after publish");
assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n");
fs.chmodSync(dest, 0o755);
}
{
const { src, dest } = fixture("read-only-remove-failure");
fs.chmodSync(src, 0o555);
assert.throws(
() => movePathRecoverablySync(src, dest, {
renameFn: injectedRename,
removeFn(target) {
if (target === src) throw new Error("remove failed");
fs.rmSync(target, { recursive: true, force: true });
},
}),
/both copies retained .*remove failed/,
);
assert.equal(fs.statSync(src).mode & 0o777, 0o555, "failed removal restores source directory mode");
fs.chmodSync(src, 0o755);
fs.chmodSync(dest, 0o755);
}
{
const { src, dest } = fixture("permission");
let copied = false;
const permission = Object.assign(new Error("permission denied"), { code: "EACCES" });
assert.throws(
() => movePathRecoverablySync(src, dest, {
renameFn() { throw permission; },
copyFn() { copied = true; },
}),
/permission denied/,
);
assert.equal(copied, false, "EACCES must not be mistaken for EXDEV");
assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n");
}
{
const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-dangling-collision-"));
roots.push(root);
const home = path.join(root, "home");
const victim = path.join(home, ".agents", "skills", "dingtalk-chat");
writeFile(path.join(victim, "SKILL.md"), "old\n");
const stamp = "20260813-150000";
const occupied = path.join(home, ".dws", "skill-backups", stamp, ".agents-skills-dingtalk-chat");
fs.mkdirSync(path.dirname(occupied), { recursive: true });
fs.symlinkSync("missing-backup", occupied);
const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => stamp });
assert.ok(backup.includes(`${stamp}-1`), "dangling backup target selects a numbered sibling");
assert.equal(fs.readlinkSync(occupied), "missing-backup", "dangling collision is never overwritten");
}
{
const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-link-rollback-"));
roots.push(root);
const home = path.join(root, "home");
const canonical = path.join(home, ".agents", "skills");
const base = path.join(home, ".cursor", "skills");
const canonicalSkill = path.join(canonical, "dingtalk-chat");
const victim = path.join(base, "dingtalk-chat");
writeFile(path.join(canonicalSkill, "SKILL.md"), "new\n");
writeFile(path.join(victim, "SKILL.md"), "old\n");
assert.throws(
() => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [victim], {
renameFn(source, target) {
if (source === victim || (source.includes(`${path.sep}skill-backups${path.sep}`) && target === victim)) {
throw crossDeviceError();
}
fs.renameSync(source, target);
},
publishLinkFn() { throw new Error("link publish failed"); },
}),
/link publish failed/,
);
assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old\n");
}
} finally {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
}
}
// stagePkg builds a fake npm package whose assets/dws-skills.zip contains
// exactly the given zip entries ({ "relative/path": "content" }) plus any
// listed empty directories. Returns { tmp, pkg, home }.
@@ -133,8 +360,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 {
@@ -193,14 +420,32 @@ scenario("multi install lays out sibling skills and caches", () => {
}
});
scenario("Codex uses its canonical root without a generic duplicate", () => {
scenario("pinned universal global topology installs canonical and retires private copies", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
"multi/dws-shared/SKILL.md": "# dws-shared\n",
});
try {
writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n");
// The pinned upstream installer treats global universal Agents as
// canonical-only even when their registry also retains a distinct
// globalSkillsDir for legacy discovery/removal. Exercise every unique
// non-canonical universal root so this distinction cannot drift again.
const retiredUniversalRoots = [
".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",
];
for (const root of retiredUniversalRoots) {
writeFile(path.join(home, root, "dingtalk-chat", "SKILL.md"), `beta.6 copy in ${root}\n`);
}
writeFile(
path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"),
"old nested duplicate\n",
@@ -209,13 +454,15 @@ scenario("Codex uses its canonical root without a generic duplicate", () => {
const res = runInstall(pkg, home, "multi");
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.ok(
fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")),
"Codex canonical Skill installed",
);
assert.ok(
!fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")),
"generic flat duplicate not installed",
fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")),
"universal canonical Skill installed",
);
for (const root of retiredUniversalRoots) {
assert.ok(
!fs.existsSync(path.join(home, root, "dingtalk-chat")),
`beta.6 universal duplicate migrated from ${root}`,
);
}
assert.ok(
!fs.existsSync(path.join(home, ".agents", "skills", "dws")),
"legacy nested duplicate retired",
@@ -225,7 +472,7 @@ scenario("Codex uses its canonical root without a generic duplicate", () => {
}
});
scenario("ZCode uses its canonical root without a generic duplicate", () => {
scenario("ZCode links its Agent root to the canonical store", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
@@ -241,8 +488,10 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => {
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.ok(
fs.existsSync(path.join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")),
"ZCode canonical Skill installed",
"ZCode Skill resolves through canonical link",
);
assert.ok(fs.lstatSync(path.join(home, ".zcode", "skills", "dingtalk-chat")).isSymbolicLink());
assert.ok(fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")));
assert.ok(
!fs.existsSync(path.join(home, ".agents", "skills", "dws")),
"legacy generic duplicate retired",
@@ -252,6 +501,172 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => {
}
});
scenario("a second install run is idempotent and preserves correct links", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
});
try {
writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n");
const first = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") });
assert.equal(first.status, 0, `exit=${first.status}\nstdout=${first.stdout}\nstderr=${first.stderr}`);
const linked = path.join(home, ".zcode", "skills", "dingtalk-chat");
const firstTarget = fs.readlinkSync(linked);
const firstState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8"));
const second = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") });
assert.equal(second.status, 0, `exit=${second.status}\nstdout=${second.stdout}\nstderr=${second.stderr}`);
assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "an already-correct link stays a link");
assert.equal(fs.readlinkSync(linked), firstTarget, "an already-correct link is not rewritten");
assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n");
assert.ok(
!second.stdout.includes(`已备份并移除 ${linked}`),
"an already-correct link must not be backed up again",
);
const secondState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8"));
assert.deepEqual(secondState.official_skills, firstState.official_skills, "state is stable across runs");
assert.deepEqual(
secondState.managed_skills.map((record) => record.digest),
firstState.managed_skills.map((record) => record.digest),
"re-installing the same bundle yields the same provenance digests",
);
assert.ok(
fs.readdirSync(path.join(home, ".dws", "skill-backups")).length <= 5,
"repeated installs keep the backup root bounded",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("unretireable universal copy warns but still completes the install", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
});
const codexSkills = path.join(home, ".codex", "skills");
try {
// Non-universal Agent that must still receive its link.
writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n");
// Universal Agent whose obsolete copy cannot be moved out: r-x on the
// parent means rename() fails with EACCES.
writeFile(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "old codex copy\n");
fs.chmodSync(codexSkills, 0o500);
const res = runInstall(pkg, home, "multi", {
XDG_CONFIG_HOME: path.join(home, ".config"),
CODEX_HOME: "",
});
assert.equal(
res.status,
0,
`a cleanup-only failure must not fail the install\nexit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`,
);
assert.match(res.stderr, /旧副本迁移失败/, "the retire failure is reported as a warning");
assert.ok(
fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")),
"canonical store is still installed",
);
const linked = path.join(home, ".zcode", "skills", "dingtalk-chat");
assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "non-universal link is still installed");
assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n");
assert.ok(
fs.existsSync(path.join(home, ".dws", "skills-state.json")),
"skills-state.json is written even when a retire failed",
);
assert.equal(
fs.readFileSync(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "utf8"),
"old codex copy\n",
"a copy that cannot be backed up is never deleted",
);
} finally {
try {
fs.chmodSync(codexSkills, 0o700);
} catch {}
fs.rmSync(tmp, { recursive: true, force: true });
}
});
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 claude = path.join(tmp, "claude config");
const codex = path.join(tmp, "codex home");
const hermes = path.join(tmp, "hermes 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(claude, "config.json"), "{}\n");
writeFile(path.join(claude, "skills", "dingtalk-chat", "SKILL.md"), "old claude copy\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(hermes, "config.json"), "{}\n");
writeFile(path.join(hermes, "skills", "dingtalk-chat", "SKILL.md"), "old hermes 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,
CLAUDE_CONFIG_DIR: claude,
CODEX_HOME: codex,
HERMES_HOME: hermes,
});
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(claude, "skills", "dingtalk-chat"),
path.join(hermes, "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");
}
const backupText = [];
const backupNames = [];
const collectBackupText = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
backupNames.push(entry.name);
collectBackupText(full);
} else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8"));
}
};
collectBackupText(path.join(home, ".dws", "skill-backups"));
assert.ok(backupText.includes("old claude copy\n"), "CLAUDE_CONFIG_DIR content is recoverably backed up");
assert.ok(backupText.includes("old hermes copy\n"), "HERMES_HOME content is recoverably backed up");
// Retired universal copies are the PR's advertised recovery story: every
// one of them must be readable back out of ~/.dws/skill-backups.
assert.ok(backupText.includes("old amp copy\n"), "retired XDG agents copy is recoverable");
assert.ok(backupText.includes("old codex copy\n"), "retired CODEX_HOME copy is recoverable");
assert.ok(backupText.includes("old DWS path\n"), "retired legacy ~/.amp copy is recoverable");
assert.ok(
backupNames.includes(".amp-skills-dingtalk-chat"),
`backup names record the HOME-relative origin, got ${backupNames.join(",")}`,
);
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",
@@ -407,6 +822,233 @@ scenario("cache copy failure preserves the previous complete cache", () => {
}
});
scenario("canonical link publication never clobbers or deletes concurrent user data", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-canonical-race-"));
try {
{
// Real no-replace property: the destination is occupied by data this
// transaction never backed up (it is not a victim), so the production
// publisher must refuse rather than replace or link into it. No stub
// creates or destroys anything here.
const home = path.join(tmp, "no-clobber-home");
const canonical = path.join(home, ".agents", "skills");
const base = path.join(home, ".cursor", "skills");
const destination = path.join(base, "dingtalk-chat");
writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n");
writeFile(path.join(destination, "concurrent-user-data.txt"), "must survive\n");
assert.throws(
() => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], []),
/Skill publish destination already exists/,
);
assert.equal(fs.readFileSync(path.join(destination, "concurrent-user-data.txt"), "utf8"), "must survive\n");
assert.deepEqual(
fs.readdirSync(destination),
["concurrent-user-data.txt"],
"refused publication must not leave anything inside the occupied destination",
);
assert.equal(fs.lstatSync(destination).isDirectory(), true, "the user's directory is still a directory");
assert.ok(
!fs.readdirSync(base).some((name) => name.startsWith(".dws-link-set.tmp-")),
"refused publication cleans its staging root",
);
}
{
const home = path.join(tmp, "rollback-home");
const canonical = path.join(home, ".agents", "skills");
const base = path.join(home, ".cursor", "skills");
const first = path.join(base, "dingtalk-first");
const second = path.join(base, "dingtalk-second");
for (const name of ["dingtalk-first", "dingtalk-second"]) {
writeFile(path.join(canonical, name, "SKILL.md"), `new ${name}\n`);
writeFile(path.join(base, name, "SKILL.md"), `old ${name}\n`);
}
let publishCalls = 0;
assert.throws(
() => publishCanonicalLinksAtomically(
home,
canonical,
base,
["dingtalk-first", "dingtalk-second"],
[first, second],
{
publishLinkFn(source, target) {
publishCalls += 1;
if (publishCalls === 1) {
const result = childProcess.spawnSync("ln", ["-P", source, target], { encoding: "utf8" });
if (result.status !== 0) throw new Error(result.stderr || "ln -P failed");
return;
}
fs.unlinkSync(first);
writeFile(path.join(first, "concurrent-user-data.txt"), "must survive\n");
throw new Error("injected second canonical publish failure");
},
},
),
/concurrent object retained|rollback failed/,
);
assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old dingtalk-first\n");
assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old dingtalk-second\n");
const retained = fs.readdirSync(base)
.filter((name) => name.startsWith(".dingtalk-first.rollback-"))
.map((name) => path.join(base, name, "payload", "concurrent-user-data.txt"))
.filter((candidate) => fs.existsSync(candidate));
assert.equal(retained.length, 1, "concurrent replacement must be retained in quarantine");
assert.equal(fs.readFileSync(retained[0], "utf8"), "must survive\n");
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("simulated win32 publishes junctions with absolute canonical targets", () => {
// Temp roots are created before entering the simulated platform: os.tmpdir()
// would otherwise resolve through Windows environment variables.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-win32-link-"));
try {
const home = path.join(tmp, "home");
const canonical = path.join(home, ".agents", "skills");
const canonicalSkill = path.join(canonical, "dingtalk-chat");
const base = path.join(home, ".zcode", "skills");
const dest = path.join(base, "dingtalk-chat");
writeFile(path.join(canonicalSkill, "SKILL.md"), "# dingtalk-chat\n");
writeFile(path.join(dest, "SKILL.md"), "old zcode copy\n");
const symlinkCalls = [];
const renameCalls = [];
withSimulatedWin32(() => {
publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [dest], {
symlinkFn(target, linkPath, type) {
symlinkCalls.push({ target, linkPath, type });
fs.symlinkSync(target, linkPath, type);
},
publishRenameFn(source, destination) {
renameCalls.push({ source, destination });
fs.renameSync(source, destination);
},
});
});
assert.equal(symlinkCalls.length, 1, "one staged link per bundle skill");
assert.equal(symlinkCalls[0].type, "junction", "Windows must publish junctions, not symlinks");
assert.equal(path.isAbsolute(symlinkCalls[0].target), true, "junctions require an absolute target");
assert.equal(symlinkCalls[0].target, fs.realpathSync(canonicalSkill), "junction targets the canonical store");
assert.equal(renameCalls.length, 1, "win32 publishes by rename, not by ln -P");
assert.equal(renameCalls[0].destination, dest);
assert.equal(fs.readlinkSync(dest), fs.realpathSync(canonicalSkill), "published junction kept its absolute target");
assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "# dingtalk-chat\n");
const backupRoot = path.join(home, ".dws", "skill-backups");
const stamps = fs.readdirSync(backupRoot);
assert.equal(stamps.length, 1, "the replaced copy produced exactly one backup stamp");
assert.deepEqual(
fs.readdirSync(path.join(backupRoot, stamps[0])),
[".zcode-skills-dingtalk-chat"],
"backups encode the HOME-relative origin of the retired copy",
);
assert.equal(
fs.readFileSync(path.join(backupRoot, stamps[0], ".zcode-skills-dingtalk-chat", "SKILL.md"), "utf8"),
"old zcode copy\n",
"the replaced copy stays recoverable",
);
// FIX 5: the win32 rename path must never replace an object that appeared
// at the destination (libuv passes MOVEFILE_REPLACE_EXISTING).
const raceBase = path.join(home, ".qoder", "skills");
const occupied = path.join(raceBase, "dingtalk-chat");
writeFile(path.join(occupied, "concurrent-user-data.txt"), "must survive\n");
let renames = 0;
withSimulatedWin32(() => {
assert.throws(
() => publishCanonicalLinksAtomically(home, canonical, raceBase, ["dingtalk-chat"], [], {
publishRenameFn(source, destination) {
renames += 1;
fs.renameSync(source, destination);
},
}),
/Skill publish destination already exists/,
);
});
assert.equal(renames, 0, "win32 publish must not rename over an existing destination");
assert.deepEqual(fs.readdirSync(occupied), ["concurrent-user-data.txt"]);
assert.equal(fs.readFileSync(path.join(occupied, "concurrent-user-data.txt"), "utf8"), "must survive\n");
assert.equal(process.platform !== "win32", true, "the simulated platform is restored");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("skill backups stay bounded to the newest five stamps", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-backup-prune-"));
const home = path.join(tmp, "home");
const backupRoot = path.join(home, ".dws", "skill-backups");
try {
for (const stamp of [
"20260101-000001", "20260101-000002", "20260101-000003",
"20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007",
]) {
writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`);
}
const victim = path.join(home, ".codex", "skills", "dingtalk-chat");
writeFile(path.join(victim, "SKILL.md"), "old codex copy\n");
const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => "20260102-000000" });
const stamps = fs.readdirSync(backupRoot).sort();
assert.equal(stamps.length, 5, `backup root must stay bounded, got ${stamps.join(",")}`);
assert.ok(stamps.includes("20260102-000000"), "the backup from this run survives pruning");
assert.ok(!stamps.includes("20260101-000001"), "the oldest stamps are pruned first");
assert.ok(!stamps.includes("20260101-000003"), "pruning removes exactly the excess");
assert.ok(stamps.includes("20260101-000004"), "the newest kept stamps are retained");
assert.equal(path.basename(backup), ".codex-skills-dingtalk-chat");
assert.equal(fs.readFileSync(path.join(backup, "SKILL.md"), "utf8"), "old codex copy\n");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("prune preserves unknown directories in skill-backups", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unknown-"));
const home = path.join(tmp, "home");
const backupRoot = path.join(home, ".dws", "skill-backups");
try {
for (const stamp of [
"20260101-000001", "20260101-000002", "20260101-000003",
"20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007",
]) {
writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`);
}
const unknowns = [
"user-personal-backup",
"20260101-00000", // too few digits in the time part
"20260101-000000-abc", // non-numeric suffix
"notes",
"20260101", // missing time portion
];
for (const name of unknowns) {
writeFile(path.join(backupRoot, name, "marker.txt"), `unknown:${name}\n`);
}
pruneSkillBackups(home);
const remaining = fs.readdirSync(backupRoot).sort();
const stamped = remaining.filter((n) => isSkillBackupStamp(n));
assert.equal(stamped.length, 5, `stamped backups = ${stamped.length}, want 5`);
assert.ok(!stamped.includes("20260101-000001"), "oldest stamps pruned first");
assert.ok(!stamped.includes("20260101-000002"), "second-oldest pruned");
assert.ok(stamped.includes("20260101-000005"), "newest kept stamps retained");
for (const name of unknowns) {
assert.ok(remaining.includes(name), `unknown directory ${name} must survive pruning`);
assert.equal(
fs.readFileSync(path.join(backupRoot, name, "marker.txt"), "utf8"),
`unknown:${name}\n`,
`unknown directory ${name} contents must be intact`,
);
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("multi set publish failure restores the complete previous set", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-"));
const home = path.join(tmp, "home");
@@ -431,6 +1073,13 @@ scenario("multi set publish failure restores the complete previous set", () => {
[first, second],
{
renameFn(src, dest) {
if (
src === first ||
src === second ||
(src.includes(`${path.sep}skill-backups${path.sep}`) && (dest === first || dest === second))
) {
throw crossDeviceError();
}
if (src.includes(".dws-multi-set.tmp-") && path.basename(src) === "dingtalk-second") {
throw new Error("injected second publish failure");
}
@@ -514,6 +1163,14 @@ for (const failureKind of ["backup", "publish"]) {
if (failureKind === "backup" && src === second) {
throw new Error("injected second backup failure");
}
if (
failureKind === "publish" &&
(src === first ||
src === second ||
(src.includes(`${path.sep}skill-backups${path.sep}`) && (target === first || target === second)))
) {
throw crossDeviceError();
}
if (
failureKind === "publish" &&
src.includes(".dws-mono-set.tmp-") &&
@@ -539,6 +1196,10 @@ for (const failureKind of ["backup", "publish"]) {
});
}
process.stdout.write("• EXDEV copy/verify/remove and transaction rollback contract ... ");
runCrossDeviceMoveContract();
process.stdout.write("ok\n");
for (const [name, fn] of scenarios) {
process.stdout.write(`• ${name} ... `);
fn();
File diff suppressed because it is too large Load Diff
+114 -4
View File
@@ -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"`,
@@ -128,11 +135,18 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T)
}
verifyCmd := exec.Command("sh", verifierPath, "--npm-only")
verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir)
hostXDG := filepath.Join(t.TempDir(), "host-xdg")
verifyCmd.Env = replaceTestEnv(os.Environ(),
"DWS_PACKAGE_DIST_DIR", distDir,
"XDG_CONFIG_HOME", hostXDG,
)
output, err := verifyCmd.CombinedOutput()
if err != nil {
t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output)
}
if _, err := os.Stat(hostXDG); !os.IsNotExist(err) {
t.Fatalf("package verifier touched inherited XDG_CONFIG_HOME %s: %v", hostXDG, err)
}
for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} {
if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") {
t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output)
@@ -140,6 +154,102 @@ 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, _, base string) {
t.Helper()
// The canonical layout publishes RELATIVE links; the verifier now
// rejects absolute targets, so the fixture must match production.
if err := os.Symlink(filepath.Join("..", "..", ".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)